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