forked from mirrors/gecko-dev
		
	 46e1a00d59
			
		
	
	
		46e1a00d59
		
	
	
	
	
		
			
			Since stylelint is now a dependency of the project root, and meow is a dependency of stylelint, we don't need to explicitly list meow as a dependency of newtab. The project root uses a newer version of meow that requires some small workarounds to work outside of an ES module, so we also need to update the newtab scripts that use meow. Differential Revision: https://phabricator.services.mozilla.com/D187603
		
			
				
	
	
		
			373 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			373 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* 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/newtab/test/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 = "./test/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/asrouter/schemas/FxMSCommon.schema.json"
 | |
|       );
 | |
|       validator.addSchema(commonSchema);
 | |
|     }
 | |
| 
 | |
|     return validator;
 | |
|   }
 | |
| 
 | |
|   const experimentValidator = await getValidator(
 | |
|     "./content-src/asrouter/schemas/MessagingExperiment.schema.json"
 | |
|   );
 | |
| 
 | |
|   const messageValidators = {
 | |
|     cfr_doorhanger: await getValidator(
 | |
|       "./content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     cfr_urlbar_chiclet: await getValidator(
 | |
|       "./content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     infobar: await getValidator(
 | |
|       "./content-src/asrouter/templates/CFR/templates/InfoBar.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     pb_newtab: await getValidator(
 | |
|       "./content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     protections_panel: await getValidator(
 | |
|       "./content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     spotlight: await getValidator(
 | |
|       "./content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     toast_notification: await getValidator(
 | |
|       "./content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     toolbar_badge: await getValidator(
 | |
|       "./content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     update_action: await getValidator(
 | |
|       "./content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     whatsnew_panel_message: await getValidator(
 | |
|       "./content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json",
 | |
|       { common: true }
 | |
|     ),
 | |
|     feature_callout: await getValidator(
 | |
|       // For now, Feature Callout and Spotlight share a common schema
 | |
|       "./content-src/asrouter/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(
 | |
|     "../lib/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/newtab/bin/import-rollouts.js
 | |
|  * Run the following from the repository root to regenerate it:
 | |
|  * ./mach npm run import-rollouts --prefix=browser/components/newtab
 | |
|  */
 | |
| 
 | |
| 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();
 |