Backed out changeset b2ee50813323 (bug 1954848) Backed out changeset 211563f87799 (bug 1953632) Backed out changeset 5003a2a06685 (bug 1951782) Backed out changeset 4a882623c86d (bug 1951426)
374 lines
12 KiB
JavaScript
374 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 }
|
|
),
|
|
};
|
|
|
|
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();
|