import-rollouts.js (12390B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * This is a script to import Nimbus experiments from a given collection into 7 * browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs. By 8 * default, it only imports messaging rollouts. This is done so that the content 9 * of off-train rollouts can be easily searched. That way, when we are cleaning 10 * up old assets (such as Fluent strings), we don't accidentally delete strings 11 * that live rollouts are using because it was too difficult to find whether 12 * they were in use. 13 * 14 * This works by fetching the message records from the Nimbus collection and 15 * then writing them to the file. The messages are converted from JSON to JS. 16 * The file is structured like this: 17 * export const NimbusRolloutMessageProvider = { 18 * getMessages() { 19 * return [ 20 * { ...message1 }, 21 * { ...message2 }, 22 * ]; 23 * }, 24 * }; 25 */ 26 27 /* eslint-disable no-console */ 28 const chalk = require("chalk"); 29 const https = require("https"); 30 const path = require("path"); 31 const { pathToFileURL } = require("url"); 32 const fs = require("fs"); 33 const util = require("util"); 34 const prettier = require("prettier"); 35 const jsonschema = require("../../../../third_party/js/cfworker/json-schema.js"); 36 37 const DEFAULT_COLLECTION_ID = "nimbus-desktop-experiments"; 38 const BASE_URL = 39 "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/"; 40 const EXPERIMENTER_URL = "https://experimenter.services.mozilla.com/nimbus/"; 41 const OUTPUT_PATH = "./tests/NimbusRolloutMessageProvider.sys.mjs"; 42 const LICENSE_STRING = `/* This Source Code Form is subject to the terms of the Mozilla Public 43 * License, v. 2.0. If a copy of the MPL was not distributed with this 44 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */`; 45 46 function fetchJSON(url) { 47 return new Promise((resolve, reject) => { 48 https 49 .get(url, resp => { 50 let data = ""; 51 resp.on("data", chunk => { 52 data += chunk; 53 }); 54 resp.on("end", () => resolve(JSON.parse(data))); 55 }) 56 .on("error", reject); 57 }); 58 } 59 60 function isMessageValid(validator, obj) { 61 if (validator) { 62 const result = validator.validate(obj); 63 return result.valid && result.errors.length === 0; 64 } 65 return true; 66 } 67 68 async function getMessageValidators(skipValidation) { 69 if (skipValidation) { 70 return { experimentValidator: null, messageValidators: {} }; 71 } 72 73 async function getSchema(filePath) { 74 const file = await util.promisify(fs.readFile)(filePath, "utf8"); 75 return JSON.parse(file); 76 } 77 78 async function getValidator(filePath, { common = false } = {}) { 79 const schema = await getSchema(filePath); 80 const validator = new jsonschema.Validator(schema); 81 82 if (common) { 83 const commonSchema = await getSchema( 84 "./content-src/schemas/FxMSCommon.schema.json" 85 ); 86 validator.addSchema(commonSchema); 87 } 88 89 return validator; 90 } 91 92 const experimentValidator = await getValidator( 93 "./content-src/schemas/MessagingExperiment.schema.json" 94 ); 95 96 const messageValidators = { 97 bookmarks_bar_button: await getValidator( 98 "./content-src/templates/OnboardingMessage/BookmarksBarButton.schema.json", 99 { common: true } 100 ), 101 cfr_doorhanger: await getValidator( 102 "./content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json", 103 { common: true } 104 ), 105 cfr_urlbar_chiclet: await getValidator( 106 "./content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json", 107 { common: true } 108 ), 109 infobar: await getValidator( 110 "./content-src/templates/CFR/templates/InfoBar.schema.json", 111 { common: true } 112 ), 113 pb_newtab: await getValidator( 114 "./content-src/templates/PBNewtab/NewtabPromoMessage.schema.json", 115 { common: true } 116 ), 117 spotlight: await getValidator( 118 "./content-src/templates/OnboardingMessage/Spotlight.schema.json", 119 { common: true } 120 ), 121 toast_notification: await getValidator( 122 "./content-src/templates/ToastNotification/ToastNotification.schema.json", 123 { common: true } 124 ), 125 toolbar_badge: await getValidator( 126 "./content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json", 127 { common: true } 128 ), 129 update_action: await getValidator( 130 "./content-src/templates/OnboardingMessage/UpdateAction.schema.json", 131 { common: true } 132 ), 133 feature_callout: await getValidator( 134 // For now, Feature Callout and Spotlight share a common schema 135 "./content-src/templates/OnboardingMessage/Spotlight.schema.json", 136 { common: true } 137 ), 138 menu_message: await getValidator( 139 "./content-src/templates/OnboardingMessage/MenuMessage.schema.json", 140 { common: true } 141 ), 142 newtab_message: await getValidator( 143 "./content-src/templates/OnboardingMessage/NewtabMessage.schema.json", 144 { common: true } 145 ), 146 }; 147 148 messageValidators.milestone_message = messageValidators.cfr_doorhanger; 149 150 return { experimentValidator, messageValidators }; 151 } 152 153 function annotateMessage({ message, slug, minVersion, maxVersion, url }) { 154 const comments = []; 155 if (slug) { 156 comments.push(`// Nimbus slug: ${slug}`); 157 } 158 let versionRange = ""; 159 if (minVersion) { 160 versionRange = minVersion; 161 if (maxVersion) { 162 versionRange += `-${maxVersion}`; 163 } else { 164 versionRange += "+"; 165 } 166 } else if (maxVersion) { 167 versionRange = `0-${maxVersion}`; 168 } 169 if (versionRange) { 170 comments.push(`// Version range: ${versionRange}`); 171 } 172 if (url) { 173 comments.push(`// Recipe: ${url}`); 174 } 175 return JSON.stringify(message, null, 2).replace( 176 /^{/, 177 `{ ${comments.join("\n")}` 178 ); 179 } 180 181 async function format(content) { 182 const config = await prettier.resolveConfig("./.prettierrc.js"); 183 return prettier.format(content, { ...config, filepath: OUTPUT_PATH }); 184 } 185 186 async function main() { 187 const { default: meow } = await import("meow"); 188 const { MESSAGING_EXPERIMENTS_DEFAULT_FEATURES } = await import( 189 "../modules/MessagingExperimentConstants.sys.mjs" 190 ); 191 192 const fileUrl = pathToFileURL(__filename); 193 194 const cli = meow( 195 ` 196 Usage 197 $ node bin/import-rollouts.js [options] 198 199 Options 200 -c ID, --collection ID The Nimbus collection ID to import from 201 default: ${DEFAULT_COLLECTION_ID} 202 -e, --experiments Import all messaging experiments, not just rollouts 203 -s, --skip-validation Skip validation of experiments and messages 204 -h, --help Show this help message 205 206 Examples 207 $ node bin/import-rollouts.js --collection nimbus-preview 208 $ ./mach npm run import-rollouts --prefix=browser/components/asrouter -- -e 209 `, 210 { 211 description: false, 212 // `pkg` is a tiny optimization. It prevents meow from looking for a package 213 // that doesn't technically exist. meow searches for a package and changes 214 // the process name to the package name. It resolves to the newtab 215 // package.json, which would give a confusing name and be wasteful. 216 pkg: { 217 name: "import-rollouts", 218 version: "1.0.0", 219 }, 220 // `importMeta` is required by meow 10+. It was added to support ESM, but 221 // meow now requires it, and no longer supports CJS style imports. But it 222 // only uses import.meta.url, which can be polyfilled like this: 223 importMeta: { url: fileUrl }, 224 flags: { 225 collection: { 226 type: "string", 227 shortFlag: "c", 228 default: DEFAULT_COLLECTION_ID, 229 }, 230 experiments: { 231 type: "boolean", 232 shortFlag: "e", 233 default: false, 234 }, 235 skipValidation: { 236 type: "boolean", 237 shortFlag: "s", 238 default: false, 239 }, 240 }, 241 } 242 ); 243 244 const RECORDS_URL = `${BASE_URL}${cli.flags.collection}/records`; 245 246 console.log(`Fetching records from ${chalk.underline.yellow(RECORDS_URL)}`); 247 248 const { data: records } = await fetchJSON(RECORDS_URL); 249 250 if (!Array.isArray(records)) { 251 throw new TypeError( 252 `Expected records to be an array, got ${typeof records}` 253 ); 254 } 255 256 const recipes = records.filter( 257 record => 258 record.application === "firefox-desktop" && 259 record.featureIds.some(id => 260 MESSAGING_EXPERIMENTS_DEFAULT_FEATURES.includes(id) 261 ) && 262 (record.isRollout || cli.flags.experiments) 263 ); 264 265 const importItems = []; 266 const { experimentValidator, messageValidators } = await getMessageValidators( 267 cli.flags.skipValidation 268 ); 269 for (const recipe of recipes) { 270 const { slug: experimentSlug, branches, targeting } = recipe; 271 if (!(experimentSlug && Array.isArray(branches) && branches.length)) { 272 continue; 273 } 274 console.log( 275 `Processing ${recipe.isRollout ? "rollout" : "experiment"}: ${chalk.blue( 276 experimentSlug 277 )}${ 278 branches.length > 1 279 ? ` with ${chalk.underline(`${String(branches.length)} branches`)}` 280 : "" 281 }` 282 ); 283 const recipeUrl = `${EXPERIMENTER_URL}${experimentSlug}/summary`; 284 const [, minVersion] = 285 targeting?.match(/\(version\|versionCompare\(\'([0-9]+)\.!\'\) >= 0/) || 286 []; 287 const [, maxVersion] = 288 targeting?.match(/\(version\|versionCompare\(\'([0-9]+)\.\*\'\) <= 0/) || 289 []; 290 let branchIndex = branches.length > 1 ? 1 : 0; 291 for (const branch of branches) { 292 const { slug: branchSlug, features } = branch; 293 console.log( 294 ` Processing branch${ 295 branchIndex > 0 ? ` ${branchIndex} of ${branches.length}` : "" 296 }: ${chalk.blue(branchSlug)}` 297 ); 298 branchIndex += 1; 299 const url = `${recipeUrl}#${branchSlug}`; 300 if (!Array.isArray(features)) { 301 continue; 302 } 303 for (const feature of features) { 304 if ( 305 feature.enabled && 306 MESSAGING_EXPERIMENTS_DEFAULT_FEATURES.includes(feature.featureId) && 307 feature.value && 308 typeof feature.value === "object" && 309 feature.value.template 310 ) { 311 if (!isMessageValid(experimentValidator, feature.value)) { 312 console.log( 313 ` ${chalk.red( 314 "✗" 315 )} Skipping invalid value for branch: ${chalk.blue(branchSlug)}` 316 ); 317 continue; 318 } 319 const messages = ( 320 feature.value.template === "multi" && 321 Array.isArray(feature.value.messages) 322 ? feature.value.messages 323 : [feature.value] 324 ).filter(m => m && m.id); 325 let msgIndex = messages.length > 1 ? 1 : 0; 326 for (const message of messages) { 327 let messageLogString = `message${ 328 msgIndex > 0 ? ` ${msgIndex} of ${messages.length}` : "" 329 }: ${chalk.italic.green(message.id)}`; 330 if (!isMessageValid(messageValidators[message.template], message)) { 331 console.log( 332 ` ${chalk.red("✗")} Skipping invalid ${messageLogString}` 333 ); 334 continue; 335 } 336 console.log(` Importing ${messageLogString}`); 337 let slug = `${experimentSlug}:${branchSlug}`; 338 if (msgIndex > 0) { 339 slug += ` (message ${msgIndex} of ${messages.length})`; 340 } 341 msgIndex += 1; 342 importItems.push({ message, slug, minVersion, maxVersion, url }); 343 } 344 } 345 } 346 } 347 } 348 349 const content = `${LICENSE_STRING} 350 351 /** 352 * This file is generated by browser/components/asrouter/bin/import-rollouts.js 353 * Run the following from the repository root to regenerate it: 354 * ./mach npm run import-rollouts --prefix=browser/components/asrouter 355 */ 356 357 export const NimbusRolloutMessageProvider = { 358 getMessages() { 359 return [${importItems.map(annotateMessage).join(",\n")}]; 360 }, 361 }; 362 `; 363 364 const formattedContent = await format(content); 365 366 await util.promisify(fs.writeFile)(OUTPUT_PATH, formattedContent); 367 368 console.log( 369 `${chalk.green("✓")} Wrote ${chalk.underline.green( 370 `${String(importItems.length)} ${ 371 importItems.length === 1 ? "message" : "messages" 372 }` 373 )} to ${chalk.underline.yellow(path.resolve(OUTPUT_PATH))}` 374 ); 375 } 376 377 main();