diff options
Diffstat (limited to 'browser/components/asrouter/bin')
-rw-r--r-- | browser/components/asrouter/bin/import-rollouts.js | 369 |
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(); |