[gnome-builder] testing: implement rudimentary unit testing



commit 135e67f16ed6d776659e7d7bea497e4b2e72a9ef
Author: Christian Hergert <chergert redhat com>
Date:   Fri Oct 13 17:40:19 2017 -0700

    testing: implement rudimentary unit testing
    
    Provides basic implementation for unit test plumbing. The UI is
    both minimal and incomplete, but enough to land on master so
    that future pieces can be implemented later in the cycle.
    
    The test provider is responsible for loading and running the
    test. I anticipate that we'll need to tweak the API so that we
    can run the tests under gdb. Additionally, we need to track
    output so it can be displayed.

 data/themes/shared/shared-treeview.css      |    8 +
 src/libide/ide-context.c                    |   50 ++-
 src/libide/ide-context.h                    |    1 +
 src/libide/ide-enums.c.in                   |    1 +
 src/libide/ide-types.h                      |    4 +
 src/libide/ide.h                            |    2 +
 src/libide/libide.gresource.xml             |    5 +
 src/libide/meson.build                      |    1 +
 src/libide/testing/ide-test-editor-addin.c  |  106 +++++
 src/libide/testing/ide-test-editor-addin.h  |   29 ++
 src/libide/testing/ide-test-manager.c       |  668 +++++++++++++++++++++++++++
 src/libide/testing/ide-test-manager.h       |   46 ++
 src/libide/testing/ide-test-panel.c         |  360 ++++++++++++++
 src/libide/testing/ide-test-panel.h         |   29 ++
 src/libide/testing/ide-test-panel.ui        |   51 ++
 src/libide/testing/ide-test-private.h       |   41 ++
 src/libide/testing/ide-test-provider.c      |  318 +++++++++++++
 src/libide/testing/ide-test-provider.h      |   75 +++
 src/libide/testing/ide-test.c               |  425 +++++++++++++++++
 src/libide/testing/ide-test.h               |   67 +++
 src/libide/testing/meson.build              |   28 ++
 src/libide/testing/testing-plugin.c         |   28 ++
 src/libide/testing/testing.plugin           |    9 +
 src/plugins/meson/gbp-meson-test-provider.c |  548 ++++++++++++++++++++++
 src/plugins/meson/gbp-meson-test-provider.h |   29 ++
 src/plugins/meson/gbp-meson-test.c          |  196 ++++++++
 src/plugins/meson/gbp-meson-test.h          |   34 ++
 src/plugins/meson/meson-plugin.c            |    2 +
 src/plugins/meson/meson.build               |    4 +
 29 files changed, 3164 insertions(+), 1 deletions(-)
---
diff --git a/data/themes/shared/shared-treeview.css b/data/themes/shared/shared-treeview.css
index 9f595fb..8846681 100644
--- a/data/themes/shared/shared-treeview.css
+++ b/data/themes/shared/shared-treeview.css
@@ -17,3 +17,11 @@ treeview.i-wanna-be-listbox:selected:backdrop {
   color: @theme_unfocused_selected_fg_color;
   background-color: @theme_unfocused_selected_bg_color;
 }
+
+ideeditorsidebar treeview.testing-tree {
+  -GtkTreeView-expander-size: 0;
+  -GtkTreeView-horizontal-separator: 0;
+  -GtkTreeView-vertical-separator: 6;
+  -gtk-icon-source: none;
+  padding-left: 6px;
+}
diff --git a/src/libide/ide-context.c b/src/libide/ide-context.c
index 9e5a826..6ad0ced 100644
--- a/src/libide/ide-context.c
+++ b/src/libide/ide-context.c
@@ -39,6 +39,7 @@
 #include "debugger/ide-debug-manager.h"
 #include "devices/ide-device-manager.h"
 #include "doap/ide-doap.h"
+#include "documentation/ide-documentation.h"
 #include "plugins/ide-extension-util.h"
 #include "projects/ide-project-files.h"
 #include "projects/ide-project-item.h"
@@ -49,7 +50,7 @@
 #include "search/ide-search-engine.h"
 #include "search/ide-search-provider.h"
 #include "snippets/ide-source-snippets-manager.h"
-#include "documentation/ide-documentation.h"
+#include "testing/ide-test-manager.h"
 #include "transfers/ide-transfer-manager.h"
 #include "util/ide-async-helper.h"
 #include "util/ide-settings.h"
@@ -78,6 +79,7 @@ struct _IdeContext
   IdeRuntimeManager        *runtime_manager;
   IdeSearchEngine          *search_engine;
   IdeSourceSnippetsManager *snippets_manager;
+  IdeTestManager           *test_manager;
   IdeProject               *project;
   GFile                    *project_file;
   gchar                    *root_build_dir;
@@ -534,6 +536,7 @@ ide_context_finalize (GObject *object)
   g_clear_object (&self->project_file);
   g_clear_object (&self->recent_manager);
   g_clear_object (&self->runtime_manager);
+  g_clear_object (&self->test_manager);
   g_clear_object (&self->unsaved_files);
   g_clear_object (&self->vcs);
 
@@ -817,6 +820,10 @@ ide_context_init (IdeContext *self)
                                         "context", self,
                                         NULL);
 
+  self->test_manager = g_object_new (IDE_TYPE_TEST_MANAGER,
+                                     "context", self,
+                                     NULL);
+
   self->unsaved_files = g_object_new (IDE_TYPE_UNSAVED_FILES,
                                       "context", self,
                                       NULL);
@@ -1118,6 +1125,28 @@ ide_context_init_snippets (gpointer             source_object,
 }
 
 static void
+ide_context_init_tests (gpointer             source_object,
+                        GCancellable        *cancellable,
+                        GAsyncReadyCallback  callback,
+                        gpointer             user_data)
+{
+  IdeContext *self = source_object;
+  g_autoptr(GTask) task = NULL;
+  g_autoptr(GError) error = NULL;
+
+  g_return_if_fail (IDE_IS_CONTEXT (self));
+
+  task = g_task_new (self, cancellable, callback, user_data);
+  g_task_set_priority (task, G_PRIORITY_LOW);
+  g_task_set_source_tag (task, ide_context_init_tests);
+
+  if (!g_initable_init (G_INITABLE (self->test_manager), cancellable, &error))
+    g_task_return_error (task, g_steal_pointer (&error));
+  else
+    g_task_return_boolean (task, TRUE);
+}
+
+static void
 ide_context_service_added (PeasExtensionSet *set,
                            PeasPluginInfo   *info,
                            PeasExtension    *exten,
@@ -1648,6 +1677,7 @@ ide_context_init_async (GAsyncInitable      *initable,
                         ide_context_init_build_manager,
                         ide_context_init_run_manager,
                         ide_context_init_diagnostics_manager,
+                        ide_context_init_tests,
                         ide_context_init_loaded,
                         NULL);
 }
@@ -2320,6 +2350,24 @@ ide_context_get_debug_manager (IdeContext *self)
 }
 
 /**
+ * ide_context_get_test_manager:
+ * @self: An #IdeTestManager
+ *
+ * Gets the test manager for the #IdeContext.
+ *
+ * Returns: (transfer none): An #IdeTestManager
+ *
+ * Since: 3.28
+ */
+IdeTestManager *
+ide_context_get_test_manager (IdeContext *self)
+{
+  g_return_val_if_fail (IDE_IS_CONTEXT (self), NULL);
+
+  return self->test_manager;
+}
+
+/**
  * ide_context_add_pausable:
  * @self: an #IdeContext
  * @pausable: an #IdePausable
diff --git a/src/libide/ide-context.h b/src/libide/ide-context.h
index e2a3351..5b5161e 100644
--- a/src/libide/ide-context.h
+++ b/src/libide/ide-context.h
@@ -47,6 +47,7 @@ IdeSettings              *ide_context_get_settings              (IdeContext
                                                                  const gchar          *schema_id,
                                                                  const gchar          *relative_path);
 IdeSourceSnippetsManager *ide_context_get_snippets_manager      (IdeContext           *self);
+IdeTestManager           *ide_context_get_test_manager          (IdeContext           *self);
 IdeUnsavedFiles          *ide_context_get_unsaved_files         (IdeContext           *self);
 IdeVcs                   *ide_context_get_vcs                   (IdeContext           *self);
 const gchar              *ide_context_get_root_build_dir        (IdeContext           *self);
diff --git a/src/libide/ide-enums.c.in b/src/libide/ide-enums.c.in
index e1a9c93..9f85d98 100644
--- a/src/libide/ide-enums.c.in
+++ b/src/libide/ide-enums.c.in
@@ -16,6 +16,7 @@
 #include "sourceview/ide-cursor.h"
 #include "sourceview/ide-source-view.h"
 #include "symbols/ide-symbol.h"
+#include "testing/ide-test.h"
 #include "threading/ide-thread-pool.h"
 #include "transfers/ide-transfer.h"
 #include "vcs/ide-vcs-config.h"
diff --git a/src/libide/ide-types.h b/src/libide/ide-types.h
index 49a43de..72a05b6 100644
--- a/src/libide/ide-types.h
+++ b/src/libide/ide-types.h
@@ -129,6 +129,10 @@ typedef struct _IdeSubprocessLauncher          IdeSubprocessLauncher;
 typedef struct _IdeSymbol                      IdeSymbol;
 typedef struct _IdeSymbolResolver              IdeSymbolResolver;
 
+typedef struct _IdeTest                        IdeTest;
+typedef struct _IdeTestManager                 IdeTestManager;
+typedef struct _IdeTestProvider                IdeTestProvider;
+
 typedef struct _IdeTransferManager             IdeTransferManager;
 typedef struct _IdeTransfer                    IdeTransfer;
 
diff --git a/src/libide/ide.h b/src/libide/ide.h
index a12d853..61e2023 100644
--- a/src/libide/ide.h
+++ b/src/libide/ide.h
@@ -165,6 +165,8 @@ G_BEGIN_DECLS
 #include "symbols/ide-tags-builder.h"
 #include "template/ide-project-template.h"
 #include "template/ide-template-provider.h"
+#include "testing/ide-test.h"
+#include "testing/ide-test-provider.h"
 #include "threading/ide-thread-pool.h"
 #include "transfers/ide-pkcon-transfer.h"
 #include "transfers/ide-transfer.h"
diff --git a/src/libide/libide.gresource.xml b/src/libide/libide.gresource.xml
index a060e6a..8a34d85 100644
--- a/src/libide/libide.gresource.xml
+++ b/src/libide/libide.gresource.xml
@@ -81,6 +81,7 @@
     <file preprocess="xml-stripblanks" 
alias="ide-preferences-language-row.ui">preferences/ide-preferences-language-row.ui</file>
     <file preprocess="xml-stripblanks" 
alias="ide-preferences-window.ui">preferences/ide-preferences-window.ui</file>
     <file preprocess="xml-stripblanks" alias="ide-run-button.ui">runner/ide-run-button.ui</file>
+    <file preprocess="xml-stripblanks" alias="ide-test-panel.ui">testing/ide-test-panel.ui</file>
     <file preprocess="xml-stripblanks" alias="ide-transfer-row.ui">transfers/ide-transfer-row.ui</file>
     <file preprocess="xml-stripblanks" 
alias="ide-transfers-button.ui">transfers/ide-transfers-button.ui</file>
     <file preprocess="xml-stripblanks" 
alias="ide-shortcuts-window.ui">keybindings/ide-shortcuts-window.ui</file>
@@ -107,6 +108,10 @@
     <file alias="directory.plugin">directory/directory.plugin</file>
   </gresource>
 
+  <gresource prefix="/org/gnome/builder/plugins/testing">
+    <file alias="testing.plugin">testing/testing.plugin</file>
+  </gresource>
+
   <gresource prefix="/org/gnome/builder/plugins/webkit">
     <file alias="webkit.plugin">webkit/webkit.plugin</file>
   </gresource>
diff --git a/src/libide/meson.build b/src/libide/meson.build
index 5e1473f..67fc7af 100644
--- a/src/libide/meson.build
+++ b/src/libide/meson.build
@@ -87,6 +87,7 @@ subdir('sourceview')
 subdir('subprocess')
 subdir('symbols')
 subdir('template')
+subdir('testing')
 subdir('threading')
 subdir('transfers')
 subdir('util')
diff --git a/src/libide/testing/ide-test-editor-addin.c b/src/libide/testing/ide-test-editor-addin.c
new file mode 100644
index 0000000..570aef0
--- /dev/null
+++ b/src/libide/testing/ide-test-editor-addin.c
@@ -0,0 +1,106 @@
+/* ide-test-editor-addin.c
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-test-editor-addin"
+
+#include <glib/gi18n.h>
+
+#include "ide-context.h"
+
+#include "editor/ide-editor-addin.h"
+#include "editor/ide-editor-perspective.h"
+#include "editor/ide-editor-sidebar.h"
+#include "testing/ide-test-editor-addin.h"
+#include "testing/ide-test-panel.h"
+#include "util/ide-gtk.h"
+
+struct _IdeTestEditorAddin
+{
+  GObject       parent_instance;
+
+  IdeTestPanel *panel;
+};
+
+static void
+ide_test_editor_addin_load (IdeEditorAddin       *addin,
+                            IdeEditorPerspective *editor)
+{
+  IdeTestEditorAddin *self = (IdeTestEditorAddin *)addin;
+  IdeEditorSidebar *sidebar;
+  IdeTestManager *manager;
+  IdeContext *context;
+
+  g_assert (IDE_IS_TEST_EDITOR_ADDIN (self));
+  g_assert (IDE_IS_EDITOR_PERSPECTIVE (editor));
+
+  context = ide_widget_get_context (GTK_WIDGET (editor));
+  manager = ide_context_get_test_manager (context);
+
+  self->panel = g_object_new (IDE_TYPE_TEST_PANEL,
+                              "manager", manager,
+                              "visible", TRUE,
+                              NULL);
+  g_signal_connect (self->panel,
+                    "destroy",
+                    G_CALLBACK (gtk_widget_destroy),
+                    &self->panel);
+
+  sidebar = ide_editor_perspective_get_sidebar (editor);
+
+  ide_editor_sidebar_add_section (sidebar,
+                                  "tests",
+                                  _("Unit Tests"),
+                                  "builder-unit-tests-symbolic",
+                                  NULL,
+                                  NULL,
+                                  GTK_WIDGET (self->panel),
+                                  400);
+}
+
+static void
+ide_test_editor_addin_unload (IdeEditorAddin       *addin,
+                              IdeEditorPerspective *editor)
+{
+  IdeTestEditorAddin *self = (IdeTestEditorAddin *)addin;
+
+  g_assert (IDE_IS_TEST_EDITOR_ADDIN (self));
+  g_assert (IDE_IS_EDITOR_PERSPECTIVE (editor));
+
+  if (self->panel)
+    gtk_widget_destroy (GTK_WIDGET (self->panel));
+}
+
+static void
+editor_addin_iface_init (IdeEditorAddinInterface *iface)
+{
+  iface->load = ide_test_editor_addin_load;
+  iface->unload = ide_test_editor_addin_unload;
+}
+
+G_DEFINE_TYPE_WITH_CODE (IdeTestEditorAddin, ide_test_editor_addin, G_TYPE_OBJECT,
+                         G_IMPLEMENT_INTERFACE (IDE_TYPE_EDITOR_ADDIN, editor_addin_iface_init))
+
+static void
+ide_test_editor_addin_class_init (IdeTestEditorAddinClass *klass)
+{
+}
+
+static void
+ide_test_editor_addin_init (IdeTestEditorAddin *self)
+{
+}
diff --git a/src/libide/testing/ide-test-editor-addin.h b/src/libide/testing/ide-test-editor-addin.h
new file mode 100644
index 0000000..64fcf1d
--- /dev/null
+++ b/src/libide/testing/ide-test-editor-addin.h
@@ -0,0 +1,29 @@
+/* ide-test-editor-addin.h
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_TEST_EDITOR_ADDIN (ide_test_editor_addin_get_type())
+
+G_DECLARE_FINAL_TYPE (IdeTestEditorAddin, ide_test_editor_addin, IDE, TEST_EDITOR_ADDIN, GObject)
+
+G_END_DECLS
diff --git a/src/libide/testing/ide-test-manager.c b/src/libide/testing/ide-test-manager.c
new file mode 100644
index 0000000..c46e205
--- /dev/null
+++ b/src/libide/testing/ide-test-manager.c
@@ -0,0 +1,668 @@
+/* ide-test-manager.c
+ *
+ * Copyright © 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-test-manager"
+
+#include <dazzle.h>
+#include <libpeas/peas.h>
+
+#include "ide-debug.h"
+
+#include "testing/ide-test-manager.h"
+#include "testing/ide-test-private.h"
+#include "testing/ide-test-provider.h"
+
+/**
+ * SECTION:ide-test-manager
+ * @title: IdeTestManager
+ * @short_description: Unit test discover and execution manager
+ *
+ * The #IdeTestManager is responsible for loading unit test provider
+ * plugins (via the #IdeTestProvider interface) and running those unit
+ * tests on behalf of the user.
+ *
+ * You can access the test manager using ide_context_get_text_manager()
+ * using the #IdeContext for the loaded project.
+ *
+ * Since: 3.28
+ */
+
+struct _IdeTestManager
+{
+  IdeObject         parent_instance;
+
+  PeasExtensionSet *providers;
+  GPtrArray        *tests_by_provider;
+  GtkTreeStore     *tests_store;
+};
+
+typedef struct
+{
+  IdeTestProvider *provider;
+  GPtrArray       *tests;
+} TestsByProvider;
+
+enum {
+  PROP_0,
+  PROP_LOADING,
+  N_PROPS
+};
+
+static void initable_iface_init              (GInitableIface *iface);
+static void ide_test_manager_actions_run_all (IdeTestManager *self,
+                                              GVariant       *param);
+
+DZL_DEFINE_ACTION_GROUP (IdeTestManager, ide_test_manager, {
+  { "run-all", ide_test_manager_actions_run_all },
+})
+
+G_DEFINE_TYPE_WITH_CODE (IdeTestManager, ide_test_manager, IDE_TYPE_OBJECT,
+                         G_IMPLEMENT_INTERFACE (G_TYPE_INITABLE, initable_iface_init)
+                         G_IMPLEMENT_INTERFACE (G_TYPE_ACTION_GROUP,
+                                                ide_test_manager_init_action_group))
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+tests_by_provider_free (gpointer data)
+{
+  TestsByProvider *info = data;
+
+  g_clear_pointer (&info->tests, g_ptr_array_unref);
+  g_clear_object (&info->provider);
+  g_slice_free (TestsByProvider, info);
+}
+
+static void
+ide_test_manager_dispose (GObject *object)
+{
+  IdeTestManager *self = (IdeTestManager *)object;
+
+  g_clear_object (&self->providers);
+  g_clear_object (&self->tests_store);
+  g_clear_pointer (&self->tests_by_provider, g_ptr_array_unref);
+
+  G_OBJECT_CLASS (ide_test_manager_parent_class)->dispose (object);
+}
+
+static void
+ide_test_manager_get_property (GObject    *object,
+                               guint       prop_id,
+                               GValue     *value,
+                               GParamSpec *pspec)
+{
+  IdeTestManager *self = IDE_TEST_MANAGER (object);
+
+  switch (prop_id)
+    {
+    case PROP_LOADING:
+      g_value_set_boolean (value, ide_test_manager_get_loading (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_test_manager_class_init (IdeTestManagerClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_test_manager_dispose;
+  object_class->get_property = ide_test_manager_get_property;
+
+  /**
+   * IdeTestManager:loading:
+   *
+   * The "loading" property denotes if a test provider is busy loading
+   * tests in the background.
+   *
+   * Since: 3.28
+   */
+  properties [PROP_LOADING] =
+    g_param_spec_boolean ("loading",
+                          "Loading",
+                          "If a test provider is loading tests",
+                          FALSE,
+                          (G_PARAM_READABLE | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+}
+
+static void
+ide_test_manager_init (IdeTestManager *self)
+{
+  self->tests_by_provider = g_ptr_array_new_with_free_func (tests_by_provider_free);
+  self->tests_store = gtk_tree_store_new (2, G_TYPE_STRING, IDE_TYPE_TEST);
+}
+
+static void
+ide_test_manager_locate_group (IdeTestManager *self,
+                               GtkTreeIter    *iter,
+                               const gchar    *group)
+{
+  g_assert (IDE_IS_TEST_MANAGER (self));
+  g_assert (iter != NULL);
+
+  if (gtk_tree_model_get_iter_first (GTK_TREE_MODEL (self->tests_store), iter))
+    {
+      do
+        {
+          g_autofree gchar *row_group = NULL;
+
+          gtk_tree_model_get (GTK_TREE_MODEL (self->tests_store), iter,
+                              IDE_TEST_COLUMN_GROUP, &row_group,
+                              -1);
+
+          if (ide_str_equal0 (row_group, group))
+            return;
+        }
+      while (gtk_tree_model_iter_next (GTK_TREE_MODEL (self->tests_store), iter));
+    }
+
+  /* TODO: Sort groups by name? */
+
+  gtk_tree_store_append (self->tests_store, iter, NULL);
+  gtk_tree_store_set (self->tests_store, iter,
+                      IDE_TEST_COLUMN_GROUP, group,
+                      -1);
+}
+
+static void
+ide_test_manager_test_notify_status (IdeTestManager *self,
+                                     GParamSpec     *pspec,
+                                     IdeTest        *test)
+{
+  const gchar *group;
+  GtkTreeIter parent;
+  GtkTreeIter iter;
+
+  g_assert (IDE_IS_TEST_MANAGER (self));
+  g_assert (IDE_IS_TEST (test));
+
+  group = ide_test_get_group (test);
+
+  ide_test_manager_locate_group (self, &parent, group);
+
+  if (gtk_tree_model_iter_children (GTK_TREE_MODEL (self->tests_store), &iter, &parent))
+    {
+      do
+        {
+          g_autoptr(IdeTest) row_test = NULL;
+
+          gtk_tree_model_get (GTK_TREE_MODEL (self->tests_store), &iter,
+                              IDE_TEST_COLUMN_TEST, &row_test,
+                              -1);
+
+          if (row_test == test)
+            {
+              GtkTreePath *path;
+
+              path = gtk_tree_model_get_path (GTK_TREE_MODEL (self->tests_store), &iter);
+              gtk_tree_model_row_changed (GTK_TREE_MODEL (self->tests_store), path, &iter);
+              gtk_tree_path_free (path);
+
+              break;
+            }
+        }
+      while (gtk_tree_model_iter_next (GTK_TREE_MODEL (self->tests_store), &iter));
+    }
+}
+
+static void
+ide_test_manager_add_test (IdeTestManager        *self,
+                           const TestsByProvider *info,
+                           guint                  position,
+                           IdeTest               *test)
+{
+  const gchar *group;
+  GtkTreeIter iter;
+  GtkTreeIter parent;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_TEST_MANAGER (self));
+  g_assert (info != NULL);
+  g_assert (IDE_IS_TEST (test));
+
+  g_ptr_array_insert (info->tests, position, g_object_ref (test));
+
+  group = ide_test_get_group (test);
+
+  ide_test_manager_locate_group (self, &parent, group);
+  gtk_tree_store_append (self->tests_store, &iter, &parent);
+  gtk_tree_store_set (self->tests_store, &iter,
+                      IDE_TEST_COLUMN_GROUP, NULL,
+                      IDE_TEST_COLUMN_TEST, test,
+                      -1);
+
+  g_signal_connect_object (test,
+                           "notify::status",
+                           G_CALLBACK (ide_test_manager_test_notify_status),
+                           self,
+                           G_CONNECT_SWAPPED);
+
+  IDE_EXIT;
+}
+
+static void
+ide_test_manager_remove_test (IdeTestManager        *self,
+                              const TestsByProvider *info,
+                              IdeTest               *test)
+{
+  const gchar *group;
+  GtkTreeIter iter;
+  GtkTreeIter parent;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_TEST_MANAGER (self));
+  g_assert (info != NULL);
+  g_assert (IDE_IS_TEST (test));
+
+  ide_test_manager_locate_group (self, &parent, group);
+
+  if (gtk_tree_model_iter_children (GTK_TREE_MODEL (self->tests_store), &iter, &parent))
+    {
+      do
+        {
+          g_autoptr(IdeTest) row = NULL;
+
+          gtk_tree_model_get (GTK_TREE_MODEL (self->tests_store), &iter,
+                              IDE_TEST_COLUMN_TEST, &row,
+                              -1);
+
+          if (row == test)
+            {
+              g_signal_handlers_disconnect_by_func (test,
+                                                    G_CALLBACK (ide_test_manager_test_notify_status),
+                                                    self);
+              gtk_tree_store_remove (self->tests_store, &iter);
+              break;
+            }
+        }
+      while (gtk_tree_model_iter_next (GTK_TREE_MODEL (self->tests_store), &iter));
+    }
+
+  g_ptr_array_remove (info->tests, test);
+
+  IDE_EXIT;
+}
+
+static void
+ide_test_manager_provider_items_changed (IdeTestManager  *self,
+                                         guint            position,
+                                         guint            removed,
+                                         guint            added,
+                                         IdeTestProvider *provider)
+{
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_TEST_MANAGER (self));
+  g_assert (IDE_IS_TEST_PROVIDER (provider));
+
+  for (guint i = 0; i < self->tests_by_provider->len; i++)
+    {
+      const TestsByProvider *info = g_ptr_array_index (self->tests_by_provider, i);
+
+      if (info->provider == provider)
+        {
+          /* Remove tests from cache that were deleted */
+          for (guint j = 0; j < removed; j++)
+            {
+              IdeTest *test = g_ptr_array_index (info->tests, position);
+              ide_test_manager_remove_test (self, info, test);
+            }
+
+          /* Add tests to cache that were added */
+          for (guint j = 0; j < added; j++)
+            {
+              g_autoptr(IdeTest) test = NULL;
+
+              test = g_list_model_get_item (G_LIST_MODEL (provider), position + j);
+              ide_test_manager_add_test (self, info, position + j, test);
+            }
+        }
+    }
+
+  IDE_EXIT;
+}
+
+static void
+ide_test_manager_provider_notify_loading (IdeTestManager  *self,
+                                          GParamSpec      *pspec,
+                                          IdeTestProvider *provider)
+{
+  g_assert (IDE_IS_TEST_MANAGER (self));
+  g_assert (IDE_IS_TEST_PROVIDER (provider));
+
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_LOADING]);
+}
+
+static void
+ide_test_manager_provider_added (PeasExtensionSet *set,
+                                 PeasPluginInfo   *plugin_info,
+                                 PeasExtension    *exten,
+                                 gpointer          user_data)
+{
+  IdeTestManager *self = user_data;
+  IdeTestProvider *provider = (IdeTestProvider *)exten;
+  TestsByProvider *tests;
+  guint len;
+
+  IDE_ENTRY;
+
+  g_assert (PEAS_IS_EXTENSION_SET (set));
+  g_assert (plugin_info != NULL);
+  g_assert (IDE_IS_TEST_PROVIDER (provider));
+  g_assert (G_IS_LIST_MODEL (provider));
+  g_assert (IDE_IS_TEST_MANAGER (self));
+
+  tests = g_slice_new0 (TestsByProvider);
+  tests->provider = g_object_ref (provider);
+  tests->tests = g_ptr_array_new_with_free_func (g_object_unref);
+  g_ptr_array_add (self->tests_by_provider, tests);
+
+  g_signal_connect_swapped (provider,
+                            "items-changed",
+                            G_CALLBACK (ide_test_manager_provider_items_changed),
+                            self);
+  g_signal_connect_swapped (provider,
+                            "notify::loading",
+                            G_CALLBACK (ide_test_manager_provider_notify_loading),
+                            self);
+
+  len = g_list_model_get_n_items (G_LIST_MODEL (provider));
+  ide_test_manager_provider_items_changed (self, 0, 0, len, provider);
+
+  IDE_EXIT;
+}
+
+static void
+ide_test_manager_provider_removed (PeasExtensionSet *set,
+                                   PeasPluginInfo   *plugin_info,
+                                   PeasExtension    *exten,
+                                   gpointer          user_data)
+{
+  IdeTestManager *self = user_data;
+  IdeTestProvider *provider = (IdeTestProvider *)exten;
+
+  IDE_ENTRY;
+
+  g_assert (PEAS_IS_EXTENSION_SET (set));
+  g_assert (plugin_info != NULL);
+  g_assert (IDE_IS_TEST_PROVIDER (provider));
+  g_assert (IDE_IS_TEST_MANAGER (self));
+
+  for (guint i = 0; i < self->tests_by_provider->len; i++)
+    {
+      const TestsByProvider *info = g_ptr_array_index (self->tests_by_provider, i);
+
+      if (info->provider == provider)
+        {
+          g_ptr_array_remove_index (self->tests_by_provider, i);
+          break;
+        }
+    }
+
+  g_signal_handlers_disconnect_by_func (provider,
+                                        G_CALLBACK (ide_test_manager_provider_items_changed),
+                                        self);
+  g_signal_handlers_disconnect_by_func (provider,
+                                        G_CALLBACK (ide_test_manager_provider_notify_loading),
+                                        self);
+
+  IDE_EXIT;
+}
+
+static gboolean
+ide_test_manager_initiable_init (GInitable     *initable,
+                                 GCancellable  *cancellable,
+                                 GError       **error)
+{
+  IdeTestManager *self = (IdeTestManager *)initable;
+  IdeContext *context;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_TEST_MANAGER (self));
+  g_assert (!cancellable || G_IS_CANCELLABLE (cancellable));
+
+  context = ide_object_get_context (IDE_OBJECT (self));
+
+  self->providers = peas_extension_set_new (peas_engine_get_default (),
+                                            IDE_TYPE_TEST_PROVIDER,
+                                            "context", context,
+                                            NULL);
+
+  g_signal_connect (self->providers,
+                    "extension-added",
+                    G_CALLBACK (ide_test_manager_provider_added),
+                    self);
+
+  g_signal_connect (self->providers,
+                    "extension-removed",
+                    G_CALLBACK (ide_test_manager_provider_removed),
+                    self);
+
+  peas_extension_set_foreach (self->providers,
+                              ide_test_manager_provider_added,
+                              self);
+
+  IDE_RETURN (TRUE);
+}
+
+static void
+initable_iface_init (GInitableIface *iface)
+{
+  iface->init = ide_test_manager_initiable_init;
+}
+
+/**
+ * ide_test_manager_run_all_async:
+ * @self: An #IdeTestManager
+ * @cancellable: (nullable): A #GCancellable or %NULL
+ * @callback: a callback to execute upon completion
+ * @user_data: user data for @callback
+ *
+ * Executes all tests in an undefined order.
+ *
+ * Upon completion, @callback will be executed which must call
+ * ide_test_manager_run_all_finish() to get the result.
+ *
+ * Note that the individual test result information will be attached
+ * to the specific #IdeTest instances.
+ *
+ * Since: 3.28
+ */
+void
+ide_test_manager_run_all_async (IdeTestManager      *self,
+                                GCancellable        *cancellable,
+                                GAsyncReadyCallback  callback,
+                                gpointer             user_data)
+{
+  g_autoptr(GTask) task = NULL;
+
+  IDE_ENTRY;
+
+  g_return_if_fail (IDE_IS_TEST_MANAGER (self));
+  g_return_if_fail (!cancellable || G_IS_CANCELLABLE (cancellable));
+
+  task = g_task_new (self, cancellable, callback, user_data);
+  g_task_set_priority (task, G_PRIORITY_LOW);
+  g_task_set_source_tag (task, ide_test_manager_run_all_async);
+
+  g_task_return_boolean (task, TRUE);
+
+  IDE_EXIT;
+}
+
+/**
+ * ide_test_manager_run_all_finish:
+ * @self: An #IdeTestManager
+ * @result: A #GAsyncResult
+ * @error: a location for a #GError, or %NULL
+ *
+ * Completes an asynchronous request to execute all unit tests.
+ *
+ * A return value of %TRUE does not indicate that all tests succeeded,
+ * only that all tests were executed. Individual test failures will be
+ * attached to the #IdeTest instances.
+ *
+ * Returns: %TRUE if successful; otherwise %FALSE and @error is set.
+ *
+ * Since: 3.28
+ */
+gboolean
+ide_test_manager_run_all_finish (IdeTestManager  *self,
+                                 GAsyncResult    *result,
+                                 GError         **error)
+{
+  gboolean ret;
+
+  IDE_ENTRY;
+
+  g_return_val_if_fail (IDE_IS_TEST_MANAGER (self), FALSE);
+  g_return_val_if_fail (G_IS_TASK (result), FALSE);
+
+  ret = g_task_propagate_boolean (G_TASK (result), error);
+
+  IDE_RETURN (ret);
+}
+
+/**
+ * ide_test_manager_run_async:
+ * @self: An #IdeTestManager
+ * @test: An #IdeTest
+ * @cancellable: (nullable): A #GCancellable, or %NULL
+ * @callback: a callback to execute upon completion
+ * @user_data: user data for @callback
+ *
+ * Executes a single unit test, asynchronously.
+ *
+ * The caller can access the result of the operation from @callback
+ * by calling ide_test_manager_run_finish() with the provided result.
+ *
+ * Since: 3.28
+ */
+void
+ide_test_manager_run_async (IdeTestManager      *self,
+                            IdeTest             *test,
+                            GCancellable        *cancellable,
+                            GAsyncReadyCallback  callback,
+                            gpointer             user_data)
+{
+  g_autoptr(GTask) task = NULL;
+
+  IDE_ENTRY;
+
+  g_return_if_fail (IDE_IS_TEST_MANAGER (self));
+  g_return_if_fail (IDE_IS_TEST (test));
+  g_return_if_fail (!cancellable || G_IS_CANCELLABLE (cancellable));
+
+  task = g_task_new (self, cancellable, callback, user_data);
+  g_task_set_priority (task, G_PRIORITY_LOW);
+  g_task_set_source_tag (task, ide_test_manager_run_async);
+
+  g_task_return_boolean (task, TRUE);
+
+  IDE_EXIT;
+}
+
+/**
+ * ide_test_manager_run_finish:
+ * @self: An #IdeTestManager
+ * @result: The #GAsyncResult provided to callback
+ * @error: A location for a #GError, or %NULL
+ *
+ * Completes a request to ide_test_manager_run_finish().
+ *
+ * When this function returns %TRUE, it does not indicate that the test
+ * succeeded; only that the test was executed. Thest #IdeTest instance
+ * itself will contain information about the success of the test.
+ *
+ * Returns: %TRUE if the test was executed; otherwise %FALSE
+ *   and @error is set.
+ *
+ * Since: 3.28
+ */
+gboolean
+ide_test_manager_run_finish (IdeTestManager  *self,
+                             GAsyncResult    *result,
+                             GError         **error)
+{
+  gboolean ret;
+
+  IDE_ENTRY;
+
+  g_return_val_if_fail (IDE_IS_TEST_MANAGER (self), FALSE);
+  g_return_val_if_fail (G_IS_TASK (result), FALSE);
+
+  ret = g_task_propagate_boolean (G_TASK (result), error);
+
+  IDE_RETURN (ret);
+}
+
+static void
+ide_test_manager_actions_run_all (IdeTestManager *self,
+                                  GVariant       *param)
+{
+  g_assert (IDE_IS_TEST_MANAGER (self));
+
+  ide_test_manager_run_all_async (self, NULL, NULL, NULL);
+}
+
+GtkTreeModel *
+_ide_test_manager_get_model (IdeTestManager *self)
+{
+  g_return_val_if_fail (IDE_IS_TEST_MANAGER (self), NULL);
+
+  return GTK_TREE_MODEL (self->tests_store);
+}
+
+static void
+ide_test_manager_get_loading_cb (PeasExtensionSet *set,
+                                 PeasPluginInfo   *plugin_info,
+                                 PeasExtension    *exten,
+                                 gpointer          user_data)
+{
+  IdeTestProvider *provider = (IdeTestProvider *)exten;
+  gboolean *loading = user_data;
+
+  g_assert (PEAS_IS_EXTENSION_SET (set));
+  g_assert (plugin_info != NULL);
+  g_assert (IDE_IS_TEST_PROVIDER (provider));
+  g_assert (loading != NULL);
+
+  *loading |= ide_test_provider_get_loading (provider);
+}
+
+gboolean
+ide_test_manager_get_loading (IdeTestManager *self)
+{
+  gboolean loading = FALSE;
+
+  g_return_val_if_fail (IDE_IS_TEST_MANAGER (self), FALSE);
+
+  peas_extension_set_foreach (self->providers,
+                              ide_test_manager_get_loading_cb,
+                              &loading);
+
+  return loading;
+}
diff --git a/src/libide/testing/ide-test-manager.h b/src/libide/testing/ide-test-manager.h
new file mode 100644
index 0000000..5d3f583
--- /dev/null
+++ b/src/libide/testing/ide-test-manager.h
@@ -0,0 +1,46 @@
+/* ide-test-manager.h
+ *
+ * Copyright © 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ide-object.h"
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_TEST_MANAGER (ide_test_manager_get_type())
+
+G_DECLARE_FINAL_TYPE (IdeTestManager, ide_test_manager, IDE, TEST_MANAGER, IdeObject)
+
+gboolean ide_test_manager_get_loading    (IdeTestManager       *self);
+void     ide_test_manager_run_async      (IdeTestManager       *self,
+                                          IdeTest              *test,
+                                          GCancellable         *cancellable,
+                                          GAsyncReadyCallback   callback,
+                                          gpointer              user_data);
+gboolean ide_test_manager_run_finish     (IdeTestManager       *self,
+                                          GAsyncResult         *result,
+                                          GError              **error);
+void     ide_test_manager_run_all_async  (IdeTestManager       *self,
+                                          GCancellable         *cancellable,
+                                          GAsyncReadyCallback   callback,
+                                          gpointer              user_data);
+gboolean ide_test_manager_run_all_finish (IdeTestManager       *self,
+                                          GAsyncResult         *result,
+                                          GError              **error);
+
+G_END_DECLS
diff --git a/src/libide/testing/ide-test-panel.c b/src/libide/testing/ide-test-panel.c
new file mode 100644
index 0000000..90957bf
--- /dev/null
+++ b/src/libide/testing/ide-test-panel.c
@@ -0,0 +1,360 @@
+/* ide-test-panel.c
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-test-panel"
+
+#include "ide-context.h"
+#include "ide-debug.h"
+
+#include "buildsystem/ide-build-manager.h"
+#include "buildsystem/ide-build-pipeline.h"
+#include "testing/ide-test.h"
+#include "testing/ide-test-manager.h"
+#include "testing/ide-test-panel.h"
+#include "testing/ide-test-private.h"
+#include "util/ide-gtk.h"
+
+struct _IdeTestPanel
+{
+  GtkBin             parent_instance;
+
+  /* Owned references */
+  IdeTestManager    *manager;
+
+  /* Template references */
+  GtkScrolledWindow *scroller;
+  GtkStack          *stack;
+  GtkTreeView       *tree_view;
+};
+
+enum {
+  PROP_0,
+  PROP_MANAGER,
+  N_PROPS
+};
+
+G_DEFINE_TYPE (IdeTestPanel, ide_test_panel, GTK_TYPE_BIN)
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+ide_test_panel_row_activated (IdeTestPanel      *self,
+                              GtkTreePath       *path,
+                              GtkTreeViewColumn *column,
+                              GtkTreeView       *tree_view)
+{
+  GtkTreeModel *model;
+  GtkTreeIter iter;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_TEST_PANEL (self));
+  g_assert (path != NULL);
+  g_assert (GTK_IS_TREE_VIEW_COLUMN (column));
+  g_assert (GTK_IS_TREE_VIEW (tree_view));
+
+  model = gtk_tree_view_get_model (tree_view);
+
+  if (gtk_tree_model_get_iter (model, &iter, path))
+    {
+      g_autoptr(IdeTest) test = NULL;
+
+      if (gtk_tree_model_iter_n_children (model, &iter))
+        {
+          if (gtk_tree_view_row_expanded (self->tree_view, path))
+            gtk_tree_view_collapse_row (self->tree_view, path);
+          else
+            gtk_tree_view_expand_row (self->tree_view, path, TRUE);
+          return;
+        }
+
+      gtk_tree_model_get (model, &iter,
+                          IDE_TEST_COLUMN_TEST, &test,
+                          -1);
+
+      if (test != NULL)
+        {
+          IdeTestProvider *provider = _ide_test_get_provider (test);
+          IdeContext *context = ide_widget_get_context (GTK_WIDGET (self));
+          IdeBuildManager *build_manager = ide_context_get_build_manager (context);
+          IdeBuildPipeline *pipeline = ide_build_manager_get_pipeline (build_manager);
+
+          /* TODO: Everything...
+           *
+           *   - We need to track output from the test
+           *   - We need to track failure/success from the test
+           *   - We need to allow the user to jump to the assertion failure if there was one
+           *   - We need to allow the user to run the test w/ the debugger
+           */
+
+          ide_test_provider_run_async (provider,
+                                       test,
+                                       pipeline,
+                                       NULL,
+                                       NULL,
+                                       NULL);
+        }
+    }
+
+  IDE_EXIT;
+}
+
+static void
+ide_test_panel_pixbuf_cell_data_func (GtkCellLayout   *layout,
+                                      GtkCellRenderer *cell,
+                                      GtkTreeModel    *model,
+                                      GtkTreeIter     *iter,
+                                      gpointer         user_data)
+{
+  IdeTestPanel *self = user_data;
+  g_autofree gchar *title = NULL;
+  g_autoptr(IdeTest) test = NULL;
+  const gchar *icon_name = NULL;
+
+  g_assert (GTK_IS_TREE_VIEW_COLUMN (layout));
+  g_assert (GTK_IS_CELL_RENDERER_PIXBUF (cell));
+  g_assert (GTK_IS_TREE_MODEL (model));
+  g_assert (iter != NULL);
+  g_assert (IDE_IS_TEST_PANEL (self));
+
+  gtk_tree_model_get (model, iter,
+                      IDE_TEST_COLUMN_GROUP, &title,
+                      IDE_TEST_COLUMN_TEST, &test,
+                      -1);
+
+  if (title)
+    {
+      GtkTreePath *path = gtk_tree_model_get_path (model, iter);
+
+      if (gtk_tree_view_row_expanded (self->tree_view, path))
+        g_object_set (cell, "icon-name", "folder-open-symbolic", NULL);
+      else
+        g_object_set (cell, "icon-name", "folder-symbolic", NULL);
+
+      gtk_tree_path_free (path);
+
+      return;
+    }
+
+  icon_name = ide_test_get_icon_name (test);
+  g_object_set (cell, "icon-name", icon_name, NULL);
+}
+
+static void
+ide_test_panel_text_cell_data_func (GtkCellLayout   *layout,
+                                    GtkCellRenderer *cell,
+                                    GtkTreeModel    *model,
+                                    GtkTreeIter     *iter,
+                                    gpointer         user_data)
+{
+  g_autofree gchar *title = NULL;
+  g_autoptr(IdeTest) test = NULL;
+
+  g_assert (GTK_IS_TREE_VIEW_COLUMN (layout));
+  g_assert (GTK_IS_CELL_RENDERER_TEXT (cell));
+  g_assert (GTK_IS_TREE_MODEL (model));
+  g_assert (iter != NULL);
+  g_assert (IDE_IS_TEST_PANEL (user_data));
+
+  gtk_tree_model_get (model, iter,
+                      IDE_TEST_COLUMN_GROUP, &title,
+                      IDE_TEST_COLUMN_TEST, &test,
+                      -1);
+
+  if (title)
+    g_object_set (cell, "text", title, NULL);
+  else if (test)
+    g_object_set (cell, "text", ide_test_get_display_name (test), NULL);
+  else
+    g_object_set (cell, "text", NULL, NULL);
+
+  /* TODO: extract test info/failures/etc */
+}
+
+static void
+ide_test_panel_row_inserted (IdeTestPanel *self,
+                             GtkTreePath  *path,
+                             GtkTreeIter  *iter,
+                             GtkTreeModel *model)
+{
+  g_assert (IDE_IS_TEST_PANEL (self));
+  g_assert (path != NULL);
+  g_assert (iter != NULL);
+  g_assert (GTK_IS_TREE_MODEL (model));
+
+  if (self->tree_view != NULL)
+    gtk_tree_view_expand_to_path (self->tree_view, path);
+}
+
+static void
+ide_test_panel_notify_loading (IdeTestPanel   *self,
+                               GParamSpec     *pspec,
+                               IdeTestManager *manager)
+{
+  g_assert (IDE_IS_TEST_PANEL (self));
+  g_assert (IDE_IS_TEST_MANAGER (manager));
+
+  if (ide_test_manager_get_loading (manager))
+    gtk_stack_set_visible_child_name (self->stack, "empty");
+  else
+    gtk_stack_set_visible_child_name (self->stack, "tests");
+}
+
+static void
+ide_test_panel_constructed (GObject *object)
+{
+  IdeTestPanel *self = (IdeTestPanel *)object;
+
+  g_assert (IDE_IS_TEST_PANEL (self));
+
+  G_OBJECT_CLASS (ide_test_panel_parent_class)->constructed (object);
+
+  if (self->manager != NULL)
+    {
+      GtkTreeModel *model;
+
+      model = _ide_test_manager_get_model (self->manager);
+
+      gtk_tree_view_set_model (self->tree_view, model);
+
+      g_signal_connect_object (model,
+                               "row-inserted",
+                               G_CALLBACK (ide_test_panel_row_inserted),
+                               self,
+                               G_CONNECT_SWAPPED);
+
+      g_signal_connect_object (self->manager,
+                               "notify::loading",
+                               G_CALLBACK (ide_test_panel_notify_loading),
+                               self,
+                               G_CONNECT_SWAPPED);
+
+      ide_test_panel_notify_loading (self, NULL, self->manager);
+    }
+}
+
+static void
+ide_test_panel_destroy (GtkWidget *widget)
+{
+  IdeTestPanel *self = (IdeTestPanel *)widget;
+
+  g_assert (IDE_IS_TEST_PANEL (self));
+
+  g_clear_object (&self->manager);
+
+  GTK_WIDGET_CLASS (ide_test_panel_parent_class)->destroy (widget);
+}
+
+static void
+ide_test_panel_get_property (GObject    *object,
+                             guint       prop_id,
+                             GValue     *value,
+                             GParamSpec *pspec)
+{
+  IdeTestPanel *self = IDE_TEST_PANEL (object);
+
+  switch (prop_id)
+    {
+    case PROP_MANAGER:
+      g_value_set_object (value, self->manager);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_test_panel_set_property (GObject      *object,
+                             guint         prop_id,
+                             const GValue *value,
+                             GParamSpec   *pspec)
+{
+  IdeTestPanel *self = IDE_TEST_PANEL (object);
+
+  switch (prop_id)
+    {
+    case PROP_MANAGER:
+      self->manager = g_value_dup_object (value);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_test_panel_class_init (IdeTestPanelClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+  object_class->constructed = ide_test_panel_constructed;
+  object_class->get_property = ide_test_panel_get_property;
+  object_class->set_property = ide_test_panel_set_property;
+
+  widget_class->destroy = ide_test_panel_destroy;
+
+  properties [PROP_MANAGER] =
+    g_param_spec_object ("manager",
+                         "Manager",
+                         "The test manager for the panel",
+                         IDE_TYPE_TEST_MANAGER,
+                         (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+
+  gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/builder/ui/ide-test-panel.ui");
+  gtk_widget_class_bind_template_child (widget_class, IdeTestPanel, scroller);
+  gtk_widget_class_bind_template_child (widget_class, IdeTestPanel, stack);
+  gtk_widget_class_bind_template_child (widget_class, IdeTestPanel, tree_view);
+  gtk_widget_class_bind_template_callback (widget_class, ide_test_panel_row_activated);
+}
+
+static void
+ide_test_panel_init (IdeTestPanel *self)
+{
+  GtkCellRenderer *cell;
+  GtkTreeViewColumn *column;
+
+  gtk_widget_init_template (GTK_WIDGET (self));
+
+  column = g_object_new (GTK_TYPE_TREE_VIEW_COLUMN,
+                         "visible", TRUE,
+                         NULL);
+  cell = g_object_new (GTK_TYPE_CELL_RENDERER_PIXBUF,
+                       "xpad", 3,
+                       NULL);
+  gtk_cell_layout_set_cell_data_func (GTK_CELL_LAYOUT (column),
+                                      cell,
+                                      ide_test_panel_pixbuf_cell_data_func,
+                                      self,
+                                      NULL);
+  gtk_cell_layout_pack_start (GTK_CELL_LAYOUT (column), cell, FALSE);
+  cell = g_object_new (GTK_TYPE_CELL_RENDERER_TEXT,
+                       "ellipsize", PANGO_ELLIPSIZE_END,
+                       "xalign", 0.0f,
+                       NULL);
+  gtk_cell_layout_pack_start (GTK_CELL_LAYOUT (column), cell, TRUE);
+  gtk_cell_layout_set_cell_data_func (GTK_CELL_LAYOUT (column),
+                                      cell,
+                                      ide_test_panel_text_cell_data_func,
+                                      self,
+                                      NULL);
+  gtk_tree_view_append_column (self->tree_view, column);
+}
diff --git a/src/libide/testing/ide-test-panel.h b/src/libide/testing/ide-test-panel.h
new file mode 100644
index 0000000..92d8455
--- /dev/null
+++ b/src/libide/testing/ide-test-panel.h
@@ -0,0 +1,29 @@
+/* ide-test-panel.h
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <gtk/gtk.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_TEST_PANEL (ide_test_panel_get_type())
+
+G_DECLARE_FINAL_TYPE (IdeTestPanel, ide_test_panel, IDE, TEST_PANEL, GtkBin)
+
+G_END_DECLS
diff --git a/src/libide/testing/ide-test-panel.ui b/src/libide/testing/ide-test-panel.ui
new file mode 100644
index 0000000..f71bb12
--- /dev/null
+++ b/src/libide/testing/ide-test-panel.ui
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="IdeTestPanel" parent="GtkBin">
+    <child>
+      <object class="GtkStack" id="stack">
+        <property name="transition-type">crossfade</property>
+        <property name="visible">true</property>
+        <child>
+          <object class="DzlEmptyState">
+            <property name="icon-name">builder-unit-tests-pass-symbolic</property>
+            <property name="pixel-size">64</property>
+            <property name="title" translatable="yes">No tests available</property>
+            <property name="subtitle" translatable="yes">Tests will be loaded after building.</property>
+            <property name="margin">12</property>
+            <property name="valign">start</property>
+            <property name="visible">true</property>
+          </object>
+          <packing>
+            <property name="name">empty</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkScrolledWindow" id="scroller">
+            <property name="visible">true</property>
+            <child>
+              <object class="GtkTreeView" id="tree_view">
+                <property name="activate-on-single-click">true</property>
+                <property name="headers-visible">false</property>
+                <property name="level-indentation">22</property>
+                <property name="visible">true</property>
+                <signal name="row-activated" handler="ide_test_panel_row_activated" swapped="true" 
object="IdeTestPanel"/>
+                <style>
+                  <class name="i-wanna-be-listbox"/>
+                  <class name="testing-tree"/>
+                </style>
+                <child internal-child="selection">
+                  <object class="GtkTreeSelection">
+                    <property name="mode">none</property>
+                  </object>
+                </child>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="name">tests</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/src/libide/testing/ide-test-private.h b/src/libide/testing/ide-test-private.h
new file mode 100644
index 0000000..e99daa6
--- /dev/null
+++ b/src/libide/testing/ide-test-private.h
@@ -0,0 +1,41 @@
+/* ide-test-private.h
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <gtk/gtk.h>
+
+#include "ide-test.h"
+#include "ide-test-manager.h"
+#include "ide-test-provider.h"
+
+G_BEGIN_DECLS
+
+typedef enum
+{
+  IDE_TEST_COLUMN_GROUP,
+  IDE_TEST_COLUMN_TEST,
+} IdeTestColumn;
+
+G_GNUC_INTERNAL GtkTreeModel    *_ide_test_manager_get_model (IdeTestManager  *self);
+G_GNUC_INTERNAL void             _ide_test_set_provider      (IdeTest         *self,
+                                                              IdeTestProvider *provider);
+G_GNUC_INTERNAL IdeTestProvider *_ide_test_get_provider      (IdeTest         *self);
+
+
+G_END_DECLS
diff --git a/src/libide/testing/ide-test-provider.c b/src/libide/testing/ide-test-provider.c
new file mode 100644
index 0000000..2a5b032
--- /dev/null
+++ b/src/libide/testing/ide-test-provider.c
@@ -0,0 +1,318 @@
+/* ide-test-provider.c
+ *
+ * Copyright © 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-test-provider"
+
+#include "testing/ide-test-provider.h"
+#include "testing/ide-test-private.h"
+
+typedef struct
+{
+  GPtrArray *items;
+  guint loading : 1;
+} IdeTestProviderPrivate;
+
+static void list_model_iface_init (GListModelInterface *iface);
+
+G_DEFINE_ABSTRACT_TYPE_WITH_CODE (IdeTestProvider, ide_test_provider, IDE_TYPE_OBJECT,
+                                  G_ADD_PRIVATE (IdeTestProvider)
+                                  G_IMPLEMENT_INTERFACE (G_TYPE_LIST_MODEL, list_model_iface_init))
+
+enum {
+  PROP_0,
+  PROP_LOADING,
+  N_PROPS
+};
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+ide_test_provider_real_run_async (IdeTestProvider     *self,
+                                  IdeTest             *test,
+                                  IdeBuildPipeline    *pipeline,
+                                  GCancellable        *cancellable,
+                                  GAsyncReadyCallback  callback,
+                                  gpointer             user_data)
+{
+  g_task_report_new_error (self, callback, user_data,
+                           ide_test_provider_real_run_async,
+                           G_IO_ERROR,
+                           G_IO_ERROR_NOT_SUPPORTED,
+                           "%s is missing test runner implementation",
+                           G_OBJECT_TYPE_NAME (self));
+}
+
+static gboolean
+ide_test_provider_real_run_finish (IdeTestProvider  *self,
+                                   GAsyncResult     *result,
+                                   GError          **error)
+{
+  return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+ide_test_provider_dispose (GObject *object)
+{
+  IdeTestProvider *self = (IdeTestProvider *)object;
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  if (priv->items != NULL && priv->items->len > 0)
+    {
+      guint len = priv->items->len;
+
+      g_ptr_array_remove_range (priv->items, 0, len);
+      g_list_model_items_changed (G_LIST_MODEL (self), 0, len, 0);
+    }
+
+  g_clear_pointer (&priv->items, g_ptr_array_unref);
+
+  G_OBJECT_CLASS (ide_test_provider_parent_class)->finalize (object);
+}
+
+static void
+ide_test_provider_get_property (GObject    *object,
+                                guint       prop_id,
+                                GValue     *value,
+                                GParamSpec *pspec)
+{
+  IdeTestProvider *self = IDE_TEST_PROVIDER (object);
+
+  switch (prop_id)
+    {
+    case PROP_LOADING:
+      g_value_set_boolean (value, ide_test_provider_get_loading (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_test_provider_set_property (GObject      *object,
+                                guint         prop_id,
+                                const GValue *value,
+                                GParamSpec   *pspec)
+{
+  IdeTestProvider *self = IDE_TEST_PROVIDER (object);
+
+  switch (prop_id)
+    {
+    case PROP_LOADING:
+      ide_test_provider_set_loading (self, g_value_get_boolean (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_test_provider_class_init (IdeTestProviderClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_test_provider_dispose;
+  object_class->get_property = ide_test_provider_get_property;
+  object_class->set_property = ide_test_provider_set_property;
+
+  klass->run_async = ide_test_provider_real_run_async;
+  klass->run_finish = ide_test_provider_real_run_finish;
+
+  properties [PROP_LOADING] =
+    g_param_spec_boolean ("loading",
+                          "Loading",
+                          "If the provider is loading tests",
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+}
+
+static void
+ide_test_provider_init (IdeTestProvider *self)
+{
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  priv->items = g_ptr_array_new_with_free_func (g_object_unref);
+}
+
+static GType
+ide_test_provider_get_item_type (GListModel *model)
+{
+  return IDE_TYPE_TEST;
+}
+
+static guint
+ide_test_provider_get_n_items (GListModel *model)
+{
+  IdeTestProvider *self = (IdeTestProvider *)model;
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  g_assert (IDE_IS_TEST_PROVIDER (self));
+
+  return priv->items ? priv->items->len : 0;
+}
+
+static gpointer
+ide_test_provider_get_item (GListModel *model,
+                            guint       position)
+{
+  IdeTestProvider *self = (IdeTestProvider *)model;
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  g_assert (IDE_IS_TEST_PROVIDER (self));
+
+  if (priv->items != NULL)
+    {
+      if (position < priv->items->len)
+        return g_object_ref (g_ptr_array_index (priv->items, position));
+    }
+
+  return NULL;
+}
+
+static void
+list_model_iface_init (GListModelInterface *iface)
+{
+  iface->get_item = ide_test_provider_get_item;
+  iface->get_n_items = ide_test_provider_get_n_items;
+  iface->get_item_type = ide_test_provider_get_item_type;
+}
+
+void
+ide_test_provider_add (IdeTestProvider *self,
+                       IdeTest         *test)
+{
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST_PROVIDER (self));
+  g_return_if_fail (IDE_IS_TEST (test));
+
+  if (priv->items != NULL)
+    {
+      g_ptr_array_add (priv->items, g_object_ref (test));
+      _ide_test_set_provider (test, self);
+      g_list_model_items_changed (G_LIST_MODEL (self), priv->items->len - 1, 0, 1);
+    }
+}
+
+void
+ide_test_provider_remove (IdeTestProvider *self,
+                          IdeTest         *test)
+{
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST_PROVIDER (self));
+  g_return_if_fail (IDE_IS_TEST (test));
+
+  if (priv->items != NULL)
+    {
+      for (guint i = 0; i < priv->items->len; i++)
+        {
+          IdeTest *element = g_ptr_array_index (priv->items, i);
+
+          if (element == test)
+            {
+              _ide_test_set_provider (test, NULL);
+              g_ptr_array_remove_index (priv->items, i);
+              g_list_model_items_changed (G_LIST_MODEL (self), i, 1, 0);
+              break;
+            }
+        }
+    }
+}
+
+void
+ide_test_provider_clear (IdeTestProvider *self)
+{
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+  g_autoptr(GPtrArray) ar = NULL;
+
+  g_return_if_fail (IDE_IS_TEST_PROVIDER (self));
+
+  ar = priv->items;
+  priv->items = g_ptr_array_new_with_free_func (g_object_unref);
+
+  for (guint i = 0; i < ar->len; i++)
+    {
+      IdeTest *test = g_ptr_array_index (ar, i);
+      _ide_test_set_provider (test, NULL);
+    }
+
+  g_list_model_items_changed (G_LIST_MODEL (self), 0, ar->len, 0);
+}
+
+void
+ide_test_provider_run_async (IdeTestProvider     *self,
+                             IdeTest             *test,
+                             IdeBuildPipeline    *pipeline,
+                             GCancellable        *cancellable,
+                             GAsyncReadyCallback  callback,
+                             gpointer             user_data)
+{
+  g_return_if_fail (IDE_IS_TEST_PROVIDER (self));
+  g_return_if_fail (IDE_IS_TEST (test));
+  g_return_if_fail (IDE_IS_BUILD_PIPELINE (pipeline));
+  g_return_if_fail (!cancellable || G_IS_CANCELLABLE (cancellable));
+
+  IDE_TEST_PROVIDER_GET_CLASS (self)->run_async (self,
+                                                 test,
+                                                 pipeline,
+                                                 cancellable,
+                                                 callback,
+                                                 user_data);
+}
+
+gboolean
+ide_test_provider_run_finish (IdeTestProvider  *self,
+                              GAsyncResult     *result,
+                              GError          **error)
+{
+  g_return_val_if_fail (IDE_IS_TEST_PROVIDER (self), FALSE);
+  g_return_val_if_fail (G_IS_ASYNC_RESULT (result), FALSE);
+
+  return IDE_TEST_PROVIDER_GET_CLASS (self)->run_finish (self, result, error);
+}
+
+gboolean
+ide_test_provider_get_loading (IdeTestProvider *self)
+{
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_TEST_PROVIDER (self), FALSE);
+
+  return priv->loading;
+}
+
+void
+ide_test_provider_set_loading (IdeTestProvider *self,
+                               gboolean         loading)
+{
+  IdeTestProviderPrivate *priv = ide_test_provider_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST_PROVIDER (self));
+
+  loading = !!loading;
+
+  if (priv->loading != loading)
+    {
+      priv->loading = loading;
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_LOADING]);
+    }
+}
diff --git a/src/libide/testing/ide-test-provider.h b/src/libide/testing/ide-test-provider.h
new file mode 100644
index 0000000..8650fc0
--- /dev/null
+++ b/src/libide/testing/ide-test-provider.h
@@ -0,0 +1,75 @@
+/* ide-test-provider.h
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ide-object.h"
+
+#include "buildsystem/ide-build-pipeline.h"
+#include "testing/ide-test.h"
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_TEST_PROVIDER (ide_test_provider_get_type ())
+
+G_DECLARE_DERIVABLE_TYPE (IdeTestProvider, ide_test_provider, IDE, TEST_PROVIDER, IdeObject)
+
+struct _IdeTestProviderClass
+{
+  IdeObjectClass parent_class;
+
+  void     (*run_async)  (IdeTestProvider      *self,
+                          IdeTest              *test,
+                          IdeBuildPipeline     *pipeline,
+                          GCancellable         *cancellable,
+                          GAsyncReadyCallback   callback,
+                          gpointer              user_data);
+  gboolean (*run_finish) (IdeTestProvider      *self,
+                          GAsyncResult         *result,
+                          GError              **error);
+
+  /*< private >*/
+  gpointer _reserved1;
+  gpointer _reserved2;
+  gpointer _reserved3;
+  gpointer _reserved4;
+  gpointer _reserved5;
+  gpointer _reserved6;
+  gpointer _reserved7;
+  gpointer _reserved8;
+};
+
+gboolean ide_test_provider_get_loading (IdeTestProvider      *self);
+void     ide_test_provider_set_loading (IdeTestProvider      *self,
+                                        gboolean              loading);
+void     ide_test_provider_clear       (IdeTestProvider      *self);
+void     ide_test_provider_add         (IdeTestProvider      *self,
+                                        IdeTest              *test);
+void     ide_test_provider_remove      (IdeTestProvider      *self,
+                                        IdeTest              *test);
+void     ide_test_provider_run_async   (IdeTestProvider      *self,
+                                        IdeTest              *test,
+                                        IdeBuildPipeline     *pipeline,
+                                        GCancellable         *cancellable,
+                                        GAsyncReadyCallback   callback,
+                                        gpointer              user_data);
+gboolean ide_test_provider_run_finish  (IdeTestProvider      *self,
+                                        GAsyncResult         *result,
+                                        GError              **error);
+
+G_END_DECLS
diff --git a/src/libide/testing/ide-test.c b/src/libide/testing/ide-test.c
new file mode 100644
index 0000000..d2deb89
--- /dev/null
+++ b/src/libide/testing/ide-test.c
@@ -0,0 +1,425 @@
+/* ide-test.c
+ *
+ * Copyright © 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-test"
+
+#include "ide-enums.h"
+
+#include "testing/ide-test.h"
+#include "testing/ide-test-private.h"
+#include "testing/ide-test-provider.h"
+
+typedef struct
+{
+  /* Unowned references */
+  IdeTestProvider *provider;
+
+  /* Owned references */
+  gchar *display_name;
+  gchar *group;
+  gchar *id;
+
+  IdeTestStatus status;
+} IdeTestPrivate;
+
+G_DEFINE_TYPE_WITH_PRIVATE (IdeTest, ide_test, G_TYPE_OBJECT)
+
+enum {
+  PROP_0,
+  PROP_DISPLAY_NAME,
+  PROP_GROUP,
+  PROP_ID,
+  PROP_STATUS,
+  N_PROPS
+};
+
+static GParamSpec *properties [N_PROPS];
+
+IdeTest *
+ide_test_new (void)
+{
+  return g_object_new (IDE_TYPE_TEST, NULL);
+}
+
+static void
+ide_test_finalize (GObject *object)
+{
+  IdeTest *self = (IdeTest *)object;
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  priv->provider = NULL;
+
+  g_clear_pointer (&priv->group, g_free);
+  g_clear_pointer (&priv->id, g_free);
+  g_clear_pointer (&priv->display_name, g_free);
+
+  G_OBJECT_CLASS (ide_test_parent_class)->finalize (object);
+}
+
+static void
+ide_test_get_property (GObject    *object,
+                       guint       prop_id,
+                       GValue     *value,
+                       GParamSpec *pspec)
+{
+  IdeTest *self = IDE_TEST (object);
+
+  switch (prop_id)
+    {
+    case PROP_ID:
+      g_value_set_string (value, ide_test_get_id (self));
+      break;
+
+    case PROP_GROUP:
+      g_value_set_string (value, ide_test_get_group (self));
+      break;
+
+    case PROP_DISPLAY_NAME:
+      g_value_set_string (value, ide_test_get_display_name (self));
+      break;
+
+    case PROP_STATUS:
+      g_value_set_enum (value, ide_test_get_status (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_test_set_property (GObject      *object,
+                       guint         prop_id,
+                       const GValue *value,
+                       GParamSpec   *pspec)
+{
+  IdeTest *self = IDE_TEST (object);
+
+  switch (prop_id)
+    {
+    case PROP_GROUP:
+      ide_test_set_group (self, g_value_get_string (value));
+      break;
+
+    case PROP_ID:
+      ide_test_set_id (self, g_value_get_string (value));
+      break;
+
+    case PROP_DISPLAY_NAME:
+      ide_test_set_display_name (self, g_value_get_string (value));
+      break;
+
+    case PROP_STATUS:
+      ide_test_set_status (self, g_value_get_enum (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_test_class_init (IdeTestClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->finalize = ide_test_finalize;
+  object_class->get_property = ide_test_get_property;
+  object_class->set_property = ide_test_set_property;
+
+  /**
+   * IdeTest:display_name:
+   *
+   * The "display-name" property contains the display name of the test as
+   * the user is expected to read in UI elements.
+   *
+   * Since: 3.28
+   */
+  properties [PROP_DISPLAY_NAME] =
+    g_param_spec_string ("display-name",
+                         "Name",
+                         "The display_name of the unit test",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  /**
+   * IdeTest:id:
+   *
+   * The "id" property contains the unique identifier of the test.
+   *
+   * Since: 3.28
+   */
+  properties [PROP_ID] =
+    g_param_spec_string ("id",
+                         "Id",
+                         "The unique identifier of the test",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  /**
+   * IdeTest:group:
+   *
+   * The "group" property contains the name of the gruop the test belongs
+   * to, if any.
+   *
+   * Since: 3.28
+   */
+  properties [PROP_GROUP] =
+    g_param_spec_string ("group",
+                         "Group",
+                         "The name of the group the test belongs to, if any",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  /**
+   * IdeTest::status:
+   *
+   * The "status" property contains the status of the test, updated by
+   * providers when they have run the test.
+   *
+   * Since: 3.28
+   */
+  properties [PROP_STATUS] =
+    g_param_spec_enum ("status",
+                       "Status",
+                       "The status of the test",
+                       IDE_TYPE_TEST_STATUS,
+                       IDE_TEST_STATUS_NONE,
+                       (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+}
+
+static void
+ide_test_init (IdeTest *self)
+{
+}
+
+IdeTestProvider *
+_ide_test_get_provider (IdeTest *self)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_TEST (self), NULL);
+
+  return priv->provider;
+}
+
+void
+_ide_test_set_provider (IdeTest         *self,
+                        IdeTestProvider *provider)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST (self));
+  g_return_if_fail (!provider || IDE_IS_TEST_PROVIDER (provider));
+
+  priv->provider = provider;
+}
+
+/**
+ * ide_test_get_display_name:
+ * @self: An #IdeTest
+ *
+ * Gets the "display-name" property of the test.
+ *
+ * Returns: (nullable): The display_name of the test or %NULL
+ *
+ * Since: 3.28
+ */
+const gchar *
+ide_test_get_display_name (IdeTest *self)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_TEST (self), NULL);
+
+  return priv->display_name;
+}
+
+/**
+ * ide_test_set_display_name:
+ * @self: An #IdeTest
+ * @display_name: (nullable): The display_name of the test, or %NULL to unset
+ *
+ * Sets the "display-name" property of the unit test.
+ *
+ * Since: 3.28
+ */
+void
+ide_test_set_display_name (IdeTest     *self,
+                           const gchar *display_name)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST (self));
+
+  if (g_strcmp0 (display_name, priv->display_name) != 0)
+    {
+      g_free (priv->display_name);
+      priv->display_name = g_strdup (display_name);
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_DISPLAY_NAME]);
+    }
+}
+
+/**
+ * ide_test_get_group:
+ * @self: a #IdeTest
+ *
+ * Gets the "group" property.
+ *
+ * The group name is used to group tests together.
+ *
+ * Returns: (nullable): The group name or %NULL.
+ *
+ * Since: 3.28
+ */
+const gchar *
+ide_test_get_group (IdeTest *self)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_TEST (self), NULL);
+
+  return priv->group;
+}
+
+/**
+ * ide_test_set_group:
+ * @self: a #IdeTest
+ * @group: (nullable): the name of the group or %NULL
+ *
+ * Sets the #IdeTest:group property.
+ *
+ * The group property is used to group related tests together.
+ *
+ * Since: 3.28
+ */
+void
+ide_test_set_group (IdeTest     *self,
+                    const gchar *group)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST (self));
+
+  if (g_strcmp0 (group, priv->group) != 0)
+    {
+      g_free (priv->group);
+      priv->group = g_strdup (group);
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_GROUP]);
+    }
+}
+
+/**
+ * ide_test_get_id:
+ * @self: a #IdeTest
+ *
+ * Gets the #IdeTest:id property.
+ *
+ * Returns: (nullable): The id of the test, or %NULL if it has not been set.
+ *
+ * Since: 3.28
+ */
+const gchar *
+ide_test_get_id (IdeTest *self)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_TEST (self), NULL);
+
+  return priv->id;
+}
+
+/**
+ * ide_test_set_id:
+ * @self: a #IdeTest
+ * @id: (nullable): the id of the test or %NULL
+ *
+ * Sets the #IdeTest:id property.
+ *
+ * The id property is used to uniquely identify the test.
+ *
+ * Since: 3.28
+ */
+void
+ide_test_set_id (IdeTest     *self,
+                 const gchar *id)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST (self));
+
+  if (g_strcmp0 (id, priv->id) != 0)
+    {
+      g_free (priv->id);
+      priv->id = g_strdup (id);
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_ID]);
+    }
+}
+
+IdeTestStatus
+ide_test_get_status (IdeTest *self)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_TEST (self), 0);
+
+  return priv->status;
+}
+
+void
+ide_test_set_status (IdeTest       *self,
+                     IdeTestStatus  status)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_TEST (self));
+
+  if (priv->status != status)
+    {
+      priv->status = status;
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_STATUS]);
+    }
+}
+
+const gchar *
+ide_test_get_icon_name (IdeTest *self)
+{
+  IdeTestPrivate *priv = ide_test_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_TEST (self), NULL);
+
+  switch (priv->status)
+    {
+    case IDE_TEST_STATUS_NONE:
+      return "builder-unit-tests-symbolic";
+
+    case IDE_TEST_STATUS_RUNNING:
+      return "builder-unit-tests-running-symbolic";
+
+    case IDE_TEST_STATUS_FAILED:
+      return "builder-unit-tests-fail-symbolic";
+
+    case IDE_TEST_STATUS_SUCCESS:
+      return "builder-unit-tests-pass-symbolic";
+
+    default:
+      g_return_val_if_reached (NULL);
+    }
+}
diff --git a/src/libide/testing/ide-test.h b/src/libide/testing/ide-test.h
new file mode 100644
index 0000000..44b5d38
--- /dev/null
+++ b/src/libide/testing/ide-test.h
@@ -0,0 +1,67 @@
+/* ide-test.h
+ *
+ * Copyright © 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_TEST (ide_test_get_type())
+
+typedef enum
+{
+  IDE_TEST_STATUS_NONE,
+  IDE_TEST_STATUS_RUNNING,
+  IDE_TEST_STATUS_SUCCESS,
+  IDE_TEST_STATUS_FAILED,
+} IdeTestStatus;
+
+G_DECLARE_DERIVABLE_TYPE (IdeTest, ide_test, IDE, TEST, GObject)
+
+struct _IdeTestClass
+{
+  GObjectClass parent;
+
+  /*< private >*/
+  gpointer _reserved1;
+  gpointer _reserved2;
+  gpointer _reserved3;
+  gpointer _reserved4;
+  gpointer _reserved5;
+  gpointer _reserved6;
+  gpointer _reserved7;
+  gpointer _reserved8;
+};
+
+IdeTest       *ide_test_new               (void);
+const gchar   *ide_test_get_display_name  (IdeTest       *self);
+void           ide_test_set_display_name  (IdeTest       *self,
+                                           const gchar   *display_name);
+const gchar   *ide_test_get_group         (IdeTest       *self);
+void           ide_test_set_group         (IdeTest       *self,
+                                           const gchar   *group);
+const gchar   *ide_test_get_icon_name     (IdeTest       *self);
+const gchar   *ide_test_get_id            (IdeTest       *self);
+void           ide_test_set_id            (IdeTest       *self,
+                                           const gchar   *id);
+IdeTestStatus  ide_test_get_status        (IdeTest       *self);
+void           ide_test_set_status        (IdeTest       *self,
+                                           IdeTestStatus  status);
+
+G_END_DECLS
diff --git a/src/libide/testing/meson.build b/src/libide/testing/meson.build
new file mode 100644
index 0000000..d5e2657
--- /dev/null
+++ b/src/libide/testing/meson.build
@@ -0,0 +1,28 @@
+testing_headers = [
+  'ide-test.h',
+  'ide-test-manager.h',
+  'ide-test-provider.h',
+]
+
+testing_sources = [
+  'ide-test.c',
+  'ide-test-manager.c',
+  'ide-test-provider.c',
+]
+
+testing_private_sources = [
+  'ide-test-panel.c',
+  'ide-test-editor-addin.c',
+  'testing-plugin.c',
+]
+
+testing_enum_headers = [
+  'ide-test.h',
+]
+
+libide_public_headers += files(testing_headers)
+libide_public_sources += files(testing_sources)
+libide_private_sources += files(testing_private_sources)
+libide_enum_headers += files(testing_enum_headers)
+
+install_headers(testing_headers, subdir: join_paths(libide_header_subdir, 'testing'))
diff --git a/src/libide/testing/testing-plugin.c b/src/libide/testing/testing-plugin.c
new file mode 100644
index 0000000..6db5872
--- /dev/null
+++ b/src/libide/testing/testing-plugin.c
@@ -0,0 +1,28 @@
+/* testing-plugin.c
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <libpeas/peas.h>
+
+#include "editor/ide-editor-addin.h"
+#include "testing/ide-test-editor-addin.h"
+
+void
+ide_test_register_types (PeasObjectModule *module)
+{
+  peas_object_module_register_extension_type (module, IDE_TYPE_EDITOR_ADDIN, IDE_TYPE_TEST_EDITOR_ADDIN);
+}
diff --git a/src/libide/testing/testing.plugin b/src/libide/testing/testing.plugin
new file mode 100644
index 0000000..89802f2
--- /dev/null
+++ b/src/libide/testing/testing.plugin
@@ -0,0 +1,9 @@
+[Plugin]
+Module=testing
+Name=Testing
+Description=Unit testing for Builder
+Authors=Christian Hergert <christian hergert me>
+Copyright=Copyright © 2017 Christian Hergert
+Builtin=true
+Hidden=true
+Embedded=ide_test_register_types
diff --git a/src/plugins/meson/gbp-meson-test-provider.c b/src/plugins/meson/gbp-meson-test-provider.c
new file mode 100644
index 0000000..0d7642e
--- /dev/null
+++ b/src/plugins/meson/gbp-meson-test-provider.c
@@ -0,0 +1,548 @@
+/* gbp-meson-test-provider.c
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "gbp-meson-test-provider"
+
+#include <json-glib/json-glib.h>
+
+#include "gbp-meson-test.h"
+#include "gbp-meson-test-provider.h"
+
+struct _GbpMesonTestProvider
+{
+  IdeTestProvider  parent_instance;
+  GCancellable    *build_cancellable;
+  guint            reload_source;
+};
+
+G_DEFINE_TYPE (GbpMesonTestProvider, gbp_meson_test_provider, IDE_TYPE_TEST_PROVIDER)
+
+static void
+gbp_meson_test_provider_load_json (GbpMesonTestProvider *self,
+                                   JsonNode             *root)
+{
+  JsonArray *array;
+  guint length;
+
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+  g_assert (root != NULL);
+
+  if (!JSON_NODE_HOLDS_ARRAY (root) || !(array = json_node_get_array (root)))
+    return;
+
+  ide_test_provider_clear (IDE_TEST_PROVIDER (self));
+
+  length = json_array_get_length (array);
+
+  for (guint i = 0; i < length; i++)
+    {
+      g_autoptr(GPtrArray) cmd = g_ptr_array_new_with_free_func (g_free);
+      g_autoptr(IdeEnvironment) env = ide_environment_new ();
+      g_autoptr(IdeTest) test = NULL;
+      g_autoptr(GFile) workdir = NULL;
+      const gchar *name;
+      const gchar *workdir_path;
+      const gchar *group = NULL;
+      JsonObject *obj;
+      JsonArray *sub_array;
+      JsonNode *sub_element;
+      JsonNode *element;
+      JsonNode *member;
+      guint timeout = 0;
+
+      if (NULL == (element = json_array_get_element (array, i)) ||
+          !JSON_NODE_HOLDS_OBJECT (element) ||
+          NULL == (obj = json_node_get_object (element)) ||
+          NULL == (member = json_object_get_member (obj, "name")) ||
+          !JSON_NODE_HOLDS_VALUE (member) ||
+          NULL == (name = json_node_get_string (member)))
+        continue;
+
+      if (NULL != (member = json_object_get_member (obj, "timeout")) &&
+          JSON_NODE_HOLDS_VALUE (member))
+        timeout = json_node_get_int (member);
+
+      if (NULL != (member = json_object_get_member (obj, "suite")) &&
+          JSON_NODE_HOLDS_ARRAY (member) &&
+          NULL != (sub_array = json_node_get_array (member)) &&
+          json_array_get_length (sub_array) > 0 &&
+          NULL != (sub_element = json_array_get_element (sub_array, 0)) &&
+          JSON_NODE_HOLDS_VALUE (sub_element))
+        group = json_node_get_string (sub_element);
+
+      if (NULL != (member = json_object_get_member (obj, "workdir")) &&
+          JSON_NODE_HOLDS_VALUE (member) &&
+          NULL != (workdir_path = json_node_get_string (member)))
+        workdir = g_file_new_for_path (workdir_path);
+
+      if (NULL != (member = json_object_get_member (obj, "cmd")) &&
+          JSON_NODE_HOLDS_ARRAY (member) &&
+          NULL != (sub_array = json_node_get_array (member)))
+        {
+          guint cmdlen = json_array_get_length (sub_array);
+
+          for (guint j = 0; j < cmdlen; j++)
+            {
+              sub_element = json_array_get_element (sub_array, j);
+              if (JSON_NODE_HOLDS_VALUE (sub_element))
+                {
+                  const gchar *str = json_node_get_string (sub_element);
+
+                  if (str)
+                    g_ptr_array_add (cmd, g_strdup (str));
+                }
+            }
+        }
+
+      if (NULL != (member = json_object_get_member (obj, "env")) &&
+          JSON_NODE_HOLDS_OBJECT (member) &&
+          NULL != (obj = json_node_get_object (member)))
+        {
+          JsonObjectIter iter;
+          const gchar *key;
+          JsonNode *value;
+
+          json_object_iter_init (&iter, obj);
+
+          while (json_object_iter_next (&iter, &key, &value))
+            {
+              if (JSON_NODE_HOLDS_VALUE (value))
+                ide_environment_setenv (env, key, json_node_get_string (value));
+            }
+        }
+
+      g_ptr_array_add (cmd, NULL);
+
+      test = g_object_new (GBP_TYPE_MESON_TEST,
+                           "command", (gchar **)cmd->pdata,
+                           "display-name", name,
+                           "env", env,
+                           "group", group,
+                           "id", name,
+                           "timeout", timeout,
+                           "workdir", workdir,
+                           NULL);
+
+      ide_test_provider_add (IDE_TEST_PROVIDER (self), test);
+    }
+}
+
+static void
+gbp_meson_test_provider_communicate_cb (GObject      *object,
+                                        GAsyncResult *result,
+                                        gpointer      user_data)
+{
+  IdeSubprocess *subprocess = (IdeSubprocess *)object;
+  g_autoptr(GbpMesonTestProvider) self = user_data;
+  g_autoptr(JsonParser) parser = NULL;
+  g_autoptr(GError) error = NULL;
+  g_autofree gchar *stdout_buf = NULL;
+  JsonNode *root;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_SUBPROCESS (subprocess));
+  g_assert (G_IS_ASYNC_RESULT (result));
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+
+  if (!ide_subprocess_communicate_utf8_finish (subprocess, result, &stdout_buf, NULL, &error))
+    IDE_GOTO (failure);
+
+  parser = json_parser_new ();
+
+  if (!json_parser_load_from_data (parser, stdout_buf, -1, &error) ||
+      NULL == (root = json_parser_get_root (parser)))
+    IDE_GOTO (failure);
+
+  gbp_meson_test_provider_load_json (self, root);
+
+failure:
+  ide_test_provider_set_loading (IDE_TEST_PROVIDER (self), FALSE);
+
+  if (error != NULL)
+    g_message ("%s", error->message);
+
+  IDE_EXIT;
+}
+
+static void
+gbp_meson_test_provider_do_reload (GbpMesonTestProvider *self,
+                                   IdeBuildPipeline     *pipeline)
+{
+  g_autoptr(IdeSubprocessLauncher) launcher = NULL;
+  g_autoptr(IdeSubprocess) subprocess = NULL;
+  g_autoptr(GError) error = NULL;
+  const gchar *builddir;
+
+  IDE_ENTRY;
+
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+  g_assert (IDE_IS_BUILD_PIPELINE (pipeline));
+
+  if (NULL == (launcher = ide_build_pipeline_create_launcher (pipeline, &error)))
+    IDE_GOTO (failure);
+
+  ide_subprocess_launcher_set_flags (launcher, G_SUBPROCESS_FLAGS_STDOUT_PIPE);
+
+  builddir = ide_build_pipeline_get_builddir (pipeline);
+  ide_subprocess_launcher_set_cwd (launcher, builddir);
+
+  ide_subprocess_launcher_push_argv (launcher, "meson");
+  ide_subprocess_launcher_push_argv (launcher, "introspect");
+  ide_subprocess_launcher_push_argv (launcher, "--tests");
+
+  if (NULL == (subprocess = ide_subprocess_launcher_spawn (launcher, NULL, &error)))
+    IDE_GOTO (failure);
+
+  ide_subprocess_communicate_utf8_async (subprocess,
+                                         NULL,
+                                         NULL,
+                                         gbp_meson_test_provider_communicate_cb,
+                                         g_object_ref (self));
+
+  IDE_EXIT;
+
+failure:
+  ide_test_provider_set_loading (IDE_TEST_PROVIDER (self), FALSE);
+
+  if (error != NULL)
+    g_message ("%s", error->message);
+
+  IDE_EXIT;
+}
+
+static void
+gbp_meson_test_provider_build_cb (GObject      *object,
+                                  GAsyncResult *result,
+                                  gpointer      user_data)
+{
+  IdeBuildPipeline *pipeline = (IdeBuildPipeline *)object;
+  g_autoptr(GbpMesonTestProvider) self = user_data;
+  g_autoptr(GError) error = NULL;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_BUILD_PIPELINE (pipeline));
+  g_assert (G_IS_ASYNC_RESULT (result));
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+
+  if (!ide_build_pipeline_build_finish (pipeline, result, &error))
+    {
+      g_message ("%s", error->message);
+      ide_test_provider_set_loading (IDE_TEST_PROVIDER (self), FALSE);
+      IDE_EXIT;
+    }
+
+  gbp_meson_test_provider_do_reload (self, pipeline);
+
+  IDE_EXIT;
+}
+
+static gboolean
+gbp_meson_test_provider_reload (gpointer user_data)
+{
+  GbpMesonTestProvider *self = user_data;
+  IdeBuildPipeline *pipeline;
+  IdeBuildManager *build_manager;
+  IdeContext *context;
+
+  IDE_ENTRY;
+
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+
+  self->reload_source = 0;
+
+  /* Cancel any other builds in-flight */
+  g_cancellable_cancel (self->build_cancellable);
+  g_clear_object (&self->build_cancellable);
+
+  /*
+   * Get access to the pipeline so we can create a launcher to
+   * introspect meson from within the build environment.
+   */
+  context = ide_object_get_context (IDE_OBJECT (self));
+  build_manager = ide_context_get_build_manager (context);
+  pipeline = ide_build_manager_get_pipeline (build_manager);
+  if (pipeline == NULL)
+    IDE_RETURN (G_SOURCE_REMOVE);
+
+  ide_test_provider_set_loading (IDE_TEST_PROVIDER (self), TRUE);
+
+  /*
+   * Make sure that the build pipeline has advanced enough for
+   * us to continue processing the tests.
+   */
+  self->build_cancellable = g_cancellable_new ();
+  /*
+   * TODO: We want to try to avoid the pipeline build like this in
+   * the future because it advances the pipeline when the project
+   * is opened. Instead, we might want a CONFIGURE stage that auto
+   * generates the info, and then watch that GFile.
+   *
+   * But to do that well, we need to coordinate with the panel to
+   * be lazy about fetching unit tests until the panel is displayed.
+   */
+  ide_build_pipeline_build_async (pipeline,
+                                  IDE_BUILD_PHASE_CONFIGURE,
+                                  self->build_cancellable,
+                                  gbp_meson_test_provider_build_cb,
+                                  g_object_ref (self));
+
+  IDE_RETURN (G_SOURCE_REMOVE);
+}
+
+static void
+gbp_meson_test_provider_notify_pipeline (GbpMesonTestProvider *self,
+                                         GParamSpec           *pspec,
+                                         IdeBuildManager      *build_manager)
+{
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+  g_assert (IDE_IS_BUILD_MANAGER (build_manager));
+
+  ide_clear_source (&self->reload_source);
+  self->reload_source = gdk_threads_add_timeout_full (G_PRIORITY_LOW,
+                                                      2000,
+                                                      gbp_meson_test_provider_reload,
+                                                      self,
+                                                      NULL);
+}
+
+static void
+gbp_meson_test_provider_run_cb (GObject      *object,
+                                GAsyncResult *result,
+                                gpointer      user_data)
+{
+  IdeSubprocess *subprocess = (IdeSubprocess *)object;
+  g_autoptr(GTask) task = user_data;
+  g_autoptr(GError) error = NULL;
+  g_autofree gchar *stdout_buf = NULL;
+  g_autofree gchar *stderr_buf = NULL;
+  IdeTest *test;
+
+  g_assert (IDE_IS_SUBPROCESS (subprocess));
+  g_assert (G_IS_ASYNC_RESULT (result));
+  g_assert (G_IS_TASK (task));
+
+  test = g_task_get_task_data (task);
+  g_assert (IDE_IS_TEST (test));
+
+  if (!ide_subprocess_communicate_utf8_finish (subprocess, result, &stdout_buf, &stderr_buf, &error))
+    {
+      ide_test_set_status (test, IDE_TEST_STATUS_FAILED);
+      g_task_return_error (task, g_steal_pointer (&error));
+      return;
+    }
+
+  /* Propagate success/failure to test */
+  if (ide_subprocess_get_successful (subprocess))
+    ide_test_set_status (test, IDE_TEST_STATUS_SUCCESS);
+  else
+    ide_test_set_status (test, IDE_TEST_STATUS_FAILED);
+
+  if (ide_subprocess_get_if_exited (subprocess))
+    g_print ("Exit: %d\n", ide_subprocess_get_exit_status (subprocess));
+  g_print ("STDOUT: %s\n", stdout_buf);
+  g_print ("STDERR: %s\n", stderr_buf);
+
+  g_task_return_boolean (task, TRUE);
+}
+
+static void
+gbp_meson_test_provider_run_build_cb (GObject      *object,
+                                      GAsyncResult *result,
+                                      gpointer      user_data)
+{
+  IdeBuildPipeline *pipeline = (IdeBuildPipeline *)object;
+  g_autoptr(IdeSubprocessLauncher) launcher = NULL;
+  g_autoptr(IdeSubprocess) subprocess = NULL;
+  g_autoptr(GTask) task = user_data;
+  g_autoptr(GError) error = NULL;
+  GCancellable *cancellable;
+  const gchar * const *command;
+  IdeEnvironment *env;
+  const gchar *builddir;
+  IdeTest *test;
+  GFile *workdir;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_BUILD_PIPELINE (pipeline));
+  g_assert (G_IS_ASYNC_RESULT (result));
+  g_assert (G_IS_TASK (task));
+
+  if (!ide_build_pipeline_build_finish (pipeline, result, &error))
+    {
+      g_task_return_error (task, g_steal_pointer (&error));
+      IDE_EXIT;
+    }
+
+  if (NULL == (launcher = ide_build_pipeline_create_launcher (pipeline, &error)))
+    {
+      g_task_return_error (task, g_steal_pointer (&error));
+      IDE_EXIT;
+    }
+
+  test = g_task_get_task_data (task);
+  cancellable = g_task_get_cancellable (task);
+
+  g_assert (IDE_IS_TEST (test));
+  g_assert (!cancellable || G_IS_CANCELLABLE (cancellable));
+
+  ide_subprocess_launcher_set_flags (launcher,
+                                     (G_SUBPROCESS_FLAGS_STDOUT_PIPE |
+                                      G_SUBPROCESS_FLAGS_STDERR_PIPE));
+
+  /* Default to running from builddir */
+  builddir = ide_build_pipeline_get_builddir (pipeline);
+  ide_subprocess_launcher_set_cwd (launcher, builddir);
+
+  /* And override of the test requires it */
+  workdir = gbp_meson_test_get_workdir (GBP_MESON_TEST (test));
+  if (workdir != NULL)
+    {
+      g_autofree gchar *path = g_file_get_path (workdir);
+      ide_subprocess_launcher_set_cwd (launcher, path);
+    }
+
+  /* Set our command as specified by meson */
+  command = gbp_meson_test_get_command (GBP_MESON_TEST (test));
+  ide_subprocess_launcher_push_args (launcher, command);
+
+  /* Make sure the environment is respected */
+  env = gbp_meson_test_get_env (GBP_MESON_TEST (test));
+  if (env != NULL)
+    ide_subprocess_launcher_overlay_environment (launcher, env);
+
+  /* All systems go */
+  if (NULL == (subprocess = ide_subprocess_launcher_spawn (launcher, cancellable, &error)))
+    {
+      g_task_return_error (task, g_steal_pointer (&error));
+      IDE_EXIT;
+    }
+
+  ide_test_set_status (test, IDE_TEST_STATUS_RUNNING);
+
+  ide_subprocess_communicate_utf8_async (subprocess,
+                                         NULL,
+                                         cancellable,
+                                         gbp_meson_test_provider_run_cb,
+                                         g_steal_pointer (&task));
+
+  IDE_EXIT;
+}
+
+static void
+gbp_meson_test_provider_run_async (IdeTestProvider     *provider,
+                                   IdeTest             *test,
+                                   IdeBuildPipeline    *pipeline,
+                                   GCancellable        *cancellable,
+                                   GAsyncReadyCallback  callback,
+                                   gpointer             user_data)
+{
+  GbpMesonTestProvider *self = (GbpMesonTestProvider *)provider;
+  g_autoptr(GTask) task = NULL;
+
+  IDE_ENTRY;
+
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+  g_assert (GBP_IS_MESON_TEST (test));
+  g_assert (IDE_IS_BUILD_PIPELINE (pipeline));
+  g_assert (!cancellable || G_IS_CANCELLABLE (cancellable));
+
+  task = g_task_new (self, cancellable, callback, user_data);
+  g_task_set_source_tag (task, gbp_meson_test_provider_run_async);
+  g_task_set_task_data (task, g_object_ref (test), g_object_unref);
+  g_task_set_priority (task, G_PRIORITY_LOW);
+
+  /* Currently, we don't have a way to determine what targets
+   * need to be built before the test can run, so we must build
+   * the entire project up to the build phase.
+   */
+
+  ide_build_pipeline_build_async (pipeline,
+                                  IDE_BUILD_PHASE_BUILD,
+                                  cancellable,
+                                  gbp_meson_test_provider_run_build_cb,
+                                  g_steal_pointer (&task));
+
+  IDE_EXIT;
+}
+
+static gboolean
+gbp_meson_test_provider_run_finish (IdeTestProvider  *provider,
+                                    GAsyncResult     *result,
+                                    GError          **error)
+{
+  g_assert (IDE_IS_TEST_PROVIDER (provider));
+  g_assert (G_IS_TASK (result));
+
+  return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+gbp_meson_test_provider_constructed (GObject *object)
+{
+  GbpMesonTestProvider *self = (GbpMesonTestProvider *)object;
+  IdeBuildManager *build_manager;
+  IdeContext *context;
+
+  g_assert (GBP_IS_MESON_TEST_PROVIDER (self));
+
+  G_OBJECT_CLASS (gbp_meson_test_provider_parent_class)->constructed (object);
+
+  context = ide_object_get_context (IDE_OBJECT (self));
+  build_manager = ide_context_get_build_manager (context);
+
+  g_signal_connect_object (build_manager,
+                           "notify::pipeline",
+                           G_CALLBACK (gbp_meson_test_provider_notify_pipeline),
+                           self,
+                           G_CONNECT_SWAPPED);
+
+  gbp_meson_test_provider_notify_pipeline (self, NULL, build_manager);
+}
+
+static void
+gbp_meson_test_provider_dispose (GObject *object)
+{
+  GbpMesonTestProvider *self = (GbpMesonTestProvider *)object;
+
+  ide_clear_source (&self->reload_source);
+  g_cancellable_cancel (self->build_cancellable);
+  g_clear_object (&self->build_cancellable);
+
+  G_OBJECT_CLASS (gbp_meson_test_provider_parent_class)->dispose (object);
+}
+
+static void
+gbp_meson_test_provider_class_init (GbpMesonTestProviderClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  IdeTestProviderClass *provider_class = IDE_TEST_PROVIDER_CLASS (klass);
+
+  object_class->constructed = gbp_meson_test_provider_constructed;
+  object_class->dispose = gbp_meson_test_provider_dispose;
+
+  provider_class->run_async = gbp_meson_test_provider_run_async;
+  provider_class->run_finish = gbp_meson_test_provider_run_finish;
+}
+
+static void
+gbp_meson_test_provider_init (GbpMesonTestProvider *self)
+{
+}
diff --git a/src/plugins/meson/gbp-meson-test-provider.h b/src/plugins/meson/gbp-meson-test-provider.h
new file mode 100644
index 0000000..40e111a
--- /dev/null
+++ b/src/plugins/meson/gbp-meson-test-provider.h
@@ -0,0 +1,29 @@
+/* gbp-meson-test-provider.h
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <ide.h>
+
+G_BEGIN_DECLS
+
+#define GBP_TYPE_MESON_TEST_PROVIDER (gbp_meson_test_provider_get_type())
+
+G_DECLARE_FINAL_TYPE (GbpMesonTestProvider, gbp_meson_test_provider, GBP, MESON_TEST_PROVIDER, 
IdeTestProvider)
+
+G_END_DECLS
diff --git a/src/plugins/meson/gbp-meson-test.c b/src/plugins/meson/gbp-meson-test.c
new file mode 100644
index 0000000..fe00e4f
--- /dev/null
+++ b/src/plugins/meson/gbp-meson-test.c
@@ -0,0 +1,196 @@
+/* gbp-meson-test.c
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "gbp-meson-test"
+
+#include "gbp-meson-test.h"
+
+struct _GbpMesonTest
+{
+  IdeTest          parent_instance;
+  IdeEnvironment  *env;
+  gchar          **command;
+  GFile           *workdir;
+  guint            timeout;
+};
+
+enum {
+  PROP_0,
+  PROP_COMMAND,
+  PROP_ENV,
+  PROP_TIMEOUT,
+  PROP_WORKDIR,
+  N_PROPS
+};
+
+G_DEFINE_TYPE (GbpMesonTest, gbp_meson_test, IDE_TYPE_TEST)
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+gbp_meson_test_finalize (GObject *object)
+{
+  GbpMesonTest *self = (GbpMesonTest *)object;
+
+  g_clear_pointer (&self->command, g_strfreev);
+  g_clear_object (&self->env);
+  g_clear_object (&self->workdir);
+
+  G_OBJECT_CLASS (gbp_meson_test_parent_class)->finalize (object);
+}
+
+static void
+gbp_meson_test_get_property (GObject    *object,
+                             guint       prop_id,
+                             GValue     *value,
+                             GParamSpec *pspec)
+{
+  GbpMesonTest *self = GBP_MESON_TEST (object);
+
+  switch (prop_id)
+    {
+    case PROP_COMMAND:
+      g_value_set_boxed (value, self->command);
+      break;
+
+    case PROP_ENV:
+      g_value_set_object (value, self->env);
+      break;
+
+    case PROP_TIMEOUT:
+      g_value_set_uint (value, self->timeout);
+      break;
+
+    case PROP_WORKDIR:
+      g_value_set_object (value, self->workdir);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+gbp_meson_test_set_property (GObject      *object,
+                             guint         prop_id,
+                             const GValue *value,
+                             GParamSpec   *pspec)
+{
+  GbpMesonTest *self = GBP_MESON_TEST (object);
+
+  switch (prop_id)
+    {
+    case PROP_COMMAND:
+      self->command = g_value_dup_boxed (value);
+      break;
+
+    case PROP_ENV:
+      self->env = g_value_dup_object (value);
+      break;
+
+    case PROP_TIMEOUT:
+      self->timeout = g_value_get_uint (value);
+      break;
+
+    case PROP_WORKDIR:
+      self->workdir = g_value_dup_object (value);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+gbp_meson_test_class_init (GbpMesonTestClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->finalize = gbp_meson_test_finalize;
+  object_class->get_property = gbp_meson_test_get_property;
+  object_class->set_property = gbp_meson_test_set_property;
+
+  properties [PROP_COMMAND] =
+    g_param_spec_boxed ("command",
+                        "Command",
+                        "The command to execute for the test",
+                        G_TYPE_STRV,
+                        (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_ENV] =
+    g_param_spec_object ("env",
+                         "Environment",
+                         "The environment for the test",
+                         IDE_TYPE_ENVIRONMENT,
+                         (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_TIMEOUT] =
+    g_param_spec_uint ("timeout",
+                       "Timeout",
+                       "The timeout in seconds, or 0 for none",
+                       0,
+                       G_MAXUINT,
+                       0,
+                       (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_WORKDIR] =
+    g_param_spec_object ("workdir",
+                         "Workdir",
+                         "The working directory to run the test",
+                         G_TYPE_FILE,
+                         (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+}
+
+static void
+gbp_meson_test_init (GbpMesonTest *self)
+{
+}
+
+const gchar * const *
+gbp_meson_test_get_command (GbpMesonTest *self)
+{
+  g_return_val_if_fail (GBP_IS_MESON_TEST (self), NULL);
+
+  return (const gchar * const *)self->command;
+}
+
+GFile *
+gbp_meson_test_get_workdir (GbpMesonTest *self)
+{
+  g_return_val_if_fail (GBP_IS_MESON_TEST (self), NULL);
+
+  return self->workdir;
+}
+
+guint
+gbp_meson_test_get_timeout (GbpMesonTest *self)
+{
+  g_return_val_if_fail (GBP_IS_MESON_TEST (self), 0);
+
+  return self->timeout;
+}
+
+IdeEnvironment *
+gbp_meson_test_get_env (GbpMesonTest *self)
+{
+  g_return_val_if_fail (GBP_IS_MESON_TEST (self), NULL);
+
+  return self->env;
+}
diff --git a/src/plugins/meson/gbp-meson-test.h b/src/plugins/meson/gbp-meson-test.h
new file mode 100644
index 0000000..f0308b6
--- /dev/null
+++ b/src/plugins/meson/gbp-meson-test.h
@@ -0,0 +1,34 @@
+/* gbp-meson-test.h
+ *
+ * Copyright (C) 2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <ide.h>
+
+G_BEGIN_DECLS
+
+#define GBP_TYPE_MESON_TEST (gbp_meson_test_get_type())
+
+G_DECLARE_FINAL_TYPE (GbpMesonTest, gbp_meson_test, GBP, MESON_TEST, IdeTest)
+
+const gchar * const *gbp_meson_test_get_command (GbpMesonTest *self);
+GFile               *gbp_meson_test_get_workdir (GbpMesonTest *self);
+guint                gbp_meson_test_get_timeout (GbpMesonTest *self);
+IdeEnvironment      *gbp_meson_test_get_env     (GbpMesonTest *self);
+
+G_END_DECLS
diff --git a/src/plugins/meson/meson-plugin.c b/src/plugins/meson/meson-plugin.c
index 36bd525..7090063 100644
--- a/src/plugins/meson/meson-plugin.c
+++ b/src/plugins/meson/meson-plugin.c
@@ -21,10 +21,12 @@
 
 #include "gbp-meson-build-system.h"
 #include "gbp-meson-pipeline-addin.h"
+#include "gbp-meson-test-provider.h"
 
 void
 gbp_meson_register_types (PeasObjectModule *module)
 {
   peas_object_module_register_extension_type (module, IDE_TYPE_BUILD_PIPELINE_ADDIN, 
GBP_TYPE_MESON_PIPELINE_ADDIN);
   peas_object_module_register_extension_type (module, IDE_TYPE_BUILD_SYSTEM, GBP_TYPE_MESON_BUILD_SYSTEM);
+  peas_object_module_register_extension_type (module, IDE_TYPE_TEST_PROVIDER, GBP_TYPE_MESON_TEST_PROVIDER);
 }
diff --git a/src/plugins/meson/meson.build b/src/plugins/meson/meson.build
index 763ab8e..44c3b92 100644
--- a/src/plugins/meson/meson.build
+++ b/src/plugins/meson/meson.build
@@ -14,6 +14,10 @@ meson_sources = [
   'gbp-meson-build-target.h',
   'gbp-meson-pipeline-addin.c',
   'gbp-meson-pipeline-addin.h',
+  'gbp-meson-test-provider.c',
+  'gbp-meson-test-provider.h',
+  'gbp-meson-test.c',
+  'gbp-meson-test.h',
 ]
 
 gnome_builder_plugins_sources += files(meson_sources)       


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]