Bug 1451033 - Extract Normandy ClientEnvironment to toolkit/components/utils r?Gijs draft
authorMathieu Leplatre <mathieu@mozilla.com>
Wed, 04 Apr 2018 15:56:10 +0200
changeset 777248 4b549410049040502e41bb54a229785c0d115cfb
parent 777247 8809e1b82aaf22be51ae7d1c4848d5d017507005
push id105131
push usermleplatre@mozilla.com
push dateWed, 04 Apr 2018 13:58:13 +0000
reviewersGijs
bugs1451033
milestone61.0a1
Bug 1451033 - Extract Normandy ClientEnvironment to toolkit/components/utils r?Gijs MozReview-Commit-ID: BugQJ3ZRqOI
toolkit/components/normandy/lib/Addons.jsm
toolkit/components/normandy/lib/ClientEnvironment.jsm
toolkit/components/normandy/lib/NormandyDriver.jsm
toolkit/components/normandy/lib/RecipeRunner.jsm
toolkit/components/normandy/lib/Utils.jsm
toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
toolkit/components/normandy/test/browser/head.js
toolkit/components/normandy/test/unit/test_Utils.js
toolkit/components/normandy/test/unit/xpcshell.ini
toolkit/components/utils/ClientEnvironment.jsm
toolkit/components/utils/moz.build
--- a/toolkit/components/normandy/lib/Addons.jsm
+++ b/toolkit/components/normandy/lib/Addons.jsm
@@ -42,26 +42,16 @@ var Addons = {
     const addon = await AddonManager.getAddonByID(addonId);
     if (!addon) {
       return null;
     }
     return this.serializeForSandbox(addon);
   },
 
   /**
-   * Get information about all installed add-ons.
-   * @async
-   * @returns {Array<SafeAddon>}
-   */
-  async getAll(addonId) {
-    const addons = await AddonManager.getAllAddons();
-    return addons.map(this.serializeForSandbox.bind(this));
-  },
-
-  /**
    * Installs an add-on
    *
    * @param {string} addonUrl
    *   Url to download the .xpi for the add-on from.
    * @param {object} options
    * @param {boolean} options.update=false
    *   If true, will update an existing installed add-on with the same ID.
    * @async
--- a/toolkit/components/normandy/lib/ClientEnvironment.jsm
+++ b/toolkit/components/normandy/lib/ClientEnvironment.jsm
@@ -1,42 +1,39 @@
 /* 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";
 
 ChromeUtils.import("resource://gre/modules/Preferences.jsm");
-ChromeUtils.import("resource://gre/modules/Services.jsm");
 
-ChromeUtils.defineModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
-ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
-ChromeUtils.defineModuleGetter(this, "TelemetryArchive", "resource://gre/modules/TelemetryArchive.jsm");
-ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "NormandyApi", "resource://normandy/lib/NormandyApi.jsm");
 ChromeUtils.defineModuleGetter(
+  this,
+  "ClientEnvironmentBase",
+  "resource://gre/modules/components-utils/ClientEnvironment.jsm"
+);
+ChromeUtils.defineModuleGetter(
     this,
     "PreferenceExperiments",
     "resource://normandy/lib/PreferenceExperiments.jsm"
 );
-ChromeUtils.defineModuleGetter(this, "Utils", "resource://normandy/lib/Utils.jsm");
-ChromeUtils.defineModuleGetter(this, "Addons", "resource://normandy/lib/Addons.jsm");
-ChromeUtils.defineModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm");
 
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 var EXPORTED_SYMBOLS = ["ClientEnvironment"];
 
 
 // Cached API request for client attributes that are determined by the Normandy
 // service.
 let _classifyRequest = null;
 
 
-class ClientEnvironment {
+class ClientEnvironment extends ClientEnvironmentBase {
   /**
    * Fetches information about the client that is calculated on the server,
    * like geolocation and the current time.
    *
    * The server request is made lazily and is cached for the entire browser
    * session.
    */
   static async getClientClassification() {
@@ -59,31 +56,16 @@ class ClientEnvironment {
     return async function inner() {
       const oldRequest = _classifyRequest;
       _classifyRequest = Promise.resolve(data);
       await testFunction();
       _classifyRequest = oldRequest;
     };
   }
 
-  /**
-   * Create an object that provides general information about the client application.
-   *
-   * RecipeRunner.jsm uses this as part of the context for filter expressions,
-   * so avoid adding non-getter functions as attributes, as filter expressions
-   * cannot execute functions.
-   *
-   * Also note that, because filter expressions implicitly resolve promises, you
-   * can add getter functions that return promises for async data.
-   * @return {Object}
-   */
-  static getEnvironment() {
-    return new ClientEnvironment();
-  }
-
   get userId() {
     let id = Preferences.get("app.normandy.user_id", "");
     if (!id) {
       // generateUUID adds leading and trailing "{" and "}". strip them off.
       id = generateUUID().toString().slice(1, -1);
       Preferences.set("app.normandy.user_id", id);
     }
     return id;
@@ -98,109 +80,16 @@ class ClientEnvironment {
 
   get request_time() {
     return (async () => {
       const { request_time } = await ClientEnvironment.getClientClassification();
       return request_time;
     })();
   }
 
-  get distribution() {
-    return Preferences.get("distribution.id", "default");
-  }
-
-  get telemetry() {
-    return (async () => {
-      const pings = await TelemetryArchive.promiseArchivedPingList();
-
-      // get most recent ping per type
-      const mostRecentPings = {};
-      for (const ping of pings) {
-        if (ping.type in mostRecentPings) {
-          if (mostRecentPings[ping.type].timestampCreated < ping.timestampCreated) {
-            mostRecentPings[ping.type] = ping;
-          }
-        } else {
-          mostRecentPings[ping.type] = ping;
-        }
-      }
-
-      const telemetry = {};
-      for (const key in mostRecentPings) {
-        const ping = mostRecentPings[key];
-        telemetry[ping.type] = await TelemetryArchive.promiseArchivedPingById(ping.id);
-      }
-      return telemetry;
-    })();
-  }
-
-  get version() {
-    return AppConstants.MOZ_APP_VERSION_DISPLAY;
-  }
-
-  get channel() {
-    return UpdateUtils.getUpdateChannel(false);
-  }
-
-  get isDefaultBrowser() {
-    return ShellService.isDefaultBrowser();
-  }
-
-  get searchEngine() {
-    return (async () => {
-      const searchInitialized = await new Promise(resolve => Services.search.init(resolve));
-      if (Components.isSuccessCode(searchInitialized)) {
-        return Services.search.defaultEngine.identifier;
-      }
-      return null;
-    })();
-  }
-
-  get syncSetup() {
-    return Preferences.isSet("services.sync.username");
-  }
-
-  get syncDesktopDevices() {
-    return Preferences.get("services.sync.clients.devices.desktop", 0);
-  }
-
-  get syncMobileDevices() {
-    return Preferences.get("services.sync.clients.devices.mobile", 0);
-  }
-
-  get syncTotalDevices() {
-    return this.syncDesktopDevices + this.syncMobileDevices;
-  }
-
-  get plugins() {
-    return (async () => {
-      let plugins = await AddonManager.getAddonsByTypes(["plugin"]);
-      plugins = plugins.map(plugin => ({
-        name: plugin.name,
-        description: plugin.description,
-        version: plugin.version,
-      }));
-      return Utils.keyBy(plugins, "name");
-    })();
-  }
-
-  get locale() {
-    if (Services.locale.getAppLocaleAsLangTag) {
-      return Services.locale.getAppLocaleAsLangTag();
-    }
-
-    return Cc["@mozilla.org/chrome/chrome-registry;1"]
-      .getService(Ci.nsIXULChromeRegistry)
-      .getSelectedLocale("global");
-  }
-
-  get doNotTrack() {
-    return Preferences.get("privacy.donottrackheader.enabled", false);
-  }
-
   get experiments() {
     return (async () => {
       const names = {all: [], active: [], expired: []};
 
       for (const experiment of await PreferenceExperiments.getAll()) {
         names.all.push(experiment.name);
         if (experiment.expired) {
           names.expired.push(experiment.name);
@@ -208,19 +97,12 @@ class ClientEnvironment {
           names.active.push(experiment.name);
         }
       }
 
       return names;
     })();
   }
 
-  get addons() {
-    return (async () => {
-      const addons = await Addons.getAll();
-      return Utils.keyBy(addons, "id");
-    })();
-  }
-
   get isFirstRun() {
     return Preferences.get("app.normandy.first_run");
   }
 }
--- a/toolkit/components/normandy/lib/NormandyDriver.jsm
+++ b/toolkit/components/normandy/lib/NormandyDriver.jsm
@@ -45,17 +45,17 @@ var NormandyDriver = function(sandboxMan
       }
 
       return Cc["@mozilla.org/chrome/chrome-registry;1"]
         .getService(Ci.nsIXULChromeRegistry)
         .getSelectedLocale("global");
     },
 
     get userId() {
-      return ClientEnvironment.getEnvironment().userId;
+      return (new ClientEnvironment()).userId;
     },
 
     log(message, level = "debug") {
       const levels = ["debug", "info", "warn", "error"];
       if (!levels.includes(level)) {
         throw new Error(`Invalid log level "${level}"`);
       }
       actionLog[level](message);
--- a/toolkit/components/normandy/lib/RecipeRunner.jsm
+++ b/toolkit/components/normandy/lib/RecipeRunner.jsm
@@ -311,18 +311,19 @@ var RecipeRunner = {
         }
         Uptake.reportAction(action.name, status);
       }
     }
     return actionSandboxManagers;
   },
 
   getFilterContext(recipe) {
+    const environment = new ClientEnvironment();
     return {
-      normandy: Object.assign(ClientEnvironment.getEnvironment(), {
+      normandy: Object.assign(environment, {
         recipe: {
           id: recipe.id,
           arguments: recipe.arguments,
         },
       }),
     };
   },
 
deleted file mode 100644
--- a/toolkit/components/normandy/lib/Utils.jsm
+++ /dev/null
@@ -1,36 +0,0 @@
-/* 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";
-
-ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
-
-var EXPORTED_SYMBOLS = ["Utils"];
-
-const log = LogManager.getLogger("utils");
-
-var Utils = {
-  /**
-   * Convert an array of objects to an object. Each item is keyed by the value
-   * of the given key on the item.
-   *
-   * > list = [{foo: "bar"}, {foo: "baz"}]
-   * > keyBy(list, "foo") == {bar: {foo: "bar"}, baz: {foo: "baz"}}
-   *
-   * @param  {Array} list
-   * @param  {String} key
-   * @return {Object}
-   */
-  keyBy(list, key) {
-    return list.reduce((map, item) => {
-      if (!(key in item)) {
-        log.warn(`Skipping list due to missing key "${key}".`);
-        return map;
-      }
-
-      map[item[key]] = item;
-      return map;
-    }, {});
-  },
-};
--- a/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
+++ b/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
@@ -10,98 +10,98 @@ ChromeUtils.import("resource://normandy/
 
 add_task(async function testTelemetry() {
   // setup
   await SpecialPowers.pushPrefEnv({set: [["privacy.reduceTimerPrecision", true]]});
 
   await TelemetryController.submitExternalPing("testfoo", {foo: 1});
   await TelemetryController.submitExternalPing("testbar", {bar: 2});
   await TelemetryController.submitExternalPing("testfoo", {foo: 3});
-  const environment = ClientEnvironment.getEnvironment();
+  const environment = new ClientEnvironment();
 
   // Test it can access telemetry
   const telemetry = await environment.telemetry;
   is(typeof telemetry, "object", "Telemetry is accesible");
 
   // Test it reads different types of telemetry
   is(telemetry.testfoo.payload.foo, 3, "telemetry filters pull the latest ping from a type");
   is(telemetry.testbar.payload.bar, 2, "telemetry filters pull from submitted telemetry pings");
 });
 
 add_task(async function testUserId() {
-  let environment = ClientEnvironment.getEnvironment();
+  let environment = new ClientEnvironment();
 
   // Test that userId is available
   ok(UUID_REGEX.test(environment.userId), "userId available");
 
   // test that it pulls from the right preference
   await SpecialPowers.pushPrefEnv({set: [["app.normandy.user_id", "fake id"]]});
-  environment = ClientEnvironment.getEnvironment();
+  environment = new ClientEnvironment();
   is(environment.userId, "fake id", "userId is pulled from preferences");
 });
 
 add_task(async function testDistribution() {
-  let environment = ClientEnvironment.getEnvironment();
+  let environment = new ClientEnvironment();
 
   // distribution id defaults to "default"
   is(environment.distribution, "default", "distribution has a default value");
 
   // distribution id is read from a preference
   await SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
-  environment = ClientEnvironment.getEnvironment();
+  environment = new ClientEnvironment();
   is(environment.distribution, "funnelcake", "distribution is read from preferences");
 });
 
 const mockClassify = {country: "FR", request_time: new Date(2017, 1, 1)};
 add_task(ClientEnvironment.withMockClassify(mockClassify, async function testCountryRequestTime() {
-  const environment = ClientEnvironment.getEnvironment();
+  const environment = new ClientEnvironment();
 
   // Test that country and request_time pull their data from the server.
   is(await environment.country, mockClassify.country, "country is read from the server API");
   is(
     await environment.request_time, mockClassify.request_time,
     "request_time is read from the server API"
   );
 }));
 
 add_task(async function testSync() {
-  let environment = ClientEnvironment.getEnvironment();
+  let environment = new ClientEnvironment();
   is(environment.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
   is(environment.syncDesktopDevices, 0, "syncDesktopDevices defaults to zero");
   is(environment.syncTotalDevices, 0, "syncTotalDevices defaults to zero");
   await SpecialPowers.pushPrefEnv({
     set: [
       ["services.sync.clients.devices.mobile", 5],
       ["services.sync.clients.devices.desktop", 4],
     ],
   });
-  environment = ClientEnvironment.getEnvironment();
+  environment = new ClientEnvironment();
   is(environment.syncMobileDevices, 5, "syncMobileDevices is read when set");
   is(environment.syncDesktopDevices, 4, "syncDesktopDevices is read when set");
   is(environment.syncTotalDevices, 9, "syncTotalDevices is read when set");
 });
 
 add_task(async function testDoNotTrack() {
-  let environment = ClientEnvironment.getEnvironment();
+  let environment = new ClientEnvironment();
 
   // doNotTrack defaults to false
   ok(!environment.doNotTrack, "doNotTrack has a default value");
 
   // doNotTrack is read from a preference
   await SpecialPowers.pushPrefEnv({set: [["privacy.donottrackheader.enabled", true]]});
-  environment = ClientEnvironment.getEnvironment();
+  environment = new ClientEnvironment();
   ok(environment.doNotTrack, "doNotTrack is read from preferences");
 });
 
 add_task(async function testExperiments() {
   const active = {name: "active", expired: false};
   const expired = {name: "expired", expired: true};
   const getAll = sinon.stub(PreferenceExperiments, "getAll", async () => [active, expired]);
 
-  const environment = ClientEnvironment.getEnvironment();
+  const environment = new ClientEnvironment();
   const experiments = await environment.experiments;
   Assert.deepEqual(
     experiments.all,
     ["active", "expired"],
     "experiments.all returns all stored experiment names",
   );
   Assert.deepEqual(
     experiments.active,
@@ -118,27 +118,27 @@ add_task(async function testExperiments(
 });
 
 add_task(withDriver(Assert, async function testAddonsInContext(driver) {
   // Create before install so that the listener is added before startup completes.
   const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
   const addonId = await driver.addons.install(TEST_XPI_URL);
   await startupPromise;
 
-  const environment = ClientEnvironment.getEnvironment();
+  const environment = new ClientEnvironment();
   const addons = await environment.addons;
   Assert.deepEqual(addons[addonId], {
     id: [addonId],
     name: "normandy_fixture",
     version: "1.0",
     installDate: addons[addonId].installDate,
     isActive: true,
     type: "extension",
   }, "addons should be available in context");
 
   await driver.addons.uninstall(addonId);
 }));
 
 add_task(async function isFirstRun() {
   await SpecialPowers.pushPrefEnv({set: [["app.normandy.first_run", true]]});
-  const environment = ClientEnvironment.getEnvironment();
+  const environment = new ClientEnvironment();
   ok(environment.isFirstRun, "isFirstRun is read from preferences");
 });
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -2,17 +2,16 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
 ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Addons.jsm", this);
 ChromeUtils.import("resource://normandy/lib/SandboxManager.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
-ChromeUtils.import("resource://normandy/lib/Utils.jsm", this);
 
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/docs/
 /* global sinon */
 Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
 
 // Make sinon assertions fail in a way that mochitest understands
 sinon.assert.fail = function(message) {
deleted file mode 100644
--- a/toolkit/components/normandy/test/unit/test_Utils.js
+++ /dev/null
@@ -1,20 +0,0 @@
-"use strict";
-
-ChromeUtils.import("resource://normandy/lib/Utils.jsm");
-
-add_task(async function testKeyBy() {
-  const list = [];
-  deepEqual(Utils.keyBy(list, "foo"), {});
-
-  const foo = {name: "foo", value: 1};
-  list.push(foo);
-  deepEqual(Utils.keyBy(list, "name"), {foo});
-
-  const bar = {name: "bar", value: 2};
-  list.push(bar);
-  deepEqual(Utils.keyBy(list, "name"), {foo, bar});
-
-  const missingKey = {value: 7};
-  list.push(missingKey);
-  deepEqual(Utils.keyBy(list, "name"), {foo, bar}, "keyBy skips items that are missing the key");
-});
--- a/toolkit/components/normandy/test/unit/xpcshell.ini
+++ b/toolkit/components/normandy/test/unit/xpcshell.ini
@@ -6,9 +6,8 @@ support-files =
   query_server.sjs
   echo_server.sjs
   utils.js
 tags = normandy
 
 [test_NormandyApi.js]
 [test_Sampling.js]
 [test_SandboxManager.js]
-[test_Utils.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/utils/ClientEnvironment.jsm
@@ -0,0 +1,133 @@
+/* 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";
+
+ChromeUtils.import("resource://gre/modules/Preferences.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
+ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryArchive", "resource://gre/modules/TelemetryArchive.jsm");
+ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm");
+
+var EXPORTED_SYMBOLS = ["ClientEnvironmentBase"];
+
+
+/**
+ * Create an object that provides general information about the client application.
+ *
+ * Components like Normandy RecipeRunner use this as part of the context for filter expressions,
+ * so avoid adding non-getter functions as attributes, as filter expressions
+ * cannot execute functions.
+ *
+ * Also note that, because filter expressions implicitly resolve promises, you
+ * can add getter functions that return promises for async data.
+ */
+class ClientEnvironmentBase {
+  get distribution() {
+    return Preferences.get("distribution.id", "default");
+  }
+
+  get telemetry() {
+    return (async () => {
+      const pings = await TelemetryArchive.promiseArchivedPingList();
+
+      // get most recent ping per type
+      const mostRecentPings = {};
+      for (const ping of pings) {
+        if (ping.type in mostRecentPings) {
+          if (mostRecentPings[ping.type].timestampCreated < ping.timestampCreated) {
+            mostRecentPings[ping.type] = ping;
+          }
+        } else {
+          mostRecentPings[ping.type] = ping;
+        }
+      }
+
+      const telemetry = {};
+      for (const key in mostRecentPings) {
+        const ping = mostRecentPings[key];
+        telemetry[ping.type] = await TelemetryArchive.promiseArchivedPingById(ping.id);
+      }
+      return telemetry;
+    })();
+  }
+
+  get version() {
+    return AppConstants.MOZ_APP_VERSION_DISPLAY;
+  }
+
+  get channel() {
+    return UpdateUtils.getUpdateChannel(false);
+  }
+
+  get isDefaultBrowser() {
+    return ShellService.isDefaultBrowser();
+  }
+
+  get searchEngine() {
+    return (async () => {
+      const searchInitialized = await new Promise(resolve => Services.search.init(resolve));
+      if (Components.isSuccessCode(searchInitialized)) {
+        return Services.search.defaultEngine.identifier;
+      }
+      return null;
+    })();
+  }
+
+  get syncSetup() {
+    return Preferences.isSet("services.sync.username");
+  }
+
+  get syncDesktopDevices() {
+    return Preferences.get("services.sync.clients.devices.desktop", 0);
+  }
+
+  get syncMobileDevices() {
+    return Preferences.get("services.sync.clients.devices.mobile", 0);
+  }
+
+  get syncTotalDevices() {
+    return this.syncDesktopDevices + this.syncMobileDevices;
+  }
+
+  get addons() {
+    return (async () => {
+      const addons = await AddonManager.getAllAddons();
+      return addons.reduce((acc, addon) => {
+        const { id, isActive, name, type, version, installDate: installDateN } = addon;
+        const installDate = new Date(installDateN);
+        acc[id] = { id, isActive, name, type, version, installDate };
+        return acc;
+      }, {});
+    })();
+  }
+
+  get plugins() {
+    return (async () => {
+      const plugins = await AddonManager.getAddonsByTypes(["plugin"]);
+      return plugins.reduce((acc, plugin) => {
+        const { name, description, version } = plugin;
+        acc[name] = { name, description, version };
+        return acc;
+      }, {});
+    })();
+  }
+
+  get locale() {
+    if (Services.locale.getAppLocaleAsLangTag) {
+      return Services.locale.getAppLocaleAsLangTag();
+    }
+
+    return Cc["@mozilla.org/chrome/chrome-registry;1"]
+      .getService(Ci.nsIXULChromeRegistry)
+      .getSelectedLocale("global");
+  }
+
+  get doNotTrack() {
+    return Preferences.get("privacy.donottrackheader.enabled", false);
+  }
+}
--- a/toolkit/components/utils/moz.build
+++ b/toolkit/components/utils/moz.build
@@ -6,8 +6,12 @@
 
 with Files('**'):
     BUG_COMPONENT = ('Toolkit', 'General')
 
 EXTRA_COMPONENTS += [
     'simpleServices.js',
     'utils.manifest',
 ]
+
+EXTRA_JS_MODULES['components-utils'] = [
+    'ClientEnvironment.jsm'
+]