summaryrefslogtreecommitdiffstats
path: root/browser/components/asrouter/bin/import-rollouts.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/asrouter/bin/import-rollouts.js')
-rw-r--r--browser/components/asrouter/bin/import-rollouts.js369
1 files changed, 369 insertions, 0 deletions
diff --git a/browser/components/asrouter/bin/import-rollouts.js b/browser/components/asrouter/bin/import-rollouts.js
new file mode 100644
index 0000000000..63f8a555b4
--- /dev/null
+++ b/browser/components/asrouter/bin/import-rollouts.js
@@ -0,0 +1,369 @@
+/* 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/. */
+
+/**
+ * This is a script to import Nimbus experiments from a given collection into
+ * browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs. By
+ * default, it only imports messaging rollouts. This is done so that the content
+ * of off-train rollouts can be easily searched. That way, when we are cleaning
+ * up old assets (such as Fluent strings), we don't accidentally delete strings
+ * that live rollouts are using because it was too difficult to find whether
+ * they were in use.
+ *
+ * This works by fetching the message records from the Nimbus collection and
+ * then writing them to the file. The messages are converted from JSON to JS.
+ * The file is structured like this:
+ * export const NimbusRolloutMessageProvider = {
+ * getMessages() {
+ * return [
+ * { ...message1 },
+ * { ...message2 },
+ * ];
+ * },
+ * };
+ */
+
+/* eslint-disable max-depth, no-console */
+const chalk = require("chalk");
+const https = require("https");
+const path = require("path");
+const { pathToFileURL } = require("url");
+const fs = require("fs");
+const util = require("util");
+const prettier = require("prettier");
+const jsonschema = require("../../../../third_party/js/cfworker/json-schema.js");
+
+const DEFAULT_COLLECTION_ID = "nimbus-desktop-experiments";
+const BASE_URL =
+ "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/";
+const EXPERIMENTER_URL = "https://experimenter.services.mozilla.com/nimbus/";
+const OUTPUT_PATH = "./tests/NimbusRolloutMessageProvider.sys.mjs";
+const LICENSE_STRING = `/* 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/. */`;
+
+function fetchJSON(url) {
+ return new Promise((resolve, reject) => {
+ https
+ .get(url, resp => {
+ let data = "";
+ resp.on("data", chunk => {
+ data += chunk;
+ });
+ resp.on("end", () => resolve(JSON.parse(data)));
+ })
+ .on("error", reject);
+ });
+}
+
+function isMessageValid(validator, obj) {
+ if (validator) {
+ const result = validator.validate(obj);
+ return result.valid && result.errors.length === 0;
+ }
+ return true;
+}
+
+async function getMessageValidators(skipValidation) {
+ if (skipValidation) {
+ return { experimentValidator: null, messageValidators: {} };
+ }
+
+ async function getSchema(filePath) {
+ const file = await util.promisify(fs.readFile)(filePath, "utf8");
+ return JSON.parse(file);
+ }
+
+ async function getValidator(filePath, { common = false } = {}) {
+ const schema = await getSchema(filePath);
+ const validator = new jsonschema.Validator(schema);
+
+ if (common) {
+ const commonSchema = await getSchema(
+ "./content-src/schemas/FxMSCommon.schema.json"
+ );
+ validator.addSchema(commonSchema);
+ }
+
+ return validator;
+ }
+
+ const experimentValidator = await getValidator(
+ "./content-src/schemas/MessagingExperiment.schema.json"
+ );
+
+ const messageValidators = {
+ cfr_doorhanger: await getValidator(
+ "./content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json",
+ { common: true }
+ ),
+ cfr_urlbar_chiclet: await getValidator(
+ "./content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json",
+ { common: true }
+ ),
+ infobar: await getValidator(
+ "./content-src/templates/CFR/templates/InfoBar.schema.json",
+ { common: true }
+ ),
+ pb_newtab: await getValidator(
+ "./content-src/templates/PBNewtab/NewtabPromoMessage.schema.json",
+ { common: true }
+ ),
+ spotlight: await getValidator(
+ "./content-src/templates/OnboardingMessage/Spotlight.schema.json",
+ { common: true }
+ ),
+ toast_notification: await getValidator(
+ "./content-src/templates/ToastNotification/ToastNotification.schema.json",
+ { common: true }
+ ),
+ toolbar_badge: await getValidator(
+ "./content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json",
+ { common: true }
+ ),
+ update_action: await getValidator(
+ "./content-src/templates/OnboardingMessage/UpdateAction.schema.json",
+ { common: true }
+ ),
+ whatsnew_panel_message: await getValidator(
+ "./content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json",
+ { common: true }
+ ),
+ feature_callout: await getValidator(
+ // For now, Feature Callout and Spotlight share a common schema
+ "./content-src/templates/OnboardingMessage/Spotlight.schema.json",
+ { common: true }
+ ),
+ };
+
+ messageValidators.milestone_message = messageValidators.cfr_doorhanger;
+
+ return { experimentValidator, messageValidators };
+}
+
+function annotateMessage({ message, slug, minVersion, maxVersion, url }) {
+ const comments = [];
+ if (slug) {
+ comments.push(`// Nimbus slug: ${slug}`);
+ }
+ let versionRange = "";
+ if (minVersion) {
+ versionRange = minVersion;
+ if (maxVersion) {
+ versionRange += `-${maxVersion}`;
+ } else {
+ versionRange += "+";
+ }
+ } else if (maxVersion) {
+ versionRange = `0-${maxVersion}`;
+ }
+ if (versionRange) {
+ comments.push(`// Version range: ${versionRange}`);
+ }
+ if (url) {
+ comments.push(`// Recipe: ${url}`);
+ }
+ return JSON.stringify(message, null, 2).replace(
+ /^{/,
+ `{ ${comments.join("\n")}`
+ );
+}
+
+async function format(content) {
+ const config = await prettier.resolveConfig("./.prettierrc.js");
+ return prettier.format(content, { ...config, filepath: OUTPUT_PATH });
+}
+
+async function main() {
+ const { default: meow } = await import("meow");
+ const { MESSAGING_EXPERIMENTS_DEFAULT_FEATURES } = await import(
+ "../modules/MessagingExperimentConstants.sys.mjs"
+ );
+
+ const fileUrl = pathToFileURL(__filename);
+
+ const cli = meow(
+ `
+ Usage
+ $ node bin/import-rollouts.js [options]
+
+ Options
+ -c ID, --collection ID The Nimbus collection ID to import from
+ default: ${DEFAULT_COLLECTION_ID}
+ -e, --experiments Import all messaging experiments, not just rollouts
+ -s, --skip-validation Skip validation of experiments and messages
+ -h, --help Show this help message
+
+ Examples
+ $ node bin/import-rollouts.js --collection nimbus-preview
+ $ ./mach npm run import-rollouts --prefix=browser/components/newtab -- -e
+ `,
+ {
+ description: false,
+ // `pkg` is a tiny optimization. It prevents meow from looking for a package
+ // that doesn't technically exist. meow searches for a package and changes
+ // the process name to the package name. It resolves to the newtab
+ // package.json, which would give a confusing name and be wasteful.
+ pkg: {
+ name: "import-rollouts",
+ version: "1.0.0",
+ },
+ // `importMeta` is required by meow 10+. It was added to support ESM, but
+ // meow now requires it, and no longer supports CJS style imports. But it
+ // only uses import.meta.url, which can be polyfilled like this:
+ importMeta: { url: fileUrl },
+ flags: {
+ collection: {
+ type: "string",
+ alias: "c",
+ default: DEFAULT_COLLECTION_ID,
+ },
+ experiments: {
+ type: "boolean",
+ alias: "e",
+ default: false,
+ },
+ skipValidation: {
+ type: "boolean",
+ alias: "s",
+ default: false,
+ },
+ },
+ }
+ );
+
+ const RECORDS_URL = `${BASE_URL}${cli.flags.collection}/records`;
+
+ console.log(`Fetching records from ${chalk.underline.yellow(RECORDS_URL)}`);
+
+ const { data: records } = await fetchJSON(RECORDS_URL);
+
+ if (!Array.isArray(records)) {
+ throw new TypeError(
+ `Expected records to be an array, got ${typeof records}`
+ );
+ }
+
+ const recipes = records.filter(
+ record =>
+ record.application === "firefox-desktop" &&
+ record.featureIds.some(id =>
+ MESSAGING_EXPERIMENTS_DEFAULT_FEATURES.includes(id)
+ ) &&
+ (record.isRollout || cli.flags.experiments)
+ );
+
+ const importItems = [];
+ const { experimentValidator, messageValidators } = await getMessageValidators(
+ cli.flags.skipValidation
+ );
+ for (const recipe of recipes) {
+ const { slug: experimentSlug, branches, targeting } = recipe;
+ if (!(experimentSlug && Array.isArray(branches) && branches.length)) {
+ continue;
+ }
+ console.log(
+ `Processing ${recipe.isRollout ? "rollout" : "experiment"}: ${chalk.blue(
+ experimentSlug
+ )}${
+ branches.length > 1
+ ? ` with ${chalk.underline(`${String(branches.length)} branches`)}`
+ : ""
+ }`
+ );
+ const recipeUrl = `${EXPERIMENTER_URL}${experimentSlug}/summary`;
+ const [, minVersion] =
+ targeting?.match(/\(version\|versionCompare\(\'([0-9]+)\.!\'\) >= 0/) ||
+ [];
+ const [, maxVersion] =
+ targeting?.match(/\(version\|versionCompare\(\'([0-9]+)\.\*\'\) <= 0/) ||
+ [];
+ let branchIndex = branches.length > 1 ? 1 : 0;
+ for (const branch of branches) {
+ const { slug: branchSlug, features } = branch;
+ console.log(
+ ` Processing branch${
+ branchIndex > 0 ? ` ${branchIndex} of ${branches.length}` : ""
+ }: ${chalk.blue(branchSlug)}`
+ );
+ branchIndex += 1;
+ const url = `${recipeUrl}#${branchSlug}`;
+ if (!Array.isArray(features)) {
+ continue;
+ }
+ for (const feature of features) {
+ if (
+ feature.enabled &&
+ MESSAGING_EXPERIMENTS_DEFAULT_FEATURES.includes(feature.featureId) &&
+ feature.value &&
+ typeof feature.value === "object" &&
+ feature.value.template
+ ) {
+ if (!isMessageValid(experimentValidator, feature.value)) {
+ console.log(
+ ` ${chalk.red(
+ "✗"
+ )} Skipping invalid value for branch: ${chalk.blue(branchSlug)}`
+ );
+ continue;
+ }
+ const messages = (
+ feature.value.template === "multi" &&
+ Array.isArray(feature.value.messages)
+ ? feature.value.messages
+ : [feature.value]
+ ).filter(m => m && m.id);
+ let msgIndex = messages.length > 1 ? 1 : 0;
+ for (const message of messages) {
+ let messageLogString = `message${
+ msgIndex > 0 ? ` ${msgIndex} of ${messages.length}` : ""
+ }: ${chalk.italic.green(message.id)}`;
+ if (!isMessageValid(messageValidators[message.template], message)) {
+ console.log(
+ ` ${chalk.red("✗")} Skipping invalid ${messageLogString}`
+ );
+ continue;
+ }
+ console.log(` Importing ${messageLogString}`);
+ let slug = `${experimentSlug}:${branchSlug}`;
+ if (msgIndex > 0) {
+ slug += ` (message ${msgIndex} of ${messages.length})`;
+ }
+ msgIndex += 1;
+ importItems.push({ message, slug, minVersion, maxVersion, url });
+ }
+ }
+ }
+ }
+ }
+
+ const content = `${LICENSE_STRING}
+
+/**
+ * This file is generated by browser/components/asrouter/bin/import-rollouts.js
+ * Run the following from the repository root to regenerate it:
+ * ./mach npm run import-rollouts --prefix=browser/components/asrouter
+ */
+
+export const NimbusRolloutMessageProvider = {
+ getMessages() {
+ return [${importItems.map(annotateMessage).join(",\n")}];
+ },
+};
+`;
+
+ const formattedContent = await format(content);
+
+ await util.promisify(fs.writeFile)(OUTPUT_PATH, formattedContent);
+
+ console.log(
+ `${chalk.green("✓")} Wrote ${chalk.underline.green(
+ `${String(importItems.length)} ${
+ importItems.length === 1 ? "message" : "messages"
+ }`
+ )} to ${chalk.underline.yellow(path.resolve(OUTPUT_PATH))}`
+ );
+}
+
+main();