377 lines
12 KiB
JavaScript
377 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/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 = {
|
|
bookmarks_bar_button: await getValidator(
|
|
"./content-src/templates/OnboardingMessage/BookmarksBarButton.schema.json",
|
|
{ common: true }
|
|
),
|
|
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 }
|
|
),
|
|
feature_callout: await getValidator(
|
|
// For now, Feature Callout and Spotlight share a common schema
|
|
"./content-src/templates/OnboardingMessage/Spotlight.schema.json",
|
|
{ common: true }
|
|
),
|
|
menu_message: await getValidator(
|
|
"./content-src/templates/OnboardingMessage/MenuMessage.schema.json",
|
|
{ common: true }
|
|
),
|
|
newtab_message: await getValidator(
|
|
"./content-src/templates/OnboardingMessage/NewtabMessage.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/asrouter -- -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",
|
|
shortFlag: "c",
|
|
default: DEFAULT_COLLECTION_ID,
|
|
},
|
|
experiments: {
|
|
type: "boolean",
|
|
shortFlag: "e",
|
|
default: false,
|
|
},
|
|
skipValidation: {
|
|
type: "boolean",
|
|
shortFlag: "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();
|