[gnome-builder/wip/chergert/unittests: 6/6] testing: start on unit testing



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

    testing: start on unit testing

 data/themes/shared/shared-treeview.css      |    8 +
 src/libide/ide-context.c                    |   50 +++-
 src/libide/ide-context.h                    |    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       |  569 +++++++++++++++++++++++++++
 src/libide/testing/ide-test-manager.h       |   45 +++
 src/libide/testing/ide-test-panel.c         |  327 +++++++++++++++
 src/libide/testing/ide-test-panel.h         |   29 ++
 src/libide/testing/ide-test-panel.ui        |   27 ++
 src/libide/testing/ide-test-private.h       |   41 ++
 src/libide/testing/ide-test-provider.c      |  233 +++++++++++
 src/libide/testing/ide-test-provider.h      |   72 ++++
 src/libide/testing/ide-test.c               |  338 ++++++++++++++++
 src/libide/testing/ide-test.h               |   55 +++
 src/libide/testing/meson.build              |   23 ++
 src/libide/testing/testing-plugin.c         |   28 ++
 src/libide/testing/testing.plugin           |    9 +
 src/plugins/meson/gbp-meson-test-provider.c |  348 ++++++++++++++++
 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 +
 28 files changed, 2614 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-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..84dd098
--- /dev/null
+++ b/src/libide/testing/ide-test-manager.c
@@ -0,0 +1,569 @@
+/* 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;
+  guint             busy : 1;
+};
+
+typedef struct
+{
+  IdeTestProvider *provider;
+  GPtrArray       *tests;
+} TestsByProvider;
+
+enum {
+  PROP_0,
+  PROP_BUSY,
+  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_BUSY:
+      g_value_set_boolean (value, self->busy);
+      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:busy:
+   *
+   * The "busy" property indicates if the #IdeTestManager is currently
+   * processing various background unit tests.
+   *
+   * Since: 3.28
+   */
+  properties [PROP_BUSY] =
+    g_param_spec_boolean ("busy",
+                          "Busy",
+                          "If the test manager is busy processing",
+                          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_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);
+
+  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)
+            {
+              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_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);
+
+  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);
+
+  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);
+}
diff --git a/src/libide/testing/ide-test-manager.h b/src/libide/testing/ide-test-manager.h
new file mode 100644
index 0000000..18f6ccd
--- /dev/null
+++ b/src/libide/testing/ide-test-manager.h
@@ -0,0 +1,45 @@
+/* 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)
+
+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..f9f30bd
--- /dev/null
+++ b/src/libide/testing/ide-test-panel.c
@@ -0,0 +1,327 @@
+/* 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-debug.h"
+
+#include "testing/ide-test.h"
+#include "testing/ide-test-manager.h"
+#include "testing/ide-test-panel.h"
+#include "testing/ide-test-private.h"
+
+struct _IdeTestPanel
+{
+  GtkBin             parent_instance;
+
+  /* Owned references */
+  IdeTestManager    *manager;
+
+  /* Template references */
+  GtkScrolledWindow *scroller;
+  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_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,
+                      -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;
+    }
+
+  g_object_set (cell, "icon-name", "builder-unit-tests-running-symbolic", 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_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);
+    }
+}
+
+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, 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..1e9b501
--- /dev/null
+++ b/src/libide/testing/ide-test-panel.ui
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="IdeTestPanel" parent="GtkBin">
+    <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="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>
+    </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..8556715
--- /dev/null
+++ b/src/libide/testing/ide-test-provider.c
@@ -0,0 +1,233 @@
+/* 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;
+} 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))
+
+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_class_init (IdeTestProviderClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_test_provider_dispose;
+
+  klass->run_async = ide_test_provider_real_run_async;
+  klass->run_finish = ide_test_provider_real_run_finish;
+}
+
+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);
+}
diff --git a/src/libide/testing/ide-test-provider.h b/src/libide/testing/ide-test-provider.h
new file mode 100644
index 0000000..7ae4615
--- /dev/null
+++ b/src/libide/testing/ide-test-provider.h
@@ -0,0 +1,72 @@
+/* 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;
+};
+
+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..467199a
--- /dev/null
+++ b/src/libide/testing/ide-test.c
@@ -0,0 +1,338 @@
+/* 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/>.
+ */
+
+#include "testing/ide-test.h"
+#include "testing/ide-test-private.h"
+#include "testing/ide-test-provider.h"
+
+typedef struct
+{
+  IdeTestProvider *provider;
+  gchar *display_name;
+  gchar *group;
+  gchar *id;
+} IdeTestPrivate;
+
+G_DEFINE_TYPE_WITH_PRIVATE (IdeTest, ide_test, G_TYPE_OBJECT)
+
+enum {
+  PROP_0,
+  PROP_DISPLAY_NAME,
+  PROP_GROUP,
+  PROP_ID,
+  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);
+
+  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;
+
+    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;
+
+    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));
+
+  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]);
+    }
+}
diff --git a/src/libide/testing/ide-test.h b/src/libide/testing/ide-test.h
new file mode 100644
index 0000000..6e1702b
--- /dev/null
+++ b/src/libide/testing/ide-test.h
@@ -0,0 +1,55 @@
+/* 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())
+
+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_id            (IdeTest     *self);
+void         ide_test_set_id            (IdeTest     *self,
+                                         const gchar *id);
+
+G_END_DECLS
diff --git a/src/libide/testing/meson.build b/src/libide/testing/meson.build
new file mode 100644
index 0000000..640ebe5
--- /dev/null
+++ b/src/libide/testing/meson.build
@@ -0,0 +1,23 @@
+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',
+]
+
+libide_public_headers += files(testing_headers)
+libide_public_sources += files(testing_sources)
+libide_private_sources += files(testing_private_sources)
+
+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..b2d6411
--- /dev/null
+++ b/src/plugins/meson/gbp-meson-test-provider.c
@@ -0,0 +1,348 @@
+/* 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;
+      const gchar *name;
+      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, "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,
+                           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;
+
+  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:
+  if (error != NULL)
+    g_message ("%s", error->message);
+}
+
+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));
+
+failure:
+  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);
+  else
+    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);
+
+  /*
+   * 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_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);
+
+  object_class->constructed = gbp_meson_test_provider_constructed;
+  object_class->dispose = gbp_meson_test_provider_dispose;
+}
+
+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]