Bug 1667791 - Update Xman to use new recipe schema r=andreio
authorKate Hudson <k88hudson@gmail.com>
Fri, 02 Oct 2020 15:24:35 +0000
changeset 551307 a8bc978b49f20bd7a6ed3530ab669238a337f0cb
parent 551306 b2c7cf46430823457190dded0323f73eaf870992
child 551308 e8d42c0afb654f9e70bf260a79fb2bc9299fd4d5
push id37830
push usernbeleuzu@mozilla.com
push dateSat, 03 Oct 2020 10:23:35 +0000
treeherdermozilla-central@7d7faf0b6d7a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersandreio
bugs1667791
milestone83.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1667791 - Update Xman to use new recipe schema r=andreio Differential Revision: https://phabricator.services.mozilla.com/D91599
browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js
toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
toolkit/components/messaging-system/moz.build
toolkit/components/messaging-system/schemas/NimbusExperiment.schema.json
toolkit/components/messaging-system/test/MSTestUtils.jsm
toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js
toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js
toolkit/components/messaging-system/test/unit/test_MSTestUtils.js
toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js
toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js
toolkit/components/messaging-system/test/unit/xpcshell.ini
--- a/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js
+++ b/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js
@@ -5,139 +5,136 @@ const { ASRouter } = ChromeUtils.import(
   "resource://activity-stream/lib/ASRouter.jsm"
 );
 const { RemoteSettingsExperimentLoader } = ChromeUtils.import(
   "resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm"
 );
 const { ExperimentAPI } = ChromeUtils.import(
   "resource://messaging-system/experiments/ExperimentAPI.jsm"
 );
+const { ExperimentFakes } = ChromeUtils.import(
+  "resource://testing-common/MSTestUtils.jsm"
+);
 
-const EXPERIMENT_PAYLOAD = {
-  enabled: true,
-  arguments: {
-    slug: "test_xman_cfr",
-    bucketConfig: {
-      count: 100,
-      start: 0,
-      total: 100,
-      namespace: "mochitest",
-      randomizationUnit: "normandy_id",
-    },
-    branches: [
-      {
-        slug: "control",
-        ratio: 1,
-        feature: {
-          featureId: "cfr",
-          enabled: true,
-          value: {
-            id: "xman_test_message",
-            content: {
-              text: "This is a test CFR",
-              addon: {
-                id: "954390",
-                icon:
-                  "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png",
-                title: "Facebook Container",
-                users: 1455872,
-                author: "Mozilla",
-                rating: 4.5,
-                amo_url:
-                  "https://addons.mozilla.org/firefox/addon/facebook-container/",
+const EXPERIMENT_PAYLOAD = ExperimentFakes.recipe("test_xman_cfr", {
+  id: "xman_test_message",
+  bucketConfig: {
+    count: 100,
+    start: 0,
+    total: 100,
+    namespace: "mochitest",
+    randomizationUnit: "normandy_id",
+  },
+  branches: [
+    {
+      slug: "control",
+      ratio: 1,
+      feature: {
+        featureId: "cfr",
+        enabled: true,
+        value: {
+          id: "xman_test_message",
+          content: {
+            text: "This is a test CFR",
+            addon: {
+              id: "954390",
+              icon:
+                "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png",
+              title: "Facebook Container",
+              users: 1455872,
+              author: "Mozilla",
+              rating: 4.5,
+              amo_url:
+                "https://addons.mozilla.org/firefox/addon/facebook-container/",
+            },
+            buttons: {
+              primary: {
+                label: {
+                  string_id: "cfr-doorhanger-extension-ok-button",
+                },
+                action: {
+                  data: {
+                    url: null,
+                  },
+                  type: "INSTALL_ADDON_FROM_URL",
+                },
               },
-              buttons: {
-                primary: {
+              secondary: [
+                {
                   label: {
-                    string_id: "cfr-doorhanger-extension-ok-button",
+                    string_id: "cfr-doorhanger-extension-cancel-button",
+                  },
+                  action: {
+                    type: "CANCEL",
+                  },
+                },
+                {
+                  label: {
+                    string_id:
+                      "cfr-doorhanger-extension-never-show-recommendation",
+                  },
+                },
+                {
+                  label: {
+                    string_id:
+                      "cfr-doorhanger-extension-manage-settings-button",
                   },
                   action: {
                     data: {
-                      url: null,
+                      origin: "CFR",
+                      category: "general-cfraddons",
                     },
-                    type: "INSTALL_ADDON_FROM_URL",
+                    type: "OPEN_PREFERENCES_PAGE",
                   },
                 },
-                secondary: [
-                  {
-                    label: {
-                      string_id: "cfr-doorhanger-extension-cancel-button",
-                    },
-                    action: {
-                      type: "CANCEL",
-                    },
-                  },
-                  {
-                    label: {
-                      string_id:
-                        "cfr-doorhanger-extension-never-show-recommendation",
-                    },
-                  },
-                  {
-                    label: {
-                      string_id:
-                        "cfr-doorhanger-extension-manage-settings-button",
-                    },
-                    action: {
-                      data: {
-                        origin: "CFR",
-                        category: "general-cfraddons",
-                      },
-                      type: "OPEN_PREFERENCES_PAGE",
-                    },
-                  },
-                ],
-              },
-              category: "cfrAddons",
-              bucket_id: "CFR_M1",
-              info_icon: {
-                label: {
-                  string_id: "cfr-doorhanger-extension-sumo-link",
-                },
-                sumo_path: "extensionrecommendations",
-              },
-              heading_text: "Welcome to the experiment",
-              notification_text: {
-                string_id: "cfr-doorhanger-extension-notification2",
-              },
-            },
-            trigger: {
-              id: "openURL",
-              params: [
-                "www.facebook.com",
-                "facebook.com",
-                "www.instagram.com",
-                "instagram.com",
-                "www.whatsapp.com",
-                "whatsapp.com",
-                "web.whatsapp.com",
-                "www.messenger.com",
-                "messenger.com",
               ],
             },
-            template: "cfr_doorhanger",
-            frequency: {
-              lifetime: 3,
+            category: "cfrAddons",
+            bucket_id: "CFR_M1",
+            info_icon: {
+              label: {
+                string_id: "cfr-doorhanger-extension-sumo-link",
+              },
+              sumo_path: "extensionrecommendations",
+            },
+            heading_text: "Welcome to the experiment",
+            notification_text: {
+              string_id: "cfr-doorhanger-extension-notification2",
             },
-            targeting: "true",
           },
+          trigger: {
+            id: "openURL",
+            params: [
+              "www.facebook.com",
+              "facebook.com",
+              "www.instagram.com",
+              "instagram.com",
+              "www.whatsapp.com",
+              "whatsapp.com",
+              "web.whatsapp.com",
+              "www.messenger.com",
+              "messenger.com",
+            ],
+          },
+          template: "cfr_doorhanger",
+          frequency: {
+            lifetime: 3,
+          },
+          targeting: "true",
         },
       },
-    ],
-    isHighVolume: "false,",
-    userFacingName: "About:Welcome Pull Factor Reinforcement",
-    isEnrollmentPaused: false,
-    experimentDocumentUrl:
-      "https://experimenter.services.mozilla.com/experiments/aboutwelcome-pull-factor-reinforcement/",
-    userFacingDescription:
-      "This study uses 4 different variants of about:welcome with a goal of testing new experiment framework and get insights on whether reinforcing pull-factors improves retention.",
-  },
-  filter_expression: "true",
-  id: "test_xman_cfr",
-};
+    },
+  ],
+  userFacingName: "About:Welcome Pull Factor Reinforcement",
+  isEnrollmentPaused: false,
+  experimentDocumentUrl:
+    "https://experimenter.services.mozilla.com/experiments/aboutwelcome-pull-factor-reinforcement/",
+  userFacingDescription:
+    "This study uses 4 different variants of about:welcome with a goal of testing new experiment framework and get insights on whether reinforcing pull-factors improves retention.",
+});
 
 add_task(async function test_loading_experimentsAPI() {
   // Force the WNPanel provider cache to 0 by modifying updateCycleInMs
   await SpecialPowers.pushPrefEnv({
     set: [
       [
         "browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments",
         `{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","messageGroups":["cfr","whats-new-panel","moments-page","snippets","cfr-fxa"],"updateCycleInMs":0}`,
--- a/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
+++ b/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
@@ -118,19 +118,17 @@ const ExperimentAPI = {
     }
 
     let recipe;
 
     try {
       [recipe] = await this._remoteSettingsClient.get({
         // Do not sync the RS store, let RemoteSettingsExperimentLoader do that
         syncIfEmpty: false,
-        filters: {
-          "arguments.slug": slug,
-        },
+        filters: { slug },
       });
     } catch (e) {
       Cu.reportError(e);
       recipe = undefined;
     }
 
     return recipe;
   },
@@ -147,17 +145,17 @@ const ExperimentAPI = {
   async getAllBranches(slug) {
     if (!IS_MAIN_PROCESS) {
       throw new Error(
         "getAllBranches() should only be called from the main process"
       );
     }
 
     const recipe = await this.getRecipe(slug);
-    return recipe?.arguments.branches;
+    return recipe?.branches;
   },
 };
 
 XPCOMUtils.defineLazyGetter(ExperimentAPI, "_store", function() {
   return IS_MAIN_PROCESS ? ExperimentManager.store : new ExperimentStore();
 });
 
 XPCOMUtils.defineLazyGetter(ExperimentAPI, "_remoteSettingsClient", function() {
--- a/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
+++ b/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
@@ -156,17 +156,17 @@ class _RemoteSettingsExperimentLoader {
     let matches = 0;
     if (recipes && !loadingError) {
       const context = this.manager.createTargetingContext();
 
       for (const r of recipes) {
         if (await this.checkTargeting(r, context)) {
           matches++;
           log.debug(`${r.id} matched`);
-          await this.manager.onRecipe(r.arguments, "rs-loader");
+          await this.manager.onRecipe(r, "rs-loader");
         } else {
           log.debug(`${r.id} did not match due to targeting`);
         }
       }
 
       log.debug(`${matches} recipes matched. Finalizing ExperimentManager.`);
       this.manager.onFinalize("rs-loader");
     }
--- a/toolkit/components/messaging-system/moz.build
+++ b/toolkit/components/messaging-system/moz.build
@@ -14,14 +14,15 @@ BROWSER_CHROME_MANIFESTS += [
 ]
 
 SPHINX_TREES['docs'] = 'schemas'
 
 XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
 XPCSHELL_TESTS_MANIFESTS += ['targeting/test/unit/xpcshell.ini']
 
 TESTING_JS_MODULES += [
+    'schemas/NimbusExperiment.schema.json',
     'schemas/SpecialMessageActionSchemas/SpecialMessageActionSchemas.json',
     'schemas/TriggerActionSchemas/TriggerActionSchemas.json',
     'test/MSTestUtils.jsm',
 ]
 
 JAR_MANIFESTS += ['jar.mn']
new file mode 100644
--- /dev/null
+++ b/toolkit/components/messaging-system/schemas/NimbusExperiment.schema.json
@@ -0,0 +1,187 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$ref": "#/definitions/NimbusExperiment",
+  "definitions": {
+    "NimbusExperiment": {
+      "type": "object",
+      "properties": {
+        "slug": {
+          "type": "string",
+          "description": "Unique identifier for the experiment"
+        },
+        "id": {
+          "type": "string",
+          "description": "Unique identifier for the experiment. This is a duplicate of slug, but is a required field\nfor all Remote Settings records."
+        },
+        "application": {
+          "type": "string",
+          "description": "A specific product such as Firefox Desktop or Fenix that supports Nimbus experiments"
+        },
+        "userFacingName": {
+          "type": "string",
+          "description": "Public name of the experiment displayed on \"about:studies\""
+        },
+        "userFacingDescription": {
+          "type": "string",
+          "description": "Short public description of the experiment displayed on on \"about:studies\""
+        },
+        "isEnrollmentPaused": {
+          "type": "boolean",
+          "description": "Should we enroll new users into the experiment?"
+        },
+        "bucketConfig": {
+          "type": "object",
+          "properties": {
+            "randomizationUnit": {
+              "type": "string",
+              "description": "A unique, stable identifier for the user used as an input to bucket hashing"
+            },
+            "namespace": {
+              "type": "string",
+              "description": "Additional inputs to the hashing function"
+            },
+            "start": {
+              "type": "number",
+              "description": "Index of start of the range of buckets"
+            },
+            "count": {
+              "type": "number",
+              "description": "Number of buckets to check"
+            },
+            "total": {
+              "type": "number",
+              "description": "Total number of buckets",
+              "default": 10000
+            }
+          },
+          "required": [
+            "randomizationUnit",
+            "namespace",
+            "start",
+            "count",
+            "total"
+          ],
+          "additionalProperties": false,
+          "description": "Bucketing configuration"
+        },
+        "probeSets": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "A list of probe set slugs relevant to the experiment analysis"
+        },
+        "branches": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "properties": {
+              "slug": {
+                "type": "string",
+                "description": "Identifier for the branch"
+              },
+              "ratio": {
+                "type": "number",
+                "description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3,\nbranch A would get 25% of the population)",
+                "default": 1
+              },
+              "feature": {
+                "type": "object",
+                "properties": {
+                  "featureId": {
+                    "type": "string",
+                    "description": "The identifier for the feature flag"
+                  },
+                  "enabled": {
+                    "type": "boolean",
+                    "description": "This can be used to turn the whole feature on/off"
+                  },
+                  "value": {
+                    "anyOf": [
+                      {
+                        "type": "object",
+                        "additionalProperties": {}
+                      },
+                      {
+                        "type": "null"
+                      }
+                    ],
+                    "description": "Optional extra params for the feature (this should be validated against a schema)"
+                  }
+                },
+                "required": [
+                  "featureId",
+                  "enabled",
+                  "value"
+                ],
+                "additionalProperties": false
+              }
+            },
+            "required": [
+              "slug",
+              "ratio"
+            ],
+            "additionalProperties": false
+          },
+          "description": "Branch configuration for the experiment"
+        },
+        "targeting": {
+          "type": "string",
+          "description": "JEXL expression used to filter experiments based on locale, geo, etc."
+        },
+        "startDate": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "description": "Actual publish date of the experiment\nNote that this value is expected to be null in Remote Settings.",
+          "format": "date-time"
+        },
+        "endDate": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "description": "Actual end date of the experiment.\nNote that this value is expected to be null in Remote Settings.",
+          "format": "date-time"
+        },
+        "proposedDuration": {
+          "type": "number",
+          "description": "Duration of the experiment from the start date in days.\nNote that this value is expected to be null in Remote Settings.\nin Remote Settings."
+        },
+        "proposedEnrollment": {
+          "type": "number",
+          "description": "Duration of enrollment from the start date in days"
+        },
+        "referenceBranch": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "description": "The slug of the reference branch"
+        },
+        "filter_expression": {
+          "type": "string",
+          "description": "This is NOT used by Nimbus, but has special functionality in Remote Settings.\nSee https://remote-settings.readthedocs.io/en/latest/target-filters.html#how"
+        }
+      },
+      "required": [
+        "slug",
+        "id",
+        "application",
+        "userFacingName",
+        "userFacingDescription",
+        "isEnrollmentPaused",
+        "bucketConfig",
+        "probeSets",
+        "branches",
+        "startDate",
+        "endDate",
+        "proposedEnrollment",
+        "referenceBranch"
+      ],
+      "additionalProperties": true,
+      "description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API"
+    }
+  }
+}
--- a/toolkit/components/messaging-system/test/MSTestUtils.jsm
+++ b/toolkit/components/messaging-system/test/MSTestUtils.jsm
@@ -1,33 +1,64 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-const { _ExperimentManager } = ChromeUtils.import(
-  "resource://messaging-system/experiments/ExperimentManager.jsm"
-);
-const { ExperimentStore } = ChromeUtils.import(
-  "resource://messaging-system/experiments/ExperimentStore.jsm"
+Cu.importGlobalProperties(["fetch"]);
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
 );
-const { NormandyUtils } = ChromeUtils.import(
-  "resource://normandy/lib/NormandyUtils.jsm"
-);
-const { FileTestUtils } = ChromeUtils.import(
-  "resource://testing-common/FileTestUtils.jsm"
-);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  _ExperimentManager:
+    "resource://messaging-system/experiments/ExperimentManager.jsm",
+  ExperimentStore:
+    "resource://messaging-system/experiments/ExperimentStore.jsm",
+  NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
+  FileTestUtils: "resource://testing-common/FileTestUtils.jsm",
+  _RemoteSettingsExperimentLoader:
+    "resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm",
+  Ajv: "resource://testing-common/ajv-4.1.1.js",
+});
+
 const PATH = FileTestUtils.getTempFile("shared-data-map").path;
 
-const { _RemoteSettingsExperimentLoader } = ChromeUtils.import(
-  "resource://messaging-system/lib/RemoteSettingsExperimentLoader.jsm"
-);
+XPCOMUtils.defineLazyGetter(this, "fetchExperimentSchema", async () => {
+  const response = await fetch(
+    "resource://testing-common/NimbusExperiment.schema.json"
+  );
+  const schema = await response.json();
+  if (!schema) {
+    throw new Error("Failed to load NimbusSchema");
+  }
+  return schema.definitions.NimbusExperiment;
+});
+
+const EXPORTED_SYMBOLS = ["ExperimentTestUtils", "ExperimentFakes"];
 
-const EXPORTED_SYMBOLS = ["ExperimentFakes"];
+const ExperimentTestUtils = {
+  /**
+   * Checks if an experiment is valid acording to existing schema
+   * @param {NimbusExperiment} experiment
+   */
+  async validateExperiment(experiment) {
+    const schema = await fetchExperimentSchema;
+    const ajv = new Ajv({ async: "co*", allErrors: true });
+    const validator = ajv.compile(schema);
+    validator(experiment);
+    if (validator.errors?.length) {
+      throw new Error(
+        "Experiment not valid:" + JSON.stringify(validator.errors, undefined, 2)
+      );
+    }
+    return experiment;
+  },
+};
 
 const ExperimentFakes = {
   manager(store) {
     return new _ExperimentManager({ store: store || this.store() });
   },
   store() {
     return new ExperimentStore("FakeStore", { path: PATH, isParent: true });
   },
@@ -66,26 +97,37 @@ const ExperimentFakes = {
         },
         ...props,
       },
       source: "test",
       isEnrollmentPaused: true,
       ...props,
     };
   },
-  recipe(slug, props = {}) {
+  recipe(slug = NormandyUtils.generateUuid(), props = {}) {
     return {
+      // This field is required for populating remote settings
+      id: NormandyUtils.generateUuid(),
       slug,
+      isEnrollmentPaused: false,
+      probeSets: [],
+      startDate: null,
+      endDate: null,
+      proposedEnrollment: 7,
+      referenceBranch: "control",
+      application: "firefox-desktop",
       branches: [
         {
           slug: "control",
+          ratio: 1,
           feature: { featureId: "aboutwelcome", enabled: true, value: null },
         },
         {
           slug: "treatment",
+          ratio: 1,
           feature: {
             featureId: "aboutwelcome",
             enabled: true,
             value: { title: "hello" },
           },
         },
       ],
       bucketConfig: {
--- a/toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js
+++ b/toolkit/components/messaging-system/test/browser/browser_remotesettings_experiment_enroll.js
@@ -11,101 +11,67 @@ const { ExperimentAPI } = ChromeUtils.im
 );
 const { ExperimentManager } = ChromeUtils.import(
   "resource://messaging-system/experiments/ExperimentManager.jsm"
 );
 const { ExperimentFakes } = ChromeUtils.import(
   "resource://testing-common/MSTestUtils.jsm"
 );
 
-const TEST_EXPERIMENT = {
-  enabled: true,
-  arguments: {
-    slug: "bug-1632140-end-to-end-mochitest-for-enrollment",
-    active: true,
-    experimentType: "mochitest",
-    branches: [
-      {
-        slug: "treatment",
-        ratio: 1,
-        feature: { featureId: "treatment", enabled: true, value: null },
-      },
-      {
-        slug: "control",
-        ratio: 1,
-        feature: { featureId: "control", enabled: true, value: null },
-      },
-    ],
-    bucketConfig: {
-      count: 100,
-      start: 0,
-      total: 100,
-      namespace: "mochitest",
-      randomizationUnit: "normandy_id",
-    },
-    userFacingName: "Test beep beep",
-    referenceBranch: "control",
-    isEnrollmentPaused: false,
-    proposedEnrollment: 7,
-    userFacingDescription: "This is a Mochitest",
-  },
-  targeting: "true",
-  id: "bug-1632140-end-to-end-mochitest-for-enrollment",
-};
 let rsClient;
 
-function createTestExperiment() {
-  return {
-    ...TEST_EXPERIMENT,
-    arguments: {
-      ...TEST_EXPERIMENT.arguments,
-      slug: TEST_EXPERIMENT.arguments.slug + Date.now(),
-    },
-    id: TEST_EXPERIMENT.id + Date.now(),
-  };
-}
-
 add_task(async function setup() {
   await SpecialPowers.pushPrefEnv({
     set: [["messaging-system.log", "all"]],
   });
   rsClient = RemoteSettings("nimbus-desktop-experiments");
 
   registerCleanupFunction(async () => {
     await SpecialPowers.popPrefEnv();
     await rsClient.db.clear();
   });
 });
 
 add_task(async function test_experimentEnrollment() {
-  const recipe = createTestExperiment();
+  // Need to randomize the slug so subsequent test runs don't skip enrollment
+  // due to a conflicting slug
+  const recipe = ExperimentFakes.recipe("foo" + Date.now(), {
+    bucketConfig: {
+      start: 0,
+      // Make sure the experiment enrolls
+      count: 10000,
+      total: 10000,
+      namespace: "mochitest",
+      randomizationUnit: "normandy_id",
+    },
+  });
   await rsClient.db.importChanges({}, 42, [recipe], {
     clear: true,
   });
 
   let waitForExperimentEnrollment = ExperimentFakes.waitForExperimentUpdate(
     ExperimentAPI,
-    recipe.arguments.slug
+    recipe.slug
   );
   RemoteSettingsExperimentLoader.updateRecipes("mochitest");
 
   await waitForExperimentEnrollment;
 
   let experiment = ExperimentAPI.getExperiment({
-    slug: recipe.arguments.slug,
+    slug: recipe.slug,
   });
 
   Assert.ok(experiment.active, "Should be enrolled in the experiment");
 
   let waitForExperimentUnenrollment = ExperimentFakes.waitForExperimentUpdate(
     ExperimentAPI,
-    recipe.arguments.slug
+    recipe.slug
   );
-  ExperimentManager.unenroll(recipe.arguments.slug, "mochitest-cleanup");
+  ExperimentManager.unenroll(recipe.slug, "mochitest-cleanup");
 
   await waitForExperimentUnenrollment;
 
   experiment = ExperimentAPI.getExperiment({
-    slug: recipe.arguments.slug,
+    slug: recipe.slug,
   });
 
   Assert.ok(!experiment.active, "Experiment is no longer active");
 });
--- a/toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js
+++ b/toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js
@@ -162,19 +162,17 @@ add_task(async function test_isFeatureEn
   sandbox.restore();
 });
 
 /**
  * #getRecipe
  */
 add_task(async function test_getRecipe() {
   const sandbox = sinon.createSandbox();
-  const RECIPE = {
-    arguments: ExperimentFakes.recipe("foo"),
-  };
+  const RECIPE = ExperimentFakes.recipe("foo");
   sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);
 
   const recipe = await ExperimentAPI.getRecipe("foo");
   Assert.deepEqual(
     recipe,
     RECIPE,
     "should return an experiment recipe if found"
   );
@@ -192,25 +190,23 @@ add_task(async function test_getRecipe_F
   sandbox.restore();
 });
 
 /**
  * #getAllBranches
  */
 add_task(async function test_getAllBranches() {
   const sandbox = sinon.createSandbox();
-  const RECIPE = {
-    arguments: ExperimentFakes.recipe("foo"),
-  };
+  const RECIPE = ExperimentFakes.recipe("foo");
   sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);
 
   const branches = await ExperimentAPI.getAllBranches("foo");
   Assert.deepEqual(
     branches,
-    RECIPE.arguments.branches,
+    RECIPE.branches,
     "should return all branches if found a recipe"
   );
 
   sandbox.restore();
 });
 
 add_task(async function test_getAllBranches_Failure() {
   const sandbox = sinon.createSandbox();
new file mode 100644
--- /dev/null
+++ b/toolkit/components/messaging-system/test/unit/test_MSTestUtils.js
@@ -0,0 +1,13 @@
+"use strict";
+
+const { ExperimentFakes, ExperimentTestUtils } = ChromeUtils.import(
+  "resource://testing-common/MSTestUtils.jsm"
+);
+
+add_task(async function test_recipe_fake_validates() {
+  const recipe = ExperimentFakes.recipe("foo");
+  Assert.ok(
+    await ExperimentTestUtils.validateExperiment(recipe),
+    "should produce a valid experiment recipe"
+  );
+});
--- a/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js
+++ b/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader.js
@@ -66,24 +66,23 @@ add_task(async function test_init() {
   Services.prefs.setBoolPref(ENABLED_PREF, true);
   await loader.init();
   ok(loader.setTimer.calledOnce, "should call .setTimer");
   ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
 });
 
 add_task(async function test_updateRecipes() {
   const loader = ExperimentFakes.rsLoader();
-  const PASS_FILTER_RECIPE = {
+
+  const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
     targeting: "true",
-    arguments: ExperimentFakes.recipe("foo"),
-  };
-  const FAIL_FILTER_RECIPE = {
+  });
+  const FAIL_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
     targeting: "false",
-    arguments: ExperimentFakes.recipe("foo"),
-  };
+  });
   sinon.stub(loader, "setTimer");
   sinon.spy(loader, "updateRecipes");
 
   sinon
     .stub(loader.remoteSettingsClient, "get")
     .resolves([PASS_FILTER_RECIPE, FAIL_FILTER_RECIPE]);
   sinon.stub(loader.manager, "onRecipe").resolves();
   sinon.stub(loader.manager, "onFinalize");
@@ -92,49 +91,44 @@ add_task(async function test_updateRecip
   await loader.init();
   ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
   equal(
     loader.manager.onRecipe.callCount,
     1,
     "should call .onRecipe only for recipes that pass"
   );
   ok(
-    loader.manager.onRecipe.calledWith(
-      PASS_FILTER_RECIPE.arguments,
-      "rs-loader"
-    ),
+    loader.manager.onRecipe.calledWith(PASS_FILTER_RECIPE, "rs-loader"),
     "should call .onRecipe with argument data"
   );
 });
 
 add_task(async function test_updateRecipes_forFirstStartup() {
   const loader = ExperimentFakes.rsLoader();
-  const PASS_FILTER_RECIPE = {
+  const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
     targeting: "isFirstStartup",
-    arguments: ExperimentFakes.recipe("foo"),
-  };
+  });
   sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]);
   sinon.stub(loader.manager, "onRecipe").resolves();
   sinon.stub(loader.manager, "onFinalize");
   sinon
     .stub(loader.manager, "createTargetingContext")
     .returns({ isFirstStartup: true });
 
   Services.prefs.setBoolPref(ENABLED_PREF, true);
   await loader.init({ isFirstStartup: true });
 
   ok(loader.manager.onRecipe.calledOnce, "should pass the targeting filter");
 });
 
 add_task(async function test_updateRecipes_forNoneFirstStartup() {
   const loader = ExperimentFakes.rsLoader();
-  const PASS_FILTER_RECIPE = {
+  const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
     targeting: "isFirstStartup",
-    arguments: ExperimentFakes.recipe("foo"),
-  };
+  });
   sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]);
   sinon.stub(loader.manager, "onRecipe").resolves();
   sinon.stub(loader.manager, "onFinalize");
   sinon
     .stub(loader.manager, "createTargetingContext")
     .returns({ isFirstStartup: false });
 
   Services.prefs.setBoolPref(ENABLED_PREF, true);
@@ -159,25 +153,24 @@ add_task(async function test_checkTarget
     await loader.checkTargeting({ targeting: "aPropertyThatDoesNotExist" }),
     false,
     "should return false for falsey expression"
   );
 });
 
 add_task(async function test_checkExperimentSelfReference() {
   const loader = ExperimentFakes.rsLoader();
-  const PASS_FILTER_RECIPE = {
+  const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
     targeting:
-      "experiment.arguments.slug == 'foo' && experiment.arguments.branches[0].slug == 'control'",
-    arguments: ExperimentFakes.recipe("foo"),
-  };
-  const FAIL_FILTER_RECIPE = {
-    targeting: "experiment.arguments.slug == 'bar'",
-    arguments: ExperimentFakes.recipe("foo"),
-  };
+      "experiment.slug == 'foo' && experiment.branches[0].slug == 'control'",
+  });
+
+  const FAIL_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
+    targeting: "experiment.slug == 'bar'",
+  });
 
   equal(
     await loader.checkTargeting(PASS_FILTER_RECIPE),
     true,
     "Should return true for matching on slug name and branch"
   );
   equal(
     await loader.checkTargeting(FAIL_FILTER_RECIPE),
--- a/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js
+++ b/toolkit/components/messaging-system/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js
@@ -17,40 +17,36 @@ const { FirstStartup } = ChromeUtils.imp
 );
 
 add_task(async function test_updateRecipes_activeExperiments() {
   const manager = ExperimentFakes.manager();
   const sandbox = sinon.createSandbox();
   const recipe = ExperimentFakes.recipe("foo");
   const loader = ExperimentFakes.rsLoader();
   loader.manager = manager;
-  const PASS_FILTER_RECIPE = {
+  const PASS_FILTER_RECIPE = ExperimentFakes.recipe("foo", {
     targeting: `"${recipe.slug}" in activeExperiments`,
-    arguments: recipe,
-  };
+  });
   const onRecipe = sandbox.stub(manager, "onRecipe");
   sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]);
   sandbox.stub(manager.store, "ready").resolves();
   sandbox.stub(manager.store, "getAllActive").returns([recipe]);
 
   await loader.init();
 
   ok(onRecipe.calledOnce, "Should match active experiments");
 });
 
 add_task(async function test_updateRecipes_isFirstRun() {
   const manager = ExperimentFakes.manager();
   const sandbox = sinon.createSandbox();
   const recipe = ExperimentFakes.recipe("foo");
   const loader = ExperimentFakes.rsLoader();
   loader.manager = manager;
-  const PASS_FILTER_RECIPE = {
-    targeting: "isFirstStartup",
-    arguments: recipe,
-  };
+  const PASS_FILTER_RECIPE = { ...recipe, targeting: "isFirstStartup" };
   const onRecipe = sandbox.stub(manager, "onRecipe");
   sinon.stub(loader.remoteSettingsClient, "get").resolves([PASS_FILTER_RECIPE]);
   sandbox.stub(manager.store, "ready").resolves();
   sandbox.stub(manager.store, "getAllActive").returns([recipe]);
 
   // Pretend to be in the first startup
   FirstStartup._state = FirstStartup.IN_PROGRESS;
   await loader.init();
--- a/toolkit/components/messaging-system/test/unit/xpcshell.ini
+++ b/toolkit/components/messaging-system/test/unit/xpcshell.ini
@@ -4,12 +4,13 @@ tags = messaging-system
 firefox-appdir = browser
 
 [test_ExperimentManager_context.js]
 [test_ExperimentManager_enroll.js]
 [test_ExperimentManager_lifecycle.js]
 [test_ExperimentManager_unenroll.js]
 [test_ExperimentManager_generateTestIds.js]
 [test_ExperimentStore.js]
+[test_MSTestUtils.js]
 [test_SharedDataMap.js]
 [test_ExperimentAPI.js]
 [test_RemoteSettingsExperimentLoader.js]
 [test_RemoteSettingsExperimentLoader_updateRecipes.js]