/* 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 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();