diff options
Diffstat (limited to 'browser/components/asrouter')
152 files changed, 59107 insertions, 0 deletions
diff --git a/browser/components/asrouter/.eslintrc.js b/browser/components/asrouter/.eslintrc.js new file mode 100644 index 0000000000..7a67e797e6 --- /dev/null +++ b/browser/components/asrouter/.eslintrc.js @@ -0,0 +1,155 @@ +/* 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/. */ + +module.exports = { + // When adding items to this file please check for effects on sub-directories. + plugins: ["import", "react", "jsx-a11y"], + settings: { + react: { + version: "16.2.0", + }, + }, + extends: ["plugin:jsx-a11y/recommended"], + overrides: [ + { + // TODO: Bug 1773467 - Move these to .mjs or figure out a generic way + // to identify these as modules. + files: ["content-src/**/*.js", "tests/unit/**/*.js"], + parserOptions: { + sourceType: "module", + }, + }, + { + files: ["bin/**", "content-src/**", "tests/unit/**"], + env: { + node: true, + }, + }, + { + // Use a configuration that's appropriate for modules, workers and + // non-production files. + files: ["tests/**", "modules/**"], + rules: { + "no-implicit-globals": "off", + }, + }, + { + files: ["content-src/**", "tests/unit/**"], + rules: { + // Disallow commonjs in these directories. + "import/no-commonjs": 2, + }, + }, + { + // These tests simulate the browser environment. + files: "tests/unit/**", + env: { + browser: true, + mocha: true, + }, + globals: { + assert: true, + chai: true, + sinon: true, + }, + }, + { + files: "tests/**", + rules: { + "func-name-matching": 0, + "lines-between-class-members": 0, + }, + }, + ], + rules: { + "fetch-options/no-fetch-credentials": "error", + + "react/jsx-boolean-value": ["error", "always"], + "react/jsx-key": "error", + "react/jsx-no-bind": [ + "error", + { allowArrowFunctions: true, allowFunctions: true }, + ], + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-undef": "error", + "react/jsx-pascal-case": "error", + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/no-access-state-in-setstate": "error", + "react/no-danger": "error", + "react/no-deprecated": "error", + "react/no-did-mount-set-state": "error", + "react/no-did-update-set-state": "error", + "react/no-direct-mutation-state": "error", + "react/no-is-mounted": "error", + "react/no-unknown-property": "error", + "react/require-render-return": "error", + + "accessor-pairs": ["error", { setWithoutGet: true, getWithoutSet: false }], + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-this": ["error", "use-bind"], + eqeqeq: "error", + "func-name-matching": "error", + "getter-return": "error", + "guard-for-in": "error", + "max-depth": ["error", 4], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 6], + "max-statements": ["error", 50], + "new-cap": ["error", { newIsCap: true, capIsNew: false }], + "no-alert": "error", + "no-console": ["error", { allow: ["error"] }], + "no-div-regex": "error", + "no-duplicate-imports": "error", + "no-eq-null": "error", + "no-extend-native": "error", + "no-extra-label": "error", + "no-implicit-coercion": ["error", { allow: ["!!"] }], + "no-implicit-globals": "error", + "no-loop-func": "error", + "no-multi-assign": "error", + "no-multi-str": "error", + "no-new": "error", + "no-new-func": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-proto": "error", + "no-prototype-builtins": "error", + "no-return-assign": ["error", "except-parens"], + "no-script-url": "error", + "no-shadow": "error", + "no-template-curly-in-string": "error", + "no-undef-init": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-use-before-define": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "no-var": "error", + "no-void": ["error", { allowAsStatement: true }], + "one-var": ["error", "never"], + "operator-assignment": ["error", "always"], + "prefer-destructuring": [ + "error", + { + AssignmentExpression: { array: true }, + VariableDeclarator: { array: true, object: true }, + }, + ], + "prefer-numeric-literals": "error", + "prefer-promise-reject-errors": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + radix: ["error", "always"], + "sort-vars": "error", + "symbol-description": "error", + "vars-on-top": "error", + yoda: ["error", "never"], + }, +}; diff --git a/browser/components/asrouter/README.md b/browser/components/asrouter/README.md new file mode 100644 index 0000000000..213093af86 --- /dev/null +++ b/browser/components/asrouter/README.md @@ -0,0 +1,23 @@ +# Activity Stream Router + +## Preferences `browser.newtab.activity-stream.asrouter.*` + +Name | Used for | Type | Example value +--- | --- | --- | --- +`allowHosts` | Allow a host in order to fetch messages from its endpoint | `[String]` | `["gist.github.com", "gist.githubusercontent.com", "localhost:8000"]` +`providers.cfr` | Message provider options for cfr | `Object` | [see below](#message-providers) +`providers.onboarding` | Message provider options for onboarding | `Object` | [see below](#message-providers) +`useRemoteL10n` | Controls whether to use the remote Fluent files for l10n, default as `true` | `Boolean` | `[true|false]` + +### Message providers examples + +```json +{ + "id" : "onboarding", + "enabled": true, + "type" : "local", + "localProvider" : "OnboardingMessageProvider" +} +``` + +### [Message format documentation](https://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/schemas/message-format.md) diff --git a/browser/components/asrouter/actors/ASRouterChild.sys.mjs b/browser/components/asrouter/actors/ASRouterChild.sys.mjs new file mode 100644 index 0000000000..8e5fd5ccf5 --- /dev/null +++ b/browser/components/asrouter/actors/ASRouterChild.sys.mjs @@ -0,0 +1,123 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// MESSAGE_TYPE_LIST and MESSAGE_TYPE_HASH, and overrides importESModule +// to be a no-op (which can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { MESSAGE_TYPE_LIST, MESSAGE_TYPE_HASH: msg } = + ChromeUtils.importESModule( + "resource:///modules/asrouter/ActorConstants.sys.mjs" + ); + +const VALID_TYPES = new Set(MESSAGE_TYPE_LIST); + +export class ASRouterChild extends JSWindowActorChild { + constructor() { + super(); + this.observers = new Set(); + } + + didDestroy() { + this.observers.clear(); + } + + actorCreated() { + // NOTE: DOMDocElementInserted may be called multiple times per + // PWindowGlobal due to the initial about:blank document's window global + // being re-used. + const window = this.contentWindow; + Cu.exportFunction(this.asRouterMessage.bind(this), window, { + defineAs: "ASRouterMessage", + }); + Cu.exportFunction(this.addParentListener.bind(this), window, { + defineAs: "ASRouterAddParentListener", + }); + Cu.exportFunction(this.removeParentListener.bind(this), window, { + defineAs: "ASRouterRemoveParentListener", + }); + } + + handleEvent(event) { + // DOMDocElementCreated is only used to create the actor. + } + + addParentListener(listener) { + this.observers.add(listener); + } + + removeParentListener(listener) { + this.observers.delete(listener); + } + + receiveMessage({ name, data }) { + switch (name) { + case "UpdateAdminState": + case "ClearProviders": { + this.observers.forEach(listener => { + let result = Cu.cloneInto( + { + type: name, + data, + }, + this.contentWindow + ); + listener(result); + }); + break; + } + } + } + + wrapPromise(promise) { + return new this.contentWindow.Promise((resolve, reject) => + promise.then(resolve, reject) + ); + } + + sendQuery(aName, aData = null) { + return this.wrapPromise( + new Promise(resolve => { + super.sendQuery(aName, aData).then(result => { + resolve(Cu.cloneInto(result, this.contentWindow)); + }); + }) + ); + } + + asRouterMessage({ type, data }) { + // Some legacy privileged addons send this message, but it got removed from + // VALID_TYPES in bug 1715158. Thankfully, these addons don't appear to + // require any actions from this message - just a Promise that resolves. + if (type === "NEWTAB_MESSAGE_REQUEST") { + return this.wrapPromise(Promise.resolve()); + } + + if (VALID_TYPES.has(type)) { + switch (type) { + case msg.DISABLE_PROVIDER: + case msg.ENABLE_PROVIDER: + case msg.EXPIRE_QUERY_CACHE: + case msg.FORCE_WHATSNEW_PANEL: + case msg.CLOSE_WHATSNEW_PANEL: + case msg.FORCE_PRIVATE_BROWSING_WINDOW: + case msg.IMPRESSION: + case msg.RESET_PROVIDER_PREF: + case msg.SET_PROVIDER_USER_PREF: + case msg.USER_ACTION: { + return this.sendAsyncMessage(type, data); + } + default: { + // these messages need a response + return this.sendQuery(type, data); + } + } + } + throw new Error(`Unexpected type "${type}"`); + } +} diff --git a/browser/components/asrouter/actors/ASRouterParent.sys.mjs b/browser/components/asrouter/actors/ASRouterParent.sys.mjs new file mode 100644 index 0000000000..aab909df05 --- /dev/null +++ b/browser/components/asrouter/actors/ASRouterParent.sys.mjs @@ -0,0 +1,98 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module, since +// it doesn't seem to understand using static import for sys.mjs +// files. +// eslint-disable-next-line mozilla/use-static-import +const { ASRouterNewTabHook } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterNewTabHook.sys.mjs" +); + +import { ASRouterDefaultConfig } from "resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs"; + +export class ASRouterTabs { + constructor({ asRouterNewTabHook }) { + this.actors = new Set(); + this.destroy = () => {}; + // This is one of several entrypoints to ASRouter Initialization. There is + // another one in BrowserGlue, and another in BackgroundTaskUtils. + asRouterNewTabHook.createInstance(ASRouterDefaultConfig()); + this.loadingMessageHandler = asRouterNewTabHook + .getInstance() + .then(initializer => { + const parentProcessMessageHandler = initializer.connect({ + clearChildMessages: ids => this.messageAll("ClearMessages", ids), + clearChildProviders: ids => this.messageAll("ClearProviders", ids), + updateAdminState: state => this.messageAll("UpdateAdminState", state), + }); + this.destroy = () => { + initializer.disconnect(); + }; + return parentProcessMessageHandler; + }); + } + + get size() { + return this.actors.size; + } + + messageAll(message, data) { + return Promise.all( + [...this.actors].map(a => a.sendAsyncMessage(message, data)) + ); + } + + registerActor(actor) { + this.actors.add(actor); + } + + unregisterActor(actor) { + this.actors.delete(actor); + } +} + +const defaultTabsFactory = () => + new ASRouterTabs({ asRouterNewTabHook: ASRouterNewTabHook }); + +export class ASRouterParent extends JSWindowActorParent { + static tabs = null; + + static nextTabId = 0; + + constructor({ tabsFactory } = { tabsFactory: defaultTabsFactory }) { + super(); + this.tabsFactory = tabsFactory; + } + + actorCreated() { + ASRouterParent.tabs = ASRouterParent.tabs || this.tabsFactory(); + this.tabsFactory = null; + this.tabId = ++ASRouterParent.nextTabId; + ASRouterParent.tabs.registerActor(this); + } + + didDestroy() { + ASRouterParent.tabs.unregisterActor(this); + if (ASRouterParent.tabs.size < 1) { + ASRouterParent.tabs.destroy(); + ASRouterParent.tabs = null; + } + } + + getTab() { + return { + id: this.tabId, + browser: this.browsingContext.embedderElement, + }; + } + + receiveMessage({ name, data }) { + return ASRouterParent.tabs.loadingMessageHandler.then(handler => { + return handler.handleMessage(name, data, this.getTab()); + }); + } +} diff --git a/browser/components/asrouter/bin/import-rollouts.js b/browser/components/asrouter/bin/import-rollouts.js new file mode 100644 index 0000000000..63f8a555b4 --- /dev/null +++ b/browser/components/asrouter/bin/import-rollouts.js @@ -0,0 +1,369 @@ +/* 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 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 = "./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 = { + 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 } + ), + whatsnew_panel_message: await getValidator( + "./content-src/templates/OnboardingMessage/WhatsNewMessage.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 } + ), + }; + + 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", + 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/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(); diff --git a/browser/components/asrouter/content-src/asrouter-utils.js b/browser/components/asrouter/content-src/asrouter-utils.js new file mode 100644 index 0000000000..65d25cb907 --- /dev/null +++ b/browser/components/asrouter/content-src/asrouter-utils.js @@ -0,0 +1,79 @@ +/* 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/. */ + +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; + +export const ASRouterUtils = { + addListener(listener) { + if (global.ASRouterAddParentListener) { + global.ASRouterAddParentListener(listener); + } + }, + removeListener(listener) { + if (global.ASRouterRemoveParentListener) { + global.ASRouterRemoveParentListener(listener); + } + }, + sendMessage(action) { + if (global.ASRouterMessage) { + return global.ASRouterMessage(action); + } + throw new Error(`Unexpected call:\n${JSON.stringify(action, null, 3)}`); + }, + blockById(id, options) { + return ASRouterUtils.sendMessage({ + type: msg.BLOCK_MESSAGE_BY_ID, + data: { id, ...options }, + }); + }, + modifyMessageJson(content) { + return ASRouterUtils.sendMessage({ + type: msg.MODIFY_MESSAGE_JSON, + data: { content }, + }); + }, + executeAction(button_action) { + return ASRouterUtils.sendMessage({ + type: msg.USER_ACTION, + data: button_action, + }); + }, + unblockById(id) { + return ASRouterUtils.sendMessage({ + type: msg.UNBLOCK_MESSAGE_BY_ID, + data: { id }, + }); + }, + blockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: msg.BLOCK_BUNDLE, + data: { bundle }, + }); + }, + unblockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: msg.UNBLOCK_BUNDLE, + data: { bundle }, + }); + }, + overrideMessage(id) { + return ASRouterUtils.sendMessage({ + type: msg.OVERRIDE_MESSAGE, + data: { id }, + }); + }, + editState(key, value) { + return ASRouterUtils.sendMessage({ + type: msg.EDIT_STATE, + data: { [key]: value }, + }); + }, + sendTelemetry(ping) { + return ASRouterUtils.sendMessage(ac.ASRouterUserEvent(ping)); + }, + getPreviewEndpoint() { + return null; + }, +}; diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx new file mode 100644 index 0000000000..f16dbacbd8 --- /dev/null +++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx @@ -0,0 +1,1498 @@ +/* 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/. */ + +import { ASRouterUtils } from "../../asrouter-utils"; +import React from "react"; +import ReactDOM from "react-dom"; +import { SimpleHashRouter } from "./SimpleHashRouter"; +import { CopyButton } from "./CopyButton"; +import { ImpressionsSection } from "./ImpressionsSection"; + +const Row = props => ( + <tr className="message-item" {...props}> + {props.children} + </tr> +); + +function relativeTime(timestamp) { + if (!timestamp) { + return ""; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + const minutes = Math.floor((Date.now() - timestamp) / 60000); + if (seconds < 2) { + return "just now"; + } else if (seconds < 60) { + return `${seconds} seconds ago`; + } else if (minutes === 1) { + return "1 minute ago"; + } else if (minutes < 600) { + return `${minutes} minutes ago`; + } + return new Date(timestamp).toLocaleString(); +} + +export class ToggleStoryButton extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.onClick(this.props.story); + } + + render() { + return <button onClick={this.handleClick}>collapse/open</button>; + } +} + +export class ToggleMessageJSON extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.toggleJSON(this.props.msgId); + } + + render() { + let iconName = this.props.isCollapsed + ? "icon icon-arrowhead-forward-small" + : "icon icon-arrowhead-down-small"; + return ( + <button className="clearButton" onClick={this.handleClick}> + <span className={iconName} /> + </button> + ); + } +} + +export class TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + <input + type="checkbox" + checked={this.props.checked} + onChange={this.onChange} + disabled={this.props.disabled} + />{" "} + {this.props.pref}{" "} + </> + ); + } +} + +export class ASRouterAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.handleEnabledToggle = this.handleEnabledToggle.bind(this); + this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); + this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); + this.onChangeMessageGroupsFilter = + this.onChangeMessageGroupsFilter.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.handleClearAllImpressionsByProvider = + this.handleClearAllImpressionsByProvider.bind(this); + this.handleExpressionEval = this.handleExpressionEval.bind(this); + this.onChangeTargetingParameters = + this.onChangeTargetingParameters.bind(this); + this.onChangeAttributionParameters = + this.onChangeAttributionParameters.bind(this); + this.setAttribution = this.setAttribution.bind(this); + this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); + this.onNewTargetingParams = this.onNewTargetingParams.bind(this); + this.handleOpenPB = this.handleOpenPB.bind(this); + this.selectPBMessage = this.selectPBMessage.bind(this); + this.resetPBJSON = this.resetPBJSON.bind(this); + this.resetPBMessageState = this.resetPBMessageState.bind(this); + this.toggleJSON = this.toggleJSON.bind(this); + this.toggleAllMessages = this.toggleAllMessages.bind(this); + this.resetGroups = this.resetGroups.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + this.setStateFromParent = this.setStateFromParent.bind(this); + this.setState = this.setState.bind(this); + this.state = { + messageFilter: "all", + messageGroupsFilter: "all", + collapsedMessages: [], + modifiedMessages: [], + selectedPBMessage: "", + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: `rta:${btoa("uBlock0@raymondhill.net")}`, + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000", + }, + }; + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "UpdateAdminState": { + this.setStateFromParent(data); + break; + } + } + } + + setStateFromParent(data) { + this.setState(data); + if (!this.state.stringTargetingParameters) { + const stringTargetingParameters = {}; + for (const param of Object.keys(data.targetingParameters)) { + stringTargetingParameters[param] = JSON.stringify( + data.targetingParameters[param], + null, + 2 + ); + } + this.setState({ stringTargetingParameters }); + } + } + + componentWillMount() { + ASRouterUtils.addListener(this.onMessageFromParent); + const endpoint = ASRouterUtils.getPreviewEndpoint(); + ASRouterUtils.sendMessage({ + type: "ADMIN_CONNECT_STATE", + data: { endpoint }, + }).then(this.setStateFromParent); + } + + componentWillUnmount() { + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + handleBlock(msg) { + return () => ASRouterUtils.blockById(msg.id); + } + + handleUnblock(msg) { + return () => ASRouterUtils.unblockById(msg.id); + } + + resetJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + // remove the message from the list of modified IDs + let index = this.state.modifiedMessages.indexOf(msg.id); + this.setState(prevState => ({ + modifiedMessages: [ + ...prevState.modifiedMessages.slice(0, index), + ...prevState.modifiedMessages.slice(index + 1), + ], + })); + } + + handleOverride(id) { + return () => + ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + }); + } + + resetPBMessageState() { + // Iterate over Private Browsing messages and block/unblock each one to clear impressions + const PBMessages = this.state.messages.filter( + message => message.template === "pb_newtab" + ); // messages from state go here + + PBMessages.forEach(message => { + if (message?.id) { + ASRouterUtils.blockById(message.id); + ASRouterUtils.unblockById(message.id); + } + }); + // Clear the selected messages & radio buttons + document.getElementById("clear radio").checked = true; + this.selectPBMessage("clear"); + } + + resetPBJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + } + + handleOpenPB() { + ASRouterUtils.sendMessage({ + type: "FORCE_PRIVATE_BROWSING_WINDOW", + data: { message: { content: this.state.selectedPBMessage } }, + }); + } + + expireCache() { + ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); + } + + resetPref() { + ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); + } + + resetGroups(id, value) { + ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE", + }).then(this.setStateFromParent); + } + + handleExpressionEval() { + const context = {}; + for (const param of Object.keys(this.state.stringTargetingParameters)) { + const value = this.state.stringTargetingParameters[param]; + context[param] = value ? JSON.parse(value) : null; + } + ASRouterUtils.sendMessage({ + type: "EVALUATE_JEXL_EXPRESSION", + data: { + expression: this.refs.expressionInput.value, + context, + }, + }).then(this.setStateFromParent); + } + + onChangeTargetingParameters(event) { + const { name } = event.target; + const { value } = event.target; + + this.setState(({ stringTargetingParameters }) => { + let targetingParametersError = null; + const updatedParameters = { ...stringTargetingParameters }; + updatedParameters[name] = value; + try { + JSON.parse(value); + } catch (e) { + console.error(`Error parsing value of parameter ${name}`); + targetingParametersError = { id: name }; + } + + return { + copiedToClipboard: false, + evaluationStatus: {}, + stringTargetingParameters: updatedParameters, + targetingParametersError, + }; + }); + } + + unblockAll() { + return ASRouterUtils.sendMessage({ + type: "UNBLOCK_ALL", + }).then(this.setStateFromParent); + } + + handleClearAllImpressionsByProvider() { + const providerId = this.state.messageFilter; + if (!providerId) { + return; + } + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + providerId in userPrefInfo ? userPrefInfo[providerId] : true; + + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: providerId, + }); + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: providerId, value: true }, + }); + } + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: providerId, + }); + } + + handleEnabledToggle(event) { + const provider = this.state.providerPrefs.find( + p => p.id === event.target.dataset.provider + ); + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = provider.enabled; + const isEnabling = event.target.checked; + + if (isEnabling) { + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: provider.id, value: true }, + }); + } + if (!isSystemEnabled) { + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: provider.id, + }); + } + } else { + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: provider.id, + }); + } + + this.setState({ messageFilter: "all" }); + } + + handleUserPrefToggle(event) { + const action = { + type: "SET_PROVIDER_USER_PREF", + data: { id: event.target.dataset.provider, value: event.target.checked }, + }; + ASRouterUtils.sendMessage(action); + this.setState({ messageFilter: "all" }); + } + + onChangeMessageFilter(event) { + this.setState({ messageFilter: event.target.value }); + } + + onChangeMessageGroupsFilter(event) { + this.setState({ messageGroupsFilter: event.target.value }); + } + + // Simulate a copy event that sets to clipboard all targeting paramters and values + onCopyTargetingParams(event) { + const stringTargetingParameters = { + ...this.state.stringTargetingParameters, + }; + for (const key of Object.keys(stringTargetingParameters)) { + // If the value is not set the parameter will be lost when we stringify + if (stringTargetingParameters[key] === undefined) { + stringTargetingParameters[key] = null; + } + } + const setClipboardData = e => { + e.preventDefault(); + e.clipboardData.setData( + "text", + JSON.stringify(stringTargetingParameters, null, 2) + ); + document.removeEventListener("copy", setClipboardData); + this.setState({ copiedToClipboard: true }); + }; + + document.addEventListener("copy", setClipboardData); + + document.execCommand("copy"); + } + + onNewTargetingParams(event) { + this.setState({ newStringTargetingParameters: event.target.value }); + event.target.classList.remove("errorState"); + this.refs.targetingParamsEval.innerText = ""; + + try { + const stringTargetingParameters = JSON.parse(event.target.value); + this.setState({ stringTargetingParameters }); + } catch (e) { + event.target.classList.add("errorState"); + this.refs.targetingParamsEval.innerText = e.message; + } + } + + toggleJSON(msgId) { + if (this.state.collapsedMessages.includes(msgId)) { + let index = this.state.collapsedMessages.indexOf(msgId); + this.setState(prevState => ({ + collapsedMessages: [ + ...prevState.collapsedMessages.slice(0, index), + ...prevState.collapsedMessages.slice(index + 1), + ], + })); + } else { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msgId), + })); + } + } + + handleChange(msgId) { + if (!this.state.modifiedMessages.includes(msgId)) { + this.setState(prevState => ({ + modifiedMessages: prevState.modifiedMessages.concat(msgId), + })); + } + } + + renderMessageItem(msg) { + const isBlockedByGroup = this.state.groups + .filter(group => msg.groups.includes(group.id)) + .some(group => !group.enabled); + const msgProvider = + this.state.providers.find(provider => provider.id === msg.provider) || {}; + const isProviderExcluded = + msgProvider.exclude && msgProvider.exclude.includes(msg.id); + const isMessageBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const isBlocked = + isMessageBlocked || isBlockedByGroup || isProviderExcluded; + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + const isModified = this.state.modifiedMessages.includes(msg.id); + const aboutMessagePreviewSupported = [ + "infobar", + "spotlight", + "cfr_doorhanger", + ].includes(msg.template); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}> + <td className="message-id"> + <span> + {msg.id} <br /> + </span> + </td> + <td> + <ToggleMessageJSON + msgId={`${msg.id}`} + toggleJSON={this.toggleJSON} + isCollapsed={isCollapsed} + /> + </td> + <td className="button-column"> + <button + className={`button ${isBlocked ? "" : " primary"}`} + onClick={ + isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + } + > + {isBlocked ? "Unblock" : "Block"} + </button> + { + // eslint-disable-next-line no-nested-ternary + isBlocked ? null : isModified ? ( + <button + className="button restore" + onClick={e => this.resetJSON(msg)} + > + Reset + </button> + ) : ( + <button + className="button show" + onClick={this.handleOverride(msg.id)} + > + Show + </button> + ) + } + {isBlocked ? null : ( + <button + className="button modify" + onClick={e => this.modifyJson(msg)} + > + Modify + </button> + )} + {aboutMessagePreviewSupported ? ( + <CopyButton + transformer={text => + `about:messagepreview?json=${encodeURIComponent(btoa(text))}` + } + label="Share" + copiedLabel="Copied!" + inputSelector={`#${msg.id}-textarea`} + className={"button share"} + /> + ) : null} + <br />({impressions} impressions) + </td> + <td className="message-summary"> + {isBlocked && ( + <tr> + Block reason: + {isBlockedByGroup && " Blocked by group"} + {isProviderExcluded && " Excluded by provider"} + {isMessageBlocked && " Message blocked"} + </tr> + )} + <tr> + <pre className={isCollapsed ? "collapsed" : "expanded"}> + <textarea + id={`${msg.id}-textarea`} + name={msg.id} + className="general-textarea" + disabled={isBlocked} + onChange={e => this.handleChange(msg.id)} + > + {JSON.stringify(msg, null, 2)} + </textarea> + </pre> + </tr> + </td> + </tr> + ); + } + + selectPBMessage(msgId) { + if (msgId === "clear") { + this.setState({ + selectedPBMessage: "", + }); + } else { + let selected = document.getElementById(`${msgId} radio`); + let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value); + + if (selected.checked) { + this.setState({ + selectedPBMessage: msg?.content, + }); + } else { + this.setState({ + selectedPBMessage: "", + }); + } + } + } + + modifyJson(content) { + const message = JSON.parse( + document.getElementById(`${content.id}-textarea`).value + ); + return ASRouterUtils.modifyMessageJson(message).then(state => { + this.setStateFromParent(state); + }); + } + + renderPBMessageItem(msg) { + const isBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}> + <td className="message-id"> + <span> + {msg.id} <br /> + <br />({impressions} impressions) + </span> + </td> + <td> + <ToggleMessageJSON + msgId={`${msg.id}`} + toggleJSON={this.toggleJSON} + isCollapsed={isCollapsed} + /> + </td> + <td> + <input + type="radio" + id={`${msg.id} radio`} + name="PB_message_radio" + style={{ marginBottom: 20 }} + onClick={() => this.selectPBMessage(msg.id)} + disabled={isBlocked} + /> + <button + className={`button ${isBlocked ? "" : " primary"}`} + onClick={ + isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + } + > + {isBlocked ? "Unblock" : "Block"} + </button> + <button + className="ASRouterButton slim button" + onClick={e => this.resetPBJSON(msg)} + > + Reset JSON + </button> + </td> + <td className={`message-summary`}> + <pre className={isCollapsed ? "collapsed" : "expanded"}> + <textarea + id={`${msg.id}-textarea`} + className="wnp-textarea" + name={msg.id} + > + {JSON.stringify(msg, null, 2)} + </textarea> + </pre> + </td> + </tr> + ); + } + + toggleAllMessages(messagesToShow) { + if (this.state.collapsedMessages.length) { + this.setState({ + collapsedMessages: [], + }); + } else { + Array.prototype.forEach.call(messagesToShow, msg => { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msg.id), + })); + }); + } + } + + renderMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageFilter === "all" + ? this.state.messages + : this.state.messages.filter( + message => + message.provider === this.state.messageFilter && + message.template !== "pb_newtab" + ); + + return ( + <div> + <button + className="ASRouterButton slim" + onClick={e => this.toggleAllMessages(messagesToShow)} + > + Collapse/Expand All + </button> + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + To modify a message, change the JSON and click 'Modify' to see your + changes. Click 'Reset' to restore the JSON to the original. Click + 'Share' to copy a link to the clipboard that can be used to preview + the message by opening the link in Nightly/local builds. + </span> + </p> + <table> + <tbody> + {messagesToShow.map(msg => this.renderMessageItem(msg))} + </tbody> + </table> + </div> + ); + } + + renderMessagesByGroup() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageGroupsFilter === "all" + ? this.state.messages.filter(m => m.groups.length) + : this.state.messages.filter(message => + message.groups.includes(this.state.messageGroupsFilter) + ); + + return ( + <table> + <tbody>{messagesToShow.map(msg => this.renderMessageItem(msg))}</tbody> + </table> + ); + } + + renderPBMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + return ( + <table> + <tbody> + {messagesToShow.map(msg => this.renderPBMessageItem(msg))} + </tbody> + </table> + ); + } + + renderMessageFilter() { + if (!this.state.providers) { + return null; + } + + return ( + <p> + <button + className="unblock-all ASRouterButton test-only" + onClick={this.unblockAll} + > + Unblock All Snippets + </button> + Show messages from{" "} + <select + value={this.state.messageFilter} + onChange={this.onChangeMessageFilter} + > + <option value="all">all providers</option> + {this.state.providers.map(provider => ( + <option key={provider.id} value={provider.id}> + {provider.id} + </option> + ))} + </select> + {this.state.messageFilter !== "all" && + !this.state.messageFilter.includes("_local_testing") ? ( + <button + className="button messages-reset" + onClick={this.handleClearAllImpressionsByProvider} + > + Reset All + </button> + ) : null} + </p> + ); + } + + renderMessageGroupsFilter() { + if (!this.state.groups) { + return null; + } + + return ( + <p> + Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select + value={this.state.messageGroupsFilter} + onChange={this.onChangeMessageGroupsFilter} + > + <option value="all">all groups</option> + {this.state.groups.map(group => ( + <option key={group.id} value={group.id}> + {group.id} + </option> + ))} + </select> + </p> + ); + } + + renderTableHead() { + return ( + <thead> + <tr className="message-item"> + <td className="min" /> + <td className="min">Provider ID</td> + <td>Source</td> + <td className="min">Cohort</td> + <td className="min">Last Updated</td> + </tr> + </thead> + ); + } + + renderProviders() { + const providersConfig = this.state.providerPrefs; + const providerInfo = this.state.providers; + const userPrefInfo = this.state.userPrefs; + + return ( + <table> + {this.renderTableHead()} + <tbody> + {providersConfig.map((provider, i) => { + const isTestProvider = provider.id.includes("_local_testing"); + const info = providerInfo.find(p => p.id === provider.id) || {}; + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = isTestProvider || provider.enabled; + + let label = "local"; + if (provider.type === "remote") { + label = ( + <span> + endpoint ( + <a + className="providerUrl" + target="_blank" + href={info.url} + rel="noopener noreferrer" + > + {info.url} + </a> + ) + </span> + ); + } else if (provider.type === "remote-settings") { + label = `remote settings (${provider.collection})`; + } else if (provider.type === "remote-experiments") { + label = ( + <span> + remote settings ( + <a + className="providerUrl" + target="_blank" + href="https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/nimbus-desktop-experiments/records" + rel="noopener noreferrer" + > + nimbus-desktop-experiments + </a> + ) + </span> + ); + } + + let reasonsDisabled = []; + if (!isSystemEnabled) { + reasonsDisabled.push("system pref"); + } + if (!isUserEnabled) { + reasonsDisabled.push("user pref"); + } + if (reasonsDisabled.length) { + label = `disabled via ${reasonsDisabled.join(", ")}`; + } + + return ( + <tr className="message-item" key={i}> + <td> + {isTestProvider ? ( + <input + type="checkbox" + disabled={true} + readOnly={true} + checked={true} + /> + ) : ( + <input + type="checkbox" + data-provider={provider.id} + checked={isUserEnabled && isSystemEnabled} + onChange={this.handleEnabledToggle} + /> + )} + </td> + <td>{provider.id}</td> + <td> + <span + className={`sourceLabel${ + isUserEnabled && isSystemEnabled ? "" : " isDisabled" + }`} + > + {label} + </span> + </td> + <td>{provider.cohort}</td> + <td style={{ whiteSpace: "nowrap" }}> + {info.lastUpdated + ? new Date(info.lastUpdated).toLocaleString() + : ""} + </td> + </tr> + ); + })} + </tbody> + </table> + ); + } + + renderTargetingParameters() { + // There was no error and the result is truthy + const success = + this.state.evaluationStatus.success && + !!this.state.evaluationStatus.result; + const result = + JSON.stringify(this.state.evaluationStatus.result, null, 2) || + "(Empty result)"; + + return ( + <table> + <tbody> + <tr> + <td> + <h2>Evaluate JEXL expression</h2> + </td> + </tr> + <tr> + <td> + <p> + <textarea + ref="expressionInput" + rows="10" + cols="60" + placeholder="Evaluate JEXL expressions and mock parameters by changing their values below" + /> + </p> + <p> + Status:{" "} + <span ref="evaluationStatus"> + {success ? "✅" : "❌"}, Result: {result} + </span> + </p> + </td> + <td> + <button + className="ASRouterButton secondary" + onClick={this.handleExpressionEval} + > + Evaluate + </button> + </td> + </tr> + <tr> + <td> + <h2>Modify targeting parameters</h2> + </td> + </tr> + <tr> + <td> + <button + className="ASRouterButton secondary" + onClick={this.onCopyTargetingParams} + disabled={this.state.copiedToClipboard} + > + {this.state.copiedToClipboard + ? "Parameters copied!" + : "Copy parameters"} + </button> + </td> + </tr> + {this.state.stringTargetingParameters && + Object.keys(this.state.stringTargetingParameters).map( + (param, i) => { + const value = this.state.stringTargetingParameters[param]; + const errorState = + this.state.targetingParametersError && + this.state.targetingParametersError.id === param; + const className = errorState ? "errorState" : ""; + const inputComp = + (value && value.length) > 30 ? ( + <textarea + name={param} + className={className} + value={value} + rows="10" + cols="60" + onChange={this.onChangeTargetingParameters} + /> + ) : ( + <input + name={param} + className={className} + value={value} + onChange={this.onChangeTargetingParameters} + /> + ); + + return ( + <tr key={i}> + <td>{param}</td> + <td>{inputComp}</td> + </tr> + ); + } + )} + </tbody> + </table> + ); + } + + onChangeAttributionParameters(event) { + const { name, value } = event.target; + + this.setState(({ attributionParameters }) => { + const updatedParameters = { ...attributionParameters }; + updatedParameters[name] = value; + + return { attributionParameters: updatedParameters }; + }); + } + + setAttribution(e) { + ASRouterUtils.sendMessage({ + type: "FORCE_ATTRIBUTION", + data: this.state.attributionParameters, + }).then(this.setStateFromParent); + } + + _getGroupImpressionsCount(id, frequency) { + if (frequency) { + return this.state.groupImpressions[id] + ? this.state.groupImpressions[id].length + : 0; + } + + return "n/a"; + } + + renderAttributionParamers() { + return ( + <div> + <h2> Attribution Parameters </h2> + <p> + {" "} + This forces the browser to set some attribution parameters, useful for + testing the Return To AMO feature. Clicking on 'Force Attribution', + with the default values in each field, will demo the Return To AMO + flow with the addon called 'uBlock Origin'. If you wish to try + different attribution parameters, enter them in the text boxes. If you + wish to try a different addon with the Return To AMO flow, make sure + the 'content' text box has a string that is 'rta:base64(addonID)', the + base64 string of the addonID prefixed with 'rta:'. The addon must + currently be a recommended addon on AMO. Then click 'Force + Attribution'. Clicking on 'Force Attribution' with blank text boxes + reset attribution data. + </p> + <table> + <tr> + <td> + <b> Source </b> + </td> + <td> + {" "} + <input + type="text" + name="source" + placeholder="addons.mozilla.org" + value={this.state.attributionParameters.source} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Medium </b> + </td> + <td> + {" "} + <input + type="text" + name="medium" + placeholder="referral" + value={this.state.attributionParameters.medium} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Campaign </b> + </td> + <td> + {" "} + <input + type="text" + name="campaign" + placeholder="non-fx-button" + value={this.state.attributionParameters.campaign} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Content </b> + </td> + <td> + {" "} + <input + type="text" + name="content" + placeholder={`rta:${btoa("uBlock0@raymondhill.net")}`} + value={this.state.attributionParameters.content} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Experiment </b> + </td> + <td> + {" "} + <input + type="text" + name="experiment" + placeholder="ua-onboarding" + value={this.state.attributionParameters.experiment} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Variation </b> + </td> + <td> + {" "} + <input + type="text" + name="variation" + placeholder="chrome" + value={this.state.attributionParameters.variation} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> User Agent </b> + </td> + <td> + {" "} + <input + type="text" + name="ua" + placeholder="Google Chrome 123" + value={this.state.attributionParameters.ua} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Download Token </b> + </td> + <td> + {" "} + <input + type="text" + name="dltoken" + placeholder="00000000-0000-0000-0000-000000000000" + value={this.state.attributionParameters.dltoken} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + {" "} + <button + className="ASRouterButton primary button" + onClick={this.setAttribution} + > + {" "} + Force Attribution{" "} + </button>{" "} + </td> + </tr> + </table> + </div> + ); + } + + renderErrorMessage({ id, errors }) { + const providerId = <td rowSpan={errors.length}>{id}</td>; + // .reverse() so that the last error (most recent) is first + return errors + .map(({ error, timestamp }, cellKey) => ( + <tr key={cellKey}> + {cellKey === errors.length - 1 ? providerId : null} + <td>{error.message}</td> + <td>{relativeTime(timestamp)}</td> + </tr> + )) + .reverse(); + } + + renderErrors() { + const providersWithErrors = + this.state.providers && + this.state.providers.filter(p => p.errors && p.errors.length); + + if (providersWithErrors && providersWithErrors.length) { + return ( + <table className="errorReporting"> + <thead> + <tr> + <th>Provider ID</th> + <th>Message</th> + <th>Timestamp</th> + </tr> + </thead> + <tbody>{providersWithErrors.map(this.renderErrorMessage)}</tbody> + </table> + ); + } + + return <p>No errors</p>; + } + + renderPBTab() { + if (!this.state.messages) { + return null; + } + let messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + + return ( + <div> + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + To view an available message, select its radio button and click + "Open a Private Browsing Window". + <br /> + To modify a message, make changes to the JSON first, then select the + radio button. (To make new changes, click "Reset Message State", + make your changes, and reselect the radio button.) + <br /> + Click "Reset Message State" to clear all message impressions and + view messages in a clean state. + <br /> + Note that ContentSearch functions do not work in debug mode. + </span> + </p> + <div> + <button + className="ASRouterButton primary button" + onClick={this.handleOpenPB} + > + Open a Private Browsing Window + </button> + <button + className="ASRouterButton primary button" + style={{ marginInlineStart: 12 }} + onClick={this.resetPBMessageState} + > + Reset Message State + </button> + <br /> + <input + type="radio" + id={`clear radio`} + name="PB_message_radio" + value="clearPBMessage" + style={{ display: "none" }} + /> + <h2>Messages</h2> + <button + className="ASRouterButton slim button" + onClick={e => this.toggleAllMessages(messagesToShow)} + > + Collapse/Expand All + </button> + {this.renderPBMessages()} + </div> + </div> + ); + } + + getSection() { + const [section] = this.props.location.routes; + switch (section) { + case "private": + return ( + <React.Fragment> + <h2>Private Browsing Messages</h2> + {this.renderPBTab()} + </React.Fragment> + ); + case "targeting": + return ( + <React.Fragment> + <h2>Targeting Utilities</h2> + <button className="button" onClick={this.expireCache}> + Expire Cache + </button>{" "} + (This expires the cache in ASR Targeting for bookmarks and top + sites) + {this.renderTargetingParameters()} + {this.renderAttributionParamers()} + </React.Fragment> + ); + case "groups": + return ( + <React.Fragment> + <h2>Message Groups</h2> + <button className="button" onClick={this.resetGroups}> + Reset group impressions + </button> + <table> + <thead> + <tr className="message-item"> + <td>Enabled</td> + <td>Impressions count</td> + <td>Custom frequency</td> + <td>User preferences</td> + </tr> + </thead> + <tbody> + {this.state.groups && + this.state.groups.map( + ( + { id, enabled, frequency, userPreferences = [] }, + index + ) => ( + <Row key={id}> + <td> + <TogglePrefCheckbox + checked={enabled} + pref={id} + disabled={true} + /> + </td> + <td>{this._getGroupImpressionsCount(id, frequency)}</td> + <td>{JSON.stringify(frequency, null, 2)}</td> + <td>{userPreferences.join(", ")}</td> + </Row> + ) + )} + </tbody> + </table> + {this.renderMessageGroupsFilter()} + {this.renderMessagesByGroup()} + </React.Fragment> + ); + case "impressions": + return ( + <React.Fragment> + <h2>Impressions</h2> + <ImpressionsSection + messageImpressions={this.state.messageImpressions} + groupImpressions={this.state.groupImpressions} + screenImpressions={this.state.screenImpressions} + /> + </React.Fragment> + ); + case "errors": + return ( + <React.Fragment> + <h2>ASRouter Errors</h2> + {this.renderErrors()} + </React.Fragment> + ); + default: + return ( + <React.Fragment> + <h2> + Message Providers{" "} + <button + title="Restore all provider settings that ship with Firefox" + className="button" + onClick={this.resetPref} + > + Restore default prefs + </button> + </h2> + {this.state.providers ? this.renderProviders() : null} + <h2>Messages</h2> + {this.renderMessageFilter()} + {this.renderMessages()} + </React.Fragment> + ); + } + } + + render() { + if (!this.state.devtoolsEnabled) { + return ( + <div className="asrouter-admin"> + You must enable the ASRouter Admin page by setting{" "} + <code> + browser.newtabpage.activity-stream.asrouter.devtoolsEnabled + </code>{" "} + to <code>true</code> and then reloading this page. + </div> + ); + } + + return ( + <div + className={`asrouter-admin ${ + this.props.collapsed ? "collapsed" : "expanded" + }`} + > + <aside className="sidebar"> + <ul> + <li> + <a href="#devtools">General</a> + </li> + <li> + <a href="#devtools-private">Private Browsing</a> + </li> + <li> + <a href="#devtools-targeting">Targeting</a> + </li> + <li> + <a href="#devtools-groups">Message Groups</a> + </li> + <li> + <a href="#devtools-impressions">Impressions</a> + </li> + <li> + <a href="#devtools-errors">Errors</a> + </li> + </ul> + </aside> + <main className="main-panel"> + <h1>AS Router Admin</h1> + + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + Need help using these tools? Check out our{" "} + <a + target="blank" + href="https://firefox-source-docs.mozilla.org/browser/components/newtab/content-src/asrouter/docs/debugging-docs.html" + > + documentation + </a> + </span> + </p> + + {this.getSection()} + </main> + </div> + ); + } +} + +export const ASRouterAdmin = props => ( + <SimpleHashRouter> + <ASRouterAdminInner {...props} /> + </SimpleHashRouter> +); + +export function renderASRouterAdmin() { + ReactDOM.render(<ASRouterAdmin />, document.getElementById("root")); +} diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss new file mode 100644 index 0000000000..67f1abcbac --- /dev/null +++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss @@ -0,0 +1,353 @@ +/* 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/. */ + +/* stylelint-disable max-nesting-depth */ + +@import '../../../../newtab/content-src/styles/variables'; +@import '../../../../newtab/content-src/styles/theme'; +@import '../../../../newtab/content-src/styles/icons'; +@import '../Button/Button'; + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif; +} + +/** + * These styles are copied verbatim from _activity-stream.scss in order to maintain + * a continuity of styling while also decoupling from the newtab code. This should + * be removed when about:asrouter starts using the default in-content style sheets. + */ +.button, +.actions button { + background-color: var(--newtab-button-secondary-color); + border: $border-primary; + border-radius: 4px; + color: inherit; + cursor: pointer; + margin-bottom: 15px; + padding: 10px 30px; + white-space: nowrap; + + &:hover:not(.dismiss), + &:focus:not(.dismiss) { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; + } + + &.dismiss { + background-color: transparent; + border: 0; + padding: 0; + text-decoration: underline; + } + + // Blue button + &.primary, + &.done { + background-color: var(--newtab-primary-action-background); + border: solid 1px var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); + margin-inline-start: auto; + } +} + +.asrouter-admin { + max-width: 1300px; + $border-color: var(--newtab-border-color); + $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', + 'Source Code Pro', monospace; + $sidebar-width: 240px; + + font-size: 14px; + padding-inline-start: $sidebar-width; + color: var(--newtab-text-primary-color); + + &.collapsed { + display: none; + } + + .sidebar { + inset-inline-start: 0; + position: fixed; + width: $sidebar-width; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li a { + padding: 10px 34px; + display: block; + color: var(--lwt-sidebar-text-color); + + &:hover { + background: var(--newtab-background-color-secondary); + } + } + } + + h1 { + font-weight: 200; + font-size: 32px; + } + + h2 .button, + p .button { + font-size: 14px; + padding: 6px 12px; + margin-inline-start: 5px; + margin-bottom: 0; + } + + .general-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .wnp-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .json-button { + display: inline-flex; + font-size: 10px; + padding: 4px 10px; + margin-bottom: 6px; + margin-inline-end: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + box-shadow: none; + } + } + + table { + border-collapse: collapse; + + &.minimal-table { + border-collapse: collapse; + border: 1px solid $border-color; + + td { + padding: 8px; + } + + td:first-child { + width: 1%; + white-space: nowrap; + } + + td:not(:first-child) { + font-family: $monospace; + } + } + + &.errorReporting { + tr { + border: 1px solid var(--newtab-background-color-secondary); + } + + td { + padding: 4px; + + &[rowspan] { + border: 1px solid var(--newtab-background-color-secondary); + } + } + } + } + + .sourceLabel { + background: var(--newtab-background-color-secondary); + padding: 2px 5px; + border-radius: 3px; + + &.isDisabled { + background: $email-input-invalid; + color: var(--newtab-status-error); + } + } + + .message-item { + &:first-child td { + border-top: 1px solid $border-color; + } + + td { + vertical-align: top; + padding: 8px; + border-bottom: 1px solid $border-color; + + &.min { + width: 1%; + white-space: nowrap; + } + + &.message-summary { + width: 60%; + } + + &.button-column { + width: 15%; + } + + &:first-child { + border-inline-start: 1px solid $border-color; + } + + &:last-child { + border-inline-end: 1px solid $border-color; + } + } + + &.blocked { + .message-id, + .message-summary { + opacity: 0.5; + } + + .message-id { + opacity: 0.5; + } + } + + .message-id { + font-family: $monospace; + font-size: 12px; + } + } + + .providerUrl { + font-size: 12px; + } + + pre { + background: var(--newtab-background-color-secondary); + margin: 0; + padding: 8px; + font-size: 12px; + max-width: 750px; + overflow: auto; + font-family: $monospace; + } + + .errorState { + border: $input-error-border; + } + + .helpLink { + padding: 10px; + display: flex; + background: $black-10; + border-radius: 3px; + align-items: center; + + a { + text-decoration: underline; + } + + .icon { + min-width: 18px; + min-height: 18px; + } + } + + .ds-component { + margin-bottom: 20px; + } + + .modalOverlayInner { + height: 80%; + } + + .clearButton { + border: 0; + padding: 4px; + border-radius: 4px; + display: flex; + + &:hover { + background: var(--newtab-element-hover-color); + } + } + + .collapsed { + display: none; + } + + .icon { + display: inline-table; + cursor: pointer; + width: 18px; + height: 18px; + } + + .button { + &:disabled, + &:disabled:active { + opacity: 0.5; + cursor: unset; + box-shadow: none; + } + } + + .impressions-section { + display: flex; + flex-direction: column; + gap: 16px; + + .impressions-item { + display: flex; + flex-flow: column nowrap; + padding: 8px; + border: 1px solid $border-color; + border-radius: 5px; + + .impressions-inner-box { + display: flex; + flex-flow: row nowrap; + gap: 8px; + } + + .impressions-category { + font-size: 1.15em; + white-space: nowrap; + flex-grow: 0.1; + } + + .impressions-buttons { + display: flex; + flex-direction: column; + gap: 8px; + + button { + margin: 0; + } + } + + .impressions-editor { + display: flex; + flex-grow: 1.5; + + .general-textarea { + width: auto; + flex-grow: 1; + } + } + } + } +} diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx new file mode 100644 index 0000000000..6739d38b97 --- /dev/null +++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx @@ -0,0 +1,33 @@ +/* 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/. */ + +import React, { useState, useRef, useCallback } from "react"; + +export const CopyButton = ({ + className, + label, + copiedLabel, + inputSelector, + transformer, + ...props +}) => { + const [copied, setCopied] = useState(false); + const timeout = useRef(null); + const onClick = useCallback(() => { + let text = document.querySelector(inputSelector).value; + if (transformer) { + text = transformer(text); + } + navigator.clipboard.writeText(text); + + clearTimeout(timeout.current); + setCopied(true); + timeout.current = setTimeout(() => setCopied(false), 1500); + }, [inputSelector, transformer]); + return ( + <button className={className} onClick={e => onClick()} {...props}> + {(copied && copiedLabel) || label} + </button> + ); +}; diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx new file mode 100644 index 0000000000..87174cb6d9 --- /dev/null +++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx @@ -0,0 +1,146 @@ +/* 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/. */ + +import { ASRouterUtils } from "../../asrouter-utils"; +import React, { + useState, + useMemo, + useCallback, + useEffect, + useRef, +} from "react"; + +const stringify = json => JSON.stringify(json, null, 2); + +export const ImpressionsSection = ({ + messageImpressions, + groupImpressions, + screenImpressions, +}) => { + const handleSaveMessageImpressions = useCallback(newImpressions => { + ASRouterUtils.editState("messageImpressions", newImpressions); + }, []); + const handleSaveGroupImpressions = useCallback(newImpressions => { + ASRouterUtils.editState("groupImpressions", newImpressions); + }, []); + const handleSaveScreenImpressions = useCallback(newImpressions => { + ASRouterUtils.editState("screenImpressions", newImpressions); + }, []); + + const handleResetMessageImpressions = useCallback(() => { + ASRouterUtils.sendMessage({ type: "RESET_MESSAGE_STATE" }); + }, []); + const handleResetGroupImpressions = useCallback(() => { + ASRouterUtils.sendMessage({ type: "RESET_GROUPS_STATE" }); + }, []); + const handleResetScreenImpressions = useCallback(() => { + ASRouterUtils.sendMessage({ type: "RESET_SCREEN_IMPRESSIONS" }); + }, []); + + return ( + <div className="impressions-section"> + <ImpressionsItem + impressions={messageImpressions} + label="Message Impressions" + description="Message impressions are stored in an object, where each key is a message ID and each value is an array of timestamps. They are cleaned up when a message with that ID stops existing in ASRouter state (such as at the end of an experiment)." + onSave={handleSaveMessageImpressions} + onReset={handleResetMessageImpressions} + /> + <ImpressionsItem + impressions={groupImpressions} + label="Group Impressions" + description="Group impressions are stored in an object, where each key is a group ID and each value is an array of timestamps. They are never cleaned up." + onSave={handleSaveGroupImpressions} + onReset={handleResetGroupImpressions} + /> + <ImpressionsItem + impressions={screenImpressions} + label="Screen Impressions" + description="Screen impressions are stored in an object, where each key is a screen ID and each value is the most recent timestamp that screen was shown. They are never cleaned up." + onSave={handleSaveScreenImpressions} + onReset={handleResetScreenImpressions} + /> + </div> + ); +}; + +const ImpressionsItem = ({ + impressions, + label, + description, + validator, + onSave, + onReset, +}) => { + const [json, setJson] = useState(stringify(impressions)); + + const modified = useRef(false); + + const isValidJson = useCallback( + text => { + try { + JSON.parse(text); + return validator ? validator(text) : true; + } catch (e) { + return false; + } + }, + [validator] + ); + + const jsonIsInvalid = useMemo(() => !isValidJson(json), [json, isValidJson]); + + const handleChange = useCallback(e => { + setJson(e.target.value); + modified.current = true; + }, []); + const handleSave = useCallback(() => { + if (jsonIsInvalid) { + return; + } + const newImpressions = JSON.parse(json); + modified.current = false; + onSave(newImpressions); + }, [json, jsonIsInvalid, onSave]); + const handleReset = useCallback(() => { + modified.current = false; + onReset(); + }, [onReset]); + + useEffect(() => { + if (!modified.current) { + setJson(stringify(impressions)); + } + }, [impressions]); + + return ( + <div className="impressions-item"> + <span className="impressions-category">{label}</span> + {description ? ( + <p className="impressions-description">{description}</p> + ) : null} + <div className="impressions-inner-box"> + <div className="impressions-buttons"> + <button + className="button primary" + disabled={jsonIsInvalid} + onClick={handleSave} + > + Save + </button> + <button className="button reset" onClick={handleReset}> + Reset + </button> + </div> + <div className="impressions-editor"> + <textarea + className="general-textarea" + value={json} + onChange={handleChange} + /> + </div> + </div> + </div> + ); +}; diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx new file mode 100644 index 0000000000..9c3fd8579c --- /dev/null +++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx @@ -0,0 +1,35 @@ +/* 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/. */ + +import React from "react"; + +export class SimpleHashRouter extends React.PureComponent { + constructor(props) { + super(props); + this.onHashChange = this.onHashChange.bind(this); + this.state = { hash: global.location.hash }; + } + + onHashChange() { + this.setState({ hash: global.location.hash }); + } + + componentWillMount() { + global.addEventListener("hashchange", this.onHashChange); + } + + componentWillUnmount() { + global.removeEventListener("hashchange", this.onHashChange); + } + + render() { + const [, ...routes] = this.state.hash.split("-"); + return React.cloneElement(this.props.children, { + location: { + hash: this.state.hash, + routes, + }, + }); + } +} diff --git a/browser/components/asrouter/content-src/components/Button/Button.jsx b/browser/components/asrouter/content-src/components/Button/Button.jsx new file mode 100644 index 0000000000..b3ece86f16 --- /dev/null +++ b/browser/components/asrouter/content-src/components/Button/Button.jsx @@ -0,0 +1,32 @@ +/* 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/. */ + +import React from "react"; + +const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"]; + +export const Button = props => { + const style = {}; + + // Add allowed style tags from props, e.g. props.color becomes style={color: props.color} + for (const tag of ALLOWED_STYLE_TAGS) { + if (typeof props[tag] !== "undefined") { + style[tag] = props[tag]; + } + } + // remove border if bg is set to something custom + if (style.backgroundColor) { + style.border = "0"; + } + + return ( + <button + onClick={props.onClick} + className={props.className || "ASRouterButton secondary"} + style={style} + > + {props.children} + </button> + ); +}; diff --git a/browser/components/asrouter/content-src/components/Button/_Button.scss b/browser/components/asrouter/content-src/components/Button/_Button.scss new file mode 100644 index 0000000000..35234be4b0 --- /dev/null +++ b/browser/components/asrouter/content-src/components/Button/_Button.scss @@ -0,0 +1,51 @@ +.ASRouterButton { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + border-radius: 2px; + border: 0; + font-family: inherit; + padding: 8px 15px; + margin-inline-start: 12px; + color: inherit; + cursor: pointer; + + .tall & { + margin-inline-start: 20px; + } + + &.test-only { + width: 0; + height: 0; + overflow: hidden; + display: block; + visibility: hidden; + } + + &.primary { + border: 1px solid var(--newtab-primary-action-background); + background-color: var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); + + &:hover { + background-color: var(--newtab-primary-element-hover-color); + } + + &:active { + background-color: var(--newtab-primary-element-active-color); + } + } + + &.slim { + border: $border-primary; + margin-inline-start: 0; + font-size: 12px; + padding: 6px 12px; + + &:hover, + &:focus { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; + } + } +} diff --git a/browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx b/browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx new file mode 100644 index 0000000000..e4b0812f26 --- /dev/null +++ b/browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx @@ -0,0 +1,9 @@ +/* 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/. */ + +// lifted from https://gist.github.com/kitze/23d82bb9eb0baabfd03a6a720b1d637f +const ConditionalWrapper = ({ condition, wrap, children }) => + condition && wrap ? wrap(children) : children; + +export default ConditionalWrapper; diff --git a/browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx b/browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx new file mode 100644 index 0000000000..8498bde03b --- /dev/null +++ b/browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx @@ -0,0 +1,76 @@ +/* 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/. */ + +import React from "react"; + +export const VISIBLE = "visible"; +export const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +/** + * Component wrapper used to send telemetry pings on every impression. + */ +export class ImpressionsWrapper extends React.PureComponent { + // This sends an event when a user sees a set of new content. If content + // changes while the page is hidden (i.e. preloaded or on a hidden tab), + // only send the event if the page becomes visible again. + sendImpressionOrAddListener() { + if (this.props.document.visibilityState === VISIBLE) { + this.props.sendImpression({ id: this.props.id }); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + // When the page becomes visible, send the impression stats ping if the section isn't collapsed. + this._onVisibilityChange = () => { + if (this.props.document.visibilityState === VISIBLE) { + this.props.sendImpression({ id: this.props.id }); + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + this.props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillUnmount() { + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentDidMount() { + if (this.props.sendOnMount) { + this.sendImpressionOrAddListener(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) { + this.sendImpressionOrAddListener(); + } + } + + render() { + return this.props.children; + } +} + +ImpressionsWrapper.defaultProps = { + document: global.document, + sendOnMount: true, +}; diff --git a/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json b/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json new file mode 100644 index 0000000000..9de01052f7 --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json @@ -0,0 +1,305 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json", + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + "if": { + "type": "object", + "properties": { + "template": { + "const": "multi" + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/MultiMessage" + }, + "else": { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/TemplatedMessage" + }, + "$defs": { + "ToastNotification": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToastNotification.schema.json", + "title": "ToastNotification", + "description": "A template for toast notifications displayed by the Alert service.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification body" + }, + "icon_url": { + "description": "The URL of the image used as an icon of the toast notification.", + "type": "string", + "format": "moz-url-format" + }, + "image_url": { + "description": "The URL of an image to be displayed as part of the notification.", + "type": "string", + "format": "moz-url-format" + }, + "launch_url": { + "description": "The URL to launch when the notification or an action button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "launch_action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The launch action to be performed when Firefox is launched." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "requireInteraction": { + "type": "boolean", + "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically." + }, + "tag": { + "type": "string", + "description": "An identifying tag for the toast notification." + }, + "data": { + "type": "object", + "description": "Arbitrary data associated with the toast notification." + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText", + "description": "The action text to be shown to the user." + }, + "action": { + "type": "string", + "description": "Opaque identifer that identifies action." + }, + "iconURL": { + "type": "string", + "format": "uri", + "description": "URL of an icon to display with the action." + }, + "windowsSystemActivationType": { + "type": "boolean", + "description": "Whether to have Windows process the given `action`." + }, + "launch_action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The launch action to be performed when Firefox is launched." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["action", "title"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["title", "body"] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": ["content", "targeting", "template", "trigger"], + "additionalProperties": true + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The message identifier" + }, + "groups": { + "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.", + "type": "array", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "template": { + "type": "string", + "description": "Which messaging template this message is using.", + "enum": ["toast_notification"] + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": ["period", "cap"] + } + } + } + }, + "priority": { + "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown", + "type": "integer" + }, + "order": { + "description": "The order in which messages should be shown. Messages will be shown in increasing order.", + "type": "integer" + }, + "targeting": { + "description": "A JEXL expression representing targeting information", + "type": "string" + }, + "trigger": { + "description": "An action to trigger potentially showing the message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action" + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["id"] + }, + "provider": { + "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", + "type": "string" + } + }, + "additionalProperties": true, + "dependentRequired": { + "content": ["id", "template"], + "template": ["id", "content"] + } + }, + "localizedText": { + "type": "object", + "properties": { + "string_id": { + "description": "Id of localized string to be rendered.", + "type": "string" + } + }, + "required": ["string_id"] + }, + "localizableText": { + "description": "Either a raw string or an object containing the string_id of the localized text", + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizedText" + } + ] + }, + "TemplatedMessage": { + "description": "An FxMS message of one of a variety of types.", + "type": "object", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message" + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["toast_notification"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/ToastNotification" + } + } + ] + }, + "MultiMessage": { + "description": "An object containing an array of messages.", + "type": "object", + "properties": { + "template": { + "type": "string", + "const": "multi" + }, + "messages": { + "type": "array", + "description": "An array of messages.", + "items": { + "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/TemplatedMessage" + } + } + }, + "required": ["template", "messages"] + } + } +} diff --git a/browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json b/browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json new file mode 100644 index 0000000000..51dbd3efa6 --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json @@ -0,0 +1,128 @@ +{ + "description": "Common elements used across FxMS schemas", + "$id": "file:///FxMSCommon.schema.json", + "$defs": { + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The message identifier" + }, + "groups": { + "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.", + "type": "array", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "template": { + "type": "string", + "description": "Which messaging template this message is using." + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": ["period", "cap"] + } + } + } + }, + "priority": { + "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown", + "type": "integer" + }, + "order": { + "description": "The order in which messages should be shown. Messages will be shown in increasing order.", + "type": "integer" + }, + "targeting": { + "description": "A JEXL expression representing targeting information", + "type": "string" + }, + "trigger": { + "description": "An action to trigger potentially showing the message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action" + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["id"] + }, + "provider": { + "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", + "type": "string" + } + }, + "additionalProperties": true, + "dependentRequired": { + "content": ["id", "template"], + "template": ["id", "content"] + } + }, + "localizedText": { + "type": "object", + "properties": { + "string_id": { + "description": "Id of localized string to be rendered.", + "type": "string" + } + }, + "required": ["string_id"] + }, + "localizableText": { + "description": "Either a raw string or an object containing the string_id of the localized text", + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "$ref": "#/$defs/localizedText" + } + ] + } + } +} diff --git a/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json b/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json new file mode 100644 index 0000000000..a395f4f990 --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json @@ -0,0 +1,1366 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json", + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + "if": { + "type": "object", + "properties": { + "template": { + "const": "multi" + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/MultiMessage" + }, + "else": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/TemplatedMessage" + }, + "$defs": { + "CFRUrlbarChiclet": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///CFRUrlbarChiclet.schema.json", + "title": "CFRUrlbarChiclet", + "description": "A template with a chiclet button with text.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Describes how content should be displayed.", + "enum": ["chiclet_open_url"] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "notification_text": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "active_color": { + "type": "string", + "description": "Background color of the button" + }, + "action": { + "type": "object", + "properties": { + "url": { + "description": "The page to open when the button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "where": { + "description": "Should it open in a new tab or the current tab", + "type": "string", + "enum": ["current", "tabshifted"] + } + }, + "additionalProperties": true, + "required": ["url", "where"] + } + }, + "additionalProperties": true, + "required": [ + "layout", + "category", + "bucket_id", + "notification_text", + "action" + ] + }, + "template": { + "type": "string", + "const": "cfr_urlbar_chiclet" + } + }, + "required": ["targeting", "trigger"] + }, + "ExtensionDoorhanger": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ExtensionDoorhanger.schema.json", + "title": "ExtensionDoorhanger", + "description": "A template with a heading, addon icon, title and description. No markup allowed.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider", + "enum": [ + "short_message", + "icon_and_message", + "addon_recommendation" + ] + }, + "anchor_id": { + "type": "string", + "description": "A DOM element ID that the pop-over will be anchored." + }, + "alt_anchor_id": { + "type": "string", + "description": "An alternate DOM element ID that the pop-over will be anchored." + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "skip_address_bar_notifier": { + "type": "boolean", + "description": "Skip the 'Recommend' notifier and show directly." + }, + "persistent_doorhanger": { + "type": "boolean", + "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications." + }, + "show_in_private_browsing": { + "type": "boolean", + "description": "Whether to allow the message to be shown in private browsing mode. Defaults to false." + }, + "notification_text": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "info_icon": { + "type": "object", + "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark.", + "properties": { + "label": { + "oneOf": [ + { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "tooltiptext": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Text for button tooltip used to provide information about the doorhanger." + } + }, + "required": ["tooltiptext"] + } + }, + "required": ["attributes"] + }, + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText" + } + ] + }, + "sumo_path": { + "type": "string", + "description": "Last part of the path in the URL to the support page with the information about the doorhanger.", + "examples": [ + "extensionpromotions", + "extensionrecommendations" + ] + } + } + }, + "learn_more": { + "type": "string", + "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.", + "examples": ["extensionpromotions", "extensionrecommendations"] + }, + "heading_text": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "icon": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg." + }, + "icon_dark_theme": { + "type": "string", + "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg." + }, + "icon_class": { + "type": "string", + "description": "CSS class of the pop-over icon." + }, + "addon": { + "description": "Addon information including AMO URL.", + "type": "object", + "properties": { + "id": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Unique addon ID" + }, + "title": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Addon name" + }, + "author": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Addon author" + }, + "icon": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg." + }, + "rating": { + "type": "string", + "description": "Star rating" + }, + "users": { + "type": "string", + "description": "Installed users" + }, + "amo_url": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl", + "description": "Link that offers more information related to the addon." + } + }, + "required": ["title", "author", "icon", "amo_url"] + }, + "text": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "descriptionDetails": { + "description": "Additional information and steps on how to use", + "type": "object", + "properties": { + "steps": { + "description": "Array of string_ids", + "type": "array", + "items": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText", + "description": "Id of string to localized addon description" + } + } + }, + "required": ["steps"] + }, + "buttons": { + "description": "The label and functionality for the buttons in the pop-over.", + "type": "object", + "properties": { + "primary": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Button label override used when a localized version is not available." + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": ["accesskey"], + "description": "Button attributes." + } + }, + "required": ["value", "attributes"] + }, + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "type": "string", + "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.sys.mjs", + "description": "URL used in combination with the primary action dispatched." + } + } + } + } + } + } + }, + "secondary": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "allOf": [ + { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText" + }, + { + "description": "Button label override used when a localized version is not available." + } + ] + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": ["accesskey"], + "description": "Button attributes." + } + }, + "required": ["value", "attributes"] + }, + { + "properties": { + "string_id": { + "allOf": [ + { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText" + }, + { + "description": "Id of localized string for button" + } + ] + } + }, + "required": ["string_id"] + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "allOf": [ + { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl" + }, + { + "description": "URL used in combination with the primary action dispatched." + } + ] + } + } + } + } + } + } + } + } + } + } + }, + "additionalProperties": true, + "required": [ + "layout", + "bucket_id", + "heading_text", + "text", + "buttons" + ], + "if": { + "properties": { + "skip_address_bar_notifier": { + "anyOf": [ + { + "const": "false" + }, + { + "const": null + } + ] + } + } + }, + "then": { + "required": ["category", "notification_text"] + } + }, + "template": { + "type": "string", + "enum": ["cfr_doorhanger", "milestone_message"] + } + }, + "additionalProperties": true, + "required": ["targeting", "trigger"], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } + }, + "InfoBar": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///InfoBar.schema.json", + "title": "InfoBar", + "description": "A template with an image, test and buttons.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).", + "enum": ["global", "tab"] + }, + "text": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text show in the notification box." + }, + "priority": { + "description": "Infobar priority level https://searchfox.org/mozilla-central/rev/3aef835f6cb12e607154d56d68726767172571e4/toolkit/content/widgets/notificationbox.js#387", + "type": "number", + "minumum": 0, + "exclusiveMaximum": 10 + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text label of the button." + }, + "primary": { + "type": "boolean", + "description": "Is this the primary button?" + }, + "accessKey": { + "type": "string", + "description": "Keyboard shortcut letter." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "supportPage": { + "type": "string", + "description": "A page title on SUMO to link to" + } + }, + "required": ["label", "action"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["text", "buttons"] + }, + "template": { + "type": "string", + "const": "infobar" + } + }, + "additionalProperties": true, + "required": ["targeting", "trigger"], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } + }, + "NewtabPromoMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///NewtabPromoMessage.schema.json", + "title": "PBNewtabPromoMessage", + "description": "Message shown on the private browsing newtab page.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "hideDefault": { + "type": "boolean", + "description": "Should we hide the default promo after the experiment promo is dismissed." + }, + "infoEnabled": { + "type": "boolean", + "description": "Should we show the info section." + }, + "infoIcon": { + "type": "string", + "description": "Icon shown in the left side of the info section. Default is the private browsing icon." + }, + "infoTitle": { + "type": "string", + "description": "Is the title in the info section enabled." + }, + "infoTitleEnabled": { + "type": "boolean", + "description": "Is the title in the info section enabled." + }, + "infoBody": { + "type": "string", + "description": "Text content in the info section." + }, + "infoLinkText": { + "type": "string", + "description": "Text for the link in the info section." + }, + "infoLinkUrl": { + "type": "string", + "description": "URL for the info section link.", + "format": "moz-url-format" + }, + "promoEnabled": { + "type": "boolean", + "description": "Should we show the promo section." + }, + "promoType": { + "type": "string", + "description": "Promo type used to determine if promo should show to a given user", + "enum": ["FOCUS", "VPN", "PIN", "COOKIE_BANNERS", "OTHER"] + }, + "promoSectionStyle": { + "type": "string", + "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.", + "enum": ["top", "below-search", "bottom"] + }, + "promoTitle": { + "type": "string", + "description": "The text content of the promo section." + }, + "promoTitleEnabled": { + "type": "boolean", + "description": "Should we show text content in the promo section." + }, + "promoLinkText": { + "type": "string", + "description": "The text of the link in the promo box." + }, + "promoHeader": { + "type": "string", + "description": "The title of the promo section." + }, + "promoButton": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["action"] + }, + "promoLinkType": { + "type": "string", + "description": "Type of promo link type. Possible values: link, button. Default is link.", + "enum": ["link", "button"] + }, + "promoImageLarge": { + "type": "string", + "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.", + "format": "uri" + }, + "promoImageSmall": { + "type": "string", + "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.", + "format": "uri" + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "promoEnabled": { + "const": true + } + }, + "required": ["promoEnabled"] + }, + "then": { + "required": ["promoButton"] + } + }, + { + "if": { + "properties": { + "infoEnabled": { + "const": true + } + }, + "required": ["infoEnabled"] + }, + "then": { + "required": ["infoLinkText"], + "if": { + "properties": { + "infoTitleEnabled": { + "const": true + } + }, + "required": ["infoTitleEnabled"] + }, + "then": { + "required": ["infoTitle"] + } + } + } + ] + }, + "template": { + "type": "string", + "const": "pb_newtab" + } + }, + "additionalProperties": true, + "required": ["targeting"] + }, + "Spotlight": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///Spotlight.schema.json", + "title": "Spotlight", + "description": "A template with an image, title, content and two buttons.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Specify the layout template for the Spotlight", + "const": "multistage" + }, + "backdrop": { + "type": "string", + "description": "Background css behind modal content" + }, + "logo": { + "type": "object", + "properties": { + "imageURL": { + "type": "string", + "description": "URL for image to use with the content" + }, + "imageId": { + "type": "string", + "description": "The ID for a remotely hosted image" + }, + "size": { + "type": "string", + "description": "The logo size." + } + }, + "additionalProperties": true + }, + "screens": { + "type": "array", + "description": "Collection of individual screen content" + }, + "transitions": { + "type": "boolean", + "description": "Show transitions within and between screens" + }, + "disableHistoryUpdates": { + "type": "boolean", + "description": "Don't alter the browser session's history stack - used with messaging surfaces like Feature Callouts" + }, + "startScreen": { + "type": "integer", + "description": "Index of first screen to show from message, defaulting to 0" + } + }, + "additionalProperties": true + }, + "template": { + "type": "string", + "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog", + "enum": ["spotlight", "feature_callout"] + } + }, + "additionalProperties": true, + "required": ["targeting"] + }, + "ToastNotification": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToastNotification.schema.json", + "title": "ToastNotification", + "description": "A template for toast notifications displayed by the Alert service.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification body" + }, + "icon_url": { + "description": "The URL of the image used as an icon of the toast notification.", + "type": "string", + "format": "moz-url-format" + }, + "image_url": { + "description": "The URL of an image to be displayed as part of the notification.", + "type": "string", + "format": "moz-url-format" + }, + "launch_url": { + "description": "The URL to launch when the notification or an action button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "launch_action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The launch action to be performed when Firefox is launched." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "requireInteraction": { + "type": "boolean", + "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically." + }, + "tag": { + "type": "string", + "description": "An identifying tag for the toast notification." + }, + "data": { + "type": "object", + "description": "Arbitrary data associated with the toast notification." + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The action text to be shown to the user." + }, + "action": { + "type": "string", + "description": "Opaque identifer that identifies action." + }, + "iconURL": { + "type": "string", + "format": "uri", + "description": "URL of an icon to display with the action." + }, + "windowsSystemActivationType": { + "type": "boolean", + "description": "Whether to have Windows process the given `action`." + }, + "launch_action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The launch action to be performed when Firefox is launched." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["action", "title"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["title", "body"] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": ["content", "targeting", "template", "trigger"], + "additionalProperties": true + }, + "ToolbarBadgeMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToolbarBadgeMessage.schema.json", + "title": "ToolbarBadgeMessage", + "description": "A template that specifies to which element in the browser toolbar to add a notification.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true, + "required": ["id"], + "description": "Optional action to take in addition to showing the notification" + }, + "delay": { + "type": "number", + "description": "Optional delay in ms after which to show the notification" + }, + "badgeDescription": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText", + "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'" + } + }, + "additionalProperties": true, + "required": ["target"] + }, + "template": { + "type": "string", + "const": "toolbar_badge" + } + }, + "additionalProperties": true, + "required": ["targeting"] + }, + "UpdateAction": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///UpdateAction.schema.json", + "title": "UpdateActionMessage", + "description": "A template for messages that execute predetermined actions.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "type": "object", + "description": "Additional data provided as argument when executing the action", + "properties": { + "url": { + "type": "string", + "description": "URL data to be used as argument to the action" + }, + "expireDelta": { + "type": "number", + "description": "Expiration timestamp to be used as argument to the action" + } + } + } + }, + "additionalProperties": true, + "description": "Optional action to take in addition to showing the notification", + "required": ["id", "data"] + } + }, + "additionalProperties": true, + "required": ["action"] + }, + "template": { + "type": "string", + "const": "update_action" + } + }, + "required": ["targeting"] + }, + "WhatsNewMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///WhatsNewMessage.schema.json", + "title": "WhatsNewMessage", + "description": "A template for the messages that appear in the What's New panel.", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "layout": { + "description": "Different message layouts", + "enum": ["tracking-protections"] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "published_date": { + "type": "integer", + "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published." + }, + "title": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message title" + }, + "subtitle": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message subtitle" + }, + "body": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message body" + }, + "link_text": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "(optional) Id of localized string or message override of What's New message link text" + }, + "cta_url": { + "description": "Target URL for the What's New message.", + "type": "string", + "format": "moz-url-format" + }, + "cta_type": { + "description": "Type of url open action", + "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"] + }, + "cta_where": { + "description": "How to open the cta: new window, tab, focused, unfocused.", + "enum": ["current", "tabshifted", "tab", "save", "window"] + }, + "icon_url": { + "description": "(optional) URL for the What's New message icon.", + "type": "string", + "format": "uri" + }, + "icon_alt": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Alt text for image." + } + }, + "additionalProperties": true, + "required": [ + "published_date", + "title", + "body", + "cta_url", + "bucket_id" + ] + }, + "template": { + "type": "string", + "const": "whatsnew_panel_message" + } + }, + "required": ["order"], + "additionalProperties": true + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The message identifier" + }, + "groups": { + "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.", + "type": "array", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "template": { + "type": "string", + "description": "Which messaging template this message is using.", + "enum": [ + "cfr_urlbar_chiclet", + "cfr_doorhanger", + "milestone_message", + "infobar", + "pb_newtab", + "spotlight", + "feature_callout", + "toast_notification", + "toolbar_badge", + "update_action", + "whatsnew_panel_message" + ] + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": ["period", "cap"] + } + } + } + }, + "priority": { + "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown", + "type": "integer" + }, + "order": { + "description": "The order in which messages should be shown. Messages will be shown in increasing order.", + "type": "integer" + }, + "targeting": { + "description": "A JEXL expression representing targeting information", + "type": "string" + }, + "trigger": { + "description": "An action to trigger potentially showing the message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action" + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["id"] + }, + "provider": { + "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", + "type": "string" + } + }, + "additionalProperties": true, + "dependentRequired": { + "content": ["id", "template"], + "template": ["id", "content"] + } + }, + "localizedText": { + "type": "object", + "properties": { + "string_id": { + "description": "Id of localized string to be rendered.", + "type": "string" + } + }, + "required": ["string_id"] + }, + "localizableText": { + "description": "Either a raw string or an object containing the string_id of the localized text", + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText" + } + ] + }, + "TemplatedMessage": { + "description": "An FxMS message of one of a variety of types.", + "type": "object", + "allOf": [ + { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message" + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["cfr_urlbar_chiclet"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/CFRUrlbarChiclet" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["cfr_doorhanger", "milestone_message"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ExtensionDoorhanger" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["infobar"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/InfoBar" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["pb_newtab"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/NewtabPromoMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["spotlight", "feature_callout"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Spotlight" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["toast_notification"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ToastNotification" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["toolbar_badge"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ToolbarBadgeMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["update_action"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/UpdateAction" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": ["whatsnew_panel_message"] + } + }, + "required": ["template"] + }, + "then": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/WhatsNewMessage" + } + } + ] + }, + "MultiMessage": { + "description": "An object containing an array of messages.", + "type": "object", + "properties": { + "template": { + "type": "string", + "const": "multi" + }, + "messages": { + "type": "array", + "description": "An array of messages.", + "items": { + "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/TemplatedMessage" + } + } + }, + "required": ["template", "messages"] + } + } +} diff --git a/browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json b/browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json new file mode 100644 index 0000000000..1ccfefe478 --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json @@ -0,0 +1,15 @@ +[ + { + "trigger": { + "id": "defaultBrowserCheck" + }, + "targeting": "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5" + }, + { + "groups": ["eco"], + "trigger": { + "id": "defaultBrowserCheck" + }, + "targeting": "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5" + } +] diff --git a/browser/components/asrouter/content-src/schemas/extract-test-corpus.js b/browser/components/asrouter/content-src/schemas/extract-test-corpus.js new file mode 100644 index 0000000000..562a467561 --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/extract-test-corpus.js @@ -0,0 +1,65 @@ +/* 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/. */ + +"use strict"; + +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); +const { OnboardingMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs" +); +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); + +const CWD = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path; +const CORPUS_DIR = PathUtils.join(CWD, "corpus"); + +const CORPUS = [ + { + name: "CFRMessageProvider.messages.json", + provider: CFRMessageProvider, + }, + { + name: "OnboardingMessageProvider.messages.json", + provider: OnboardingMessageProvider, + }, + { + name: "PanelTestProvider.messages.json", + provider: PanelTestProvider, + }, + { + name: "PanelTestProvider_toast_notification.messages.json", + provider: PanelTestProvider, + filter: message => message.template === "toast_notification", + }, +]; + +let exit = false; +async function main() { + try { + await IOUtils.makeDirectory(CORPUS_DIR); + + for (const entry of CORPUS) { + const { name, provider } = entry; + const filter = entry.filter ?? (() => true); + const messages = await provider.getMessages(); + const json = `${JSON.stringify(messages.filter(filter), undefined, 2)}\n`; + + const path = PathUtils.join(CORPUS_DIR, name); + await IOUtils.writeUTF8(path, json); + } + } finally { + exit = true; + } +} + +main(); + +// We need to spin the event loop here, otherwise everything goes out of scope. +Services.tm.spinEventLoopUntil( + "extract-test-corpus.js: waiting for completion", + () => exit +); diff --git a/browser/components/asrouter/content-src/schemas/make-schemas.py b/browser/components/asrouter/content-src/schemas/make-schemas.py new file mode 100755 index 0000000000..f66490f23a --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/make-schemas.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +# 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/. + +"""Firefox Messaging System Messaging Experiment schema generator + +The Firefox Messaging System handles several types of messages. This program +patches and combines those schemas into a single schema +(MessagingExperiment.schema.json) which is used to validate messaging +experiments coming from Nimbus. + +Definitions from FxMsCommon.schema.json are bundled into this schema. This +allows all of the FxMS schemas to reference common definitions, e.g. +`localizableText` for translatable strings, via referencing the common schema. +The bundled schema will be re-written so that the references now point at the +top-level, generated schema. + +Additionally, all self-references in each messaging schema will be rewritten +into absolute references, referencing each sub-schemas `$id`. This is requried +due to the JSONSchema validation library used by Experimenter not fully +supporting self-references and bundled schema. +""" + +import json +import sys +from argparse import ArgumentParser +from itertools import chain +from pathlib import Path +from typing import Any, Dict, List, NamedTuple, Union +from urllib.parse import urlparse + +import jsonschema + + +class SchemaDefinition(NamedTuple): + """A definition of a schema that is to be bundled.""" + + #: The $id of the generated schema. + schema_id: str + + #: The path of the generated schema. + schema_path: Path + + #: The message types that will be bundled into the schema. + message_types: Dict[str, Path] + + #: What common definitions to bundle into the schema. + #: + #: If `True`, all definitions will be bundled. + #: If `False`, no definitons will be bundled. + #: If a list, only the named definitions will be bundled. + bundle_common: Union[bool, List[str]] + + #: The testing corpus for the schema. + test_corpus: Dict[str, Path] + + +SCHEMA_DIR = Path("..", "templates") + +SCHEMAS = [ + SchemaDefinition( + schema_id="chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json", + schema_path=Path("MessagingExperiment.schema.json"), + message_types={ + "CFRUrlbarChiclet": ( + SCHEMA_DIR / "CFR" / "templates" / "CFRUrlbarChiclet.schema.json" + ), + "ExtensionDoorhanger": ( + SCHEMA_DIR / "CFR" / "templates" / "ExtensionDoorhanger.schema.json" + ), + "InfoBar": SCHEMA_DIR / "CFR" / "templates" / "InfoBar.schema.json", + "NewtabPromoMessage": ( + SCHEMA_DIR / "PBNewtab" / "NewtabPromoMessage.schema.json" + ), + "Spotlight": SCHEMA_DIR / "OnboardingMessage" / "Spotlight.schema.json", + "ToastNotification": ( + SCHEMA_DIR / "ToastNotification" / "ToastNotification.schema.json" + ), + "ToolbarBadgeMessage": ( + SCHEMA_DIR / "OnboardingMessage" / "ToolbarBadgeMessage.schema.json" + ), + "UpdateAction": ( + SCHEMA_DIR / "OnboardingMessage" / "UpdateAction.schema.json" + ), + "WhatsNewMessage": ( + SCHEMA_DIR / "OnboardingMessage" / "WhatsNewMessage.schema.json" + ), + }, + bundle_common=True, + test_corpus={ + "ReachExperiments": Path("corpus", "ReachExperiments.messages.json"), + # These are generated via extract-test-corpus.js + "CFRMessageProvider": Path("corpus", "CFRMessageProvider.messages.json"), + "OnboardingMessageProvider": Path( + "corpus", "OnboardingMessageProvider.messages.json" + ), + "PanelTestProvider": Path("corpus", "PanelTestProvider.messages.json"), + }, + ), + SchemaDefinition( + schema_id=( + "chrome://browser/content/asrouter/schemas/" + "BackgroundTaskMessagingExperiment.schema.json" + ), + schema_path=Path("BackgroundTaskMessagingExperiment.schema.json"), + message_types={ + "ToastNotification": ( + SCHEMA_DIR / "ToastNotification" / "ToastNotification.schema.json" + ), + }, + bundle_common=True, + # These are generated via extract-test-corpus.js + test_corpus={ + # Just the "toast_notification" messages. + "PanelTestProvider": Path( + "corpus", "PanelTestProvider_toast_notification.messages.json" + ), + }, + ), +] + +COMMON_SCHEMA_NAME = "FxMSCommon.schema.json" +COMMON_SCHEMA_PATH = Path(COMMON_SCHEMA_NAME) + + +class NestedRefResolver(jsonschema.RefResolver): + """A custom ref resolver that handles bundled schema. + + This is the resolver used by Experimenter. + """ + + def __init__(self, schema): + super().__init__(base_uri=None, referrer=None) + + if "$id" in schema: + self.store[schema["$id"]] = schema + + if "$defs" in schema: + for dfn in schema["$defs"].values(): + if "$id" in dfn: + self.store[dfn["$id"]] = dfn + + +def read_schema(path): + """Read a schema from disk and parse it as JSON.""" + with path.open("r") as f: + return json.load(f) + + +def extract_template_values(template): + """Extract the possible template values (either via JSON Schema enum or const).""" + enum = template.get("enum") + if enum: + return enum + + const = template.get("const") + if const: + return [const] + + +def patch_schema(schema, bundled_id, schema_id=None): + """Patch the given schema. + + The JSON schema validator that Experimenter uses + (https://pypi.org/project/jsonschema/) does not support relative references, + nor does it support bundled schemas. We rewrite the schema so that all + relative refs are transformed into absolute refs via the schema's `$id`. + + Additionally, we merge in the contents of FxMSCommon.schema.json, so all + refs relative to that schema will be transformed to become relative to this + schema. + + See-also: https://github.com/python-jsonschema/jsonschema/issues/313 + """ + if schema_id is None: + schema_id = schema["$id"] + + def patch_impl(schema): + ref = schema.get("$ref") + + if ref: + uri = urlparse(ref) + if ( + uri.scheme == "" + and uri.netloc == "" + and uri.path == "" + and uri.fragment != "" + ): + schema["$ref"] = f"{schema_id}#{uri.fragment}" + elif (uri.scheme, uri.path) == ("file", f"/{COMMON_SCHEMA_NAME}"): + schema["$ref"] = f"{bundled_id}#{uri.fragment}" + + # If `schema` is object-like, inspect each of its indivual properties + # and patch them. + properties = schema.get("properties") + if properties: + for prop in properties.keys(): + patch_impl(properties[prop]) + + # If `schema` is array-like, inspect each of its items and patch them. + items = schema.get("items") + if items: + patch_impl(items) + + # Patch each `if`, `then`, `else`, and `not` sub-schema that is present. + for key in ("if", "then", "else", "not"): + if key in schema: + patch_impl(schema[key]) + + # Patch the items of each `oneOf`, `allOf`, and `anyOf` sub-schema that + # is present. + for key in ("oneOf", "allOf", "anyOf"): + subschema = schema.get(key) + if subschema: + for i, alternate in enumerate(subschema): + patch_impl(alternate) + + # Patch the top-level type defined in the schema. + patch_impl(schema) + + # Patch each named definition in the schema. + for key in ("$defs", "definitions"): + defns = schema.get(key) + if defns: + for defn_name, defn_value in defns.items(): + patch_impl(defn_value) + + return schema + + +def bundle_schema(schema_def: SchemaDefinition): + """Create a bundled schema based on the schema definition.""" + # Patch each message type schema to resolve all self-references to be + # absolute and rewrite # references to FxMSCommon.schema.json to be relative + # to the new schema (because we are about to bundle its definitions). + defs = { + name: patch_schema(read_schema(path), bundled_id=schema_def.schema_id) + for name, path in schema_def.message_types.items() + } + + # Bundle the definitions from FxMSCommon.schema.json into this schema. + if schema_def.bundle_common: + + def dfn_filter(name): + if schema_def.bundle_common is True: + return True + + return name in schema_def.bundle_common + + common_schema = patch_schema( + read_schema(COMMON_SCHEMA_PATH), + bundled_id=schema_def.schema_id, + schema_id=schema_def.schema_id, + ) + + # patch_schema mutates the given schema, so we read a new copy in for + # each bundle operation. + defs.update( + { + name: dfn + for name, dfn in common_schema["$defs"].items() + if dfn_filter(name) + } + ) + + # Ensure all bundled schemas have an $id so that $refs inside the + # bundled schema work correctly (i.e, they will reference the subschema + # and not the bundle). + for name in schema_def.message_types.keys(): + subschema = defs[name] + if "$id" not in subschema: + raise ValueError(f"Schema {name} is missing an $id") + + props = subschema["properties"] + if "template" not in props: + raise ValueError(f"Schema {name} is missing a template") + + template = props["template"] + if "enum" not in template and "const" not in template: + raise ValueError(f"Schema {name} should have const or enum template") + + templates = { + name: extract_template_values(defs[name]["properties"]["template"]) + for name in schema_def.message_types.keys() + } + + # Ensure that each schema has a unique set of template values. + for a in templates.keys(): + a_keys = set(templates[a]) + + for b in templates.keys(): + if a == b: + continue + + b_keys = set(templates[b]) + intersection = a_keys.intersection(b_keys) + + if len(intersection): + raise ValueError( + f"Schema {a} and {b} have overlapping template values: " + f"{', '.join(intersection)}" + ) + + all_templates = list(chain.from_iterable(templates.values())) + + # Enforce that one of the templates must match (so that one of the if + # branches will match). + defs["Message"]["properties"]["template"]["enum"] = all_templates + defs["TemplatedMessage"] = { + "description": "An FxMS message of one of a variety of types.", + "type": "object", + "allOf": [ + # Ensure each message has all the fields defined in the base + # Message type. + # + # This is slightly redundant because each message should + # already inherit from this message type, but it is easier + # to add this requirement here than to verify that each + # message's schema is properly inheriting. + {"$ref": f"{schema_def.schema_id}#/$defs/Message"}, + # For each message type, create a subschema that says if the + # template field matches a value for a message type defined + # in MESSAGE_TYPES, then the message must also match the + # schema for that message type. + # + # This is done using `allOf: [{ if, then }]` instead of `oneOf: []` + # because it provides better error messages. Using `if-then` + # will only show validation errors for the sub-schema that + # matches template, whereas using `oneOf` will show + # validation errors for *all* sub-schemas, which makes + # debugging messages much harder. + *( + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": templates[message_type], + }, + }, + "required": ["template"], + }, + "then": {"$ref": f"{schema_def.schema_id}#/$defs/{message_type}"}, + } + for message_type in schema_def.message_types + ), + ], + } + defs["MultiMessage"] = { + "description": "An object containing an array of messages.", + "type": "object", + "properties": { + "template": {"type": "string", "const": "multi"}, + "messages": { + "type": "array", + "description": "An array of messages.", + "items": {"$ref": f"{schema_def.schema_id}#/$defs/TemplatedMessage"}, + }, + }, + "required": ["template", "messages"], + } + + # Generate the combined schema. + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": schema_def.schema_id, + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + # A message must be one of: + # - An object that contains id, template, and content fields + # - An object that contains none of the above fields (empty message) + # - An array of messages like the above + "if": { + "type": "object", + "properties": {"template": {"const": "multi"}}, + "required": ["template"], + }, + "then": { + "$ref": f"{schema_def.schema_id}#/$defs/MultiMessage", + }, + "else": { + "$ref": f"{schema_def.schema_id}#/$defs/TemplatedMessage", + }, + "$defs": defs, + } + + +def check_diff(schema_def: SchemaDefinition, schema: Dict[str, Any]): + """Check the generated schema matches the on-disk schema.""" + print(f" Checking {schema_def.schema_path} for differences...") + + with schema_def.schema_path.open("r") as f: + on_disk = json.load(f) + + if on_disk != schema: + print(f"{schema_def.schema_path} does not match generated schema:") + print("Generated schema:") + json.dump(schema, sys.stdout, indent=2) + print("\n\nOn Disk schema:") + json.dump(on_disk, sys.stdout, indent=2) + print("\n\n") + + raise ValueError("Schemas do not match!") + + +def validate_corpus(schema_def: SchemaDefinition, schema: Dict[str, Any]): + """Check that the schema validates. + + This uses the same validation configuration that is used in Experimenter. + """ + print(" Validating messages with Experimenter JSON Schema validator...") + + resolver = NestedRefResolver(schema) + + for provider, provider_path in schema_def.test_corpus.items(): + print(f" Validating messages from {provider}:") + + try: + with provider_path.open("r") as f: + messages = json.load(f) + except FileNotFoundError as e: + if not provider_path.parent.exists(): + new_exc = Exception( + f"Could not find {provider_path}: Did you run " + "`mach xpcshell extract-test-corpus.js` ?" + ) + raise new_exc from e + + raise e + + for i, message in enumerate(messages): + template = message.get("template", "(no template)") + msg_id = message.get("id", f"index {i}") + + print( + f" Validating {msg_id} {template} message with {schema_def.schema_path}..." + ) + jsonschema.validate(instance=message, schema=schema, resolver=resolver) + + print() + + +def main(check=False): + """Generate Nimbus feature schemas for Firefox Messaging System.""" + for schema_def in SCHEMAS: + print(f"Generating {schema_def.schema_path} ...") + schema = bundle_schema(schema_def) + + if check: + print(f"Checking {schema_def.schema_path} ...") + check_diff(schema_def, schema) + validate_corpus(schema_def, schema) + else: + with schema_def.schema_path.open("wb") as f: + print(f"Writing {schema_def.schema_path} ...") + f.write(json.dumps(schema, indent=2).encode("utf-8")) + f.write(b"\n") + + +if __name__ == "__main__": + parser = ArgumentParser(description=main.__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Check that the generated schemas have not changed and run validation tests.", + default=False, + ) + args = parser.parse_args() + + main(args.check) diff --git a/browser/components/asrouter/content-src/schemas/message-format.md b/browser/components/asrouter/content-src/schemas/message-format.md new file mode 100644 index 0000000000..65f031e260 --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/message-format.md @@ -0,0 +1,111 @@ +## Activity Stream Router message format + +Field name | Type | Required | Description | Example / Note +--- | --- | --- | --- | --- +`id` | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1` +`template` | `string` | Yes | An id matching an existing Activity Stream Router template | +`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset) +`bundled` | `integer` | No | The number of messages of the same template this one should be shown with | [See example below](#a-bundled-message-example) +`order` | `integer` | No | If bundled with other messages of the same template, which order should this one be placed in? Defaults to 0 if no order is desired | [See example below](#a-bundled-message-example) +`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly` +`targeting` | `string` `JEXL` | No | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [Examples](#targeting-attributes) +`trigger` | `string` | No | An event or condition upon which the message will be immediately shown. This can be combined with `targeting`. Messages that define a trigger will not be shown during non-trigger-based passive message rotation. +`trigger.params` | `[string]` | No | A set of hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-params) +`trigger.patterns` | `[string]` | No | A set of patterns that match multiple hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-patterns) +`frequency` | `object` | No | A definition for frequency cap information for the message +`frequency.lifetime` | `integer` | No | The maximum number of lifetime impressions for the message. +`frequency.custom` | `array` | No | An array of frequency cap definition objects including `period`, a time period in milliseconds, and `cap`, a max number of impressions for that period. + +### Message example +```javascript +{ + weight: 100, + id: "PROTECTIONS_PANEL_1", + template: "protections_panel", + content: { + title: { + string_id: "cfr-protections-panel-header" + }, + body: { + string_id: "cfr-protections-panel-body" + }, + link_text: { + string_id: "cfr-protections-panel-link-text" + }, + cta_url: "https://support.mozilla.org/1/firefox/121.0a1/Darwin/en-US/etp-promotions?as=u&utm_source=inproduct", + cta_type: "OPEN_URL" + }, + trigger: { + id: "protectionsPanelOpen" + }, + groups: [], + provider: "onboarding" +} +``` + +### A Bundled Message example +The following 2 messages have a `bundled` property, indicating that they should be shown together, since they have the same template. The number `2` indicates that this message should be shown in a bundle of 2 messages of the same template. The order property defines that ONBOARDING_2 should be shown after ONBOARDING_3 in the bundle. +```javascript +{ + id: "ONBOARDING_2", + template: "onboarding", + bundled: 2, + order: 2, + content: { + title: "Private Browsing", + body: "Browse by yourself. Private Browsing with Tracking Protection blocks online trackers that follow you around the web." + }, + targeting: "", + trigger: "firstRun" +} +{ + id: "ONBOARDING_3", + template: "onboarding", + bundled: 2, + order: 1, + content: { + title: "Find it faster", + body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box." + }, + targeting: "", + trigger: "firstRun" +} +``` + +### HTML subset +The following tags are allowed in the content of a message: `i, b, u, strong, em, br`. + +Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload: +``` +{ + "id": "7899", + "content": { + "text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>", + "links": { + "cta": { + "url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly" + } + } + } +} +``` +If a tag that is not on the allowed is used, the text content will be extracted and displayed. + +Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`. + +### Trigger params +A set of hostnames that need to exactly match the location of the selected tab in order for the trigger to execute. +``` +["github.com", "wwww.github.com"] +``` +More examples in the [CFRMessageProvider](https://github.com/mozilla/activity-stream/blob/e76ce12fbaaac1182aa492b84fc038f78c3acc33/lib/CFRMessageProvider.jsm#L40-L47). + +### Trigger patterns +A set of patterns that can match multiple hostnames. When the location of the selected tab matches one of the patterns it can execute a trigger. +``` +["*://*.github.com"] // can match `github.com` but also match `https://gist.github.com/` +``` +More [MatchPattern examples](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#Examples). + +### Targeting attributes +(This section has moved to [targeting-attributes.md](../docs/targeting-attributes.md)). diff --git a/browser/components/asrouter/content-src/schemas/message-group.schema.json b/browser/components/asrouter/content-src/schemas/message-group.schema.json new file mode 100644 index 0000000000..421acf159a --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/message-group.schema.json @@ -0,0 +1,64 @@ +{ + "title": "MessageGroup", + "description": "Configuration object for groups of Messaging System messages", + "type": "object", + "version": "1.0.0", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the message that should not conflict with any other previous message." + }, + "enabled": { + "type": "boolean", + "description": "Enables or disables all messages associated with this group." + }, + "userPreferences": { + "type": "array", + "description": "Collection of preferences that control if the group is enabled.", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": ["period", "cap"] + } + } + } + }, + "type": { + "type": "string", + "description": "Local auto-generated group or remote group configuration from RS.", + "enum": ["remote-settings", "local", "default"] + } + }, + "required": ["id", "enabled", "type"], + "additionalProperties": true +} diff --git a/browser/components/asrouter/content-src/schemas/provider-response.schema.json b/browser/components/asrouter/content-src/schemas/provider-response.schema.json new file mode 100644 index 0000000000..b6bdc04f36 --- /dev/null +++ b/browser/components/asrouter/content-src/schemas/provider-response.schema.json @@ -0,0 +1,67 @@ +{ + "title": "ProviderResponse", + "description": "A response object for remote providers of AS Router", + "type": "object", + "version": "6.1.0", + "properties": { + "messages": { + "type": "array", + "description": "An array of router messages", + "items": { + "title": "RouterMessage", + "description": "A definition of an individual message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the message that should not conflict with any other previous message" + }, + "template": { + "type": "string", + "description": "An id matching an existing Activity Stream Router template", + "enum": ["cfr_doorhanger"] + }, + "bundled": { + "type": "integer", + "description": "The number of messages of the same template this one should be shown with (optional)" + }, + "order": { + "type": "integer", + "minimum": 0, + "description": "If bundled with other messages of the same template, which order should this one be placed in? (optional - defaults to 0)" + }, + "content": { + "type": "object", + "description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details." + }, + "targeting": { + "type": "string", + "description": "A JEXL expression representing targeting information" + }, + "trigger": { + "type": "object", + "description": "An action to trigger potentially showing the message", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action", + "enum": ["firstRun", "openURL"] + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "type": "string", + "description": "A parameter for the trigger action" + } + } + }, + "required": ["id"] + } + }, + "required": ["id", "template", "content"] + } + } + }, + "required": ["messages"] +} diff --git a/browser/components/asrouter/content-src/styles/_feature-callout-theme.scss b/browser/components/asrouter/content-src/styles/_feature-callout-theme.scss new file mode 100644 index 0000000000..657d4fa6a3 --- /dev/null +++ b/browser/components/asrouter/content-src/styles/_feature-callout-theme.scss @@ -0,0 +1,92 @@ +// 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/. + +// stylelint-disable color-hex-length, color-hex-case -- we want to preserve +// these values exactly, since they're drawn from other parts of the browser. + +@mixin light-theme { + --fc-background: var(--fc-background-light, #fff); + --fc-color: var(--fc-color-light, rgb(21, 20, 26)); + --fc-border: var(--fc-border-light, #CFCFD8); + --fc-accent-color: var(--fc-accent-color-light, rgb(0, 97, 224)); + --fc-button-background: var(--fc-button-background-light, #F0F0F4); + --fc-button-color: var(--fc-button-color-light, rgb(21, 20, 26)); + --fc-button-border: var(--fc-button-border-light, transparent); + --fc-button-background-hover: var(--fc-button-background-hover-light, #E0E0E6); + --fc-button-color-hover: var(--fc-button-color-hover-light, rgb(21, 20, 26)); + --fc-button-border-hover: var(--fc-button-border-hover-light, transparent); + --fc-button-background-active: var(--fc-button-background-active-light, #CFCFD8); + --fc-button-color-active: var(--fc-button-color-active-light, rgb(21, 20, 26)); + --fc-button-border-active: var(--fc-button-border-active-light, transparent); + --fc-primary-button-background: var(--fc-primary-button-background-light, #0061e0); + --fc-primary-button-color: var(--fc-primary-button-color-light, rgb(251,251,254)); + --fc-primary-button-border: var(--fc-primary-button-border-light, transparent); + --fc-primary-button-background-hover: var(--fc-primary-button-background-hover-light, #0250bb); + --fc-primary-button-color-hover: var(--fc-primary-button-color-hover-light, rgb(251,251,254)); + --fc-primary-button-border-hover: var(--fc-primary-button-border-hover-light, transparent); + --fc-primary-button-background-active: var(--fc-primary-button-background-active-light, #053e94); + --fc-primary-button-color-active: var(--fc-primary-button-color-active-light, rgb(251,251,254)); + --fc-primary-button-border-active: var(--fc-primary-button-border-active-light, transparent); + --fc-step-color: color-mix(in srgb, currentColor 50%, transparent); + --fc-link-color: var(--fc-link-color-light, #0061E0); + --fc-link-color-hover: var(--fc-link-color-hover-light, #0250BB); + --fc-link-color-active: var(--fc-link-color-active-light, #053E94); +} + +@mixin dark-theme { + --fc-background: var(--fc-background-dark, rgb(43, 42, 51)); + --fc-color: var(--fc-color-dark, rgb(251, 251, 254)); + --fc-border: var(--fc-border-dark, #3A3944); + --fc-accent-color: var(--fc-accent-color-dark, rgb(0, 221, 255)); + --fc-button-background: var(--fc-button-background-dark, #2B2A33); + --fc-button-color: var(--fc-button-color-dark, rgb(251, 251, 254)); + --fc-button-border: var(--fc-button-border-dark, transparent); + --fc-button-background-hover: var(--fc-button-background-hover-dark, #52525E); + --fc-button-color-hover: var(--fc-button-color-hover-dark, rgb(251, 251, 254)); + --fc-button-border-hover: var(--fc-button-border-hover-dark, transparent); + --fc-button-background-active: var(--fc-button-background-active-dark, #5B5B66); + --fc-button-color-active: var(--fc-button-color-active-dark, rgb(251, 251, 254)); + --fc-button-border-active: var(--fc-button-border-active-dark, transparent); + --fc-primary-button-background: var(--fc-primary-button-background-dark, rgb(0,221,255)); + --fc-primary-button-color: var(--fc-primary-button-color-dark, rgb(43,42,51)); + --fc-primary-button-border: var(--fc-primary-button-border-dark, transparent); + --fc-primary-button-background-hover: var(--fc-primary-button-background-hover-dark, rgb(128,235,255)); + --fc-primary-button-color-hover: var(--fc-primary-button-color-hover-dark, rgb(43,42,51)); + --fc-primary-button-border-hover: var(--fc-primary-button-border-hover-dark, transparent); + --fc-primary-button-background-active: var(--fc-primary-button-background-active-dark, rgb(170,242,255)); + --fc-primary-button-color-active: var(--fc-primary-button-color-active-dark, rgb(43,42,51)); + --fc-primary-button-border-active: var(--fc-primary-button-border-active-dark, transparent); + --fc-link-color: var(--fc-link-color-dark, #00DDFF); + --fc-link-color-hover: var(--fc-link-color-hover-dark, #80EBFF); + --fc-link-color-active: var(--fc-link-color-hover-active, #AAF2FF); +} + +@mixin hcm-theme { + --fc-background: var(--fc-background-hcm, -moz-dialog); + --fc-color: var(--fc-color-hcm, -moz-dialogtext); + --fc-border: var(--fc-border-hcm, -moz-dialogtext); + --fc-accent-color: var(--fc-accent-color-hcm, LinkText); + --fc-button-background: var(--fc-button-background-hcm, ButtonFace); + --fc-button-color: var(--fc-button-color-hcm, ButtonText); + --fc-button-border: var(--fc-button-border-hcm, ButtonText); + --fc-button-background-hover: var(--fc-button-background-hover-hcm, ButtonText); + --fc-button-color-hover: var(--fc-button-color-hover-hcm, ButtonFace); + --fc-button-border-hover: var(--fc-button-border-hover-hcm, ButtonText); + --fc-button-background-active: var(--fc-button-background-active-hcm, ButtonText); + --fc-button-color-active: var(--fc-button-color-active-hcm, ButtonFace); + --fc-button-border-active: var(--fc-button-border-active-hcm, ButtonText); + --fc-primary-button-background: var(--fc-primary-button-background-hcm, ButtonText); + --fc-primary-button-color: var(--fc-primary-button-color-hcm, ButtonFace); + --fc-primary-button-border: var(--fc-primary-button-border-hcm, ButtonFace); + --fc-primary-button-background-hover: var(--fc-primary-button-background-hover-hcm, SelectedItem); + --fc-primary-button-color-hover: var(--fc-primary-button-color-hover-hcm, SelectedItemText); + --fc-primary-button-border-hover: var(--fc-primary-button-border-hover-hcm, SelectedItemText); + --fc-primary-button-background-active: var(--fc-primary-button-background-active-hcm, SelectedItemText); + --fc-primary-button-color-active: var(--fc-primary-button-color-active-hcm, SelectedItem); + --fc-primary-button-border-active: var(--fc-primary-button-border-active-hcm, SelectedItem); + --fc-step-color: var(--fc-accent-color-hcm, LinkText); + --fc-link-color: var(--fc-link-color-hcm, LinkText); + --fc-link-color-hover: var(--fc-link-color-hover-hcm, LinkText); + --fc-link-color-active: var(--fc-link-color-active-hcm, ActiveText); +} diff --git a/browser/components/asrouter/content-src/styles/_feature-callout.scss b/browser/components/asrouter/content-src/styles/_feature-callout.scss new file mode 100644 index 0000000000..66770c2238 --- /dev/null +++ b/browser/components/asrouter/content-src/styles/_feature-callout.scss @@ -0,0 +1,775 @@ +// 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/. + +@import 'feature-callout-theme'; + +/* stylelint-disable max-nesting-depth */ + +#feature-callout { + // See _feature-callout-theme.scss for the theme mixins and + // FeatureCallout.sys.mjs for the color values + @include light-theme; + + position: absolute; + z-index: 2147483647; + outline: none; + color: var(--fc-color); + accent-color: var(--fc-accent-color); + // Make sure HTML content uses non-native theming, even in chrome windows. + -moz-theme: non-native; + + @media (prefers-color-scheme: dark) { + @include dark-theme; + } + + @media (prefers-contrast) { + @include hcm-theme; + } + + // Account for feature callouts that may be rendered in the chrome but + // displayed on top of content. Each context has its own color scheme, so they + // may not match. In that case, we use the special media query below. + &.simulateContent { + color-scheme: env(-moz-content-preferred-color-scheme); + + // TODO - replace 2 mixins with a single mixin with light-dark() values. + @media (-moz-content-prefers-color-scheme: light) { + @include light-theme; + } + + @media (-moz-content-prefers-color-scheme: dark) { + @include dark-theme; + } + + @media (prefers-contrast) { + @include hcm-theme; + } + } + + // The desired width of the arrow (the triangle base). + --arrow-width: 33.9411px; + // The width/height of the square that, rotated 90deg, will become the arrow. + --arrow-square-size: calc(var(--arrow-width) / sqrt(2)); + // After rotating, the width is no longer the square width. It's now equal to + // the diagonal from corner to corner, i.e. √2 * the square width. We need to + // account for this extra width in some calculations. + --extra-width-from-rotation: calc(var(--arrow-width) - var(--arrow-square-size)); + // The height of the arrow, once rotated and cut in half. + --arrow-visible-height: calc(var(--arrow-width) / 2); + // Half the width/height of the square. Calculations on the arrow itself need + // to treat the arrow as a square, since they are operating on the element + // _before_ it is rotated. Calculations on other elements (like the panel + // margin that needs to make space for the arrow) should use the visible + // height that treats it as a triangle. + --arrow-visible-size: calc(var(--arrow-square-size) / 2); + --arrow-center-inset: calc(50% - var(--arrow-visible-size)); + // Move the arrow 1.5px closer to the callout to account for subpixel rounding + // differences, which might cause the corners of the arrow (which is actually + // a rotated square) to be visible. + --arrow-offset: calc(1.5px - var(--arrow-visible-size)); + // For positions like top-end, the arrow is 12px away from the corner. + --arrow-corner-distance: 12px; + --arrow-corner-inset: calc(var(--arrow-corner-distance) + (var(--extra-width-from-rotation) / 2)); + --arrow-overlap-magnitude: 5px; + + @at-root panel#{&} { + --panel-color: var(--fc-color); + --panel-shadow: none; + // Extra space around the panel for the shadow to be drawn in. The panel + // content can't overflow the XUL popup frame, so the frame must be extended. + --panel-shadow-margin: 6px; + // The panel needs more extra space on the side that the arrow is on, since + // the arrow is absolute positioned. This adds the visible height of the + // arrow to the margin and subtracts 1 since the arrow is inset by 1.5px + // (see --arrow-offset). + --panel-arrow-space: calc(var(--panel-shadow-margin) + var(--arrow-visible-height) - 1.5px); + // The callout starts with its edge aligned with the edge of the anchor. But + // we want the arrow to align to the anchor, not the callout edge. So we need + // to offset the callout by the arrow size and margin, as well as the margin + // of the entire callout (it has margins on all sides to make room for the + // shadow when displayed in a panel, which would normally cut off the shadow). + --panel-margin-offset: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-corner-distance) + (var(--arrow-width) / 2))); + } + + @at-root panel#{&}::part(content) { + width: initial; + border: 0; + border-radius: 0; + padding: 0; + margin: var(--panel-shadow-margin); + background: none; + color: inherit; + // stylelint-disable-next-line declaration-no-important + overflow: visible !important; + } + + @at-root div#{&} { + transition: opacity 0.5s ease; + + &.hidden { + opacity: 0; + pointer-events: none; + } + } + + .onboardingContainer, + .onboardingContainer .outer-wrapper { + // Override the element transitions from aboutwelcome.scss + --transition: none; + + // auto height to allow for arrow positioning based on height + height: auto; + } + + // use a different approach to flipping to avoid the fuzzy aliasing that + // transform causes. + &:dir(rtl) { + transform: none; + direction: ltr; + } + + & .outer-wrapper:dir(rtl) { + transform: none; + direction: rtl; + } + + .screen { + // override transform in about:welcome + &:dir(rtl) { + transform: none; + } + + &[pos='callout'] { + height: fit-content; + min-height: unset; + overflow: visible; + + &[layout='inline'] { + .section-main { + .main-content, + .main-content.no-steps { + width: 18em; + padding-inline: 16px; + padding-block: 0; + + .welcome-text { + // Same height as the dismiss button + height: 24px; + margin-block: 12px; + margin-inline: 0; + padding: 0; + white-space: nowrap; + } + } + + .dismiss-button { + height: 24px; + width: 24px; + min-height: 24px; + min-width: 24px; + margin: 0; + top: calc(50% - 12px); + inset-inline-end: 12px; + } + } + } + + .logo-container { + display: flex; + justify-content: center; + + .brand-logo { + margin: 0; + + // This may not work for all future messages, so we may want to make + // flipping the logo image in RTL mode configurable + &:dir(rtl) { + transform: rotateY(180deg); + } + } + } + + .welcome-text { + align-items: baseline; + text-align: start; + margin: 0; + padding: 0; + gap: 8px; + + h1, + h2 { + font-size: 0.813em; + margin: 0; + color: inherit; + } + + h1 { + font-weight: 600; + } + + .inline-icon-container { + display: flex; + flex-flow: row wrap; + align-items: center; + + .logo-container { + height: 16px; + width: 16px; + margin-inline-end: 6px; + box-sizing: border-box; + -moz-context-properties: fill; + fill: currentColor; + + img { + height: 16px; + width: 16px; + margin: 0; + } + } + + &[alignment='top'], + &[alignment='bottom'] { + flex-wrap: nowrap; + + .logo-container { + height: 1.5em; // match the title's line-height + align-items: center; + padding-bottom: 0.15em; + box-sizing: border-box; + } + } + + &[alignment='top'] { + align-items: start; + } + + &[alignment='bottom'] { + align-items: end; + } + } + + } + + .multi-select-container { + margin: 0; + font-size: 0.813em; + row-gap: 12px; + color: inherit; + overflow: visible; + + #multi-stage-multi-select-label { + font-size: inherit; + // There's a 12px gap that pushes the .multi-select-container down + // away from the .welcome-text. And there's an 8px gap between the h1 + // and h2 in the .welcome-text container. So subtract 4px to get the + // desired 8px margin, so spacing is the same as for `subtitle`. + margin: -4px 0 0; + color: inherit; + } + } + + .cta-link { + background: none; + text-decoration: underline; + cursor: pointer; + border: none; + padding: 0; + color: var(--fc-link-color); + order: -1; + margin-inline-end: auto; + margin-block: 8px; + + &:hover { + color: var(--fc-link-color-hover); + } + + &:active { + color: var(--fc-link-color-active); + } + } + + // Secondary section is not included in callouts + .section-secondary { + display: none; + } + + .section-main { + height: fit-content; + width: fit-content; + + .main-content { + position: relative; + overflow: hidden; + border: 1px solid var(--fc-border); + box-shadow: 0 2px 6px rgba(0, 0, 0, 15%); + border-radius: 4px; + padding: var(--callout-padding, 24px); + width: 25em; + gap: 16px; + background-color: var(--fc-background); + + .main-content-inner { + gap: 12px; + } + + .steps { + height: auto; + position: absolute; + // 24px is the callout's bottom padding. The CTAs are 32px tall, and + // the steps are 8px tall. So we need to offset the steps by half + // the difference in order to center them. 32/2 - 8/2 = 12. + bottom: calc(var(--callout-padding, 24px) + 12px); + padding-block: 0; + + .indicator { + // using border will show up in Windows High Contrast Mode to improve accessibility. + border: 4px solid var(--fc-step-color); + + &.current { + border-color: var(--fc-accent-color); + } + } + + &:not(.progress-bar) { + flex-flow: row nowrap; + gap: 8px; + + .indicator { + margin: 0; + } + } + + & .indicator.current, + &.progress-bar .indicator.complete { + border-color: var(--fc-accent-color); + } + } + } + + .dismiss-button { + font-size: 1em; + inset-block: 0 auto; + inset-inline: auto 0; + margin-block: 16px 0; + margin-inline: 0 16px; + background-color: var(--fc-background); + + &[button-size='small'] { + height: 24px; + width: 24px; + min-height: 24px; + min-width: 24px; + } + } + } + + .action-buttons { + display: flex; + flex-flow: row nowrap; + align-items: stretch; + justify-content: end; + gap: 10px; + // The Figma spec wants a 16px gap between major content blocks and the + // action buttons. But the action buttons are siblings with the minor + // content blocks, which want a 12px gap. So we use a 12px gap and just + // add 4px of margin to the action buttons. + margin-top: 4px; + + &[alignment='start'] { + justify-content: start; + } + + &[alignment='space-between'] { + justify-content: space-between; + } + + .secondary-cta { + font-size: inherit; + } + + .primary, + .secondary { + padding: 4px 16px; + margin: 0; + font-size: 0.813em; + font-weight: 600; + line-height: 16px; + min-height: 32px; + text-decoration: none; + cursor: default; + } + + .secondary { + background-color: var(--fc-button-background); + } + + .primary { + background-color: var(--fc-primary-button-background); + } + + .split-button-container { + align-items: stretch; + + &:not([hidden]) { + display: flex; + } + + .primary, + .secondary, + .additional-cta { + &:not(.submenu-button) { + border-start-end-radius: 0; + border-end-end-radius: 0; + margin-inline-end: 0; + } + + &:focus-visible { + z-index: 2; + } + } + + .submenu-button { + border-start-start-radius: 0; + border-end-start-radius: 0; + margin-inline-start: 1px; + padding: 8px; + min-width: 30px; + box-sizing: border-box; + background-image: url('chrome://global/skin/icons/arrow-down.svg'); + background-repeat: no-repeat; + background-size: 16px; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + } + } + } + + .action-buttons .primary, + .action-buttons .secondary, + .dismiss-button { + border-radius: 4px; + + &:focus-visible { + box-shadow: none; + outline: 2px solid var(--fc-accent-color); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.4; + cursor: auto; + } + } + + .action-buttons .secondary, + .dismiss-button { + border: 1px solid var(--fc-button-border); + color: var(--fc-button-color); + + &:hover:not(:disabled), + &[open] { + background-color: var(--fc-button-background-hover); + color: var(--fc-button-color-hover); + border: 1px solid var(--fc-button-border-hover); + + &:active { + background-color: var(--fc-button-background-active); + color: var(--fc-button-color-active); + border: 1px solid var(--fc-button-border-active); + } + } + } + + .action-buttons .primary { + border: 1px solid var(--fc-primary-button-border); + color: var(--fc-primary-button-color); + + &:hover:not(:disabled), + &[open] { + background-color: var(--fc-primary-button-background-hover); + color: var(--fc-primary-button-color-hover); + border: 1px solid var(--fc-primary-button-border-hover); + + &:active { + background-color: var(--fc-primary-button-background-active); + color: var(--fc-primary-button-color-active); + border: 1px solid var(--fc-primary-button-border-active); + } + } + } + } + } + + @at-root panel#{&}:is([side='top'], [side='bottom']):not([hide-arrow='permanent']) { + margin-inline: var(--panel-margin-offset); + } + + @at-root panel#{&}:is([side='left'], [side='right']):not([hide-arrow='permanent']) { + margin-block: var(--panel-margin-offset); + } + + @at-root panel#{&}::part(content) { + position: relative; + } + + // all visible callout arrow boxes. boxes are for rotating 45 degrees, arrows + // are for the actual arrow shape and are children of the boxes. + .arrow-box { + position: absolute; + overflow: visible; + transform: rotate(45deg); + // keep the border crisp under transformation + transform-style: preserve-3d; + } + + &:not([arrow-position]) .arrow-box, + &[hide-arrow] .arrow-box { + display: none; + } + + // both shadow arrow and background arrow + .arrow { + width: var(--arrow-square-size); + height: var(--arrow-square-size); + } + + // the arrow's shadow box + .shadow-arrow-box { + z-index: -1; + } + + // the arrow's shadow + .shadow-arrow { + background: transparent; + outline: 1px solid var(--fc-border); + box-shadow: 0 2px 6px rgba(0, 0, 0, 15%); + } + + // the 'filled' arrow box + .background-arrow-box { + z-index: 1; + // the background arrow technically can overlap the dismiss button. it + // doesn't visibly overlap it because of the clip-path rule below, but it + // can still be clicked. so we need to make sure it doesn't block inputs on + // the button. the visible part of the arrow can still catch clicks because + // we don't add this rule to .shadow-arrow-box. + pointer-events: none; + } + + // the 'filled' arrow + .background-arrow { + background-color: var(--fc-background); + clip-path: var(--fc-arrow-clip-path); + } + + // top (center) arrow positioning + &[arrow-position='top'] .arrow-box { + top: var(--arrow-offset); + inset-inline-start: var(--arrow-center-inset); + // the callout arrow is actually a diamond (a rotated square), with the + // lower half invisible. the part that appears in front of the callout has + // only a background, so that where it overlaps the callout's border, the + // border is not visible. the part that appears behind the callout has only + // a border/shadow, so that it can't be seen overlapping the callout. but + // because the background is the same color as the callout, that half of the + // diamond would visibly overlap any callout content that happens to be in + // the same place. so we clip it to a triangle, with a 2% extension on the + // bottom to account for any subpixel rounding differences. + --fc-arrow-clip-path: polygon(100% 0, 100% 2%, 2% 100%, 0 100%, 0 0); + } + + @at-root panel#{&}[arrow-position='top']::part(content) { + margin-top: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='top'] { + margin-top: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // bottom (center) arrow positioning + &[arrow-position='bottom'] .arrow-box { + bottom: var(--arrow-offset); + inset-inline-start: var(--arrow-center-inset); + --fc-arrow-clip-path: polygon(100% 0, 98% 0, 0 98%, 0 100%, 100% 100%); + } + + @at-root panel#{&}[arrow-position='bottom']::part(content) { + margin-bottom: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='bottom'] { + margin-bottom: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // end (center) arrow positioning + &[arrow-position='inline-end'] .arrow-box { + top: var(--arrow-center-inset); + inset-inline-end: var(--arrow-offset); + --fc-arrow-clip-path: polygon(100% 0, 100% 100%, 98% 100%, 0 2%, 0 0); + } + + @at-root panel#{&}[arrow-position='inline-end']::part(content) { + margin-inline-end: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='inline-end'] { + margin-inline-end: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // start (center) arrow positioning + &[arrow-position='inline-start'] .arrow-box { + top: var(--arrow-center-inset); + inset-inline-start: var(--arrow-offset); + --fc-arrow-clip-path: polygon(0 100%, 100% 100%, 100% 98%, 2% 0, 0 0); + } + + @at-root panel#{&}[arrow-position='inline-start']::part(content) { + margin-inline-start: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='inline-start'] { + margin-inline-start: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // top-end arrow positioning + &[arrow-position='top-end'] .arrow-box { + top: var(--arrow-offset); + inset-inline-end: var(--arrow-corner-inset); + --fc-arrow-clip-path: polygon(100% 0, 100% 2%, 2% 100%, 0 100%, 0 0); + } + + @at-root panel#{&}[arrow-position='top-end']::part(content) { + margin-top: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='top-end'] { + margin-top: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // top-start arrow positioning + &[arrow-position='top-start'] .arrow-box { + top: var(--arrow-offset); + inset-inline-start: var(--arrow-corner-inset); + --fc-arrow-clip-path: polygon(100% 0, 100% 2%, 2% 100%, 0 100%, 0 0); + } + + @at-root panel#{&}[arrow-position='top-start']::part(content) { + margin-top: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='top-start'] { + margin-top: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // bottom-end arrow positioning + &[arrow-position='bottom-end'] .arrow-box { + bottom: var(--arrow-offset); + inset-inline-end: var(--arrow-corner-inset); + --fc-arrow-clip-path: polygon(100% 0, 98% 0, 0 98%, 0 100%, 100% 100%); + } + + @at-root panel#{&}[arrow-position='bottom-end']::part(content) { + margin-bottom: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='bottom-end'] { + margin-bottom: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // bottom-start arrow positioning + &[arrow-position='bottom-start'] .arrow-box { + bottom: var(--arrow-offset); + inset-inline-start: var(--arrow-corner-inset); + --fc-arrow-clip-path: polygon(100% 0, 98% 0, 0 98%, 0 100%, 100% 100%); + } + + @at-root panel#{&}[arrow-position='bottom-start']::part(content) { + margin-bottom: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='bottom-start'] { + margin-bottom: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // inline-end-top arrow positioning + &[arrow-position='inline-end-top'] .arrow-box { + top: var(--arrow-corner-inset); + inset-inline-end: var(--arrow-offset); + --fc-arrow-clip-path: polygon(100% 0, 100% 100%, 98% 100%, 0 2%, 0 0); + } + + @at-root panel#{&}[arrow-position='inline-end-top']::part(content) { + margin-inline-end: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='inline-end-top'] { + margin-inline-end: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // inline-end-bottom arrow positioning + &[arrow-position='inline-end-bottom'] .arrow-box { + bottom: var(--arrow-corner-inset); + inset-inline-end: var(--arrow-offset); + --fc-arrow-clip-path: polygon(100% 0, 100% 100%, 98% 100%, 0 2%, 0 0); + } + + @at-root panel#{&}[arrow-position='inline-end-bottom']::part(content) { + margin-inline-end: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='inline-end-bottom'] { + margin-inline-end: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // inline-start-top arrow positioning + &[arrow-position='inline-start-top'] .arrow-box { + top: var(--arrow-corner-inset); + inset-inline-start: var(--arrow-offset); + --fc-arrow-clip-path: polygon(0 100%, 100% 100%, 100% 98%, 2% 0, 0 0); + } + + @at-root panel#{&}[arrow-position='inline-start-top']::part(content) { + margin-inline-start: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='inline-start-top'] { + margin-inline-start: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // inline-start-bottom arrow positioning + &[arrow-position='inline-start-bottom'] .arrow-box { + bottom: var(--arrow-corner-inset); + inset-inline-start: var(--arrow-offset); + --fc-arrow-clip-path: polygon(0 100%, 100% 100%, 100% 98%, 2% 0, 0 0); + } + + @at-root panel#{&}[arrow-position='inline-start-bottom']::part(content) { + margin-inline-start: var(--panel-arrow-space); + } + + @at-root panel#{&}[arrow-position='inline-start-bottom'] { + margin-inline-start: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude))); + } + + // focus outline for the callout itself + &:focus-visible { + .screen { + &[pos='callout'] { + .section-main .main-content { + outline: 2px solid var(--fc-accent-color); + border-color: transparent; + + @media (prefers-contrast) { + border-color: var(--fc-background); + } + } + } + } + + .shadow-arrow { + outline: 2px solid var(--fc-accent-color); + } + } +} diff --git a/browser/components/asrouter/content-src/styles/_shopping.scss b/browser/components/asrouter/content-src/styles/_shopping.scss new file mode 100644 index 0000000000..218e996cb8 --- /dev/null +++ b/browser/components/asrouter/content-src/styles/_shopping.scss @@ -0,0 +1,209 @@ +// 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/. + +/* stylelint-disable max-nesting-depth */ + +.onboardingContainer.shopping { + height: auto; + + .outer-wrapper { + height: auto; + } +} + +.onboardingContainer.shopping .screen[pos='split'] { + height: auto; + margin: 0 auto; + min-height: fit-content; + border-radius: 8px; + box-shadow: 0 2px 6px rgba(58, 57, 68, 20%); + overflow-x: auto; + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + box-shadow: 0 2px 6px rgba(21, 20, 26, 100%); + } + + &::before { + display: none; + } + + .section-main { + width: auto; + height: auto; + margin: 0 auto; + + .main-content { + border-radius: 4px; + color: inherit; + font: menu; + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + background-color: #52525E; + } + + &.no-steps { + padding: 16px 0 36px; + } + + .welcome-text { + text-align: start; + margin-block: 10px 12px; + + h1 { + width: auto; + font-weight: 400; + line-height: 1.5; + font-size: 1.7em; + } + + h2 { + color: inherit; + font-size: 1em; + } + } + + .action-buttons { + .primary, + .secondary { + min-width: auto; + } + + .primary { + font-weight: 400; + padding: 4px 16px; + } + + &.additional-cta-container { + align-items: center; + } + } + + .legal-paragraph { + font-size: 0.85em; + line-height: 1.5; + margin-block: 0 20px; + padding-inline: 30px; + text-align: start; + + a { + text-decoration: underline; + } + } + + .brand-logo { + width: 100%; + max-width: 294px; + max-height: 290px; + height: auto; + } + } + + .dismiss-button { + top: 0; + margin: 14px 10px; + } + } + + .section-secondary { + display: none; + } + + .info-text, .link-paragraph { + font-size: 1em; + margin: 10px auto; + line-height: 1.5; + } + + .link-paragraph { + margin-block: 0 10px; + padding-inline: 30px; + text-align: start; + + a { + text-decoration: underline; + } + } +} + +.onboardingContainer.shopping .screen[pos='split'][layout='survey'] { + .main-content { + padding: 12px; + + .main-content-inner { + min-height: auto; + align-items: initial; + + .welcome-text { + align-items: initial; + padding: 0; + margin-top: 0; + + h1, + h2 { + line-height: 20px; + } + + h1 { + font-size: 1em; + font-weight: 590; + margin: 0; + margin-inline-end: 28px; + } + + h2 { + color: inherit; + margin-block: 10px 0; + } + } + + .action-buttons { + .cta-link { + padding: 4px; + margin-block: -4px; + outline-offset: 0; + min-height: revert; + } + } + + .multi-select-container { + color: inherit; + padding: 0; + margin-block: 0 24px; + align-items: center; + overflow: visible; + font-size: 1em; + gap: 12px; + width: 100%; + + #multi-stage-multi-select-label { + color: inherit; + line-height: 20px; + margin-block: -2px 0; + font-size: 1em; + } + + .multi-select-item input { + margin-block: 0; + } + } + + .steps { + height: auto; + margin-bottom: 12px; + } + } + } + + .dismiss-button { + width: 24px; + height: 24px; + min-width: 24px; + min-height: 24px; + margin: 10px; + } +} + +.onboardingContainer.shopping shopping-message-bar { + font: menu; +} diff --git a/browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json b/browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json new file mode 100644 index 0000000000..ff5dff535a --- /dev/null +++ b/browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///CFRUrlbarChiclet.schema.json", + "title": "CFRUrlbarChiclet", + "description": "A template with a chiclet button with text.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Describes how content should be displayed.", + "enum": ["chiclet_open_url"] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "notification_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "active_color": { + "type": "string", + "description": "Background color of the button" + }, + "action": { + "type": "object", + "properties": { + "url": { + "description": "The page to open when the button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "where": { + "description": "Should it open in a new tab or the current tab", + "type": "string", + "enum": ["current", "tabshifted"] + } + }, + "additionalProperties": true, + "required": ["url", "where"] + } + }, + "additionalProperties": true, + "required": [ + "layout", + "category", + "bucket_id", + "notification_text", + "action" + ] + }, + "template": { + "type": "string", + "const": "cfr_urlbar_chiclet" + } + }, + "required": ["targeting", "trigger"] +} diff --git a/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json b/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json new file mode 100644 index 0000000000..f25e6cc92c --- /dev/null +++ b/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json @@ -0,0 +1,320 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ExtensionDoorhanger.schema.json", + "title": "ExtensionDoorhanger", + "description": "A template with a heading, addon icon, title and description. No markup allowed.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider", + "enum": ["short_message", "icon_and_message", "addon_recommendation"] + }, + "anchor_id": { + "type": "string", + "description": "A DOM element ID that the pop-over will be anchored." + }, + "alt_anchor_id": { + "type": "string", + "description": "An alternate DOM element ID that the pop-over will be anchored." + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "skip_address_bar_notifier": { + "type": "boolean", + "description": "Skip the 'Recommend' notifier and show directly." + }, + "persistent_doorhanger": { + "type": "boolean", + "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications." + }, + "show_in_private_browsing": { + "type": "boolean", + "description": "Whether to allow the message to be shown in private browsing mode. Defaults to false." + }, + "notification_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "info_icon": { + "type": "object", + "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark.", + "properties": { + "label": { + "oneOf": [ + { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "tooltiptext": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Text for button tooltip used to provide information about the doorhanger." + } + }, + "required": ["tooltiptext"] + } + }, + "required": ["attributes"] + }, + { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText" + } + ] + }, + "sumo_path": { + "type": "string", + "description": "Last part of the path in the URL to the support page with the information about the doorhanger.", + "examples": ["extensionpromotions", "extensionrecommendations"] + } + } + }, + "learn_more": { + "type": "string", + "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.", + "examples": ["extensionpromotions", "extensionrecommendations"] + }, + "heading_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "icon": { + "$ref": "#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg." + }, + "icon_dark_theme": { + "type": "string", + "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg." + }, + "icon_class": { + "type": "string", + "description": "CSS class of the pop-over icon." + }, + "addon": { + "description": "Addon information including AMO URL.", + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/plainText", + "description": "Unique addon ID" + }, + "title": { + "$ref": "#/$defs/plainText", + "description": "Addon name" + }, + "author": { + "$ref": "#/$defs/plainText", + "description": "Addon author" + }, + "icon": { + "$ref": "#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg." + }, + "rating": { + "type": "string", + "description": "Star rating" + }, + "users": { + "type": "string", + "description": "Installed users" + }, + "amo_url": { + "$ref": "#/$defs/linkUrl", + "description": "Link that offers more information related to the addon." + } + }, + "required": ["title", "author", "icon", "amo_url"] + }, + "text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "descriptionDetails": { + "description": "Additional information and steps on how to use", + "type": "object", + "properties": { + "steps": { + "description": "Array of string_ids", + "type": "array", + "items": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText", + "description": "Id of string to localized addon description" + } + } + }, + "required": ["steps"] + }, + "buttons": { + "description": "The label and functionality for the buttons in the pop-over.", + "type": "object", + "properties": { + "primary": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "$ref": "#/$defs/plainText", + "description": "Button label override used when a localized version is not available." + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": ["accesskey"], + "description": "Button attributes." + } + }, + "required": ["value", "attributes"] + }, + { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "type": "string", + "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.sys.mjs", + "description": "URL used in combination with the primary action dispatched." + } + } + } + } + } + } + }, + "secondary": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "allOf": [ + { "$ref": "#/$defs/plainText" }, + { + "description": "Button label override used when a localized version is not available." + } + ] + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": ["accesskey"], + "description": "Button attributes." + } + }, + "required": ["value", "attributes"] + }, + { + "properties": { + "string_id": { + "allOf": [ + { "$ref": "#/$defs/plainText" }, + { + "description": "Id of localized string for button" + } + ] + } + }, + "required": ["string_id"] + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "allOf": [ + { "$ref": "#/$defs/linkUrl" }, + { + "description": "URL used in combination with the primary action dispatched." + } + ] + } + } + } + } + } + } + } + } + } + } + }, + "additionalProperties": true, + "required": ["layout", "bucket_id", "heading_text", "text", "buttons"], + "if": { + "properties": { + "skip_address_bar_notifier": { + "anyOf": [{ "const": "false" }, { "const": null }] + } + } + }, + "then": { + "required": ["category", "notification_text"] + } + }, + "template": { + "type": "string", + "enum": ["cfr_doorhanger", "milestone_message"] + } + }, + "additionalProperties": true, + "required": ["targeting", "trigger"], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } +} diff --git a/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json b/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json new file mode 100644 index 0000000000..ca0c0745bb --- /dev/null +++ b/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///InfoBar.schema.json", + "title": "InfoBar", + "description": "A template with an image, test and buttons.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).", + "enum": ["global", "tab"] + }, + "text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text show in the notification box." + }, + "priority": { + "description": "Infobar priority level https://searchfox.org/mozilla-central/rev/3aef835f6cb12e607154d56d68726767172571e4/toolkit/content/widgets/notificationbox.js#387", + "type": "number", + "minumum": 0, + "exclusiveMaximum": 10 + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text label of the button." + }, + "primary": { + "type": "boolean", + "description": "Is this the primary button?" + }, + "accessKey": { + "type": "string", + "description": "Keyboard shortcut letter." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "supportPage": { + "type": "string", + "description": "A page title on SUMO to link to" + } + }, + "required": ["label", "action"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["text", "buttons"] + }, + "template": { + "type": "string", + "const": "infobar" + } + }, + "additionalProperties": true, + "required": ["targeting", "trigger"], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } +} diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json new file mode 100644 index 0000000000..5d5b98f594 --- /dev/null +++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///Spotlight.schema.json", + "title": "Spotlight", + "description": "A template with an image, title, content and two buttons.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Specify the layout template for the Spotlight", + "const": "multistage" + }, + "backdrop": { + "type": "string", + "description": "Background css behind modal content" + }, + "logo": { + "type": "object", + "properties": { + "imageURL": { + "type": "string", + "description": "URL for image to use with the content" + }, + "imageId": { + "type": "string", + "description": "The ID for a remotely hosted image" + }, + "size": { + "type": "string", + "description": "The logo size." + } + }, + "additionalProperties": true + }, + "screens": { + "type": "array", + "description": "Collection of individual screen content" + }, + "transitions": { + "type": "boolean", + "description": "Show transitions within and between screens" + }, + "disableHistoryUpdates": { + "type": "boolean", + "description": "Don't alter the browser session's history stack - used with messaging surfaces like Feature Callouts" + }, + "startScreen": { + "type": "integer", + "description": "Index of first screen to show from message, defaulting to 0" + } + }, + "additionalProperties": true + }, + "template": { + "type": "string", + "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog", + "enum": ["spotlight", "feature_callout"] + } + }, + "additionalProperties": true, + "required": ["targeting"] +} diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json new file mode 100644 index 0000000000..4ec7dc9522 --- /dev/null +++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToolbarBadgeMessage.schema.json", + "title": "ToolbarBadgeMessage", + "description": "A template that specifies to which element in the browser toolbar to add a notification.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true, + "required": ["id"], + "description": "Optional action to take in addition to showing the notification" + }, + "delay": { + "type": "number", + "description": "Optional delay in ms after which to show the notification" + }, + "badgeDescription": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText", + "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'" + } + }, + "additionalProperties": true, + "required": ["target"] + }, + "template": { + "type": "string", + "const": "toolbar_badge" + } + }, + "additionalProperties": true, + "required": ["targeting"] +} diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json new file mode 100644 index 0000000000..c5a466a6e5 --- /dev/null +++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///UpdateAction.schema.json", + "title": "UpdateActionMessage", + "description": "A template for messages that execute predetermined actions.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "type": "object", + "description": "Additional data provided as argument when executing the action", + "properties": { + "url": { + "type": "string", + "description": "URL data to be used as argument to the action" + }, + "expireDelta": { + "type": "number", + "description": "Expiration timestamp to be used as argument to the action" + } + } + } + }, + "additionalProperties": true, + "description": "Optional action to take in addition to showing the notification", + "required": ["id", "data"] + } + }, + "additionalProperties": true, + "required": ["action"] + }, + "template": { + "type": "string", + "const": "update_action" + } + }, + "required": ["targeting"] +} diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json new file mode 100644 index 0000000000..26e795d068 --- /dev/null +++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///WhatsNewMessage.schema.json", + "title": "WhatsNewMessage", + "description": "A template for the messages that appear in the What's New panel.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "layout": { + "description": "Different message layouts", + "enum": ["tracking-protections"] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "published_date": { + "type": "integer", + "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published." + }, + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message title" + }, + "subtitle": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message subtitle" + }, + "body": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message body" + }, + "link_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "(optional) Id of localized string or message override of What's New message link text" + }, + "cta_url": { + "description": "Target URL for the What's New message.", + "type": "string", + "format": "moz-url-format" + }, + "cta_type": { + "description": "Type of url open action", + "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"] + }, + "cta_where": { + "description": "How to open the cta: new window, tab, focused, unfocused.", + "enum": ["current", "tabshifted", "tab", "save", "window"] + }, + "icon_url": { + "description": "(optional) URL for the What's New message icon.", + "type": "string", + "format": "uri" + }, + "icon_alt": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Alt text for image." + } + }, + "additionalProperties": true, + "required": ["published_date", "title", "body", "cta_url", "bucket_id"] + }, + "template": { + "type": "string", + "const": "whatsnew_panel_message" + } + }, + "required": ["order"], + "additionalProperties": true +} diff --git a/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json b/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json new file mode 100644 index 0000000000..3719419428 --- /dev/null +++ b/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json @@ -0,0 +1,153 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///NewtabPromoMessage.schema.json", + "title": "PBNewtabPromoMessage", + "description": "Message shown on the private browsing newtab page.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "hideDefault": { + "type": "boolean", + "description": "Should we hide the default promo after the experiment promo is dismissed." + }, + "infoEnabled": { + "type": "boolean", + "description": "Should we show the info section." + }, + "infoIcon": { + "type": "string", + "description": "Icon shown in the left side of the info section. Default is the private browsing icon." + }, + "infoTitle": { + "type": "string", + "description": "Is the title in the info section enabled." + }, + "infoTitleEnabled": { + "type": "boolean", + "description": "Is the title in the info section enabled." + }, + "infoBody": { + "type": "string", + "description": "Text content in the info section." + }, + "infoLinkText": { + "type": "string", + "description": "Text for the link in the info section." + }, + "infoLinkUrl": { + "type": "string", + "description": "URL for the info section link.", + "format": "moz-url-format" + }, + "promoEnabled": { + "type": "boolean", + "description": "Should we show the promo section." + }, + "promoType": { + "type": "string", + "description": "Promo type used to determine if promo should show to a given user", + "enum": ["FOCUS", "VPN", "PIN", "COOKIE_BANNERS", "OTHER"] + }, + "promoSectionStyle": { + "type": "string", + "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.", + "enum": ["top", "below-search", "bottom"] + }, + "promoTitle": { + "type": "string", + "description": "The text content of the promo section." + }, + "promoTitleEnabled": { + "type": "boolean", + "description": "Should we show text content in the promo section." + }, + "promoLinkText": { + "type": "string", + "description": "The text of the link in the promo box." + }, + "promoHeader": { + "type": "string", + "description": "The title of the promo section." + }, + "promoButton": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["action"] + }, + "promoLinkType": { + "type": "string", + "description": "Type of promo link type. Possible values: link, button. Default is link.", + "enum": ["link", "button"] + }, + "promoImageLarge": { + "type": "string", + "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.", + "format": "uri" + }, + "promoImageSmall": { + "type": "string", + "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.", + "format": "uri" + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "promoEnabled": { "const": true } + }, + "required": ["promoEnabled"] + }, + "then": { + "required": ["promoButton"] + } + }, + { + "if": { + "properties": { + "infoEnabled": { "const": true } + }, + "required": ["infoEnabled"] + }, + "then": { + "required": ["infoLinkText"], + "if": { + "properties": { + "infoTitleEnabled": { "const": true } + }, + "required": ["infoTitleEnabled"] + }, + "then": { + "required": ["infoTitle"] + } + } + } + ] + }, + "template": { + "type": "string", + "const": "pb_newtab" + } + }, + "additionalProperties": true, + "required": ["targeting"] +} diff --git a/browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json b/browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json new file mode 100644 index 0000000000..1fa3af5b69 --- /dev/null +++ b/browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToastNotification.schema.json", + "title": "ToastNotification", + "description": "A template for toast notifications displayed by the Alert service.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification body" + }, + "icon_url": { + "description": "The URL of the image used as an icon of the toast notification.", + "type": "string", + "format": "moz-url-format" + }, + "image_url": { + "description": "The URL of an image to be displayed as part of the notification.", + "type": "string", + "format": "moz-url-format" + }, + "launch_url": { + "description": "The URL to launch when the notification or an action button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "launch_action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The launch action to be performed when Firefox is launched." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "requireInteraction": { + "type": "boolean", + "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically." + }, + "tag": { + "type": "string", + "description": "An identifying tag for the toast notification." + }, + "data": { + "type": "object", + "description": "Arbitrary data associated with the toast notification." + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The action text to be shown to the user." + }, + "action": { + "type": "string", + "description": "Opaque identifer that identifies action." + }, + "iconURL": { + "type": "string", + "format": "uri", + "description": "URL of an icon to display with the action." + }, + "windowsSystemActivationType": { + "type": "boolean", + "description": "Whether to have Windows process the given `action`." + }, + "launch_action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The launch action to be performed when Firefox is launched." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["action", "title"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["title", "body"] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": ["content", "targeting", "template", "trigger"], + "additionalProperties": true +} diff --git a/browser/components/asrouter/content/asrouter-admin.bundle.js b/browser/components/asrouter/content/asrouter-admin.bundle.js new file mode 100644 index 0000000000..b92be649c6 --- /dev/null +++ b/browser/components/asrouter/content/asrouter-admin.bundle.js @@ -0,0 +1,1936 @@ +/*! + * + * NOTE: This file is generated by webpack from ASRouterAdmin.jsx + * using the npm bundle task. + * + */ +var ASRouterAdminRenderUtils; +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ([ +/* 0 */, +/* 1 */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "ASRouterUtils": () => (/* binding */ ASRouterUtils) +/* harmony export */ }); +/* harmony import */ var modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); +/* harmony import */ var common_Actions_sys_mjs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3); +/* 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/. */ + + + +const ASRouterUtils = { + addListener(listener) { + if (__webpack_require__.g.ASRouterAddParentListener) { + __webpack_require__.g.ASRouterAddParentListener(listener); + } + }, + removeListener(listener) { + if (__webpack_require__.g.ASRouterRemoveParentListener) { + __webpack_require__.g.ASRouterRemoveParentListener(listener); + } + }, + sendMessage(action) { + if (__webpack_require__.g.ASRouterMessage) { + return __webpack_require__.g.ASRouterMessage(action); + } + throw new Error(`Unexpected call:\n${JSON.stringify(action, null, 3)}`); + }, + blockById(id, options) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.BLOCK_MESSAGE_BY_ID, + data: { + id, + ...options + } + }); + }, + modifyMessageJson(content) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.MODIFY_MESSAGE_JSON, + data: { + content + } + }); + }, + executeAction(button_action) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.USER_ACTION, + data: button_action + }); + }, + unblockById(id) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.UNBLOCK_MESSAGE_BY_ID, + data: { + id + } + }); + }, + blockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.BLOCK_BUNDLE, + data: { + bundle + } + }); + }, + unblockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.UNBLOCK_BUNDLE, + data: { + bundle + } + }); + }, + overrideMessage(id) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.OVERRIDE_MESSAGE, + data: { + id + } + }); + }, + editState(key, value) { + return ASRouterUtils.sendMessage({ + type: modules_ActorConstants_sys_mjs__WEBPACK_IMPORTED_MODULE_0__.MESSAGE_TYPE_HASH.EDIT_STATE, + data: { + [key]: value + } + }); + }, + sendTelemetry(ping) { + return ASRouterUtils.sendMessage(common_Actions_sys_mjs__WEBPACK_IMPORTED_MODULE_1__.actionCreators.ASRouterUserEvent(ping)); + }, + getPreviewEndpoint() { + return null; + } +}; + +/***/ }), +/* 2 */ +/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "MESSAGE_TYPE_LIST": () => (/* binding */ MESSAGE_TYPE_LIST), +/* harmony export */ "MESSAGE_TYPE_HASH": () => (/* binding */ MESSAGE_TYPE_HASH) +/* harmony export */ }); +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +const MESSAGE_TYPE_LIST = [ + "BLOCK_MESSAGE_BY_ID", + "USER_ACTION", + "IMPRESSION", + "TRIGGER", + // PB is Private Browsing + "PBNEWTAB_MESSAGE_REQUEST", + "DOORHANGER_TELEMETRY", + "TOOLBAR_BADGE_TELEMETRY", + "TOOLBAR_PANEL_TELEMETRY", + "MOMENTS_PAGE_TELEMETRY", + "INFOBAR_TELEMETRY", + "SPOTLIGHT_TELEMETRY", + "TOAST_NOTIFICATION_TELEMETRY", + "AS_ROUTER_TELEMETRY_USER_EVENT", + + // Admin types + "ADMIN_CONNECT_STATE", + "UNBLOCK_MESSAGE_BY_ID", + "UNBLOCK_ALL", + "BLOCK_BUNDLE", + "UNBLOCK_BUNDLE", + "DISABLE_PROVIDER", + "ENABLE_PROVIDER", + "EVALUATE_JEXL_EXPRESSION", + "EXPIRE_QUERY_CACHE", + "FORCE_ATTRIBUTION", + "FORCE_WHATSNEW_PANEL", + "FORCE_PRIVATE_BROWSING_WINDOW", + "CLOSE_WHATSNEW_PANEL", + "OVERRIDE_MESSAGE", + "MODIFY_MESSAGE_JSON", + "RESET_PROVIDER_PREF", + "SET_PROVIDER_USER_PREF", + "RESET_GROUPS_STATE", + "RESET_MESSAGE_STATE", + "RESET_SCREEN_IMPRESSIONS", + "EDIT_STATE", +]; + +const MESSAGE_TYPE_HASH = MESSAGE_TYPE_LIST.reduce((hash, value) => { + hash[value] = value; + return hash; +}, {}); + + +/***/ }), +/* 3 */ +/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "MAIN_MESSAGE_TYPE": () => (/* binding */ MAIN_MESSAGE_TYPE), +/* harmony export */ "CONTENT_MESSAGE_TYPE": () => (/* binding */ CONTENT_MESSAGE_TYPE), +/* harmony export */ "PRELOAD_MESSAGE_TYPE": () => (/* binding */ PRELOAD_MESSAGE_TYPE), +/* harmony export */ "UI_CODE": () => (/* binding */ UI_CODE), +/* harmony export */ "BACKGROUND_PROCESS": () => (/* binding */ BACKGROUND_PROCESS), +/* harmony export */ "globalImportContext": () => (/* binding */ globalImportContext), +/* harmony export */ "actionTypes": () => (/* binding */ actionTypes), +/* harmony export */ "actionCreators": () => (/* binding */ actionCreators), +/* harmony export */ "actionUtils": () => (/* binding */ actionUtils) +/* harmony export */ }); +/* 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/. */ + +const MAIN_MESSAGE_TYPE = "ActivityStream:Main"; +const CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; +const PRELOAD_MESSAGE_TYPE = "ActivityStream:PreloadedBrowser"; +const UI_CODE = 1; +const BACKGROUND_PROCESS = 2; + +/** + * globalImportContext - Are we in UI code (i.e. react, a dom) or some kind of background process? + * Use this in action creators if you need different logic + * for ui/background processes. + */ +const globalImportContext = + typeof Window === "undefined" ? BACKGROUND_PROCESS : UI_CODE; + +// Create an object that avoids accidental differing key/value pairs: +// { +// INIT: "INIT", +// UNINIT: "UNINIT" +// } +const actionTypes = {}; + +for (const type of [ + "ABOUT_SPONSORED_TOP_SITES", + "ADDONS_INFO_REQUEST", + "ADDONS_INFO_RESPONSE", + "ARCHIVE_FROM_POCKET", + "AS_ROUTER_INITIALIZED", + "AS_ROUTER_PREF_CHANGED", + "AS_ROUTER_TARGETING_UPDATE", + "AS_ROUTER_TELEMETRY_USER_EVENT", + "BLOCK_URL", + "BOOKMARK_URL", + "CLEAR_PREF", + "COPY_DOWNLOAD_LINK", + "DELETE_BOOKMARK_BY_ID", + "DELETE_FROM_POCKET", + "DELETE_HISTORY_URL", + "DIALOG_CANCEL", + "DIALOG_OPEN", + "DISABLE_SEARCH", + "DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE", + "DISCOVERY_STREAM_CONFIG_CHANGE", + "DISCOVERY_STREAM_CONFIG_RESET", + "DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", + "DISCOVERY_STREAM_CONFIG_SETUP", + "DISCOVERY_STREAM_CONFIG_SET_VALUE", + "DISCOVERY_STREAM_DEV_EXPIRE_CACHE", + "DISCOVERY_STREAM_DEV_IDLE_DAILY", + "DISCOVERY_STREAM_DEV_SYNC_RS", + "DISCOVERY_STREAM_DEV_SYSTEM_TICK", + "DISCOVERY_STREAM_EXPERIMENT_DATA", + "DISCOVERY_STREAM_FEEDS_UPDATE", + "DISCOVERY_STREAM_FEED_UPDATE", + "DISCOVERY_STREAM_IMPRESSION_STATS", + "DISCOVERY_STREAM_LAYOUT_RESET", + "DISCOVERY_STREAM_LAYOUT_UPDATE", + "DISCOVERY_STREAM_LINK_BLOCKED", + "DISCOVERY_STREAM_LOADED_CONTENT", + "DISCOVERY_STREAM_PERSONALIZATION_INIT", + "DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED", + "DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE", + "DISCOVERY_STREAM_PERSONALIZATION_RESET", + "DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", + "DISCOVERY_STREAM_PERSONALIZATION_UPDATED", + "DISCOVERY_STREAM_POCKET_STATE_INIT", + "DISCOVERY_STREAM_POCKET_STATE_SET", + "DISCOVERY_STREAM_PREFS_SETUP", + "DISCOVERY_STREAM_RECENT_SAVES", + "DISCOVERY_STREAM_RETRY_FEED", + "DISCOVERY_STREAM_SPOCS_CAPS", + "DISCOVERY_STREAM_SPOCS_ENDPOINT", + "DISCOVERY_STREAM_SPOCS_PLACEMENTS", + "DISCOVERY_STREAM_SPOCS_UPDATE", + "DISCOVERY_STREAM_SPOC_BLOCKED", + "DISCOVERY_STREAM_SPOC_IMPRESSION", + "DISCOVERY_STREAM_USER_EVENT", + "DOWNLOAD_CHANGED", + "FAKE_FOCUS_SEARCH", + "FILL_SEARCH_TERM", + "HANDOFF_SEARCH_TO_AWESOMEBAR", + "HIDE_PERSONALIZE", + "HIDE_PRIVACY_INFO", + "INIT", + "NEW_TAB_INIT", + "NEW_TAB_INITIAL_STATE", + "NEW_TAB_LOAD", + "NEW_TAB_REHYDRATED", + "NEW_TAB_STATE_REQUEST", + "NEW_TAB_UNLOAD", + "OPEN_DOWNLOAD_FILE", + "OPEN_LINK", + "OPEN_NEW_WINDOW", + "OPEN_PRIVATE_WINDOW", + "OPEN_WEBEXT_SETTINGS", + "PARTNER_LINK_ATTRIBUTION", + "PLACES_BOOKMARKS_REMOVED", + "PLACES_BOOKMARK_ADDED", + "PLACES_HISTORY_CLEARED", + "PLACES_LINKS_CHANGED", + "PLACES_LINKS_DELETED", + "PLACES_LINK_BLOCKED", + "PLACES_SAVED_TO_POCKET", + "POCKET_CTA", + "POCKET_LINK_DELETED_OR_ARCHIVED", + "POCKET_LOGGED_IN", + "POCKET_WAITING_FOR_SPOC", + "PREFS_INITIAL_VALUES", + "PREF_CHANGED", + "PREVIEW_REQUEST", + "PREVIEW_REQUEST_CANCEL", + "PREVIEW_RESPONSE", + "REMOVE_DOWNLOAD_FILE", + "RICH_ICON_MISSING", + "SAVE_SESSION_PERF_DATA", + "SAVE_TO_POCKET", + "SCREENSHOT_UPDATED", + "SECTION_DEREGISTER", + "SECTION_DISABLE", + "SECTION_ENABLE", + "SECTION_MOVE", + "SECTION_OPTIONS_CHANGED", + "SECTION_REGISTER", + "SECTION_UPDATE", + "SECTION_UPDATE_CARD", + "SETTINGS_CLOSE", + "SETTINGS_OPEN", + "SET_PREF", + "SHOW_DOWNLOAD_FILE", + "SHOW_FIREFOX_ACCOUNTS", + "SHOW_PERSONALIZE", + "SHOW_PRIVACY_INFO", + "SHOW_SEARCH", + "SKIPPED_SIGNIN", + "SOV_UPDATED", + "SUBMIT_EMAIL", + "SUBMIT_SIGNIN", + "SYSTEM_TICK", + "TELEMETRY_IMPRESSION_STATS", + "TELEMETRY_USER_EVENT", + "TOP_SITES_CANCEL_EDIT", + "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", + "TOP_SITES_EDIT", + "TOP_SITES_INSERT", + "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", + "TOP_SITES_ORGANIC_IMPRESSION_STATS", + "TOP_SITES_PIN", + "TOP_SITES_PREFS_UPDATED", + "TOP_SITES_SPONSORED_IMPRESSION_STATS", + "TOP_SITES_UNPIN", + "TOP_SITES_UPDATED", + "TOTAL_BOOKMARKS_REQUEST", + "TOTAL_BOOKMARKS_RESPONSE", + "UNINIT", + "UPDATE_PINNED_SEARCH_SHORTCUTS", + "UPDATE_SEARCH_SHORTCUTS", + "UPDATE_SECTION_PREFS", + "WEBEXT_CLICK", + "WEBEXT_DISMISS", +]) { + actionTypes[type] = type; +} + +// Helper function for creating routed actions between content and main +// Not intended to be used by consumers +function _RouteMessage(action, options) { + const meta = action.meta ? { ...action.meta } : {}; + if (!options || !options.from || !options.to) { + throw new Error( + "Routed Messages must have options as the second parameter, and must at least include a .from and .to property." + ); + } + // For each of these fields, if they are passed as an option, + // add them to the action. If they are not defined, remove them. + ["from", "to", "toTarget", "fromTarget", "skipMain", "skipLocal"].forEach( + o => { + if (typeof options[o] !== "undefined") { + meta[o] = options[o]; + } else if (meta[o]) { + delete meta[o]; + } + } + ); + return { ...action, meta }; +} + +/** + * AlsoToMain - Creates a message that will be dispatched locally and also sent to the Main process. + * + * @param {object} action Any redux action (required) + * @param {object} options + * @param {bool} skipLocal Used by OnlyToMain to skip the main reducer + * @param {string} fromTarget The id of the content port from which the action originated. (optional) + * @return {object} An action with added .meta properties + */ +function AlsoToMain(action, fromTarget, skipLocal) { + return _RouteMessage(action, { + from: CONTENT_MESSAGE_TYPE, + to: MAIN_MESSAGE_TYPE, + fromTarget, + skipLocal, + }); +} + +/** + * OnlyToMain - Creates a message that will be sent to the Main process and skip the local reducer. + * + * @param {object} action Any redux action (required) + * @param {object} options + * @param {string} fromTarget The id of the content port from which the action originated. (optional) + * @return {object} An action with added .meta properties + */ +function OnlyToMain(action, fromTarget) { + return AlsoToMain(action, fromTarget, true); +} + +/** + * BroadcastToContent - Creates a message that will be dispatched to main and sent to ALL content processes. + * + * @param {object} action Any redux action (required) + * @return {object} An action with added .meta properties + */ +function BroadcastToContent(action) { + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + }); +} + +/** + * AlsoToOneContent - Creates a message that will be will be dispatched to the main store + * and also sent to a particular Content process. + * + * @param {object} action Any redux action (required) + * @param {string} target The id of a content port + * @param {bool} skipMain Used by OnlyToOneContent to skip the main process + * @return {object} An action with added .meta properties + */ +function AlsoToOneContent(action, target, skipMain) { + if (!target) { + throw new Error( + "You must provide a target ID as the second parameter of AlsoToOneContent. If you want to send to all content processes, use BroadcastToContent" + ); + } + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + toTarget: target, + skipMain, + }); +} + +/** + * OnlyToOneContent - Creates a message that will be sent to a particular Content process + * and skip the main reducer. + * + * @param {object} action Any redux action (required) + * @param {string} target The id of a content port + * @return {object} An action with added .meta properties + */ +function OnlyToOneContent(action, target) { + return AlsoToOneContent(action, target, true); +} + +/** + * AlsoToPreloaded - Creates a message that dispatched to the main reducer and also sent to the preloaded tab. + * + * @param {object} action Any redux action (required) + * @return {object} An action with added .meta properties + */ +function AlsoToPreloaded(action) { + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: PRELOAD_MESSAGE_TYPE, + }); +} + +/** + * UserEvent - A telemetry ping indicating a user action. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function UserEvent(data) { + return AlsoToMain({ + type: actionTypes.TELEMETRY_USER_EVENT, + data, + }); +} + +/** + * DiscoveryStreamUserEvent - A telemetry ping indicating a user action from Discovery Stream. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function DiscoveryStreamUserEvent(data) { + return AlsoToMain({ + type: actionTypes.DISCOVERY_STREAM_USER_EVENT, + data, + }); +} + +/** + * ASRouterUserEvent - A telemetry ping indicating a user action from AS router. This should only + * be sent from the UI during a user session. + * + * @param {object} data Fields to include in the ping (source, etc.) + * @return {object} An AlsoToMain action + */ +function ASRouterUserEvent(data) { + return AlsoToMain({ + type: actionTypes.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); +} + +/** + * ImpressionStats - A telemetry ping indicating an impression stats. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function ImpressionStats(data, importContext = globalImportContext) { + const action = { + type: actionTypes.TELEMETRY_IMPRESSION_STATS, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +/** + * DiscoveryStreamImpressionStats - A telemetry ping indicating an impression stats in Discovery Stream. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function DiscoveryStreamImpressionStats( + data, + importContext = globalImportContext +) { + const action = { + type: actionTypes.DISCOVERY_STREAM_IMPRESSION_STATS, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +/** + * DiscoveryStreamLoadedContent - A telemetry ping indicating a content gets loaded in Discovery Stream. + * + * @param {object} data Fields to include in the ping + * @param {int} importContext (For testing) Override the import context for testing. + * #return {object} An action. For UI code, a AlsoToMain action. + */ +function DiscoveryStreamLoadedContent( + data, + importContext = globalImportContext +) { + const action = { + type: actionTypes.DISCOVERY_STREAM_LOADED_CONTENT, + data, + }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +function SetPref(name, value, importContext = globalImportContext) { + const action = { type: actionTypes.SET_PREF, data: { name, value } }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +function WebExtEvent(type, data, importContext = globalImportContext) { + if (!data || !data.source) { + throw new Error( + 'WebExtEvent actions should include a property "source", the id of the webextension that should receive the event.' + ); + } + const action = { type, data }; + return importContext === UI_CODE ? AlsoToMain(action) : action; +} + +const actionCreators = { + BroadcastToContent, + UserEvent, + DiscoveryStreamUserEvent, + ASRouterUserEvent, + ImpressionStats, + AlsoToOneContent, + OnlyToOneContent, + AlsoToMain, + OnlyToMain, + AlsoToPreloaded, + SetPref, + WebExtEvent, + DiscoveryStreamImpressionStats, + DiscoveryStreamLoadedContent, +}; + +// These are helpers to test for certain kinds of actions +const actionUtils = { + isSendToMain(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.to === MAIN_MESSAGE_TYPE && + action.meta.from === CONTENT_MESSAGE_TYPE + ); + }, + isBroadcastToContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && !action.meta.toTarget) { + return true; + } + return false; + }, + isSendToOneContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && action.meta.toTarget) { + return true; + } + return false; + }, + isSendToPreloaded(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.to === PRELOAD_MESSAGE_TYPE && + action.meta.from === MAIN_MESSAGE_TYPE + ); + }, + isFromMain(action) { + if (!action.meta) { + return false; + } + return ( + action.meta.from === MAIN_MESSAGE_TYPE && + action.meta.to === CONTENT_MESSAGE_TYPE + ); + }, + getPortIdOfSender(action) { + return (action.meta && action.meta.fromTarget) || null; + }, + _RouteMessage, +}; + + +/***/ }), +/* 4 */ +/***/ ((module) => { + +module.exports = React; + +/***/ }), +/* 5 */ +/***/ ((module) => { + +module.exports = ReactDOM; + +/***/ }), +/* 6 */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "SimpleHashRouter": () => (/* binding */ SimpleHashRouter) +/* harmony export */ }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +/* 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/. */ + + +class SimpleHashRouter extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) { + constructor(props) { + super(props); + this.onHashChange = this.onHashChange.bind(this); + this.state = { + hash: __webpack_require__.g.location.hash + }; + } + onHashChange() { + this.setState({ + hash: __webpack_require__.g.location.hash + }); + } + componentWillMount() { + __webpack_require__.g.addEventListener("hashchange", this.onHashChange); + } + componentWillUnmount() { + __webpack_require__.g.removeEventListener("hashchange", this.onHashChange); + } + render() { + const [, ...routes] = this.state.hash.split("-"); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().cloneElement(this.props.children, { + location: { + hash: this.state.hash, + routes + } + }); + } +} + +/***/ }), +/* 7 */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "CopyButton": () => (/* binding */ CopyButton) +/* harmony export */ }); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); +function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } +/* 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/. */ + + +const CopyButton = ({ + className, + label, + copiedLabel, + inputSelector, + transformer, + ...props +}) => { + const [copied, setCopied] = (0,react__WEBPACK_IMPORTED_MODULE_0__.useState)(false); + const timeout = (0,react__WEBPACK_IMPORTED_MODULE_0__.useRef)(null); + const onClick = (0,react__WEBPACK_IMPORTED_MODULE_0__.useCallback)(() => { + let text = document.querySelector(inputSelector).value; + if (transformer) { + text = transformer(text); + } + navigator.clipboard.writeText(text); + clearTimeout(timeout.current); + setCopied(true); + timeout.current = setTimeout(() => setCopied(false), 1500); + }, [inputSelector, transformer]); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", _extends({ + className: className, + onClick: e => onClick() + }, props), copied && copiedLabel || label); +}; + +/***/ }), +/* 8 */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "ImpressionsSection": () => (/* binding */ ImpressionsSection) +/* harmony export */ }); +/* harmony import */ var _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__); +/* 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/. */ + + + +const stringify = json => JSON.stringify(json, null, 2); +const ImpressionsSection = ({ + messageImpressions, + groupImpressions, + screenImpressions +}) => { + const handleSaveMessageImpressions = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(newImpressions => { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.editState("messageImpressions", newImpressions); + }, []); + const handleSaveGroupImpressions = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(newImpressions => { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.editState("groupImpressions", newImpressions); + }, []); + const handleSaveScreenImpressions = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(newImpressions => { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.editState("screenImpressions", newImpressions); + }, []); + const handleResetMessageImpressions = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(() => { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "RESET_MESSAGE_STATE" + }); + }, []); + const handleResetGroupImpressions = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(() => { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE" + }); + }, []); + const handleResetScreenImpressions = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(() => { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "RESET_SCREEN_IMPRESSIONS" + }); + }, []); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", { + className: "impressions-section" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(ImpressionsItem, { + impressions: messageImpressions, + label: "Message Impressions", + description: "Message impressions are stored in an object, where each key is a message ID and each value is an array of timestamps. They are cleaned up when a message with that ID stops existing in ASRouter state (such as at the end of an experiment).", + onSave: handleSaveMessageImpressions, + onReset: handleResetMessageImpressions + }), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(ImpressionsItem, { + impressions: groupImpressions, + label: "Group Impressions", + description: "Group impressions are stored in an object, where each key is a group ID and each value is an array of timestamps. They are never cleaned up.", + onSave: handleSaveGroupImpressions, + onReset: handleResetGroupImpressions + }), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(ImpressionsItem, { + impressions: screenImpressions, + label: "Screen Impressions", + description: "Screen impressions are stored in an object, where each key is a screen ID and each value is the most recent timestamp that screen was shown. They are never cleaned up.", + onSave: handleSaveScreenImpressions, + onReset: handleResetScreenImpressions + })); +}; +const ImpressionsItem = ({ + impressions, + label, + description, + validator, + onSave, + onReset +}) => { + const [json, setJson] = (0,react__WEBPACK_IMPORTED_MODULE_1__.useState)(stringify(impressions)); + const modified = (0,react__WEBPACK_IMPORTED_MODULE_1__.useRef)(false); + const isValidJson = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(text => { + try { + JSON.parse(text); + return validator ? validator(text) : true; + } catch (e) { + return false; + } + }, [validator]); + const jsonIsInvalid = (0,react__WEBPACK_IMPORTED_MODULE_1__.useMemo)(() => !isValidJson(json), [json, isValidJson]); + const handleChange = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(e => { + setJson(e.target.value); + modified.current = true; + }, []); + const handleSave = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(() => { + if (jsonIsInvalid) { + return; + } + const newImpressions = JSON.parse(json); + modified.current = false; + onSave(newImpressions); + }, [json, jsonIsInvalid, onSave]); + const handleReset = (0,react__WEBPACK_IMPORTED_MODULE_1__.useCallback)(() => { + modified.current = false; + onReset(); + }, [onReset]); + (0,react__WEBPACK_IMPORTED_MODULE_1__.useEffect)(() => { + if (!modified.current) { + setJson(stringify(impressions)); + } + }, [impressions]); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", { + className: "impressions-item" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", { + className: "impressions-category" + }, label), description ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", { + className: "impressions-description" + }, description) : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", { + className: "impressions-inner-box" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", { + className: "impressions-buttons" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button primary", + disabled: jsonIsInvalid, + onClick: handleSave + }, "Save"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button reset", + onClick: handleReset + }, "Reset")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", { + className: "impressions-editor" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("textarea", { + className: "general-textarea", + value: json, + onChange: handleChange + })))); +}; + +/***/ }) +/******/ ]); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/compat get default export */ +/******/ (() => { +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = (module) => { +/******/ var getter = module && module.__esModule ? +/******/ () => (module['default']) : +/******/ () => (module); +/******/ __webpack_require__.d(getter, { a: getter }); +/******/ return getter; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/global */ +/******/ (() => { +/******/ __webpack_require__.g = (function() { +/******/ if (typeof globalThis === 'object') return globalThis; +/******/ try { +/******/ return this || new Function('return this')(); +/******/ } catch (e) { +/******/ if (typeof window === 'object') return window; +/******/ } +/******/ })(); +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; +// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. +(() => { +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ "ToggleStoryButton": () => (/* binding */ ToggleStoryButton), +/* harmony export */ "ToggleMessageJSON": () => (/* binding */ ToggleMessageJSON), +/* harmony export */ "TogglePrefCheckbox": () => (/* binding */ TogglePrefCheckbox), +/* harmony export */ "ASRouterAdminInner": () => (/* binding */ ASRouterAdminInner), +/* harmony export */ "ASRouterAdmin": () => (/* binding */ ASRouterAdmin), +/* harmony export */ "renderASRouterAdmin": () => (/* binding */ renderASRouterAdmin) +/* harmony export */ }); +/* harmony import */ var _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5); +/* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_2__); +/* harmony import */ var _SimpleHashRouter__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(6); +/* harmony import */ var _CopyButton__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(7); +/* harmony import */ var _ImpressionsSection__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(8); +function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } +/* 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/. */ + + + + + + + +const Row = props => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", _extends({ + className: "message-item" +}, props), props.children); +function relativeTime(timestamp) { + if (!timestamp) { + return ""; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + const minutes = Math.floor((Date.now() - timestamp) / 60000); + if (seconds < 2) { + return "just now"; + } else if (seconds < 60) { + return `${seconds} seconds ago`; + } else if (minutes === 1) { + return "1 minute ago"; + } else if (minutes < 600) { + return `${minutes} minutes ago`; + } + return new Date(timestamp).toLocaleString(); +} +class ToggleStoryButton extends (react__WEBPACK_IMPORTED_MODULE_1___default().PureComponent) { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + handleClick() { + this.props.onClick(this.props.story); + } + render() { + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + onClick: this.handleClick + }, "collapse/open"); + } +} +class ToggleMessageJSON extends (react__WEBPACK_IMPORTED_MODULE_1___default().PureComponent) { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + handleClick() { + this.props.toggleJSON(this.props.msgId); + } + render() { + let iconName = this.props.isCollapsed ? "icon icon-arrowhead-forward-small" : "icon icon-arrowhead-down-small"; + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "clearButton", + onClick: this.handleClick + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", { + className: iconName + })); + } +} +class TogglePrefCheckbox extends (react__WEBPACK_IMPORTED_MODULE_1___default().PureComponent) { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + render() { + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement((react__WEBPACK_IMPORTED_MODULE_1___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "checkbox", + checked: this.props.checked, + onChange: this.onChange, + disabled: this.props.disabled + }), " ", this.props.pref, " "); + } +} +class ASRouterAdminInner extends (react__WEBPACK_IMPORTED_MODULE_1___default().PureComponent) { + constructor(props) { + super(props); + this.handleEnabledToggle = this.handleEnabledToggle.bind(this); + this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); + this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); + this.onChangeMessageGroupsFilter = this.onChangeMessageGroupsFilter.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.handleClearAllImpressionsByProvider = this.handleClearAllImpressionsByProvider.bind(this); + this.handleExpressionEval = this.handleExpressionEval.bind(this); + this.onChangeTargetingParameters = this.onChangeTargetingParameters.bind(this); + this.onChangeAttributionParameters = this.onChangeAttributionParameters.bind(this); + this.setAttribution = this.setAttribution.bind(this); + this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); + this.onNewTargetingParams = this.onNewTargetingParams.bind(this); + this.handleOpenPB = this.handleOpenPB.bind(this); + this.selectPBMessage = this.selectPBMessage.bind(this); + this.resetPBJSON = this.resetPBJSON.bind(this); + this.resetPBMessageState = this.resetPBMessageState.bind(this); + this.toggleJSON = this.toggleJSON.bind(this); + this.toggleAllMessages = this.toggleAllMessages.bind(this); + this.resetGroups = this.resetGroups.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + this.setStateFromParent = this.setStateFromParent.bind(this); + this.setState = this.setState.bind(this); + this.state = { + messageFilter: "all", + messageGroupsFilter: "all", + collapsedMessages: [], + modifiedMessages: [], + selectedPBMessage: "", + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: `rta:${btoa("uBlock0@raymondhill.net")}`, + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000" + } + }; + } + onMessageFromParent({ + type, + data + }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "UpdateAdminState": + { + this.setStateFromParent(data); + break; + } + } + } + setStateFromParent(data) { + this.setState(data); + if (!this.state.stringTargetingParameters) { + const stringTargetingParameters = {}; + for (const param of Object.keys(data.targetingParameters)) { + stringTargetingParameters[param] = JSON.stringify(data.targetingParameters[param], null, 2); + } + this.setState({ + stringTargetingParameters + }); + } + } + componentWillMount() { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.addListener(this.onMessageFromParent); + const endpoint = _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.getPreviewEndpoint(); + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "ADMIN_CONNECT_STATE", + data: { + endpoint + } + }).then(this.setStateFromParent); + } + componentWillUnmount() { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.removeListener(this.onMessageFromParent); + } + handleBlock(msg) { + return () => _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.blockById(msg.id); + } + handleUnblock(msg) { + return () => _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.unblockById(msg.id); + } + resetJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify(msg, null, 2); + // remove the message from the list of modified IDs + let index = this.state.modifiedMessages.indexOf(msg.id); + this.setState(prevState => ({ + modifiedMessages: [...prevState.modifiedMessages.slice(0, index), ...prevState.modifiedMessages.slice(index + 1)] + })); + } + handleOverride(id) { + return () => _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + }); + } + resetPBMessageState() { + // Iterate over Private Browsing messages and block/unblock each one to clear impressions + const PBMessages = this.state.messages.filter(message => message.template === "pb_newtab"); // messages from state go here + + PBMessages.forEach(message => { + if (message?.id) { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.blockById(message.id); + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.unblockById(message.id); + } + }); + // Clear the selected messages & radio buttons + document.getElementById("clear radio").checked = true; + this.selectPBMessage("clear"); + } + resetPBJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify(msg, null, 2); + } + handleOpenPB() { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "FORCE_PRIVATE_BROWSING_WINDOW", + data: { + message: { + content: this.state.selectedPBMessage + } + } + }); + } + expireCache() { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "EXPIRE_QUERY_CACHE" + }); + } + resetPref() { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "RESET_PROVIDER_PREF" + }); + } + resetGroups(id, value) { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE" + }).then(this.setStateFromParent); + } + handleExpressionEval() { + const context = {}; + for (const param of Object.keys(this.state.stringTargetingParameters)) { + const value = this.state.stringTargetingParameters[param]; + context[param] = value ? JSON.parse(value) : null; + } + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "EVALUATE_JEXL_EXPRESSION", + data: { + expression: this.refs.expressionInput.value, + context + } + }).then(this.setStateFromParent); + } + onChangeTargetingParameters(event) { + const { + name + } = event.target; + const { + value + } = event.target; + this.setState(({ + stringTargetingParameters + }) => { + let targetingParametersError = null; + const updatedParameters = { + ...stringTargetingParameters + }; + updatedParameters[name] = value; + try { + JSON.parse(value); + } catch (e) { + console.error(`Error parsing value of parameter ${name}`); + targetingParametersError = { + id: name + }; + } + return { + copiedToClipboard: false, + evaluationStatus: {}, + stringTargetingParameters: updatedParameters, + targetingParametersError + }; + }); + } + unblockAll() { + return _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "UNBLOCK_ALL" + }).then(this.setStateFromParent); + } + handleClearAllImpressionsByProvider() { + const providerId = this.state.messageFilter; + if (!providerId) { + return; + } + const userPrefInfo = this.state.userPrefs; + const isUserEnabled = providerId in userPrefInfo ? userPrefInfo[providerId] : true; + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: providerId + }); + if (!isUserEnabled) { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { + id: providerId, + value: true + } + }); + } + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: providerId + }); + } + handleEnabledToggle(event) { + const provider = this.state.providerPrefs.find(p => p.id === event.target.dataset.provider); + const userPrefInfo = this.state.userPrefs; + const isUserEnabled = provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = provider.enabled; + const isEnabling = event.target.checked; + if (isEnabling) { + if (!isUserEnabled) { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { + id: provider.id, + value: true + } + }); + } + if (!isSystemEnabled) { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: provider.id + }); + } + } else { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: provider.id + }); + } + this.setState({ + messageFilter: "all" + }); + } + handleUserPrefToggle(event) { + const action = { + type: "SET_PROVIDER_USER_PREF", + data: { + id: event.target.dataset.provider, + value: event.target.checked + } + }; + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage(action); + this.setState({ + messageFilter: "all" + }); + } + onChangeMessageFilter(event) { + this.setState({ + messageFilter: event.target.value + }); + } + onChangeMessageGroupsFilter(event) { + this.setState({ + messageGroupsFilter: event.target.value + }); + } + + // Simulate a copy event that sets to clipboard all targeting paramters and values + onCopyTargetingParams(event) { + const stringTargetingParameters = { + ...this.state.stringTargetingParameters + }; + for (const key of Object.keys(stringTargetingParameters)) { + // If the value is not set the parameter will be lost when we stringify + if (stringTargetingParameters[key] === undefined) { + stringTargetingParameters[key] = null; + } + } + const setClipboardData = e => { + e.preventDefault(); + e.clipboardData.setData("text", JSON.stringify(stringTargetingParameters, null, 2)); + document.removeEventListener("copy", setClipboardData); + this.setState({ + copiedToClipboard: true + }); + }; + document.addEventListener("copy", setClipboardData); + document.execCommand("copy"); + } + onNewTargetingParams(event) { + this.setState({ + newStringTargetingParameters: event.target.value + }); + event.target.classList.remove("errorState"); + this.refs.targetingParamsEval.innerText = ""; + try { + const stringTargetingParameters = JSON.parse(event.target.value); + this.setState({ + stringTargetingParameters + }); + } catch (e) { + event.target.classList.add("errorState"); + this.refs.targetingParamsEval.innerText = e.message; + } + } + toggleJSON(msgId) { + if (this.state.collapsedMessages.includes(msgId)) { + let index = this.state.collapsedMessages.indexOf(msgId); + this.setState(prevState => ({ + collapsedMessages: [...prevState.collapsedMessages.slice(0, index), ...prevState.collapsedMessages.slice(index + 1)] + })); + } else { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msgId) + })); + } + } + handleChange(msgId) { + if (!this.state.modifiedMessages.includes(msgId)) { + this.setState(prevState => ({ + modifiedMessages: prevState.modifiedMessages.concat(msgId) + })); + } + } + renderMessageItem(msg) { + const isBlockedByGroup = this.state.groups.filter(group => msg.groups.includes(group.id)).some(group => !group.enabled); + const msgProvider = this.state.providers.find(provider => provider.id === msg.provider) || {}; + const isProviderExcluded = msgProvider.exclude && msgProvider.exclude.includes(msg.id); + const isMessageBlocked = this.state.messageBlockList.includes(msg.id) || this.state.messageBlockList.includes(msg.campaign); + const isBlocked = isMessageBlocked || isBlockedByGroup || isProviderExcluded; + const impressions = this.state.messageImpressions[msg.id] ? this.state.messageImpressions[msg.id].length : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + const isModified = this.state.modifiedMessages.includes(msg.id); + const aboutMessagePreviewSupported = ["infobar", "spotlight", "cfr_doorhanger"].includes(msg.template); + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", { + className: itemClassName, + key: `${msg.id}-${msg.provider}` + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "message-id" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", null, msg.id, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(ToggleMessageJSON, { + msgId: `${msg.id}`, + toggleJSON: this.toggleJSON, + isCollapsed: isCollapsed + })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "button-column" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: `button ${isBlocked ? "" : " primary"}`, + onClick: isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + }, isBlocked ? "Unblock" : "Block"), + // eslint-disable-next-line no-nested-ternary + isBlocked ? null : isModified ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button restore", + onClick: e => this.resetJSON(msg) + }, "Reset") : /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button show", + onClick: this.handleOverride(msg.id) + }, "Show"), isBlocked ? null : /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button modify", + onClick: e => this.modifyJson(msg) + }, "Modify"), aboutMessagePreviewSupported ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(_CopyButton__WEBPACK_IMPORTED_MODULE_4__.CopyButton, { + transformer: text => `about:messagepreview?json=${encodeURIComponent(btoa(text))}`, + label: "Share", + copiedLabel: "Copied!", + inputSelector: `#${msg.id}-textarea`, + className: "button share" + }) : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null), "(", impressions, " impressions)"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "message-summary" + }, isBlocked && /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, "Block reason:", isBlockedByGroup && " Blocked by group", isProviderExcluded && " Excluded by provider", isMessageBlocked && " Message blocked"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("pre", { + className: isCollapsed ? "collapsed" : "expanded" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("textarea", { + id: `${msg.id}-textarea`, + name: msg.id, + className: "general-textarea", + disabled: isBlocked, + onChange: e => this.handleChange(msg.id) + }, JSON.stringify(msg, null, 2)))))); + } + selectPBMessage(msgId) { + if (msgId === "clear") { + this.setState({ + selectedPBMessage: "" + }); + } else { + let selected = document.getElementById(`${msgId} radio`); + let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value); + if (selected.checked) { + this.setState({ + selectedPBMessage: msg?.content + }); + } else { + this.setState({ + selectedPBMessage: "" + }); + } + } + } + modifyJson(content) { + const message = JSON.parse(document.getElementById(`${content.id}-textarea`).value); + return _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.modifyMessageJson(message).then(state => { + this.setStateFromParent(state); + }); + } + renderPBMessageItem(msg) { + const isBlocked = this.state.messageBlockList.includes(msg.id) || this.state.messageBlockList.includes(msg.campaign); + const impressions = this.state.messageImpressions[msg.id] ? this.state.messageImpressions[msg.id].length : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", { + className: itemClassName, + key: `${msg.id}-${msg.provider}` + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "message-id" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", null, msg.id, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null), "(", impressions, " impressions)")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(ToggleMessageJSON, { + msgId: `${msg.id}`, + toggleJSON: this.toggleJSON, + isCollapsed: isCollapsed + })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "radio", + id: `${msg.id} radio`, + name: "PB_message_radio", + style: { + marginBottom: 20 + }, + onClick: () => this.selectPBMessage(msg.id), + disabled: isBlocked + }), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: `button ${isBlocked ? "" : " primary"}`, + onClick: isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + }, isBlocked ? "Unblock" : "Block"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton slim button", + onClick: e => this.resetPBJSON(msg) + }, "Reset JSON")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: `message-summary` + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("pre", { + className: isCollapsed ? "collapsed" : "expanded" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("textarea", { + id: `${msg.id}-textarea`, + className: "wnp-textarea", + name: msg.id + }, JSON.stringify(msg, null, 2))))); + } + toggleAllMessages(messagesToShow) { + if (this.state.collapsedMessages.length) { + this.setState({ + collapsedMessages: [] + }); + } else { + Array.prototype.forEach.call(messagesToShow, msg => { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msg.id) + })); + }); + } + } + renderMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messageFilter === "all" ? this.state.messages : this.state.messages.filter(message => message.provider === this.state.messageFilter && message.template !== "pb_newtab"); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton slim", + onClick: e => this.toggleAllMessages(messagesToShow) + }, "Collapse/Expand All"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", { + className: "helpLink" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", { + className: "icon icon-small-spacer icon-info" + }), " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", null, "To modify a message, change the JSON and click 'Modify' to see your changes. Click 'Reset' to restore the JSON to the original. Click 'Share' to copy a link to the clipboard that can be used to preview the message by opening the link in Nightly/local builds.")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tbody", null, messagesToShow.map(msg => this.renderMessageItem(msg))))); + } + renderMessagesByGroup() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messageGroupsFilter === "all" ? this.state.messages.filter(m => m.groups.length) : this.state.messages.filter(message => message.groups.includes(this.state.messageGroupsFilter)); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tbody", null, messagesToShow.map(msg => this.renderMessageItem(msg)))); + } + renderPBMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter(message => message.template === "pb_newtab"); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tbody", null, messagesToShow.map(msg => this.renderPBMessageItem(msg)))); + } + renderMessageFilter() { + if (!this.state.providers) { + return null; + } + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "unblock-all ASRouterButton test-only", + onClick: this.unblockAll + }, "Unblock All Snippets"), "Show messages from", " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("select", { + value: this.state.messageFilter, + onChange: this.onChangeMessageFilter + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("option", { + value: "all" + }, "all providers"), this.state.providers.map(provider => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("option", { + key: provider.id, + value: provider.id + }, provider.id))), this.state.messageFilter !== "all" && !this.state.messageFilter.includes("_local_testing") ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button messages-reset", + onClick: this.handleClearAllImpressionsByProvider + }, "Reset All") : null); + } + renderMessageGroupsFilter() { + if (!this.state.groups) { + return null; + } + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", null, "Show messages from ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("select", { + value: this.state.messageGroupsFilter, + onChange: this.onChangeMessageGroupsFilter + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("option", { + value: "all" + }, "all groups"), this.state.groups.map(group => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("option", { + key: group.id, + value: group.id + }, group.id)))); + } + renderTableHead() { + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("thead", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", { + className: "message-item" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "min" + }), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "min" + }, "Provider ID"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, "Source"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "min" + }, "Cohort"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + className: "min" + }, "Last Updated"))); + } + renderProviders() { + const providersConfig = this.state.providerPrefs; + const providerInfo = this.state.providers; + const userPrefInfo = this.state.userPrefs; + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", null, this.renderTableHead(), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tbody", null, providersConfig.map((provider, i) => { + const isTestProvider = provider.id.includes("_local_testing"); + const info = providerInfo.find(p => p.id === provider.id) || {}; + const isUserEnabled = provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = isTestProvider || provider.enabled; + let label = "local"; + if (provider.type === "remote") { + label = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", null, "endpoint (", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + className: "providerUrl", + target: "_blank", + href: info.url, + rel: "noopener noreferrer" + }, info.url), ")"); + } else if (provider.type === "remote-settings") { + label = `remote settings (${provider.collection})`; + } else if (provider.type === "remote-experiments") { + label = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", null, "remote settings (", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + className: "providerUrl", + target: "_blank", + href: "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/nimbus-desktop-experiments/records", + rel: "noopener noreferrer" + }, "nimbus-desktop-experiments"), ")"); + } + let reasonsDisabled = []; + if (!isSystemEnabled) { + reasonsDisabled.push("system pref"); + } + if (!isUserEnabled) { + reasonsDisabled.push("user pref"); + } + if (reasonsDisabled.length) { + label = `disabled via ${reasonsDisabled.join(", ")}`; + } + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", { + className: "message-item", + key: i + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, isTestProvider ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "checkbox", + disabled: true, + readOnly: true, + checked: true + }) : /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "checkbox", + "data-provider": provider.id, + checked: isUserEnabled && isSystemEnabled, + onChange: this.handleEnabledToggle + })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, provider.id), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", { + className: `sourceLabel${isUserEnabled && isSystemEnabled ? "" : " isDisabled"}` + }, label)), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, provider.cohort), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + style: { + whiteSpace: "nowrap" + } + }, info.lastUpdated ? new Date(info.lastUpdated).toLocaleString() : "")); + }))); + } + renderTargetingParameters() { + // There was no error and the result is truthy + const success = this.state.evaluationStatus.success && !!this.state.evaluationStatus.result; + const result = JSON.stringify(this.state.evaluationStatus.result, null, 2) || "(Empty result)"; + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tbody", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Evaluate JEXL expression"))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("textarea", { + ref: "expressionInput", + rows: "10", + cols: "60", + placeholder: "Evaluate JEXL expressions and mock parameters by changing their values below" + })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", null, "Status:", " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", { + ref: "evaluationStatus" + }, success ? "✅" : "❌", ", Result: ", result))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton secondary", + onClick: this.handleExpressionEval + }, "Evaluate"))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Modify targeting parameters"))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton secondary", + onClick: this.onCopyTargetingParams, + disabled: this.state.copiedToClipboard + }, this.state.copiedToClipboard ? "Parameters copied!" : "Copy parameters"))), this.state.stringTargetingParameters && Object.keys(this.state.stringTargetingParameters).map((param, i) => { + const value = this.state.stringTargetingParameters[param]; + const errorState = this.state.targetingParametersError && this.state.targetingParametersError.id === param; + const className = errorState ? "errorState" : ""; + const inputComp = (value && value.length) > 30 ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("textarea", { + name: param, + className: className, + value: value, + rows: "10", + cols: "60", + onChange: this.onChangeTargetingParameters + }) : /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + name: param, + className: className, + value: value, + onChange: this.onChangeTargetingParameters + }); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", { + key: i + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, param), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, inputComp)); + }))); + } + onChangeAttributionParameters(event) { + const { + name, + value + } = event.target; + this.setState(({ + attributionParameters + }) => { + const updatedParameters = { + ...attributionParameters + }; + updatedParameters[name] = value; + return { + attributionParameters: updatedParameters + }; + }); + } + setAttribution(e) { + _asrouter_utils__WEBPACK_IMPORTED_MODULE_0__.ASRouterUtils.sendMessage({ + type: "FORCE_ATTRIBUTION", + data: this.state.attributionParameters + }).then(this.setStateFromParent); + } + _getGroupImpressionsCount(id, frequency) { + if (frequency) { + return this.state.groupImpressions[id] ? this.state.groupImpressions[id].length : 0; + } + return "n/a"; + } + renderAttributionParamers() { + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, " Attribution Parameters "), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", null, " ", "This forces the browser to set some attribution parameters, useful for testing the Return To AMO feature. Clicking on 'Force Attribution', with the default values in each field, will demo the Return To AMO flow with the addon called 'uBlock Origin'. If you wish to try different attribution parameters, enter them in the text boxes. If you wish to try a different addon with the Return To AMO flow, make sure the 'content' text box has a string that is 'rta:base64(addonID)', the base64 string of the addonID prefixed with 'rta:'. The addon must currently be a recommended addon on AMO. Then click 'Force Attribution'. Clicking on 'Force Attribution' with blank text boxes reset attribution data."), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " Source ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "source", + placeholder: "addons.mozilla.org", + value: this.state.attributionParameters.source, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " Medium ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "medium", + placeholder: "referral", + value: this.state.attributionParameters.medium, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " Campaign ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "campaign", + placeholder: "non-fx-button", + value: this.state.attributionParameters.campaign, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " Content ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "content", + placeholder: `rta:${btoa("uBlock0@raymondhill.net")}`, + value: this.state.attributionParameters.content, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " Experiment ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "experiment", + placeholder: "ua-onboarding", + value: this.state.attributionParameters.experiment, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " Variation ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "variation", + placeholder: "chrome", + value: this.state.attributionParameters.variation, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " User Agent ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "ua", + placeholder: "Google Chrome 123", + value: this.state.attributionParameters.ua, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("b", null, " Download Token ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "text", + name: "dltoken", + placeholder: "00000000-0000-0000-0000-000000000000", + value: this.state.attributionParameters.dltoken, + onChange: this.onChangeAttributionParameters + }), " ")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton primary button", + onClick: this.setAttribution + }, " ", "Force Attribution", " "), " ")))); + } + renderErrorMessage({ + id, + errors + }) { + const providerId = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", { + rowSpan: errors.length + }, id); + // .reverse() so that the last error (most recent) is first + return errors.map(({ + error, + timestamp + }, cellKey) => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", { + key: cellKey + }, cellKey === errors.length - 1 ? providerId : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, error.message), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, relativeTime(timestamp)))).reverse(); + } + renderErrors() { + const providersWithErrors = this.state.providers && this.state.providers.filter(p => p.errors && p.errors.length); + if (providersWithErrors && providersWithErrors.length) { + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", { + className: "errorReporting" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("thead", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("th", null, "Provider ID"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("th", null, "Message"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("th", null, "Timestamp"))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tbody", null, providersWithErrors.map(this.renderErrorMessage))); + } + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", null, "No errors"); + } + renderPBTab() { + if (!this.state.messages) { + return null; + } + let messagesToShow = this.state.messages.filter(message => message.template === "pb_newtab"); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", { + className: "helpLink" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", { + className: "icon icon-small-spacer icon-info" + }), " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", null, "To view an available message, select its radio button and click \"Open a Private Browsing Window\".", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null), "To modify a message, make changes to the JSON first, then select the radio button. (To make new changes, click \"Reset Message State\", make your changes, and reselect the radio button.)", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null), "Click \"Reset Message State\" to clear all message impressions and view messages in a clean state.", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null), "Note that ContentSearch functions do not work in debug mode.")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton primary button", + onClick: this.handleOpenPB + }, "Open a Private Browsing Window"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton primary button", + style: { + marginInlineStart: 12 + }, + onClick: this.resetPBMessageState + }, "Reset Message State"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("br", null), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("input", { + type: "radio", + id: `clear radio`, + name: "PB_message_radio", + value: "clearPBMessage", + style: { + display: "none" + } + }), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Messages"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "ASRouterButton slim button", + onClick: e => this.toggleAllMessages(messagesToShow) + }, "Collapse/Expand All"), this.renderPBMessages())); + } + getSection() { + const [section] = this.props.location.routes; + switch (section) { + case "private": + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement((react__WEBPACK_IMPORTED_MODULE_1___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Private Browsing Messages"), this.renderPBTab()); + case "targeting": + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement((react__WEBPACK_IMPORTED_MODULE_1___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Targeting Utilities"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button", + onClick: this.expireCache + }, "Expire Cache"), " ", "(This expires the cache in ASR Targeting for bookmarks and top sites)", this.renderTargetingParameters(), this.renderAttributionParamers()); + case "groups": + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement((react__WEBPACK_IMPORTED_MODULE_1___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Message Groups"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + className: "button", + onClick: this.resetGroups + }, "Reset group impressions"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("table", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("thead", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tr", { + className: "message-item" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, "Enabled"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, "Impressions count"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, "Custom frequency"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, "User preferences"))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("tbody", null, this.state.groups && this.state.groups.map(({ + id, + enabled, + frequency, + userPreferences = [] + }, index) => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(Row, { + key: id + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(TogglePrefCheckbox, { + checked: enabled, + pref: id, + disabled: true + })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, this._getGroupImpressionsCount(id, frequency)), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, JSON.stringify(frequency, null, 2)), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("td", null, userPreferences.join(", ")))))), this.renderMessageGroupsFilter(), this.renderMessagesByGroup()); + case "impressions": + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement((react__WEBPACK_IMPORTED_MODULE_1___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Impressions"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(_ImpressionsSection__WEBPACK_IMPORTED_MODULE_5__.ImpressionsSection, { + messageImpressions: this.state.messageImpressions, + groupImpressions: this.state.groupImpressions, + screenImpressions: this.state.screenImpressions + })); + case "errors": + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement((react__WEBPACK_IMPORTED_MODULE_1___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "ASRouter Errors"), this.renderErrors()); + default: + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement((react__WEBPACK_IMPORTED_MODULE_1___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Message Providers", " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("button", { + title: "Restore all provider settings that ship with Firefox", + className: "button", + onClick: this.resetPref + }, "Restore default prefs")), this.state.providers ? this.renderProviders() : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h2", null, "Messages"), this.renderMessageFilter(), this.renderMessages()); + } + } + render() { + if (!this.state.devtoolsEnabled) { + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", { + className: "asrouter-admin" + }, "You must enable the ASRouter Admin page by setting", " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("code", null, "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled"), " ", "to ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("code", null, "true"), " and then reloading this page."); + } + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("div", { + className: `asrouter-admin ${this.props.collapsed ? "collapsed" : "expanded"}` + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("aside", { + className: "sidebar" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("ul", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("li", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + href: "#devtools" + }, "General")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("li", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + href: "#devtools-private" + }, "Private Browsing")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("li", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + href: "#devtools-targeting" + }, "Targeting")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("li", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + href: "#devtools-groups" + }, "Message Groups")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("li", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + href: "#devtools-impressions" + }, "Impressions")), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("li", null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + href: "#devtools-errors" + }, "Errors")))), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("main", { + className: "main-panel" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("h1", null, "AS Router Admin"), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("p", { + className: "helpLink" + }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", { + className: "icon icon-small-spacer icon-info" + }), " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("span", null, "Need help using these tools? Check out our", " ", /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement("a", { + target: "blank", + href: "https://firefox-source-docs.mozilla.org/browser/components/newtab/content-src/asrouter/docs/debugging-docs.html" + }, "documentation"))), this.getSection())); + } +} +const ASRouterAdmin = props => /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(_SimpleHashRouter__WEBPACK_IMPORTED_MODULE_3__.SimpleHashRouter, null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(ASRouterAdminInner, props)); +function renderASRouterAdmin() { + react_dom__WEBPACK_IMPORTED_MODULE_2___default().render( /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(ASRouterAdmin, null), document.getElementById("root")); +} +})(); + +ASRouterAdminRenderUtils = __webpack_exports__; +/******/ })() +;
\ No newline at end of file diff --git a/browser/components/asrouter/content/asrouter-admin.html b/browser/components/asrouter/content/asrouter-admin.html new file mode 100644 index 0000000000..3c5e0b378d --- /dev/null +++ b/browser/components/asrouter/content/asrouter-admin.html @@ -0,0 +1,38 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'none'; object-src 'none'; script-src resource: chrome:;" + /> + <meta name="color-scheme" content="light dark" /> + <title>ASRouter Admin</title> + <link + rel="icon" + type="image/png" + href="chrome://branding/content/icon32.png" + /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + <link + rel="stylesheet" + href="chrome://browser/content/asrouter/components/ASRouterAdmin/ASRouterAdmin.css" + /> + </head> + <body> + <div id="root"></div> + <script src="chrome://browser/content/contentTheme.js"></script> + <script src="resource://activity-stream/vendor/react.js"></script> + <script src="resource://activity-stream/vendor/react-dom.js"></script> + <script src="resource://activity-stream/vendor/prop-types.js"></script> + <script src="chrome://browser/content/asrouter/asrouter-admin.bundle.js"></script> + + <!-- The render.js script is the main entrypoint for the page. --> + <script src="chrome://browser/content/asrouter/render.js"></script> + </body> +</html> diff --git a/browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css b/browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css new file mode 100644 index 0000000000..aaf15a9a15 --- /dev/null +++ b/browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css @@ -0,0 +1,546 @@ +/* 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/. */ +/* stylelint-disable max-nesting-depth */ +:root { + --newtab-background-color: #F9F9FB; + --newtab-background-color-secondary: #FFF; + --newtab-text-primary-color: #15141a; + --newtab-primary-action-background: #0061e0; + --newtab-primary-action-background-pocket: #008078; + --newtab-text-secondary-color: color-mix(in srgb, var(--newtab-text-primary-color) 70%, transparent); + --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #000); + --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #000); + --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); + --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 12%, transparent); + --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 25%, transparent); + --newtab-primary-element-hover-color: color-mix(in srgb, var(--newtab-primary-action-background) 90%, #000); + --newtab-primary-element-hover-pocket-color: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 90%, #000); + --newtab-primary-element-active-color: color-mix(in srgb, var(--newtab-primary-action-background) 80%, #000); + --newtab-primary-element-text-color: #FFF; + --newtab-primary-action-background-dimmed: color-mix(in srgb, var(--newtab-primary-action-background) 25%, transparent); + --newtab-primary-action-background-pocket-dimmed: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 25%, transparent); + --newtab-border-color: color-mix(in srgb, var(--newtab-background-color) 75%, #000); + --newtab-wordmark-color: #20123A; + --newtab-status-success: #058B00; + --newtab-status-error: #D70022; + --newtab-inner-box-shadow-color: rgba(0, 0, 0, 0.1); + --newtab-overlay-color: color-mix(in srgb, var(--newtab-background-color) 85%, transparent); + --newtab-textbox-focus-color: var(--newtab-primary-action-background); + --newtab-textbox-focus-boxshadow: 0 0 0 1px var(--newtab-primary-action-background), 0 0 0 4px rgba(var(--newtab-primary-action-background), 0.3); + --newtab-button-secondary-color: inherit; +} +:root[lwt-newtab-brighttext] { + --newtab-background-color: #2B2A33; + --newtab-background-color-secondary: #42414d; + --newtab-text-primary-color: #fbfbfe; + --newtab-primary-action-background: #00ddff; + --newtab-primary-action-background-pocket: #00ddff; + --newtab-primary-action-background-pocket-dimmed: color-mix(in srgb, var(--newtab-primary-action-background) 25%, transparent); + --newtab-primary-element-hover-color: color-mix(in srgb, var(--newtab-primary-action-background) 55%, #FFF); + --newtab-primary-element-hover-pocket-color: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 55%, #FFF); + --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 80%, #FFF); + --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 60%, #FFF); + --newtab-element-secondary-color: color-mix(in srgb, currentColor 10%, transparent); + --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 17%, transparent); + --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 30%, transparent); + --newtab-border-color: color-mix(in srgb, var(--newtab-background-color) 75%, #FFF); + --newtab-primary-element-text-color: #2b2a33; + --newtab-wordmark-color: #fbfbfe; + --newtab-status-success: #7C6; +} + +@media (prefers-contrast) { + :root { + --newtab-text-secondary-color: var(--newtab-text-primary-color); + } +} +.icon { + background-position: center center; + background-repeat: no-repeat; + background-size: 16px; + -moz-context-properties: fill; + display: inline-block; + color: var(--newtab-text-primary-color); + fill: currentColor; + height: 16px; + vertical-align: middle; + width: 16px; +} +.icon.icon-spacer { + margin-inline-end: 8px; +} +.icon.icon-small-spacer { + margin-inline-end: 6px; +} +.icon.icon-button-style { + fill: var(--newtab-text-secondary-color); + border: 0; +} +.icon.icon-button-style:focus, .icon.icon-button-style:hover { + fill: var(--newtab-text-primary-color); +} +.icon.icon-bookmark-added { + background-image: url("chrome://browser/skin/bookmark.svg"); +} +.icon.icon-bookmark-hollow { + background-image: url("chrome://browser/skin/bookmark-hollow.svg"); +} +.icon.icon-clear-input { + background-image: url("chrome://global/skin/icons/close-fill.svg"); +} +.icon.icon-delete { + background-image: url("chrome://global/skin/icons/delete.svg"); +} +.icon.icon-search { + background-image: url("chrome://global/skin/icons/search-glass.svg"); +} +.icon.icon-modal-delete { + flex-shrink: 0; + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-modal-delete-20.svg"); + background-size: 32px; + height: 32px; + width: 32px; +} +.icon.icon-mail { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-mail-16.svg"); +} +.icon.icon-dismiss { + background-image: url("chrome://global/skin/icons/close.svg"); +} +.icon.icon-info { + background-image: url("chrome://global/skin/icons/info.svg"); +} +.icon.icon-new-window { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-newWindow-16.svg"); +} +.icon.icon-new-window:dir(rtl) { + transform: scaleX(-1); +} +.icon.icon-new-window-private { + background-image: url("chrome://browser/skin/privateBrowsing.svg"); +} +.icon.icon-settings { + background-image: url("chrome://global/skin/icons/settings.svg"); +} +.icon.icon-pin { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-pin-16.svg"); +} +.icon.icon-pin:dir(rtl) { + transform: scaleX(-1); +} +.icon.icon-unpin { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-unpin-16.svg"); +} +.icon.icon-unpin:dir(rtl) { + transform: scaleX(-1); +} +.icon.icon-edit { + background-image: url("chrome://global/skin/icons/edit.svg"); +} +.icon.icon-pocket { + background-image: url("chrome://global/skin/icons/pocket.svg"); +} +.icon.icon-pocket-save { + background-image: url("chrome://global/skin/icons/pocket.svg"); + fill: #FFF; +} +.icon.icon-pocket-delete { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-pocket-delete-16.svg"); +} +.icon.icon-pocket-archive { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-pocket-archive-16.svg"); +} +.icon.icon-history-item { + background-image: url("chrome://browser/skin/history.svg"); +} +.icon.icon-trending { + background-image: url("chrome://browser/skin/trending.svg"); + transform: translateY(2px); +} +.icon.icon-now { + background-image: url("chrome://browser/skin/history.svg"); +} +.icon.icon-topsites { + background-image: url("chrome://browser/skin/topsites.svg"); +} +.icon.icon-pin-small { + background-image: url("chrome://browser/skin/pin-12.svg"); + background-size: 12px; + height: 12px; + width: 12px; +} +.icon.icon-pin-small:dir(rtl) { + transform: scaleX(-1); +} +.icon.icon-check { + background-image: url("chrome://global/skin/icons/check.svg"); +} +.icon.icon-download { + background-image: url("chrome://browser/skin/downloads/downloads.svg"); +} +.icon.icon-copy { + background-image: url("chrome://global/skin/icons/edit-copy.svg"); +} +.icon.icon-open-file { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-open-file-16.svg"); +} +.icon.icon-webextension { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg"); +} +.icon.icon-highlights { + background-image: url("chrome://global/skin/icons/highlights.svg"); +} +.icon.icon-arrowhead-down { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); +} +.icon.icon-arrowhead-down-small { + background-image: url("chrome://global/skin/icons/arrow-down-12.svg"); + background-size: 12px; + height: 12px; + width: 12px; +} +.icon.icon-arrowhead-forward-small { + background-image: url("chrome://global/skin/icons/arrow-right-12.svg"); + background-size: 12px; + height: 12px; + width: 12px; +} +.icon.icon-arrowhead-forward-small:dir(rtl) { + background-image: url("chrome://global/skin/icons/arrow-left-12.svg"); +} +.icon.icon-arrowhead-up { + background-image: url("chrome://global/skin/icons/arrow-up.svg"); +} +.icon.icon-add { + background-image: url("chrome://global/skin/icons/plus.svg"); +} +.icon.icon-minimize { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-minimize-16.svg"); +} +.icon.icon-maximize { + background-image: url("chrome://activity-stream/content/data/content/assets/glyph-maximize-16.svg"); +} +.icon.icon-arrow { + background-image: url("chrome://global/skin/icons/arrow-right-12.svg"); +} + +.ASRouterButton { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + border-radius: 2px; + border: 0; + font-family: inherit; + padding: 8px 15px; + margin-inline-start: 12px; + color: inherit; + cursor: pointer; +} +.tall .ASRouterButton { + margin-inline-start: 20px; +} +.ASRouterButton.test-only { + width: 0; + height: 0; + overflow: hidden; + display: block; + visibility: hidden; +} +.ASRouterButton.primary { + border: 1px solid var(--newtab-primary-action-background); + background-color: var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); +} +.ASRouterButton.primary:hover { + background-color: var(--newtab-primary-element-hover-color); +} +.ASRouterButton.primary:active { + background-color: var(--newtab-primary-element-active-color); +} +.ASRouterButton.slim { + border: 1px solid var(--newtab-border-color); + margin-inline-start: 0; + font-size: 12px; + padding: 6px 12px; +} +.ASRouterButton.slim:hover, .ASRouterButton.slim:focus { + box-shadow: 0 0 0 5px var(--newtab-element-secondary-color); + transition: box-shadow 150ms; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif; +} + +/** + * These styles are copied verbatim from _activity-stream.scss in order to maintain + * a continuity of styling while also decoupling from the newtab code. This should + * be removed when about:asrouter starts using the default in-content style sheets. + */ +.button, +.actions button { + background-color: var(--newtab-button-secondary-color); + border: 1px solid var(--newtab-border-color); + border-radius: 4px; + color: inherit; + cursor: pointer; + margin-bottom: 15px; + padding: 10px 30px; + white-space: nowrap; +} +.button:hover:not(.dismiss), .button:focus:not(.dismiss), +.actions button:hover:not(.dismiss), +.actions button:focus:not(.dismiss) { + box-shadow: 0 0 0 5px var(--newtab-element-secondary-color); + transition: box-shadow 150ms; +} +.button.dismiss, +.actions button.dismiss { + background-color: transparent; + border: 0; + padding: 0; + text-decoration: underline; +} +.button.primary, .button.done, +.actions button.primary, +.actions button.done { + background-color: var(--newtab-primary-action-background); + border: solid 1px var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); + margin-inline-start: auto; +} + +.asrouter-admin { + max-width: 1300px; + font-size: 14px; + padding-inline-start: 240px; + color: var(--newtab-text-primary-color); +} +.asrouter-admin.collapsed { + display: none; +} +.asrouter-admin .sidebar { + inset-inline-start: 0; + position: fixed; + width: 240px; +} +.asrouter-admin .sidebar ul { + margin: 0; + padding: 0; + list-style: none; +} +.asrouter-admin .sidebar li a { + padding: 10px 34px; + display: block; + color: var(--lwt-sidebar-text-color); +} +.asrouter-admin .sidebar li a:hover { + background: var(--newtab-background-color-secondary); +} +.asrouter-admin h1 { + font-weight: 200; + font-size: 32px; +} +.asrouter-admin h2 .button, +.asrouter-admin p .button { + font-size: 14px; + padding: 6px 12px; + margin-inline-start: 5px; + margin-bottom: 0; +} +.asrouter-admin .general-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; +} +.asrouter-admin .wnp-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; +} +.asrouter-admin .json-button { + display: inline-flex; + font-size: 10px; + padding: 4px 10px; + margin-bottom: 6px; + margin-inline-end: 4px; +} +.asrouter-admin .json-button:hover { + background-color: var(--newtab-element-hover-color); + box-shadow: none; +} +.asrouter-admin table { + border-collapse: collapse; +} +.asrouter-admin table.minimal-table { + border-collapse: collapse; + border: 1px solid var(--newtab-border-color); +} +.asrouter-admin table.minimal-table td { + padding: 8px; +} +.asrouter-admin table.minimal-table td:first-child { + width: 1%; + white-space: nowrap; +} +.asrouter-admin table.minimal-table td:not(:first-child) { + font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", "Source Code Pro", monospace; +} +.asrouter-admin table.errorReporting tr { + border: 1px solid var(--newtab-background-color-secondary); +} +.asrouter-admin table.errorReporting td { + padding: 4px; +} +.asrouter-admin table.errorReporting td[rowspan] { + border: 1px solid var(--newtab-background-color-secondary); +} +.asrouter-admin .sourceLabel { + background: var(--newtab-background-color-secondary); + padding: 2px 5px; + border-radius: 3px; +} +.asrouter-admin .sourceLabel.isDisabled { + background: rgba(215, 0, 34, 0.3); + color: var(--newtab-status-error); +} +.asrouter-admin .message-item:first-child td { + border-top: 1px solid var(--newtab-border-color); +} +.asrouter-admin .message-item td { + vertical-align: top; + padding: 8px; + border-bottom: 1px solid var(--newtab-border-color); +} +.asrouter-admin .message-item td.min { + width: 1%; + white-space: nowrap; +} +.asrouter-admin .message-item td.message-summary { + width: 60%; +} +.asrouter-admin .message-item td.button-column { + width: 15%; +} +.asrouter-admin .message-item td:first-child { + border-inline-start: 1px solid var(--newtab-border-color); +} +.asrouter-admin .message-item td:last-child { + border-inline-end: 1px solid var(--newtab-border-color); +} +.asrouter-admin .message-item.blocked .message-id, +.asrouter-admin .message-item.blocked .message-summary { + opacity: 0.5; +} +.asrouter-admin .message-item.blocked .message-id { + opacity: 0.5; +} +.asrouter-admin .message-item .message-id { + font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", "Source Code Pro", monospace; + font-size: 12px; +} +.asrouter-admin .providerUrl { + font-size: 12px; +} +.asrouter-admin pre { + background: var(--newtab-background-color-secondary); + margin: 0; + padding: 8px; + font-size: 12px; + max-width: 750px; + overflow: auto; + font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", "Source Code Pro", monospace; +} +.asrouter-admin .errorState { + border: 1px solid var(--newtab-status-error); +} +.asrouter-admin .helpLink { + padding: 10px; + display: flex; + background: rgba(0, 0, 0, 0.1); + border-radius: 3px; + align-items: center; +} +.asrouter-admin .helpLink a { + text-decoration: underline; +} +.asrouter-admin .helpLink .icon { + min-width: 18px; + min-height: 18px; +} +.asrouter-admin .ds-component { + margin-bottom: 20px; +} +.asrouter-admin .modalOverlayInner { + height: 80%; +} +.asrouter-admin .clearButton { + border: 0; + padding: 4px; + border-radius: 4px; + display: flex; +} +.asrouter-admin .clearButton:hover { + background: var(--newtab-element-hover-color); +} +.asrouter-admin .collapsed { + display: none; +} +.asrouter-admin .icon { + display: inline-table; + cursor: pointer; + width: 18px; + height: 18px; +} +.asrouter-admin .button:disabled, .asrouter-admin .button:disabled:active { + opacity: 0.5; + cursor: unset; + box-shadow: none; +} +.asrouter-admin .impressions-section { + display: flex; + flex-direction: column; + gap: 16px; +} +.asrouter-admin .impressions-section .impressions-item { + display: flex; + flex-flow: column nowrap; + padding: 8px; + border: 1px solid var(--newtab-border-color); + border-radius: 5px; +} +.asrouter-admin .impressions-section .impressions-item .impressions-inner-box { + display: flex; + flex-flow: row nowrap; + gap: 8px; +} +.asrouter-admin .impressions-section .impressions-item .impressions-category { + font-size: 1.15em; + white-space: nowrap; + flex-grow: 0.1; +} +.asrouter-admin .impressions-section .impressions-item .impressions-buttons { + display: flex; + flex-direction: column; + gap: 8px; +} +.asrouter-admin .impressions-section .impressions-item .impressions-buttons button { + margin: 0; +} +.asrouter-admin .impressions-section .impressions-item .impressions-editor { + display: flex; + flex-grow: 1.5; +} +.asrouter-admin .impressions-section .impressions-item .impressions-editor .general-textarea { + width: auto; + flex-grow: 1; +} diff --git a/browser/components/asrouter/content/render.js b/browser/components/asrouter/content/render.js new file mode 100644 index 0000000000..66d6a05d3b --- /dev/null +++ b/browser/components/asrouter/content/render.js @@ -0,0 +1,7 @@ +/* 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/. */ +"use strict"; + +// exported by asrouter-admin.bundle.js +window.ASRouterAdminRenderUtils.renderASRouterAdmin(); diff --git a/browser/components/asrouter/docs/about-welcome.md b/browser/components/asrouter/docs/about-welcome.md new file mode 100644 index 0000000000..b8f549370d --- /dev/null +++ b/browser/components/asrouter/docs/about-welcome.md @@ -0,0 +1,105 @@ +# About Welcome + +## What is about:welcome +A full-page multistep onboarding experience on `about:welcome` that appears to all new Firefox users after Firefox has successfully been installed. + +Onboarding experience on `about:welcome` shows screens following below precedence order: +- Has AMO attribution + - Return to AMO custom onboarding screens +- Experiments +- Return `MR_ABOUT_WELCOME_DEFAULT` [screens](https://searchfox.org/mozilla-central/rev/3b707c8fd7e978eebf24279ee51ccf07895cfbcb/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#523) after applying dynamic rules inside [prepareContentForReact](https://searchfox.org/mozilla-central/rev/3b707c8fd7e978eebf24279ee51ccf07895cfbcb/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#577) method. Each screen can additionally be dynamically configured to show/hide via [screen level targeting](https://searchfox.org/mozilla-central/rev/3b707c8fd7e978eebf24279ee51ccf07895cfbcb/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#90). + +Spotlight messaging surface shows `about:welcome` screens content with the appearance of a spotlight modal by using exposed window.AW* interfaces. This [Unified Onboarding](https://docs.google.com/document/d/1o8W-gEkgw2GC7KFSfQRkKfWzNJg1-6fpkVPrYmmot4Y/edit) approach enables reusing `about:welcome` as rendering engine for multiple messaging surfaces such as Spotlight and Feature Callout. + +## Testing about:welcome screens + +### Via Screens Pref: +1. Go to `about:config`, search for `browser.aboutwelcome.screens` and set it to the array of screens value to be used in JSON recipe +2. Go to about:welcome and you should see the about:welcome screen(s) +**Note:** If you are enrolled in a nimbus experiment, about.welcome.screens will not show up + +### Via Experiments +You can test custom `about:welcome` UI by creating an experiment. [Messaging Journey](https://experimenter.info/messaging/desktop-messaging-journey) captures creating and testing experiments via Nimbus. + +### Example JSON screens property +``` +[ + { + "id": "TEST_SCREEN_1", + "content": { + "position": "split", + "split_narrow_bkg_position": "-155px", + "progress_bar": "true", + "background": "url('chrome://activity-stream/content/data/content/assets/mr-pintaskbar.svg') var(--mr-secondary-position) no-repeat #F8F6F4", + "logo": {}, + "title": "Welcome to Firefox", + "subtitle": "Launch a healthier internet from anywhere with a single click.", + "primary_button": { + "label": { + "string_id": "mr2022-onboarding-pin-primary-button-label" + }, + "action": { + "navigate": true, + "type": "PIN_FIREFOX_TO_TASKBAR" + } + }, + "checkbox": { + "label": "Also add private browsing", + "defaultValue": true, + "action": { + "type": "MULTI_ACTION", + "navigate": true, + "data": { + "actions": [ + { + "type": "PIN_FIREFOX_TO_TASKBAR", + "data": { + "privatePin": true + } + }, + { + "type": "PIN_FIREFOX_TO_TASKBAR" + } + ] + } + } + }, + "secondary_button": { + "label": "Next Screen", + "action": { + "navigate": true + }, + "has_arrow_icon": true + } + } + }, + { + "id": "TEST_SCREEN_2", + "content": { + "position": "split", + "progress_bar": "true", + "split_narrow_bkg_position": "-228px", + "background": "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat #F8F6F4", + "logo": {}, + "title": "You’re helping us build a better web", + "subtitle": "With your support, we’re working to make the internet more open, accessible, and better for everyone.", + "primary_button": { + "label": "Start browsing", + "action": { + "navigate": true + } + } + } + } +] +``` + +### Example about:welcome screens + +### Browser View +![About Welcome 1](./aboutwelcome-1.png) +![About Welcome 2](./aboutwelcome-2.png) + +### Responsive View +![About Welcome Responsive 1](./aboutwelcome-res-1.png) +![About Welcome Responsive 2](./aboutwelcome-res-2.png) diff --git a/browser/components/asrouter/docs/aboutwelcome-1.png b/browser/components/asrouter/docs/aboutwelcome-1.png Binary files differnew file mode 100644 index 0000000000..92b5d4a878 --- /dev/null +++ b/browser/components/asrouter/docs/aboutwelcome-1.png diff --git a/browser/components/asrouter/docs/aboutwelcome-2.png b/browser/components/asrouter/docs/aboutwelcome-2.png Binary files differnew file mode 100644 index 0000000000..b2f22abc38 --- /dev/null +++ b/browser/components/asrouter/docs/aboutwelcome-2.png diff --git a/browser/components/asrouter/docs/aboutwelcome-res-1.png b/browser/components/asrouter/docs/aboutwelcome-res-1.png Binary files differnew file mode 100644 index 0000000000..443a5f2f2c --- /dev/null +++ b/browser/components/asrouter/docs/aboutwelcome-res-1.png diff --git a/browser/components/asrouter/docs/aboutwelcome-res-2.png b/browser/components/asrouter/docs/aboutwelcome-res-2.png Binary files differnew file mode 100644 index 0000000000..1b3510db98 --- /dev/null +++ b/browser/components/asrouter/docs/aboutwelcome-res-2.png diff --git a/browser/components/asrouter/docs/cfr-doorhanger.png b/browser/components/asrouter/docs/cfr-doorhanger.png Binary files differnew file mode 100644 index 0000000000..04ea001ccf --- /dev/null +++ b/browser/components/asrouter/docs/cfr-doorhanger.png diff --git a/browser/components/asrouter/docs/cfr_doorhanger_screenshot.png b/browser/components/asrouter/docs/cfr_doorhanger_screenshot.png Binary files differnew file mode 100644 index 0000000000..aee3bcf3bd --- /dev/null +++ b/browser/components/asrouter/docs/cfr_doorhanger_screenshot.png diff --git a/browser/components/asrouter/docs/contextual-feature-recommendation.md b/browser/components/asrouter/docs/contextual-feature-recommendation.md new file mode 100644 index 0000000000..2492d68814 --- /dev/null +++ b/browser/components/asrouter/docs/contextual-feature-recommendation.md @@ -0,0 +1,83 @@ +# Contextual Feature Recommendation + +## What are CFRs? +The most commonly used CFR as a Messaging Surface is the doorhanger, which anchors to one of the UI elements such as the application menu, the identity panel and so on. +CFRs like any other messaging screen has specific triggers. You can learn more about triggers [here](https://firefox-source-docs.mozilla.org/toolkit/components/messaging-system/docs/TriggerActionSchemas/index.html). + +[More examples of templates supported with CFR](https://experimenter.info/messaging/desktop-messaging-surfaces/#doorhanger) + +### Note: +For new messages, [Feature Callout](./feature-callout.md) is recommended instead of CFR. + +### Example of Doorhanger +![Doorhanger](./cfr-doorhanger.png) + +## Testing CFRs + +### Via the dev tools: +1. Go to `about:config`, set pref `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +2. Open a new tab and go to `about:asrouter` in the url bar +3. In devtools Messages section, select and show messages from `cfr` as provider +4. You should see example JSON messages with `"template": "cfr_doorhanger"` or `"template": "milestone_message"`. Clicking `Show` next to CFR message should show respective message UI +5. You can directly modify the message in the text area with your changes or by pasting your custom message JSON. Clicking `Modify` shows your new updated CFR message. +6. Ensure that all required properties are covered according to the [Doorhanger Schema](https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json) +7. Clicking `Share`, copies link to clipboard that can be pasted in the url bar to preview doorhanger UI in browser and can be shared to get feedback from your team. +- **Note:** Some messages will not be shown when testing multiple CFRs due to overlap, ensure you close the previous message before testing another + +- **Note:** The `"anchor_id"` prop is the ID of the element the CFR will attach to (example below: `tracking-protection-icon-box`). Setting prop skip_address_bar_notifier to true will show the doorhanger straight away skipping url bar notifier (See [Bug 1831198](https://bugzilla.mozilla.org/show_bug.cgi?id=1831198)). + +### Via Experiments: +You can test CFR messaging surface by creating an experiment or landing message in tree. [Messaging Journey](https://experimenter.info/messaging/desktop-messaging-journey) captures creating and testing experiments via Nimbus. + +### Example JSON for CFR +``` +{ + "id": "Test_CFR", + "groups": [ + "cfr" + ], + "template": "cfr_doorhanger", + "content": { + "persistent_doorhanger": true, + "anchor_id": "tracking-protection-icon-container", + "layout": "icon_and_message", + "icon": "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg", + "icon_dark_theme": "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg", + "icon_class": "cfr-doorhanger-small-icon", + "heading_text": "Update Nightly to play Video", + "text": "Videos on this site may not play correctly on this version of Nightly. For full video support, update Nightly now.", + "buttons": { + "primary": { + "label": { + "string_id": "cfr-doorhanger-extension-ok-button" + }, + "action": { + "type": "OPEN_PREFERENCES_PAGE", + "data": { + "category": "sync", + "entrypoint": "cfr-test" + } + } + }, + "secondary": [ + { + "label": { + "string_id": "cfr-doorhanger-extension-cancel-button" + }, + "action": { + "type": "CANCEL" + } + } + ] + }, + "skip_address_bar_notifier": true + }, + "frequency": { + "lifetime": 1 + }, + "trigger": { + "id": "nthTabClosed" + }, + "targeting": "firefoxVersion >= 115" +} +``` diff --git a/browser/components/asrouter/docs/debugging-docs.md b/browser/components/asrouter/docs/debugging-docs.md new file mode 100644 index 0000000000..dab1c0895d --- /dev/null +++ b/browser/components/asrouter/docs/debugging-docs.md @@ -0,0 +1,32 @@ +# Using ASRouter Devtools + +## How to enable ASRouter devtools +- In `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +- Visit `about:asrouter` to see the devtools. + +## Overview of ASRouter devtools + +![Devtools image](./debugging-guide.png) + +## How to enable/disable a provider + +To enable a provider such as `cfr`, Look at the list of "Message Providers" at the top of the page. Make sure the checkbox is checked next to the provider you want to enable. + +To disable it, uncheck the checkbox. You should see a red label indicating the provider is now disabled. + +## How to see all messages from a provider + +In order to see all active messages for a current provider such as `cfr`, use the drop down selector under the "Messages" section. Select the name of the provider you are interested in. + +The messages on the page should now be filtered to include only the provider you selected. + +## How to test data collection + +All of Messaging System, including ASRouter, is instrumented in Glean. +To test this instrumentation, please consult [this guide](/toolkit/components/glean/user/instrumentation_tests.md), and: + +- In about:config, set: + - `browser.newtabpage.activity-stream.telemetry` to `true` +- To view additional debug logs for messaging system or about:welcome, set: + - `messaging-system.log` to `debug` + - `browser.aboutwelcome.log` to `debug` diff --git a/browser/components/asrouter/docs/debugging-guide.png b/browser/components/asrouter/docs/debugging-guide.png Binary files differnew file mode 100644 index 0000000000..8616a29ab3 --- /dev/null +++ b/browser/components/asrouter/docs/debugging-guide.png diff --git a/browser/components/asrouter/docs/feature-callout.md b/browser/components/asrouter/docs/feature-callout.md new file mode 100644 index 0000000000..9cd083e5f2 --- /dev/null +++ b/browser/components/asrouter/docs/feature-callout.md @@ -0,0 +1,614 @@ +# Feature Callout + +Feature Callouts point to and describe features in content pages or the browser chrome. They can consist of a single message or of a sequence of messages. Callouts are different from Spotlights or other dialogs in that they do not block other interactions with the browser. Feature callouts are currently only available for experimentation in the browser chrome. For example, callouts can easily be configured to point to toolbar buttons in the browser chrome. + +Callouts may be configured with the following content elements (each of which is optional): + +- title +- subtitle +- inline title icon +- a large illustration above the title +- primary action button +- secondary action button +- additional action button +- dismiss button +- checkboxes and/or radio buttons + +The callout's arrow (the triangle-shaped caret pointing to the anchor) can be positioned in the middle or in the corners of any of the callout's edges, and it can be anchored to the same positions on its anchor element. The arrow position and anchor position each support all 8 cardinal and ordinal directions. The arrow can also be hidden entirely. There is also an optional effect to highlight the button the callout is anchored to. This highlight only works if the anchor element is a button. + +## Examples + +![Feature Callout](./feature-callout.png) + +## Testing Feature Callouts + +### Via the devtools: + +1. Go to `about:config`, set pref `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +2. Open a new tab and go to `about:asrouter` in the urlbar +3. In the devtools Messages section, search for `feature_callout` using the findbar +4. You should see an example JSON message labeled `TEST_FEATURE_TOUR`. Clicking `Show` next to it should show the callout +5. You can directly modify the message in the text area with your changes or by pasting your custom message JSON. Clicking `Modify` shows your updated message. Make sure it's valid JSON and be careful not to add unnecessary commas after the final member in an array or the final property of an object, as they will invalidate the message. +6. For these testing purposes, targeting and trigger are ignored, as the message will be triggered by pressing the "Modify" button. So you won't be able to test triggers and targeting by this method. +6. Ensure that all required properties are covered according to the schema below +7. Clicking `Share` copies a link to your clipboard that can be pasted in the urlbar to preview the message and can be shared to get feedback from your team + +- **Note:** Only one Feature Callout can be shown at a time. You must dismiss existing callouts before new ones can be shown. + +### Via local provider: + +You can also test Feature Callouts by adding them to the [local provider](https://searchfox.org/mozilla-central/source/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs). While slower than using the devtools, this is useful when you want to test the trigger or targeting, or when your callout's anchor is an element that is not visible while on `about:asrouter` (such as a urlbar button). + +### Via Experiments: + +You can test Feature Callouts by creating an experiment or landing message in tree. [Messaging Journey](https://experimenter.info/messaging/desktop-messaging-journey) captures creating and testing experiments via Nimbus. This is the most time-consuming method, but if your callout will be launched as an experiment, then it also provides the most accurate preview. + +### Schema + +```ts +interface FeatureCallout { + // Unique id for the message. Used to store impressions, recorded in telemetry + id: string; + template: "feature_callout"; + // Targeting expression string. JEXL is used for evaluation. See the Targeting + // section below for details. + targeting: string; + // Trigger object. See the Triggers section below for details. + trigger: { + // The trigger's unique identifier, e.g. "nthTabClosed". + id: string; + // A set of parameters for the triggers. Usage depends on the trigger id. + params?: any; + // A set of URL match patterns (like globs) used by some triggers. + // See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns + patterns?: string[]; + }; + // An optional object specifying frequency caps for the message. + frequency?: { + // A basic limit on the number of times the message can be shown to a user + // across the entire lifetime of the user profile. + lifetime?: number; + // An array specifying any number of limits on the number of times the + // message can be shown to a user within a specific time period. This can be + // specified in addition to or instead of a lifetime limit. + custom?: Array<{ + // The number of times the message can be shown within the period. + cap: number; + // The period of time in milliseconds. For example, 24 hours is 86400000. + period: number; + }>; + }; + // An array of message groups, which are used for frequency capping. Typically + // this should be ["cfr"], unless you have a specific reason to do otherwise. + groups?: string[]; + // Messages can optionally have a priority or weight, influencing the order in + // which they're shown. Higher priority messages are shown first. Messages can + // also be selected randomly based on their weight. However, weight is rarely + // used. We recommend using neither weight nor priority, unless you are adding + // multiple messages with the same trigger and similar targeting. + weight?: number; // e.g. weight: 200 is more likely than weight: 100 + priority?: number; // e.g. priority: 2 beats priority: 1 + // Whether the message should be skipped in automated tests. If omitted, the + // message can be shown in tests. A truthy value will skip the message in + // tests. The value should be a string explaining why the message needs to be + // skipped. You can still test messages with this property in automation by + // stubbing `ASRouter.messagesEnabledInAutomation` (adding the message id to + // the array). This way, you can avoid showing the message in all tests except + // the specific test where you want to test it. This has no effect for + // messages in Nimbus experiments. It's for local messages only. + skip_in_tests?: string; + content: { + // The same as the id above + id: string; + template: "multistage"; + backdrop: "transparent"; + transitions: false; + disableHistoryUpdates: true; + // The name of a preference that will be used to store screen progress. Only + // relevant if your callout has multiple screens and serves as a tour. This + // allows tour progress to persist across sessions and even devices, if the + // pref is synced via FxA. In most cases, this will not be needed. + tour_pref_name?: string; + // A default value for the pref. Can be used if the pref is not set in + // Firefox's default prefs. This is the default value that will be used + // until the pref is set by the user interacting with the callout. It will + // be used to determine the starting screen. Values are JSON objects like + // this: { "screen": "SCREEN_1", "complete": false } + // The "screen" property is the id of the screen to start on, and the + // "complete" property is a boolean indicating whether the tour has been + // completed (it should always be false here). As with tour_pref_name, this + // should usually be omitted. + tour_pref_default_value?: string; + // Set to "block" to block all telemetry. Recommended to omit this. + metrics?: string; + // An array of screens that should be shown in sequence. The first screen + // will be shown immediately. If the screen includes actions (such as + // `primary_button.action`) with `navigate: true`, the user can advance to + // the next screen, causing the first screen to fade out and the next screen + // to fade in. + screens: [ + { + id: string; + // Feature callouts with multiple screens show a series of dots at the + // bottom, indicating which screen the user is on. This property allows + // you to hide those dots. The steps indicator is already hidden if + // there's only one screen, since it's unnecessary. Defaults to false. + force_hide_steps_indicator?: boolean; + // An array of anchor objects. Each anchor object represents a single + // element on the page that the callout should be anchored to. The + // callout will be anchored to the first visible element in the array. + anchors: [ + { + // A CSS selector for the element to anchor to. The callout will be + // anchored to the first visible element that matches this selector. + selector: string; + // An object representing how the callout should be positioned + // relative to the anchor element. + panel_position: { + // The point on the anchor that the callout should be tied to. See + // PopupAttachmentPoint below for the possible values. These are + // the same values used by XULPopupElements. + anchor_attachment: PopupAttachmentPoint; + // The point on the callout that should be tied to the anchor. + callout_attachment: PopupAttachmentPoint; + // Offsets in pixels to apply to the callout position in the + // horizontal and vertical directions. Generally not needed. + offset_x?: number; + offset_y?: number; + }; + // Hide the arrow that points from the callout to the anchor? + hide_arrow?: boolean; + // Whether to set the [open] style on the anchor element while the + // callout is showing. False to set it, true to not set it. Not all + // elements have an [open] style. Buttons do, for example. It's + // usually similar to :active. + no_open_on_anchor?: boolean; + // The desired width of the arrow in a number of pixels. 33.94113 by + // default (this corresponds to a triangle with 24px edges). This + // also affects the height of the arrow. + arrow_width?: number; + } + ]; + content: { + position: "callout"; + // By default, callouts don't hide if the user clicks outside of them. + // Set this to true to make the callout hide on outside clicks. + autohide?: boolean; + // Callout card width as a CSS value, e.g. "400px" or "min-content". + // Defaults to "400px". + width?: string; + // Callout card padding in pixels. Defaults to 24. + padding?: number; + // Callouts normally have a vertical layout, with rows of content. If + // you want a single row with a more inline layout, you can use this + // property, which works well in tandem with title_logo. + layout?: "inline"; + // An optional object representing a large illustration to show above + // other content. See Logo below for the possible properties. + logo?: Logo; + // The callout's headline. This is optional but commonly used. Can be + // a raw string or a LocalizableThing (see interface below). + title?: Label; + // An optional object representing an icon to show next to the title. + // See TitleLogo below for the possible properties. + title_logo?: TitleLogo; + // A subtitle to show below the title. Typically a longer paragraph. + subtitle?: Label; + primary_button?: { + // Text to show inside the button. + label: Label; + // Buttons can optionally show an arrow icon, indicating that + // clicking the button will advance to the next screen. + has_arrow_icon?: boolean; + // Buttons can be disabled. The boolean option isn't really useful, + // since there's no logic to enable the button. However, if your + // screen uses the "multiselect" tile (see tiles), you can use + // "hasActiveMultiSelect" to disable the button until the user + // selects something. + disabled?: boolean | "hasActiveMultiSelect"; + // Primary buttons can have a "primary" or "secondary" style. This + // is useful because you can't change the order of the buttons, but + // you can swap the primary and secondary buttons' styles. + style?: "primary" | "secondary"; + // The action to take when the button is clicked. See Action below. + action: Action; + }; + secondary_button?: { + label: Label; + // Extra text to show before the button. + text: Label; + has_arrow_icon?: boolean; + disabled?: boolean | "hasActiveMultiSelect"; + style?: "primary" | "secondary"; + action: Action; + }; + additional_button?: { + label: Label; + // If you have several buttons, you can use this property to control + // the orientation of the buttons. By default, buttons are laid out + // in a complex way. Use row or column to override this. + flow?: "row" | "column"; + disabled?: boolean; + // The additional button can also be styled as a link. + style?: "primary" | "secondary" | "link"; + action: Action; + // Justification/alignment of the buttons row/column. Defaults to + // "end" (right-justified buttons). You can use space-between if, + // for example, you have 2 buttons and you want one on the left and + // one on the right. + alignment?: "start" | "end" | "space-between"; + }; + dismiss_button?: { + // This can be used to control the ARIA attributes and tooltip. + // Usually it's omitted, since it has a correct default value. + label?: Label; + // The button can be 32px or 24px. Defaults to 32px. + size?: "small" | "large"; + action: Action; + // CSS overrides. + marginBlock?: string; + marginInline?: string; + }; + // A split button is an additional_button or secondary_button split + // into 2 buttons: one that performs the main action, and one with an + // arrow that opens a dropdown submenu (which this property controls). + submenu_button?: { + // This defines the dropdown menu that appears when the user clicks + // the split button. + submenu: SubmenuItem[]; + // The submenu button can only be a split button, so a secondary or + // additional button needs to exist for it to attach to. + attached_to: "secondary_button" | "additional_button"; + // Used mainly to control the ARIA label and tooltip (tooltips are + // currently broken), but can also be used to override CSS styles. + label?: Label; + // Whether the split button should follow the primary or secondary + // button style. Set this to the same style you specified for the + // button it's attached to. Defaults to "secondary". + style?: "primary" | "secondary"; + }; + // Predefined content modules. The only one currently supported in + // feature callout is "multiselect", which allows you to show a series + // of checkboxes and/or radio buttons. + tiles?: { + type: "multiselect"; + // Depends on the type, but we only support "multiselect" currently. + data: MultiSelectItem[]; + // By default, multiselect items appear in the order they're listed + // in the data array. Set this to true to randomize the order. This + // is most commonly used to randomize the order of answer choices + // for a survey question, to avoid the first-choice bias. + randomize?: boolean; + // Allows CSS overrides of the multiselect container. + style?: { + color?: string; + fontSize?: string; + fontWeight?: string; + letterSpacing?: string; + lineHeight?: string; + marginBlock?: string; + marginInline?: string; + paddingBlock?: string; + paddingInline?: string; + whiteSpace?: string; + flexDirection?: string; + flexWrap?: string; + flexFlow?: string; + flexGrow?: string; + flexShrink?: string; + justifyContent?: string; + alignItems?: string; + gap?: string; + // Any CSS properties starting with "--" are also allowed, to + // override CSS variables used in _feature-callout.scss. + "--some-variable"?: string; + }; + }; + // The dots in the corner that show what screen you're on and how many + // screens there are in total. This property is only used to override + // the ARIA attributes or tooltip. Not recommended. + steps_indicator?: { + string_id: string; + }; + // An extra block of configurable content below the title/subtitle but + // above the optional `tiles` section and the main buttons. Styles not + // yet implemented; not recommended. + above_button_content?: LinkParagraphOrImage[]; + // An optional array of event listeners to add to the page where the + // feature callout is shown. This can be used to perform actions in + // response to interactions and other events outside of the feature + // callout itself. The prototypical use case is dismissing the feature + // callout when the user clicks the button the callout is anchored to. + // It also supports performing actions on a timeout/interval. + page_event_listeners?: Array<{ + params: { + // Event type string, e.g. "click". This supports: + // 1. Any DOM event type + // 2. "timeout" and "interval" for timers + // 3. Internal feature callout events: "touradvance" and + // "tourend". This can be used to perform actions when the user + // advances to the next screen or finishes the callout tour. + type: string; + // Target selector, e.g. `tag.class, #id[attr]` - Not needed for + // all types. + selectors?: string; + // addEventListener options + options: { + // Handle events in capturing phase? + capture?: boolean; + // Remove listener after first event? + once?: boolean; + // Prevent default action in event handler? + preventDefault?: boolean; + // Used only for `timeout` and `interval` event types. These + // don't set up real event listeners, but instead invoke the + // action on a timer. + interval?: number; + }; + }; + action: { + // One of the special message action ids. + type?: "string"; + // Data to pass to the action. Depends on the action. + data?: any; + // Dismiss screen after performing action? If there's no type, the + // action will *only_ dismiss the callout. + dismiss?: boolean; + }; + }>; + }; + } + ]; + // Specify the index of the screen to start on. Generally unused. + startScreen?: number; + }; +} + +type PopupAttachmentPoint = + | "topleft" + | "topright" + | "bottomleft" + | "bottomright" + | "leftcenter" + | "rightcenter" + | "topcenter" + | "bottomcenter"; + +type Label = string | LocalizableThing; + +interface LocalizableThing { + // A raw, untranslated string, typically used for EN-only experiments. + raw?: string; + // A Fluent string id from a .ftl file. + string_id?: string; + // Arguments to pass to Fluent. Used for Fluent strings that have variables. + args?: { + [key: string]: string; + }; + // A string to use as the element's aria-label attribute value. + aria_label?: string; + // CSS overrides. + color?: string; + fontSize?: string; + fontWeight?: string; + letterSpacing?: string; + lineHeight?: string; + marginBlock?: string; + marginInline?: string; + paddingBlock?: string; + paddingInline?: string; + whiteSpace?: string; +} + +interface Logo { + imageURL: "chrome://branding/content/about-logo.svg"; + darkModeImageURL: string; + reducedMotionImageURL: string; + darkModeReducedMotionImageURL: string; + // <img> alt text. Defaults to "" + alt: string; + // CSS style overrides for the icon. + width: string; + height: string; + marginBlock: string; + marginInline: string; +} + +interface TitleLogo extends Logo { + // Logo alignment relative to the title. Use "top" if you have multiple rows + // of text and you want the logo aligned to the top row. Defaults to "center". + alignment: "top" | "bottom" | "center"; +} + +// Click the Special Message Actions link at the bottom of the page. +interface Action { + // One of the special message action ids. + type?: "string"; + // Data to pass to the action. Depends on the action. + data?: any; + // Set to true if you want the action to advance to the next screen or hide + // the callout if it's the last screen. Can be used in lieu of "type" and + // "data" to create a button that just advances the screen. + navigate?: boolean; + // Same as "navigate" but dismisses the callout instead of advancing to the + // next screen. + dismiss?: boolean; + // Set to true if this action is for the primary button and you're using the + // "multiselect" tile. This is what allows the primary button to perform the + // actions specified by the user's checkbox/radio selections. It will combine + // all the actions for all the selected checkboxes/radios into this action's + // data.actions array, and perform them in series. + collectSelect?: boolean; +} + +// Either an image or a paragraph that supports inline links. Currently requires +// Fluent strings. Raw strings are not supported. +interface LinkParagraphOrImage extends Logo { + // Which type of content this is. + type: "image" | "text"; + + // Each of these is only used if `type` is "text". + // The `text` object contains the Fluent string id. Doesn't support raw text. + text: LocalizableThing; + // An array of key names. Each link key must exist in screen.content. For + // example, if link_keys is ["learn_more"], then there must be a key named + // "learn_more" in screen.content. The value of that key must be an object + // with an `action` property (which is an Action). Moreover, the string_id in + // the `text` object (see the property above) must refer to a Fluent string + // that contains an anchor element with `data-l10n-name="learn_more"`, e.g.: + // my-string = Do the thing! <a data-l10n-name="learn_more">Learn more</a> + link_keys: string[]; +} + +interface MultiSelectItem { + // A unique id for this item, distinguishing it from other items. + id: string; + type: "checkbox" | "radio"; + // Radios need to be members of radio groups to work properly. Set the same + // group for your radios to make sure only one can be selected at a time. + group?: string; + // Set to true to make it selected/checked by default. + defaultValue: false; + label?: Label; + // CSS overrides for the div box containing the item and its optional label. + style?: { + color?: string; + fontSize?: string; + fontWeight?: string; + letterSpacing?: string; + lineHeight?: string; + marginBlock?: string; + marginInline?: string; + paddingBlock?: string; + paddingInline?: string; + whiteSpace?: string; + flexDirection?: string; + flexWrap?: string; + flexFlow?: string; + flexGrow?: string; + flexShrink?: string; + justifyContent?: string; + alignItems?: string; + gap?: string; + }; + // You can replace the checkbox check/radio circle with an icon by using a + // bunch of CSS overrides. + icon?: { + style: { + color?: string; + fontSize?: string; + fontWeight?: string; + letterSpacing?: string; + lineHeight?: string; + marginBlock?: string; + marginInline?: string; + paddingBlock?: string; + paddingInline?: string; + whiteSpace?: string; + width?: string; + height?: string; + background?: string; + backgroundColor?: string; + backgroundImage?: string; + backgroundSize?: string; + backgroundPosition?: string; + backgroundRepeat?: string; + backgroundOrigin?: string; + backgroundClip?: string; + border?: string; + borderRadius?: string; + appearance?: string; + fill?: string; + stroke?: string; + outline?: string; + outlineOffset?: string; + boxShadow?: string; + }; + }; + // The action is not performed until the user clicks the primary button. + action: Action; +} + +interface SubmenuItem { + // Submenus can have 3 types of items, just like normal menupopups + // in Firefox. + type: "action" | "menu" | "separator"; + // The id is used to identify the submenu item in telemetry. + id?: string; + label: Label; + // Used only for type "action". The action to perform when the + // submenu item is clicked. + action?: Action; + // Used only for type "menu". The submenu items to show when the + // user hovers over this item. This is a recursive structure. + submenu: SubmenuItem[]; + // An optional URL specifying an icon to show next to the label. + icon?: string; +} +``` + +### Example JSON + +```json +{ + "id": "TEST_FEATURE_TOUR", + "template": "feature_callout", + "groups": [], + "targeting": "true", + "content": { + "id": "TEST_FEATURE_TOUR", + "template": "multistage", + "backdrop": "transparent", + "transitions": false, + "disableHistoryUpdates": true, + "screens": [ + { + "id": "FEATURE_CALLOUT_1", + "anchors": [ + { + "selector": "#PanelUI-menu-button", + "panel_position": { + "anchor_attachment": "bottomcenter", + "callout_attachment": "topright" + } + } + ], + "content": { + "position": "callout", + "title": { + "raw": "Panel Feature Callout" + }, + "subtitle": { + "raw": "Hello!" + }, + "primary_button": { + "label": { + "raw": "Advance" + }, + "action": { + "navigate": true + } + }, + "dismiss_button": { + "action": { + "dismiss": true + } + } + } + } + ] + }, +} +``` + +### Targeting + +Messages use JEXL targeting expressions to determine whether the user is eligible to see the message. See [Guide to targeting with JEXL](./targeting-guide.md) and [Targeting attributes](./targeting-attributes.md) for details. + +### Triggers + +Triggers are used to determine when a message should be shown. See [Trigger Listeners](/toolkit/components/messaging-system/docs/TriggerActionSchemas/index.md) for details. + +### Special Message Actions + +Buttons, links, and other calls to action can use one or more of a set of predefined actions. [Click here](/toolkit/components/messaging-system/docs/SpecialMessageActionSchemas/index.md) for a full list of valid actions. diff --git a/browser/components/asrouter/docs/feature-callout.png b/browser/components/asrouter/docs/feature-callout.png Binary files differnew file mode 100644 index 0000000000..8c710145b9 --- /dev/null +++ b/browser/components/asrouter/docs/feature-callout.png diff --git a/browser/components/asrouter/docs/first-run.md b/browser/components/asrouter/docs/first-run.md new file mode 100644 index 0000000000..59cb1d298a --- /dev/null +++ b/browser/components/asrouter/docs/first-run.md @@ -0,0 +1,68 @@ +# Onboarding flow + +Onboarding flow comprises of entire flow users have after Firefox has successfully been installed or upgraded. + +For new users, the first instance of new tab shows relevant messaging on about:welcome. For existing users, an upgrade dialog with release highlights is shown on major release upgrades. + + +## New User Onboarding + +A full-page multistep experience that shows up on first run since Fx80 with `browser.aboutwelcome.enabled` pref as `true`. Setting `browser.aboutwelcome.enabled` to `false` takes user to about:newtab and hides about:welcome. + +### Default values + +Multistage proton onboarding experience is live since Fx89 and its major variations are: + +#### Zero onboarding + +No about:welcome experience is shown (users see about:newtab during first run). + +Testing instructions: Set `browser.aboutwelcome.enabled` to `false` in about:config + +#### Proton + +A full-page multistep experience that shows a large splash screen and several subsequent screens. See [Default experience variations](#default-experience-variations) for more information. + +#### Return to AMO (RTAMO) + +Special custom onboarding experience shown to users when they try to download an addon from addons.mozilla.org but don’t have Firefox installed. This experience allows them to install the addon they were trying to install directly from a button on [RTAMO](https://docs.google.com/document/d/1QOJ8P0xQbdynAmEzOIx8I5qARwA-VqmOMpHzK9h9msg/edit?usp=sharing). + +Note that this uses [attribution data](https://docs.google.com/document/d/1zB5zwiyNVOiTD4I3aZ-Wm8KFai9nnWuRHsPg-NW4tcc/edit#heading=h.szk066tfte4n) added to the browser during the download process, which is only currently implemented for Windows. + +Testing instructions: +- Set pref browser.newtabpage.activity-stream.asrouter.devtoolsEnabled as true +- Open about:asrouter +- Click Targeting -> Attribution -> Force Attribution +- Open about:welcome, should display RTAMO page + +### General capabilities +- Run experiments and roll-outs through Nimbus (see [FeatureManifests](https://searchfox.org/mozilla-central/rev/5e955a47c4af398e2a859b34056017764e7a2252/toolkit/components/nimbus/FeatureManifest.js#56)), only windows is supported. FeatureConfig (from prefs or experiments) has higher precedence to defaults. See [Default experience variations](#default-experience-variations) +- AboutWelcomeDefaults methods [getDefaults](https://searchfox.org/mozilla-central/rev/81c32a2ea5605c5cb22bd02d28c362c140b5cfb4/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#539) and [prepareContentForReact](https://searchfox.org/mozilla-central/rev/81c32a2ea5605c5cb22bd02d28c362c140b5cfb4/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#566) have dynamic rules which are applied to both experiments and default UI before content is shown to user. +- about:welcome only shows up for users who download Firefox Beta or release (currently not enabled on Nightly) +- [Enterprise builds](https://searchfox.org/mozilla-central/rev/5e955a47c4af398e2a859b34056017764e7a2252/browser/components/enterprisepolicies/Policies.jsm#1385) can turn off about:welcome by setting the browser.aboutwelcome.enabled preference to false. + +### Default experience variations +In order of precedence: +- Has AMO attribution + - Return to AMO +- Experiments +- Defaults + - Proton default content with below screens + - Welcome Screen with option to 'Pin Firefox', 'Set default' or 'Get Started' + - Import screen allows user to import password, bookmarks and browsing history from previous browser. + - Set a theme lets users personalize Firefox with a theme. + +## Upgrade Dialog +Upgrade Dialog was first introduced in Fx89 with MR1 release. It replaces whatsnew tab with an upgrade modal explaining proton changes, setting Firefox as default and/or pinning, and allowing theme change. + +### Feature Details: +- Hides whatsnew tab on release channel when Upgrade Modal is shown +- Modal dialog appears on major version upgrade to 89 for MR1 + - It’s a window modal preventing access to tabs and other toolbar UI +- Support desired content and actions on each screen. For MR1 initial screen explains proton changes, highlight option to set Firefox as default and pin. Subsequent screen allows theme changes. + +### Testing Instructions: +- In about:config, set: + - `browser.startup.homepage_override.mstone` to `88.0` . The dialog only shows after it detects a major upgrade and need to set to 88 to trigger MR1 upgrade dialog. + - Ensure pref `browser.startup.upgradeDialog.version` is empty. After the dialog shows, `browser.startup.upgradeDialog.version` remembers what version of the dialog to avoid reshowing. +- Restart Firefox diff --git a/browser/components/asrouter/docs/index.rst b/browser/components/asrouter/docs/index.rst new file mode 100644 index 0000000000..dc2d8e4ebf --- /dev/null +++ b/browser/components/asrouter/docs/index.rst @@ -0,0 +1,108 @@ +================ +Messaging System +================ + +Vision +------ +Firefox must be an opinionated user agent that keeps folks safe, informed and +effective while browsing the Web. In order to have an opinion, Firefox must +have a voice. + +That voice will **respect the user’s attention** while surfacing contextually +relevant and timely information tailored to their individual needs and choices. + +What does Messaging System support? +----------------------------------- +There are several key windows of opportunity, such as the first-run activation +phase or coordinated feature releases, where Firefox engages with users. + +The Firefox Messaging System supports this engagement by targeting messages +exactly to the users who need to see them and enables the development of new +user messages that can be easily tested and deployed. It offers standard +mechanisms to measure user engagement and to perform user messaging experiments +with reduced effort across engineering teams and a faster delivery cycle from +ideation to analysis of results. + +This translates to **users seeing fewer and more relevant in-product +messages**, while supporting fast delivery, experimentation, and protection of +our users time and attention. + +Messaging System Overview +------------------------- +At the core of the Firefox Messaging System is the Messaging System Router +(called ASRouter for historical reasons). The router is a generalized Firefox +component and set of conventions that provides: + +* Flexible and configurable routing of local or remote Messages to UI + Templates. This allows new message campaigns to be started and controlled + on or off-trains +* Traffic Cop message sequencing and intermediation to prevent multiple + messages being concurrently shown +* Programmable message targeting language to show the right message to the + right user at the right time +* A template library of reusable Message and Notification UIs +* Full compatibility with Normandy pref-flip experiments +* Generalized and privacy conscious event telemetry +* Flexible Frequency Capping to mitigate user message fatigue +* Localized off train Messages +* Powerful development/debugging/QA tools on about:asrouter + +Message Routing +--------------- +.. image:: ./message-routing-overview.png + :align: center + :alt: Message Routing Overview + +The Firefox Messaging System implements a separation-of-concerns pattern for +Messages, UI Templates, and Timing/Targeting mechanisms. This allows us to +maintain a high standard of security and quality while still allowing for +maximum flexibility around content creation. + + +UI Templates +------------ +We have built a library of reusable Notification and Message interfaces which +land in the Firefox codebase and ride the trains. These templates have a +defined schema according to the available design components (e.g. titles, text, +icons) and access to a set of enhanced user actions such as triggering URLs, +launching menus, or installing addons, which can be attached to interactive +elements (such as buttons). + +Current templates include\: + +.. In theory, we ought to be able to use the :glob: directive here to +.. automatically generate the list below. For unknown reasons, however, +.. `mach doc` _sometimes_ gets confused and refuses to find patterns like +.. `*.md`. +.. toctree:: + :maxdepth: 1 + + moments-page + feature-callout + contextual-feature-recommendation + about-welcome + infobars + spotlight + private-browsing + +Detailed Docs +------------- + +* Read more about `trigger listeners and user action schemas`__. + +.. __: /toolkit/components/messaging-system/docs + +.. In theory, we ought to be able to use the :glob: directive here to +.. automatically generate the list below. For unknown reasons, however, +.. `mach doc` _sometimes_ gets confused and refuses to find patterns like +.. `*.md`. +.. toctree:: + :maxdepth: 2 + + simple-cfr-template + debugging-docs + first-run + remote_cfr + targeting-attributes + targeting-guide + telemetry diff --git a/browser/components/asrouter/docs/infobar.png b/browser/components/asrouter/docs/infobar.png Binary files differnew file mode 100644 index 0000000000..a0bf137689 --- /dev/null +++ b/browser/components/asrouter/docs/infobar.png diff --git a/browser/components/asrouter/docs/infobars.md b/browser/components/asrouter/docs/infobars.md new file mode 100644 index 0000000000..29472a9f73 --- /dev/null +++ b/browser/components/asrouter/docs/infobars.md @@ -0,0 +1,60 @@ +# Infobars +Infobars are shown at the top of the browser content area, these can be per tab (switching tabs hides it) or global (persistent across tabs). + +## Example of a Infobar +![Infobars](./infobar.png) + +## Testing Infobars + +### Via the dev tools: +1. Go to `about:config`, set pref `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +2. Open a new tab and go to `about:asrouter` in the url bar +3. In devtools Messages section, select and show messages from `cfr` as provider +4. You should see example JSON messages with `"template": "infobar"`. Clicking `Show` next to infobar message should show respective message UI +5. You can directly modify the message in the text area with your changes or by pasting your custom message JSON. Clicking `Modify` shows your updated message. +6. Ensure that all required properties are covered according to the [Infobar Schema](https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json) +7. Clicking `Share`, copies link to clipboard that can be pasted in the url bar to preview infobar message UI in browser and can be shared to get feedback from your team. +- **Note:** Overlapping infobars will not be shown when testing multiple infobar messages +- **Note:** Modifying the `label` property will change the text within the buttons, eg: `"label": "Disable"` + +### Via Experiments: +You can test Infobar messaging surface by creating an experiment or landing message in tree. [Messaging Journey](https://experimenter.info/messaging/desktop-messaging-journey) captures creating and testing experiments via Nimbus. + +### Example JSON for Infobar +``` +{ + "content": { + "text": "Your privacy matters. Nightly now securely routes your DNS requests whenever possible to a partner service to protect you while you browse.", + + "buttons": [ + { + "label": "Okay", + "action": { + "type": "ACCEPT_DOH" + }, + "primary": true + }, + { + "label": "Disable", + "action": { + "type": "DISABLE_DOH" + } + } + ], + "priority": 1, + "bucket_id": "TEST_DOH_BUCKET" + }, + "trigger": { + "id": "openURL", + "patterns": [ + "*://*/*" + ] + }, + "template": "infobar", + "frequency": { + "lifetime": 3 + }, + "targeting": "firefoxVersion >= 89", + "id": "Test_Infobar" +} +``` diff --git a/browser/components/asrouter/docs/message-routing-overview.png b/browser/components/asrouter/docs/message-routing-overview.png Binary files differnew file mode 100644 index 0000000000..0ec2ec3c14 --- /dev/null +++ b/browser/components/asrouter/docs/message-routing-overview.png diff --git a/browser/components/asrouter/docs/moments-page.md b/browser/components/asrouter/docs/moments-page.md new file mode 100644 index 0000000000..97442baf29 --- /dev/null +++ b/browser/components/asrouter/docs/moments-page.md @@ -0,0 +1,64 @@ +# Moments Page + +## What are Moments pages? +Moments Page is a web page URL that’s loaded for existing Firefox Desktop users on subsequent startup for user profiles meeting the targeting specified in moments message config. + +Moments Pages are different from WNP (What’s New Page) that shows up when users update to a new major version based on configurations built into the executable for that channel/build. Moments are shown outside of an upgrade on regular restarts and are remotely configurable via Messaging System. + +The constraint of synchronous start-up behavior prevents waiting for Remote Settings to make a targeting decision resulting in “Moments” shown on subsequent start-ups. + +### Startup pref lifecycle +The process of selecting/blocking/showing is as follows: +1. At the start of any `“update”` cycle (i.e. on a regular interval, and preferably when remote settings updates): +2. Check the override pref `browser.startup.homepage_override.once`; if a message ID is set, unblock that message since it has not yet been shown. Clear the override pref. +3. Run messages through targeting and select a message. +4. [Set the message with expiration data](https://searchfox.org/mozilla-central/rev/3b707c8fd7e978eebf24279ee51ccf07895cfbcb/browser/components/newtab/lib/MomentsPageHub.jsm#87) in the pref. +5. Block the message that was chosen immediately. + + +When the message is shown at startup: +1. Clear the override pref. + + + + +### Example of a Moments page +![Moments](./moments.png) + +## Testing Moments Page + +### Via the dev tools: +1. In the search tab go to `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +2. Open a new tab, in the search tab go to `about:asrouter` +3. In devtools, select and show messages from `panel_local_testing` as provider +4. You should see example JSON messages with `"template": "update_action"`. You can directly modify the message in the text area with your changes or by pasting your custom message JSON. For testing, please keep `id` property in config same as respective message modified. +5. Clicking `Modify` updates the override pref `browser.startup.homepage_override.once` and configures the Messaging System to open moments url in message config on next browser restart. +6. Ensure that all required properties are covered according to the [Moments Schema](https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json) +7. Restart firefox and your moments page should pop up on re-run + +### Via Experiments: +You can test the moments page by creating an experiment. [Messaging Journey](https://experimenter.info/messaging/desktop-messaging-journey) captures creating experiments via Nimbus. + +### Example JSON for Moments page +``` +{ + "groups": [ + "moments-pages" + ], + "content": { + "action": { + "id": "moments-wnp", + "data": { + "url": "https://www.mozilla.org/firefox/welcome/12", + "expireDelta": 172800000 + } + } + }, + "trigger": { + "id": "momentsUpdate" + }, + "template": "update_action", + "targeting": "true", + "id": "WNP_THANK_YOU" +} +``` diff --git a/browser/components/asrouter/docs/moments.png b/browser/components/asrouter/docs/moments.png new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/browser/components/asrouter/docs/moments.png diff --git a/browser/components/asrouter/docs/private-browsing.md b/browser/components/asrouter/docs/private-browsing.md new file mode 100644 index 0000000000..405a5a1019 --- /dev/null +++ b/browser/components/asrouter/docs/private-browsing.md @@ -0,0 +1,59 @@ +# PrivateBrowsing + +## What is PrivateBrowsing? +Messages shown inside `about:privatebrowsing` (incognito) content page. This messaging surface allows experimentation on content shown (promotion and info section) when new private window or tab is opened. + +### Example of a PrivateBrowsing window +![Private Browsing](./private-browsing.png) + +## Testing PrivateBrowsing + +### Via the dev tools: +1. In the search tab go to `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +2. Open a new tab, in the search tab go to `about:asrouter` +3. On the left navigation, click on `Private Browsing` +4. This shows all example messages developed for `about:privatebrowsing` messaging surface +5. You can directly modify the message in the text area with your changes or by pasting your custom message JSON. Ensure that all required properties are covered according to the [Private Browsing Schema](https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json) +6. To view message in private browsing window, click the circle to select respective message. Then hit the blue `Open a Private Browsing Window` at the top of the screen. This opens new private window with promotion and info section updated with custom message. + +![Circle](./selected-PB.png) + +7. To reset the chosen PrivateBrowsing window and choose another, click `Reset Message State` at the top of the screen + +### Via Experiments: +You can test any `privatebrowsing` custom message UI by creating an experiment. [Messaging Journey](https://experimenter.info/messaging/desktop-messaging-journey) captures creating experiments via Nimbus. + +### Example of messaging in privatebrowsing window +``` +{ + "id": "TEST_PBM_PROMO", + "template": "pb_newtab", + "content": { + "promoEnabled": true, + "promoType": "VPN", + "infoEnabled": true, + "infoBody": "Nightly clears your search and browsing history", + "infoLinkText": "Click Here to Learn More", + "infoTitleEnabled": false, + "promoLinkType": "button", + "promoLinkText": "fluent:about-private-browsing-prominent-cta", + "promoSectionStyle": "below-search", + "promoHeader": "Get privacy protections", + "promoTitle": "Hide browsing activity and location with Mozilla VPN. One click creates a secure connection", + "promoTitleEnabled": true, + "promoImageLarge": "chrome://browser/content/assets/moz-vpn.svg", + "promoButton": { + "action": { + "type": "OPEN_URL", + "data": { + "args": "https://vpn.mozilla.org/" + } + } + } + }, + "targeting": "firefoxVersion >= 89", + "frequency": { + "lifetime": 3 + } +} +``` diff --git a/browser/components/asrouter/docs/private-browsing.png b/browser/components/asrouter/docs/private-browsing.png Binary files differnew file mode 100644 index 0000000000..0ed1f56c4e --- /dev/null +++ b/browser/components/asrouter/docs/private-browsing.png diff --git a/browser/components/asrouter/docs/remote_cfr.md b/browser/components/asrouter/docs/remote_cfr.md new file mode 100644 index 0000000000..47daade633 --- /dev/null +++ b/browser/components/asrouter/docs/remote_cfr.md @@ -0,0 +1,82 @@ +# Remote CFR Messages +Starting in Firefox 68, CFR messages will be defined using [Remote Settings](https://remote-settings.readthedocs.io/en/latest/index.html). In this document, we'll cover how to set up a dev environment. + +## Using a dev server for Remote CFR + +> Note: Since Novembre 2021, Remote Settings has a proper DEV instance, which is +> reachable without VPN, but has the same config (openid, multi-signoff, ...) +> and collections as STAGE/PROD. + +**1. Obtain your Bearer Token** + +Until [Bug 1630651](https://bugzilla.mozilla.org/show_bug.cgi?id=1630651) happens, the easiest way to obtain your OpenID credentials is to use the admin interface. + +1. [Login on the Admin UI](https://remote-settings-dev.allizom.org/v1/admin/) using your LDAP identity +2. Copy the authentication header (📋 icon in the top bar) +3. Test your credentials with ``curl``. When reaching out the server root URL with this bearer token you should see a ``user`` entry whose ``id`` field is ``ldap:<you>@mozilla.com``. + +```bash +SERVER=https://settings.dev.mozaws.net/v1 +BEARER_TOKEN="Bearer uLdb-Yafefe....2Hyl5_w" + +curl -s ${SERVER}/ -H "Authorization:${BEARER_TOKEN}" | jq .user +``` + +**2. Create/Update/Delete CFR entries** + +> The messages can also be created manually using the [admin interface](https://settings.dev.mozaws.net/v1/admin/). + +In following example, we will create a new entry using the REST API (reusing `SERVER` and `BEARER_TOKEN` from previous step). + +```bash +CID=cfr + +# post a message +curl -X POST ${SERVER}/buckets/main-workspace/collections/${CID}/records \ + -d '{"data":{"id":"PIN_TAB","template":"cfr_doorhanger","content":{"category":"cfrFeatures","bucket_id":"CFR_PIN_TAB","notification_text":{"string_id":"cfr-doorhanger-extension-notification"},"heading_text":{"string_id":"cfr-doorhanger-pintab-heading"},"info_icon":{"label":{"string_id":"cfr-doorhanger-extension-sumo-link"},"sumo_path":"extensionrecommendations"},"text":{"string_id":"cfr-doorhanger-pintab-description"},"descriptionDetails":{"steps":[{"string_id":"cfr-doorhanger-pintab-step1"},{"string_id":"cfr-doorhanger-pintab-step2"},{"string_id":"cfr-doorhanger-pintab-step3"}]},"buttons":{"primary":{"label":{"string_id":"cfr-doorhanger-pintab-ok-button"},"action":{"type":"PIN_CURRENT_TAB"}},"secondary":[{"label":{"string_id":"cfr-doorhanger-extension-cancel-button"},"action":{"type":"CANCEL"}},{"label":{"string_id":"cfr-doorhanger-extension-never-show-recommendation"}},{"label":{"string_id":"cfr-doorhanger-extension-manage-settings-button"},"action":{"type":"OPEN_PREFERENCES_PAGE","data":{"category":"general-cfrfeatures"}}}]}},"targeting":"locale == \"en-US\" && !hasPinnedTabs && recentVisits[.timestamp > (currentDate|date - 3600 * 1000 * 1)]|length >= 3","frequency":{"lifetime":3},"trigger":{"id":"frequentVisits","params":["docs.google.com","www.docs.google.com","calendar.google.com","messenger.com","www.messenger.com","web.whatsapp.com","mail.google.com","outlook.live.com","facebook.com","www.facebook.com","twitter.com","www.twitter.com","reddit.com","www.reddit.com","github.com","www.github.com","youtube.com","www.youtube.com","feedly.com","www.feedly.com","drive.google.com","amazon.com","www.amazon.com","messages.android.com"]}}}' \ + -H 'Content-Type:application/json' \ + -H "Authorization:${BEARER_TOKEN}" +``` + +The collection was modified and now with pending changes in the workspace. We will now request a review, so that the changes become visible in the **preview** bucket. + +```bash +# request review +curl -X PATCH ${SERVER}/buckets/main-workspace/collections/${CID} \ + -H 'Content-Type:application/json' \ + -d '{"data": {"status": "to-review"}}' \ + -H "Authorization:${BEARER_TOKEN}" +``` + +Now this new record should be listed here: https://settings.dev.mozaws.net/v1/buckets/main-preview/collections/cfr/records + +**3. Set Remote Settings prefs to use the dev server.** + +Until [support for the DEV environment](https://github.com/mozilla-extensions/remote-settings-devtools/issues/66) is added to the [Remote Settings dev tools](https://github.com/mozilla-extensions/remote-settings-devtools/), we'll change the preferences manually. + +> These are critical preferences, you should use a dedicated Firefox profile for development. + +```javascript + Services.prefs.setCharPref("services.settings.loglevel", "debug"); + Services.prefs.setCharPref("services.settings.server", "https://settings.dev.mozaws.net/v1"); + // Pull data from the preview bucket. + RemoteSettings.enablePreviewMode(true); +``` + +**3. Set ASRouter CFR pref to use Remote Settings provider and enable asrouter devtools.** + +```javascript +Services.prefs.setStringPref("browser.newtabpage.activity-stream.asrouter.providers.cfr", JSON.stringify({"id":"cfr-remote","enabled":true,"type":"remote-settings","collection":"cfr"})); +Services.prefs.setBoolPref("browser.newtabpage.activity-stream.asrouter.devtoolsEnabled", true); +``` + +**4. Go to `about:asrouter`** +There should be a "cfr-remote" provider listed. + +## Using the staging server for Remote CFR + +If your message is published in the staging environment the easiest way to test is using the [Remote Settings Devtools](https://github.com/mozilla/remote-settings-devtools/releases) addon. You can install this by going to `about:debugging` and using the `Load Temporary Addon` feature. +The devtools allow you to switch your profile between production and staging and takes care of correctly flipping all the required preferences. + +## Remote l10n +By default, all CFR messages are localized with the remote Fluent files hosted in `ms-language-packs` on Remote Settings. For local test and development, you can force ASRouter to use the local Fluent files by flipping the pref `browser.newtabpage.activity-stream.asrouter.useRemoteL10n`. diff --git a/browser/components/asrouter/docs/selected-PB.png b/browser/components/asrouter/docs/selected-PB.png Binary files differnew file mode 100644 index 0000000000..ee6fdcc26e --- /dev/null +++ b/browser/components/asrouter/docs/selected-PB.png diff --git a/browser/components/asrouter/docs/simple-cfr-template.rst b/browser/components/asrouter/docs/simple-cfr-template.rst new file mode 100644 index 0000000000..a1edf4cc8a --- /dev/null +++ b/browser/components/asrouter/docs/simple-cfr-template.rst @@ -0,0 +1,37 @@ +Simple CFR Template +-------------------- + +The “Simple CFR Template” is a two-stage UI (a chiclet notification and a door-hanger) +that shows up on a configurable `trigger condition`__, such as when the user visits a +particular web page. + +.. __: /toolkit/components/messaging-system/docs/TriggerActionSchemas + +Warning! Before reading, you should consider whether a `Messaging Experiment is relevant for your needs`__. + +.. __: https://docs.google.com/document/d/1S45a_nFn8QRM8gvsxCM6HHROrIQlQQl6fUlJ2j63PGI/edit + +.. image:: ./cfr_doorhanger_screenshot.png + :align: center + :alt: Simple CFR Template 2 stage + +Doorhanger Configuration +========================= + +Stage 1 – Chiclet +++++++++++++++++++ + +* **chiclet_label**: The text that shows up in the chiclet. 20 characters max. +* **chiclet_color**: The background color of the chiclet as a HEX code. + + +Stage 2 – Door-hanger +++++++++++++++++++++++ + +* **title**: Title text at the top of the door hanger. +* **body**: A longer paragraph of text. +* **icon**: An image (please provide a URL or the image file up to 96x96px). +* **primary_button_label**: The label of the button. +* **primary_button_action**: The special action triggered by clicking on the button. Choose any of the available `button actions`__. Common examples include opening a section of about:preferences, or opening a URL. + +.. __: /toolkit/components/messaging-system/docs/SpecialMessageActionSchemas diff --git a/browser/components/asrouter/docs/spotlight.md b/browser/components/asrouter/docs/spotlight.md new file mode 100644 index 0000000000..99d3efe59f --- /dev/null +++ b/browser/components/asrouter/docs/spotlight.md @@ -0,0 +1,90 @@ +# Spotlight +This is a window or tab level modal, the user is given a primary and a secondary button to interact with the modal. +Spotlights by default are `“window modal”` preventing access to the rest of the browser including opening and switching tabs. `“Tab modal”` grays out page content and allows interacting with tabs and the rest of the browser. + +[More examples of templates supported in Spotlight](https://experimenter.info/messaging/desktop-messaging-surfaces/#multistage-spotlight) + +## Example of Spotlight page + +![Spotlight](./spotlight.png) + +## Testing Spotlight +1. Go to `about:config`, set pref `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +2. Open a new tab and go to `about:asrouter` in the url bar +3. In devtools `Messages` section, select and show messages from `onboarding` as provider +4. You should see example JSON messages with `"template": "spotlight"`. Clicking `Show` next to spotlight template message should show respective message UI +5. For quick testing, you can directly modify the message in the text area with your changes or by pasting your custom screen message JSON. Clicking `Modify` shows your new updated spotlight message. +6. Ensure that all required properties are covered according to the [Spotlight Schema](https://searchfox.org/mozilla-central/source/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json) +7. Clicking `Share`, copies link to clipboard that can be pasted in the url bar to preview spotlight custom screen(s) in browser and can be shared to get feedback from your team. +- **Note:** Spotlight can be either window or tab level, with the `"modal": "tab"` or `"modal": "window"` property in the recipe + +### Via the Experiments: +You can test the spotlight by creating an experiment or landing a message in tree. [Messaging Journey](https://experimenter.info/messaging/desktop-messaging-journey) captures creating and testing experiments via Nimbus and landing messages in Firefox. + +### Example JSON recipe for Spotlight + +``` +{ + "template": "spotlight", + "targeting": "firefoxVersion >= 114", + "frequency": { + "lifetime": 1 + }, + "trigger": { + "id": "defaultBrowserCheck" + }, + "content": { + "template": "multistage", + "id": "Spotlight_MESSAGE_ID", + "transitions": true, + "modal": "tab", + "screens": [ + { + "id": "Screen_ID", + "content": { + "logo": { + "imageURL": "chrome://activity-stream/content/data/content/assets/heart.webp", + "height": "73px" + }, + "title": { + "fontSize": "36px", + "raw": "Say hello to Firefox" + }, + "title_style": "fancy shine", + "subtitle": { + "lineHeight": "1.4", + "marginBlock": "8px 16px", + "raw": "Here’s a quick reminder that you can keep your favorite browser just one click away." + }, + "primary_button": { + "label": { + "string_id": "onboarding-start-browsing-button-label" + }, + "action": { + "type": "OPEN_URL", + "data": { + "args": "https://www.mozilla.org", + "where": "tab" + } + } + }, + "secondary_button": { + "action": { + "navigate": true + }, + "label": { + "marginBlock": "0 -20px", + "raw": "Not now" + } + }, + "dismiss_button": { + "action": { + "navigate": true + } + } + } + } + ] + } +} +``` diff --git a/browser/components/asrouter/docs/spotlight.png b/browser/components/asrouter/docs/spotlight.png Binary files differnew file mode 100644 index 0000000000..fd417a1897 --- /dev/null +++ b/browser/components/asrouter/docs/spotlight.png diff --git a/browser/components/asrouter/docs/targeting-attributes.md b/browser/components/asrouter/docs/targeting-attributes.md new file mode 100644 index 0000000000..89c5a6b6c6 --- /dev/null +++ b/browser/components/asrouter/docs/targeting-attributes.md @@ -0,0 +1,1033 @@ +# Targeting attributes + +When you create ASRouter messages such as contextual feature recommendations or onboarding cards, you may choose to include **targeting information** with those messages. + +Targeting information must be captured in [an expression](./targeting-guide.md) that has access to the following attributes. You may combine and compare any of these attributes as needed. + +Please note that some targeting attributes require stricter controls on the telemetry than can be collected, so when in doubt, ask for review. + +## Available attributes + +* [activeNotifications](#activenotifications) +* [addonsInfo](#addonsinfo) +* [addressesSaved](#addressessaved) +* [archBits](#archbits) +* [attachedFxAOAuthClients](#attachedfxaoauthclients) +* [attributionData](#attributiondata) +* [backgroundTaskName](#backgroundtaskname) +* [blockedCountByType](#blockedcountbytype) +* [browserSettings](#browsersettings) +* [creditCardsSaved](#creditcardssaved) +* [currentDate](#currentdate) +* [defaultPDFHandler](#defaultpdfhandler) +* [devToolsOpenedCount](#devtoolsopenedcount) +* [distributionId](#distributionId) +* [doesAppNeedPin](#doesappneedpin) +* [doesAppNeedPrivatePin](#doesappneedprivatepin) +* [firefoxVersion](#firefoxversion) +* [fxViewButtonAreaType](#fxviewbuttonareatype) +* [hasAccessedFxAPanel](#hasaccessedfxapanel) +* [hasActiveEnterprisePolicies](#hasactiveenterprisepolicies) +* [hasMigratedBookmarks](#hasmigratedbookmarks) +* [hasMigratedCSVPasswords](#hasmigratedcsvpasswords) +* [hasMigratedHistory](#hasmigratedhistory) +* [hasMigratedPasswords](#hasmigratedpasswords) +* [hasPinnedTabs](#haspinnedtabs) +* [homePageSettings](#homepagesettings) +* [inMr2022Holdback](#inmr2022holdback) +* [isBackgroundTaskMode](#isbackgroundtaskmode) +* [isChinaRepack](#ischinarepack) +* [isDefaultBrowser](#isdefaultbrowser) +* [isDefaultHandler](#isdefaulthandler) +* [isDeviceMigration](#isdevicemigration) +* [isFxAEnabled](#isfxaenabled) +* [isFxASignedIn](#isFxASignedIn) +* [isMajorUpgrade](#ismajorupgrade) +* [isRTAMO](#isrtamo) +* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled) +* [launchOnLoginEnabled](#launchonloginenabled) +* [locale](#locale) +* [localeLanguageCode](#localelanguagecode) +* [memoryMB](#memorymb) +* [messageImpressions](#messageimpressions) +* [needsUpdate](#needsupdate) +* [newtabSettings](#newtabsettings) +* [pinnedSites](#pinnedsites) +* [platformName](#platformname) +* [previousSessionEnd](#previoussessionend) +* [primaryResolution](#primaryresolution) +* [profileAgeCreated](#profileagecreated) +* [profileAgeReset](#profileagereset) +* [profileRestartCount](#profilerestartcount) +* [providerCohorts](#providercohorts) +* [recentBookmarks](#recentbookmarks) +* [region](#region) +* [screenImpressions](#screenImpressions) +* [searchEngines](#searchengines) +* [sync](#sync) +* [topFrecentSites](#topfrecentsites) +* [totalBlockedCount](#totalblockedcount) +* [totalBookmarksCount](#totalbookmarkscount) +* [userId](#userid) +* [userMonthlyActivity](#usermonthlyactivity) +* [userPrefersReducedMotion](#userprefersreducedmotion) +* [useEmbeddedMigrationWizard](#useembeddedmigrationwizard) +* [userPrefs](#userprefs) +* [usesFirefoxSync](#usesfirefoxsync) +* [xpinstallEnabled](#xpinstallEnabled) + +## Detailed usage + +### `addonsInfo` +Provides information about the add-ons the user has installed. + +Note that the `name`, `userDisabled`, and `installDate` is only available if `isFullData` is `true` (this is usually not the case right at start-up). + +**Due to an existing bug, `userDisabled` is not currently available** + +#### Examples +* Has the user installed the unicorn addon? +```java +addonsInfo.addons["unicornaddon@mozilla.org"] +``` + +* Has the user installed and disabled the unicorn addon? +```java +addonsInfo.isFullData && addonsInfo.addons["unicornaddon@mozilla.org"].userDisabled +``` + +#### Definition +```ts +declare const addonsInfo: Promise<AddonsInfoResponse>; +interface AddonsInfoResponse { + // Does this include extra information requiring I/O? + isFullData: boolean; + // addonId should be something like activity-stream@mozilla.org + [addonId: string]: { + // Version of the add-on + version: string; + // (string) e.g. "extension" + type: AddonType; + // Version of the add-on + isSystem: boolean; + // Is the add-on a webextension? + isWebExtension: boolean; + // The name of the add-on + name: string; + // Is the add-on disabled? + // CURRENTLY UNAVAILABLE due to an outstanding bug + userDisabled: boolean; + // When was it installed? e.g. "2018-03-10T03:41:06.000Z" + installDate: string; + }; +} +``` +### `attributionData` + +An object containing information on exactly how Firefox was downloaded + +#### Examples +* Was the browser installed via the `"back_to_school"` campaign? +```java +attributionData && attributionData.campaign == "back_to_school" +``` + +#### Definition +```ts +declare const attributionData: AttributionCode; +interface AttributionCode { + // Descriptor for where the download started from + campaign: string, + // A source, like addons.mozilla.org, or google.com + source: string, + // The medium for the download, like if this was referral + medium: string, + // Additional content, like an addonID for instance + content: string +} +``` + +### `browserSettings` + +* `update`, which has information about Firefox update channel + +#### Examples + +* Is updating enabled? +```java +browserSettings.update.enabled +``` + +* Is beta channel? +```js +browserSettings.update.channel == 'beta' +``` + +#### Definition + +```ts +declare const browserSettings: { + attribution: undefined | { + // Referring partner domain, when install happens via a known partner + // e.g. google.com + source: string; + // category of the source, such as "organic" for a search engine + // e.g. organic + medium: string; + // identifier of the particular campaign that led to the download of the product + // e.g. back_to_school + campaign: string; + // identifier to indicate the particular link within a campaign + // e.g. https://mozilla.org/some-page + content: string; + }, + update: { + // Is auto-downloading enabled? + autoDownload: boolean; + // What release channel, e.g. "nightly" + channel: string; + // Is updating enabled? + enabled: boolean; + } +} +``` + +### `currentDate` + +The current date at the moment message targeting is checked. + +#### Examples +* Is the current date after Oct 3, 2018? +```java +currentDate > "Wed Oct 03 2018 00:00:00"|date +``` + +#### Definition + +```ts +declare const currentDate; ECMA262DateString; +// ECMA262DateString = Date.toString() +type ECMA262DateString = string; +``` + +### `devToolsOpenedCount` +Number of usages of the web console. + +#### Examples +* Has the user opened the web console more than 10 times? +```java +devToolsOpenedCount > 10 +``` + +#### Definition +```ts +declare const devToolsOpenedCount: number; +``` + +### `isDefaultBrowser` + +Is Firefox the user's default browser? + +#### Definition + +```ts +declare const isDefaultBrowser: boolean; +``` + +### `isDefaultHandler` + +Is Firefox the user's default handler for various file extensions? + +Windows-only. + +#### Definition + +```ts +declare const isDefaultHandler: { + pdf: boolean; + html: boolean; +}; +``` + +#### Examples +* Is Firefox the default PDF handler? +```ts +isDefaultHandler.pdf +``` + +### `defaultPDFHandler` + +Information about the user's default PDF handler + +Windows-only. + +#### Definition + +```ts +declare const defaultPDFHandler: { + // Does the user have a default PDF handler registered? + registered: boolean; + + // Is the default PDF handler a known browser? + knownBrowser: boolean; +}; +``` + +### `firefoxVersion` + +The major Firefox version of the browser + +#### Examples +* Is the version of the browser greater than 63? +```java +firefoxVersion > 63 +``` + +#### Definition + +```ts +declare const firefoxVersion: number; +``` + +### `launchOnLoginEnabled` + +Is the launch on login option enabled? + +```ts +declare const launchOnLoginEnabled: boolean; +``` + +### `locale` +The current locale of the browser including country code, e.g. `en-US`. + +#### Examples +* Is the locale of the browser either English (US) or German (Germany)? +```java +locale in ["en-US", "de-DE"] +``` + +#### Definition +```ts +declare const locale: string; +``` + +### `localeLanguageCode` +The current locale of the browser NOT including country code, e.g. `en`. +This is useful for matching all countries of a particular language. + +#### Examples +* Is the locale of the browser any English locale? +```java +localeLanguageCode == "en" +``` + +#### Definition +```ts +declare const localeLanguageCode: string; +``` + +### `needsUpdate` + +Does the client have the latest available version installed + +```ts +declare const needsUpdate: boolean; +``` + +### `pinnedSites` +The sites (including search shortcuts) that are pinned on a user's new tab page. + +#### Examples +* Has the user pinned any site on `foo.com`? +```java +"foo.com" in pinnedSites|mapToProperty("host") +``` + +* Does the user have a pinned `duckduckgo.com` search shortcut? +```java +"duckduckgo.com" in pinnedSites[.searchTopSite == true]|mapToProperty("host") +``` + +#### Definition +```ts +interface PinnedSite { + // e.g. https://foo.mozilla.com/foo/bar + url: string; + // e.g. foo.mozilla.com + host: string; + // is the pin a search shortcut? + searchTopSite: boolean; +} +declare const pinnedSites: Array<PinnedSite> +``` + +### `previousSessionEnd` + +Timestamp of the previously closed session. + +#### Definition +```ts +declare const previousSessionEnd: UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `primaryResolution` + +An object containing the available width and available height of the primary monitor in pixel values. The values take into account the existence of docks and task bars. + +#### Definition + +```ts +interface primaryResolution { + width: number; + height: number; +} +``` + +### `profileAgeCreated` + +The date the profile was created as a UNIX Epoch timestamp. + +#### Definition + +```ts +declare const profileAgeCreated: UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `profileAgeReset` + +The date the profile was reset as a UNIX Epoch timestamp (if it was reset). + +#### Examples +* Was the profile never reset? +```java +!profileAgeReset +``` + +#### Definition +```ts +// profileAgeReset can be undefined if the profile was never reset +// UnixEpochNumber is number, e.g. 1522843725924 +declare const profileAgeReset: undefined | UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `providerCohorts` + +Information about cohort settings (from prefs, including shield studies) for each provider. + +#### Examples +* Is the user in the "foo_test" cohort for cfr? +```java +providerCohorts.cfr == "foo_test" +``` + +#### Definition + +```ts +declare const providerCohorts: { + [providerId: string]: string; +} +``` + +### `region` + +Country code retrieved from `location.services.mozilla.com`. Can be `""` if request did not finish or encountered an error. + +#### Examples +* Is the user in Canada? +```java +region == "CA" +``` + +#### Definition + +```ts +declare const region: string; +``` + +### `searchEngines` + +Information about the current and available search engines. + +#### Examples +* Is the current default search engine set to google? +```java +searchEngines.current == "google" +``` + +#### Definition + +```ts +declare const searchEngines: Promise<SearchEnginesResponse>; +interface SearchEnginesResponse: { + current: SearchEngineId; + installed: Array<SearchEngineId>; +} +// This is an identifier for a search engine such as "google" or "amazondotcom" +type SearchEngineId = string; +``` + +### `sync` + +Information about synced devices. + +#### Examples +* Is at least 1 mobile device synced to this profile? +```java +sync.mobileDevices > 0 +``` + +#### Definition + +```ts +declare const sync: { + desktopDevices: number; + mobileDevices: number; + totalDevices: number; +} +``` + +### `topFrecentSites` + +Information about the browser's top 25 frecent sites. + +**Please note this is a restricted targeting property that influences what telemetry is allowed to be collected may not be used without review** + + +#### Examples +* Is mozilla.com in the user's top frecent sites with a frececy greater than 400? +```java +"mozilla.com" in topFrecentSites[.frecency >= 400]|mapToProperty("host") +``` + +#### Definition +```ts +declare const topFrecentSites: Promise<Array<TopSite>> +interface TopSite { + // e.g. https://foo.mozilla.com/foo/bar + url: string; + // e.g. foo.mozilla.com + host: string; + frecency: number; + lastVisitDate: UnixEpochNumber; +} +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `totalBookmarksCount` + +Total number of bookmarks. + +#### Definition + +```ts +declare const totalBookmarksCount: number; +``` + +### `usesFirefoxSync` + +Does the user use Firefox sync? + +#### Definition + +```ts +declare const usesFirefoxSync: boolean; +``` + +### `isFxAEnabled` + +Does the user have Firefox sync enabled? The service could potentially be turned off [for enterprise builds](https://searchfox.org/mozilla-central/rev/b59a99943de4dd314bae4e44ab43ce7687ccbbec/browser/components/enterprisepolicies/Policies.jsm#327). + +#### Definition + +```ts +declare const isFxAEnabled: boolean; +``` + +### `isFxASignedIn` + +Is the user signed in to a Firefox Account? + +#### Definition + +```ts +declare const isFxASignedIn: Promise<boolean> +``` + +### `creditCardsSaved` + +The number of credit cards the user has saved for Forms and Autofill. + +#### Examples +```java +creditCardsSaved > 1 +``` + +#### Definition + +```ts +declare const creditCardsSaved: Promise<number> +``` + +### `addressesSaved` + +The number of addresses the user has saved for Forms and Autofill. + +#### Examples +```java +addressesSaved > 1 +``` + +#### Definition + +```ts +declare const addressesSaved: Promise<number> +``` + +### `archBits` + +The number of bits used to represent a pointer in this build. + +#### Definition + +```ts +declare const archBits: number; +``` + +### `xpinstallEnabled` + +Pref used by system administrators to disallow add-ons from installed altogether. + +#### Definition + +```ts +declare const xpinstallEnabled: boolean; +``` + +### `hasPinnedTabs` + +Does the user have any pinned tabs in any windows. + +#### Definition + +```ts +declare const hasPinnedTabs: boolean; +``` + +### `hasAccessedFxAPanel` + +Boolean pref that gets set the first time the user opens the FxA toolbar panel + +#### Definition + +```ts +declare const hasAccessedFxAPanel: boolean; +``` + +### `isWhatsNewPanelEnabled` + +Boolean pref that controls if the What's New panel feature is enabled + +#### Definition + +```ts +declare const isWhatsNewPanelEnabled: boolean; +``` + +### `totalBlockedCount` + +Total number of events from the content blocking database + +#### Definition + +```ts +declare const totalBlockedCount: number; +``` + +### `recentBookmarks` + +An array of GUIDs of recent bookmarks as provided by [`NewTabUtils.getRecentBookmarks`](https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/NewTabUtils.sys.mjs#1059) + +#### Definition + +```ts +interface Bookmark { + bookmarkGuid: string; + url: string; + title: string; + ... +} +declare const recentBookmarks: Array<Bookmark> +``` + +### `userPrefs` + +Information about user facing prefs configurable from `about:preferences`. + +#### Examples +```java +userPrefs.cfrFeatures == false +``` + +#### Definition + +```ts +declare const userPrefs: { + cfrFeatures: boolean; + cfrAddons: boolean; +} +``` + +### `attachedFxAOAuthClients` + +Information about connected services associated with the FxA Account. +Return an empty array if no account is found or an error occurs. + +#### Definition + +``` +interface OAuthClient { + // OAuth client_id of the service + // https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution + id: string; + lastAccessedDaysAgo: number; +} + +declare const attachedFxAOAuthClients: Promise<OAuthClient[]> +``` + +#### Examples +```javascript +{ + id: "7377719276ad44ee", + name: "Pocket", + lastAccessTime: 1513599164000 +} +``` + +### `platformName` + +[Platform information](https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/AppConstants.sys.mjs#153). + +#### Definition + +``` +declare const platformName = "linux" | "win" | "macosx" | "android" | "other"; +``` + +### `memoryMB` + +The amount of RAM available to Firefox, in megabytes. + +#### Definition + +```ts +declare const memoryMB = number; +``` + +### `messageImpressions` + +Dictionary that maps message ids to impression timestamps. Timestamps are stored in +consecutive order. Can be used to detect first impression of a message, number of +impressions. Can be used in targeting to show a message if another message has been +seen. +Impressions are used for frequency capping so we only store them if the message has +`frequency` configured. +Impressions for badges might not work as expected: we add a badge for every opened +window so the number of impressions stored might be higher than expected. Additionally +not all badges have `frequency` cap so `messageImpressions` might not be defined. +Badge impressions should not be used for targeting. + +#### Definition + +``` +declare const messageImpressions: { [key: string]: Array<UnixEpochNumber> }; +``` + +### `blockedCountByType` + +Returns a breakdown by category of all blocked resources in the past 42 days. + +#### Definition + +``` +declare const messageImpressions: { [key: string]: number }; +``` + +#### Examples + +```javascript +Object { + trackerCount: 0, + cookieCount: 34, + cryptominerCount: 0, + fingerprinterCount: 3, + socialCount: 2 +} +``` + +### `isChinaRepack` + +Does the user use [the partner repack distributed by Mozilla Online](https://github.com/mozilla-partners/mozillaonline), +a wholly owned subsidiary of the Mozilla Corporation that operates in China. + +#### Definition + +```ts +declare const isChinaRepack: boolean; +``` + +### `userId` + +A unique user id generated by Normandy (note that this is not clientId). + +#### Definition + +```ts +declare const userId: string; +``` + +### `profileRestartCount` + +A session counter that shows how many times the browser was started. +More info about the details in [the telemetry docs](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/concepts/sessions.html). + +#### Definition + +```ts +declare const profileRestartCount: number; +``` + +### `homePageSettings` + +An object reflecting the current settings of the browser home page (about:home) + +#### Definition + +```ts +declare const homePageSettings: { + isDefault: boolean; + isLocked: boolean; + isWebExt: boolean; + isCustomUrl: boolean; + urls: Array<URL>; +} + +interface URL { + url: string; + host: string; +} +``` + +#### Examples + +* Default about:home +```javascript +Object { + isDefault: true, + isLocked: false, + isCustomUrl: false, + isWebExt: false, + urls: [ + { url: "about:home", host: "" } + ], +} +``` + +* Default about:home with locked preference +```javascript +Object { + isDefault: true, + isLocked: true, + isCustomUrl: false, + isWebExt: false, + urls: [ + { url: "about:home", host: "" } + ], +} +``` + +* Custom URL +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: true, + isWebExt: false, + urls: [ + { url: "https://www.google.com", host: "google.com" } + ], +} +``` + +* Custom URLs +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: true, + isWebExt: false, + urls: [ + { url: "https://www.google.com", host: "google.com" }, + { url: "https://www.youtube.com", host: "youtube.com" } + ], +} +``` + +* Web extension +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: false, + isWebExt: true, + urls: [ + { url: "moz-extension://123dsa43213acklncd/home.html", host: "" } + ], +} +``` + +### `newtabSettings` + +An object reflecting the current settings of the browser newtab page (about:newtab) + +#### Definition + +```ts +declare const newtabSettings: { + isDefault: boolean; + isWebExt: boolean; + isCustomUrl: boolean; + url: string; + host: string; +} +``` + +#### Examples + +* Default about:newtab +```javascript +Object { + isDefault: true, + isCustomUrl: false, + isWebExt: false, + url: "about:newtab", + host: "", +} +``` + +* Custom URL +```javascript +Object { + isDefault: false, + isCustomUrl: true, + isWebExt: false, + url: "https://www.google.com", + host: "google.com", +} +``` + +* Web extension +```javascript +Object { + isDefault: false, + isCustomUrl: false, + isWebExt: true, + url: "moz-extension://123dsa43213acklncd/home.html", + host: "", +} +``` + +### `activeNotifications` + +True when an infobar style message is displayed or when the awesomebar is +expanded to show a message (for example onboarding tips). + +### `isMajorUpgrade` + +A boolean. `true` if the browser just updated to a new major version. + +### `hasActiveEnterprisePolicies` + +A boolean. `true` if any Enterprise Policies are active. + +### `userMonthlyActivity` + +Returns an array of entries in the form `[int, unixTimestamp]` for each day of +user activity where the first entry is the total urls visited for that day. + +### `doesAppNeedPin` + +Checks if Firefox app can be and isn't pinned to OS taskbar/dock. + +### `doesAppNeedPrivatePin` + +Checks if Firefox Private Browsing Mode can be and isn't pinned to OS taskbar/dock. Currently this only works on certain Windows versions. + +### `isBackgroundTaskMode` + +Checks if this invocation is running in background task mode. + +### `backgroundTaskName` + +A non-empty string task name if this invocation is running in background task +mode, or `null` if this invocation is not running in background task mode. + +### `userPrefersReducedMotion` + +Checks if user prefers reduced motion as indicated by the value of a media query for `prefers-reduced-motion`. + +### `inMr2022Holdback` + +A boolean. `true` when the user is in the Major Release 2022 holdback study. + +### `distributionId` + +A string containing the id of the distribution, or the empty string if there +is no distribution associated with the build. + +### `fxViewButtonAreaType` + +A string of the name of the container where the Firefox View button is shown, null if the button has been removed. + +### `hasMigratedBookmarks` + +A boolean. `true` if the user ever used the Migration Wizard to migrate bookmarks since Firefox 113 released. Available in Firefox 113+; will not be true if the user had only ever migrated bookmarks prior to Firefox 113 being released. + +### `hasMigratedCSVPasswords` + +A boolean. `true` if CSV passwords have been imported via the migration wizard since Firefox 116 released. Available in Firefox 116+; ; will not be true if the user had only ever migrated CSV passwords prior to Firefox 116 being released. + +### `hasMigratedHistory` + +A boolean. `true` if the user ever used the Migration Wizard to migrate history since Firefox 113 released. Available in Firefox 113+; will not be true if the user had only ever migrated history prior to Firefox 113 being released. + +### `hasMigratedPasswords` + +A boolean. `true` if the user ever used the Migration Wizard to migrate passwords since Firefox 113 released. Available in Firefox 113+; will not be true if the user had only ever migrated passwords prior to Firefox 113 being released. + +### `useEmbeddedMigrationWizard` + +A boolean. `true` if the user is configured to use the embedded Migration Wizard in about:welcome. + +### `isRTAMO` + +A boolean. `true` when [RTAMO](first-run.md#return-to-amo-rtamo) has been used to download Firefox, `false` otherwise. + +### `isDeviceMigration` + +A boolean. `true` when [support.mozilla.org](https://support.mozilla.org) has been used to download the browser as part of a "migration" campaign, for device migration guidance, `false` otherwise. +### `screenImpressions` + +An array that maps about:welcome screen IDs to their most recent impression timestamp. Should only be used for unique screen IDs to avoid unintentionally targeting messages with identical screen IDs. +#### Definition + +``` +declare const screenImpressions: { [key: string]: Array<UnixEpochNumber> }; +``` diff --git a/browser/components/asrouter/docs/targeting-guide.md b/browser/components/asrouter/docs/targeting-guide.md new file mode 100644 index 0000000000..3172cece81 --- /dev/null +++ b/browser/components/asrouter/docs/targeting-guide.md @@ -0,0 +1,37 @@ +# Guide to targeting with JEXL + +For a more in-depth explanation of JEXL syntax you can read the [Normady project docs](https://mozilla.github.io/normandy/user/filters.html?highlight=jexl). + +## How to write JEXL targeting expressions +A message needs to contain the `targeting` property (JEXL string) which is evaluated against the provided attributes. +Examples: + +```javascript +{ + "id": "7864", + "content": {...}, + // simple equality check + "targeting": "usesFirefoxSync == true" +} + +{ + "id": "7865", + "content": {...}, + // using JEXL transforms and combining two attributes + "targeting": "usesFirefoxSync == true && profileAgeCreated > '2018-01-07'|date" +} + +{ + "id": "7866", + "content": {...}, + // targeting addon information + "targeting": "addonsInfo.addons['activity-stream@mozilla.org'].name == 'Activity Stream'" +} + +{ + "id": "7866", + "content": {...}, + // targeting based on time + "targeting": "currentDate > '2018-08-08'|date" +} +``` diff --git a/browser/components/asrouter/docs/telemetry.md b/browser/components/asrouter/docs/telemetry.md new file mode 100644 index 0000000000..4484a3eaf4 --- /dev/null +++ b/browser/components/asrouter/docs/telemetry.md @@ -0,0 +1,90 @@ +# Messaging System & Onboarding Telemetry + +This document (combined with the [messaging system ping section of the Glean Dictionary](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/messaging-system)), is now the place to look first for Messaging System and Onboarding telemetry information. For historical reasons, there is still some related documentation mixed in with the Activity Stream documentation. If you can't find what you need here, check [old metrics we collect](/browser/components/newtab/docs/v2-system-addon/data_events.md) and the +[old data dictionary](/browser/components/newtab/docs/v2-system-addon/data_dictionary.md). + +## Collection with Glean + +Code all over the messaging system passes JSON ping objects up to a few +central spots. It may be [annotated with +attribution](https://searchfox.org/mozilla-central/search?q=symbol:AboutWelcomeTelemetry%23_maybeAttachAttribution&redirect=false) +along the way, and/or adjusted by some [policy +routines](https://searchfox.org/mozilla-central/search?q=symbol:TelemetryFeed%23createASRouterEvent&redirect=false) +before it's sent. The JSON will be transformed slightly further before being [sent to +Glean][submit-glean-for-glean]. + +## Design of Messaging System Data Collections + +Data is sent in the +[Messaging System Ping](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/messaging-system). +Which Messaging System Ping you get is recorded with +[Ping Type](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/messaging_system_ping_type). +If you wish to expand the collection of data, +consider whether data belongs on the `messaging-system` ping +(usually when data is timely to the ping itself) +or if it's a more general collection, +in which case the data can go on the default `send_in_pings` entry, +which is the `metrics` ping. +In either case, you can add a metric definition in the +[metrics.yaml][metrics-yaml] +file. + +## Adding or changing telemetry + +A general process overview can be found in the +[Activity Stream telemetry document](/browser/components/newtab/docs/v2-system-addon/telemetry.md). + +Note that when you need to add new metrics (i.e. JSON keys), +they MUST to be +[added](https://mozilla.github.io/glean/book/user/metrics/adding-new-metrics.html) to +[browser/components/newtab/metrics.yaml][metrics-yaml] +in order to show up correctly in the Glean data. + +Avoid adding any new nested objects, because Glean can't handle these. In the best case, any such additions will end up being flattened or stringified before being sent. + +## Monitoring FxMS Telemetry Health + +The OMC team owns an [OpMon](https://github.com/mozilla/opmon) dashboard for the FxMS Desktop Glean telemetry with +alerts. Note that it can only show one channel at any given time, here's a link +to [Windows +Release](https://mozilla.cloud.looker.com/dashboards/operational_monitoring::firefox_messaging_system?Percentile=50&Normalized+Channel=release&Normalized+Os=Windows). +The dashboard is specified in +[firefox-messaging-system.toml](https://github.com/mozilla/metric-hub/blob/main/opmon/firefox-messaging-system.toml), +and reading the source can help clarify exactly what it means. We are the owner +of this file, and are encouraged to adjust it to our needs, though it's probably +a good idea to get review from someone in Data Science. + +The current plan is to review the OpMon dashboard as a group in our weekly +triage meeting, note anything that seems unusual to our [Google docs +log](https://docs.google.com/document/d/1d16GCuul9sENMOMDAcD1kKNBtnJLouDxZtIgz2u-70U/edit), +and, if we want to investigate further, file [a bug that blocks +`fxms-glean`](https://bugzilla.mozilla.org/showdependencytree.cgi?id=1843409&hide_resolved=1). + +The dashboard is configured to alert in various cases, and those alerts can be +seen at the bottom of the dashboard. As of this writing, the alerts have some +noise [to be cleaned up](https://bugzilla.mozilla.org/show_bug.cgi?id=1843406) +before we can automatically act on them. + +## Appendix: A Short Glean Primer, as it applies to this project (courtesy of Chris H-C) + +* [Glean](https://mozilla.github.io/glean/book/) is a data collection library by + Mozilla for Mozilla. You define metrics like counts and timings and things, + and package those into pings which are the payloads sent to our servers. +* You can see current [FxMS Glean metrics and pings](https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/pings/messaging-system) + in the desktop section of the Glean Dictionary. The layer embedding Glean into + Firefox Desktop is called [Firefox on Glean (FOG)](https://firefox-source-docs.mozilla.org/toolkit/components/glean/index.html). +* Documentation will be automatically generated and hosted on the Glean + Dictionary, so write long rich-text Descriptions and augment them off-train + with Glean Annotations. +* Schemas for ingestion are automatically generated. You can go from landing a + new ping to querying the data being sent [within two + days](https://blog.mozilla.org/data/2021/12/14/this-week-in-glean-how-long-must-i-wait-before-i-can-see-my-data/). +* Make a mistake? No worries. Changes are quick and easy and are reflected in + the received data within a day. +* Local debugging involves using Glean's ergonomic test APIs and/or the Glean + Debug Ping Viewer which you can learn more about on `about:glean`. +* If you have any questions, the Glean Team is available across a lot of + timezones on the [`#glean:mozilla.org` channel](https://chat.mozilla.org/#/room/#glean:mozilla.org) on Matrix and Slack `#data-help`. + + [submit-glean-for-glean]: https://searchfox.org/mozilla-central/search?q=.submitGleanPingForPing&path=*.jsm&case=false®exp=false + [metrics-yaml]: https://searchfox.org/mozilla-central/source/browser/components/newtab/metrics.yaml diff --git a/browser/components/asrouter/jar.mn b/browser/components/asrouter/jar.mn new file mode 100644 index 0000000000..951e12126a --- /dev/null +++ b/browser/components/asrouter/jar.mn @@ -0,0 +1,11 @@ +# 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/. + +browser.jar: + content/browser/asrouter/asrouter-admin.html (content/asrouter-admin.html) + content/browser/asrouter/asrouter-admin.bundle.js (content/asrouter-admin.bundle.js) + content/browser/asrouter/components/ASRouterAdmin/ASRouterAdmin.css (content/components/ASRouterAdmin/ASRouterAdmin.css) + content/browser/asrouter/render.js (content/render.js) + content/browser/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json (content-src/schemas/BackgroundTaskMessagingExperiment.schema.json) + content/browser/asrouter/schemas/MessagingExperiment.schema.json (content-src/schemas/MessagingExperiment.schema.json) diff --git a/browser/components/asrouter/karma.mc.config.js b/browser/components/asrouter/karma.mc.config.js new file mode 100644 index 0000000000..b27a8dc447 --- /dev/null +++ b/browser/components/asrouter/karma.mc.config.js @@ -0,0 +1,214 @@ +/* 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/. */ + +const path = require("path"); +const webpack = require("webpack"); +const { ResourceUriPlugin } = require("../newtab/tools/resourceUriPlugin"); + +const PATHS = { + // Where is the entry point for the unit tests? + testEntryFile: path.resolve(__dirname, "./tests/unit/unit-entry.js"), + + // A glob-style pattern matching all unit tests + testFilesPattern: "./tests/unit/unit-entry.js", + + // The base directory of all source files (used for path resolution in webpack importing) + moduleResolveDirectory: __dirname, + newtabResolveDirectory: "../newtab", + + // a RegEx matching all Cu.import statements of local files + resourcePathRegEx: /^resource:\/\/activity-stream\//, + + coverageReportingPath: "logs/coverage/", +}; + +// When tweaking here, be sure to review the docs about the execution ordering +// semantics of the preprocessors array, as they are somewhat odd. +const preprocessors = {}; +preprocessors[PATHS.testFilesPattern] = [ + "webpack", // require("karma-webpack") + "sourcemap", // require("karma-sourcemap-loader") +]; + +module.exports = function (config) { + const isTDD = config.tdd; + const browsers = isTDD ? ["Firefox"] : ["FirefoxHeadless"]; // require("karma-firefox-launcher") + config.set({ + singleRun: !isTDD, + browsers, + customLaunchers: { + FirefoxHeadless: { + base: "Firefox", + flags: ["--headless"], + }, + }, + frameworks: [ + "chai", // require("chai") require("karma-chai") + "mocha", // require("mocha") require("karma-mocha") + "sinon", // require("sinon") require("karma-sinon") + ], + reporters: [ + "coverage-istanbul", // require("karma-coverage") + "mocha", // require("karma-mocha-reporter") + + // for bin/try-runner.js to parse the output easily + "json", // require("karma-json-reporter") + ], + jsonReporter: { + // So this doesn't get interleaved with other karma output + stdout: false, + outputFile: path.join("logs", "karma-run-results.json"), + }, + coverageIstanbulReporter: { + reports: ["lcov", "text-summary"], // for some reason "lcov" reallys means "lcov" and "html" + "report-config": { + // so the full m-c path gets printed; needed for https://coverage.moz.tools/ integration + lcov: { + projectRoot: "../../..", + }, + }, + dir: PATHS.coverageReportingPath, + // This will make karma fail if coverage reporting is less than the minimums here + thresholds: !isTDD && { + each: { + statements: 80, + lines: 80, + functions: 80, + branches: 66, + overrides: { + "content-src/asrouter-utils.js": { + statements: 66, + lines: 66, + functions: 76, + branches: 33, + }, + "content-src/components/ASRouterAdmin/*.jsx": { + statements: 0, + lines: 0, + functions: 0, + branches: 0, + }, + "content-src/components/ModalOverlay/ModalOverlay.jsx": { + statements: 92, + lines: 92, + functions: 100, + branches: 66, + }, + "modules/ASRouter.sys.mjs": { + statements: 75, + lines: 75, + functions: 64, + branches: 66, + }, + "modules/ASRouterParentProcessMessageHandler.sys.mjs": { + statements: 98, + lines: 98, + functions: 100, + branches: 88, + }, + "modules/ToolbarPanelHub.sys.mjs": { + statements: 88, + lines: 88, + functions: 94, + branches: 84, + }, + }, + }, + }, + }, + files: [PATHS.testEntryFile], + preprocessors, + webpack: { + mode: "none", + devtool: "inline-source-map", + // This loader allows us to override required files in tests + resolveLoader: { + alias: { + inject: path.join(__dirname, "../newtab/loaders/inject-loader"), + }, + }, + // This resolve config allows us to import with paths relative to the root directory + resolve: { + extensions: [".js", ".jsx"], + modules: [ + PATHS.moduleResolveDirectory, + "node_modules", + PATHS.newtabResolveDirectory, + ], + fallback: { + stream: require.resolve("stream-browserify"), + buffer: require.resolve("buffer"), + }, + alias: { + newtab: path.join(__dirname, "../newtab"), + }, + }, + plugins: [ + // The ResourceUriPlugin handles translating resource URIs in import + // statements in .mjs files to paths on the filesystem. + new ResourceUriPlugin({ + resourcePathRegExes: [ + [ + new RegExp("^resource://activity-stream/"), + path.join(__dirname, "../newtab/"), + ], + [ + new RegExp("^resource:///modules/asrouter/"), + path.join(__dirname, "./modules/"), + ], + ], + }), + new webpack.DefinePlugin({ + "process.env.NODE_ENV": JSON.stringify("development"), + }), + ], + externals: { + // enzyme needs these for backwards compatibility with 0.13. + // see https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md#using-enzyme-with-webpack + "react/addons": true, + "react/lib/ReactContext": true, + "react/lib/ExecutionEnvironment": true, + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: [/node_modules\/(?!@fluent\/).*/, /tests/], + loader: "babel-loader", + }, + { + test: /\.jsx$/, + exclude: /node_modules/, + loader: "babel-loader", + options: { + presets: ["@babel/preset-react"], + }, + }, + { + test: /\.md$/, + use: "raw-loader", + }, + { + enforce: "post", + test: /\.js[mx]?$/, + loader: "@jsdevtools/coverage-istanbul-loader", + options: { esModules: true }, + include: [path.resolve("content-src"), path.resolve("modules")], + exclude: [ + path.resolve("tests"), + path.resolve("../newtab"), + path.resolve("modules/ASRouterTargeting.sys.mjs"), + path.resolve("modules/ASRouterTriggerListeners.sys.mjs"), + path.resolve("modules/CFRMessageProvider.sys.mjs"), + path.resolve("modules/CFRPageActions.sys.mjs"), + path.resolve("modules/OnboardingMessageProvider.sys.mjs"), + ], + }, + ], + }, + }, + // Silences some overly-verbose logging of individual module builds + webpackMiddleware: { noInfo: true }, + }); +}; diff --git a/browser/components/asrouter/modules/ASRouter.sys.mjs b/browser/components/asrouter/modules/ASRouter.sys.mjs new file mode 100644 index 0000000000..f6657a39b9 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouter.sys.mjs @@ -0,0 +1,2079 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils, AppConstants and RemoteSettings, and overrides +// importESModule to be a no-op (which can't be done for a static import +// statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ASRouterPreferences: + "resource:///modules/asrouter/ASRouterPreferences.sys.mjs", + ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + ASRouterTriggerListeners: + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs", + AttributionCode: "resource:///modules/AttributionCode.sys.mjs", + Downloader: "resource://services-settings/Attachments.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + FeatureCalloutBroker: + "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs", + InfoBar: "resource:///modules/asrouter/InfoBar.sys.mjs", + KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs", + MacAttribution: "resource:///modules/MacAttribution.sys.mjs", + MomentsPageHub: "resource:///modules/asrouter/MomentsPageHub.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PanelTestProvider: "resource:///modules/asrouter/PanelTestProvider.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", + TARGETING_PREFERENCES: + "resource:///modules/asrouter/ASRouterPreferences.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + Spotlight: "resource:///modules/asrouter/Spotlight.sys.mjs", + ToastNotification: "resource:///modules/asrouter/ToastNotification.sys.mjs", + ToolbarBadgeHub: "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs", + ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"], +}); +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("ASRouter"); +}); +import { actionCreators as ac } from "resource://activity-stream/common/Actions.sys.mjs"; +import { MESSAGING_EXPERIMENTS_DEFAULT_FEATURES } from "resource:///modules/asrouter/MessagingExperimentConstants.sys.mjs"; +import { CFRMessageProvider } from "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"; +import { OnboardingMessageProvider } from "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs"; +import { CFRPageActions } from "resource:///modules/asrouter/CFRPageActions.sys.mjs"; + +// List of hosts for endpoints that serve router messages. +// Key is allowed host, value is a name for the endpoint host. +const DEFAULT_ALLOWLIST_HOSTS = { + "activity-stream-icons.services.mozilla.com": "production", +}; +// Max possible impressions cap for any message +const MAX_MESSAGE_LIFETIME_CAP = 100; + +const LOCAL_MESSAGE_PROVIDERS = { + OnboardingMessageProvider, + CFRMessageProvider, +}; +const STARTPAGE_VERSION = "6"; + +// Remote Settings +const RS_MAIN_BUCKET = "main"; +const RS_COLLECTION_L10N = "ms-language-packs"; // "ms" stands for Messaging System +const RS_PROVIDERS_WITH_L10N = ["cfr"]; +const RS_FLUENT_VERSION = "v1"; +const RS_FLUENT_RECORD_PREFIX = `cfr-${RS_FLUENT_VERSION}`; +const RS_DOWNLOAD_MAX_RETRIES = 2; +// This is the list of providers for which we want to cache the targeting +// expression result and reuse between calls. Cache duration is defined in +// ASRouterTargeting where evaluation takes place. +const JEXL_PROVIDER_CACHE = new Set(); + +// To observe the app locale change notification. +const TOPIC_INTL_LOCALE_CHANGED = "intl:app-locales-changed"; +const TOPIC_EXPERIMENT_ENROLLMENT_CHANGED = "nimbus:enrollments-updated"; +// To observe the pref that controls if ASRouter should use the remote Fluent files for l10n. +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +// Experiment groups that need to report the reach event in Messaging-Experiments. +// If you're adding new groups to it, make sure they're also added in the +// `messaging_experiments.reach.objects` defined in "toolkit/components/telemetry/Events.yaml" +const REACH_EVENT_GROUPS = [ + "cfr", + "moments-page", + "infobar", + "spotlight", + "featureCallout", +]; +const REACH_EVENT_CATEGORY = "messaging_experiments"; +const REACH_EVENT_METHOD = "reach"; + +export const MessageLoaderUtils = { + STARTPAGE_VERSION, + REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache", + _errors: [], + + reportError(e) { + console.error(e); + this._errors.push({ + timestamp: new Date(), + error: { message: e.toString(), stack: e.stack }, + }); + }, + + get errors() { + const errors = this._errors; + this._errors = []; + return errors; + }, + + /** + * _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central) + * + * @param {obj} provider An AS router provider + * @param {Array} provider.messages An array of messages + * @returns {Array} the array of messages + */ + _localLoader(provider) { + return provider.messages; + }, + + async _remoteLoaderCache(storage) { + let allCached; + try { + allCached = + (await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY)) || {}; + } catch (e) { + // istanbul ignore next + MessageLoaderUtils.reportError(e); + // istanbul ignore next + allCached = {}; + } + return allCached; + }, + + /** + * _remoteLoader - Loads messages for a remote provider + * + * @param {obj} provider An AS router provider + * @param {string} provider.url An endpoint that returns an array of messages as JSON + * @param {obj} options.storage A storage object with get() and set() methods for caching. + * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched + */ + async _remoteLoader(provider, options) { + let remoteMessages = []; + if (provider.url) { + const allCached = await MessageLoaderUtils._remoteLoaderCache( + options.storage + ); + const cached = allCached[provider.id]; + let etag; + + if ( + cached && + cached.url === provider.url && + cached.version === STARTPAGE_VERSION + ) { + const { lastFetched, messages } = cached; + if ( + !MessageLoaderUtils.shouldProviderUpdate({ + ...provider, + lastUpdated: lastFetched, + }) + ) { + // Cached messages haven't expired, return early. + return messages; + } + etag = cached.etag; + remoteMessages = messages; + } + + let headers = new Headers(); + if (etag) { + headers.set("If-None-Match", etag); + } + + let response; + try { + response = await fetch(provider.url, { + headers, + credentials: "omit", + }); + } catch (e) { + MessageLoaderUtils.reportError(e); + } + if ( + response && + response.ok && + response.status >= 200 && + response.status < 400 + ) { + let jsonResponse; + try { + jsonResponse = await response.json(); + } catch (e) { + MessageLoaderUtils.reportError(e); + return remoteMessages; + } + if (jsonResponse && jsonResponse.messages) { + remoteMessages = jsonResponse.messages.map(msg => ({ + ...msg, + provider_url: provider.url, + })); + + // Cache the results if this isn't a preview URL. + if (provider.updateCycleInMs > 0) { + etag = response.headers.get("ETag"); + const cacheInfo = { + messages: remoteMessages, + etag, + lastFetched: Date.now(), + version: STARTPAGE_VERSION, + }; + + options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, { + ...allCached, + [provider.id]: cacheInfo, + }); + } + } else { + MessageLoaderUtils.reportError( + `No messages returned from ${provider.url}.` + ); + } + } else if (response) { + MessageLoaderUtils.reportError( + `Invalid response status ${response.status} from ${provider.url}.` + ); + } + } + return remoteMessages; + }, + + /** + * _remoteSettingsLoader - Loads messages for a RemoteSettings provider + * + * Note: + * 1). The "cfr" provider requires the Fluent file for l10n, so there is + * another file downloading phase for those two providers after their messages + * are successfully fetched from Remote Settings. Currently, they share the same + * attachment of the record "${RS_FLUENT_RECORD_PREFIX}-${locale}" in the + * "ms-language-packs" collection. E.g. for "en-US" with version "v1", + * the Fluent file is attched to the record with ID "cfr-v1-en-US". + * + * 2). The Remote Settings downloader is able to detect the duplicate download + * requests for the same attachment and ignore the redundent requests automatically. + * + * @param {object} provider An AS router provider + * @param {string} provider.id The id of the provider + * @param {string} provider.collection Remote Settings collection name + * @param {object} options + * @param {function} options.dispatchCFRAction Action handler function + * @returns {Promise<object[]>} Resolves with an array of messages, or an + * empty array if none could be fetched + */ + async _remoteSettingsLoader(provider, options) { + let messages = []; + if (provider.collection) { + try { + messages = await MessageLoaderUtils._getRemoteSettingsMessages( + provider.collection + ); + if (!messages.length) { + MessageLoaderUtils._handleRemoteSettingsUndesiredEvent( + "ASR_RS_NO_MESSAGES", + provider.id, + options.dispatchCFRAction + ); + } else if ( + RS_PROVIDERS_WITH_L10N.includes(provider.id) && + lazy.RemoteL10n.isLocaleSupported(MessageLoaderUtils.locale) + ) { + const recordId = `${RS_FLUENT_RECORD_PREFIX}-${MessageLoaderUtils.locale}`; + const kinto = new lazy.KintoHttpClient(lazy.Utils.SERVER_URL); + const record = await kinto + .bucket(RS_MAIN_BUCKET) + .collection(RS_COLLECTION_L10N) + .getRecord(recordId); + if (record && record.data) { + const downloader = new lazy.Downloader( + RS_MAIN_BUCKET, + RS_COLLECTION_L10N, + "browser", + "newtab" + ); + // Await here in order to capture the exceptions for reporting. + await downloader.downloadToDisk(record.data, { + retries: RS_DOWNLOAD_MAX_RETRIES, + }); + lazy.RemoteL10n.reloadL10n(); + } else { + MessageLoaderUtils._handleRemoteSettingsUndesiredEvent( + "ASR_RS_NO_MESSAGES", + RS_COLLECTION_L10N, + options.dispatchCFRAction + ); + } + } + } catch (e) { + MessageLoaderUtils._handleRemoteSettingsUndesiredEvent( + "ASR_RS_ERROR", + provider.id, + options.dispatchCFRAction + ); + MessageLoaderUtils.reportError(e); + } + } + return messages; + }, + + /** + * Fetch messages from a given collection in Remote Settings. + * + * @param {string} collection The remote settings collection identifier + * @returns {Promise<object[]>} Resolves with an array of messages + */ + _getRemoteSettingsMessages(collection) { + return RemoteSettings(collection).get(); + }, + + /** + * Return messages from active Nimbus experiments and rollouts. + * + * @param {object} provider A messaging experiments provider. + * @param {string[]?} provider.featureIds + * An optional array of Nimbus feature IDs to check for + * enrollments. If not provided, we will fall back to the + * set of default features. Otherwise, if provided and + * empty, we will not ingest messages from any features. + * + * @return {object[]} The list of messages from active enrollments, as well as + * the messages defined in unenrolled branches so that they + * reach events can be recorded (if we record reach events + * for that feature). + */ + async _experimentsAPILoader(provider) { + // Allow tests to override the set of featureIds + const featureIds = Array.isArray(provider.featureIds) + ? provider.featureIds + : MESSAGING_EXPERIMENTS_DEFAULT_FEATURES; + let experiments = []; + for (const featureId of featureIds) { + const featureAPI = lazy.NimbusFeatures[featureId]; + const experimentData = lazy.ExperimentAPI.getExperimentMetaData({ + featureId, + }); + + // We are not enrolled in any experiment or rollout for this feature, so + // we can skip the feature. + if ( + !experimentData && + !lazy.ExperimentAPI.getRolloutMetaData({ featureId }) + ) { + continue; + } + + const featureValue = featureAPI.getAllVariables(); + + // If the value is a multi-message config, add each message in the + // messages array. Cache the Nimbus feature ID on each message, because + // there is not a 1-1 correspondance between templates and features. + // This is used when recording expose events (see |sendTriggerMessage|). + const messages = + featureValue?.template === "multi" && + Array.isArray(featureValue.messages) + ? featureValue.messages + : [featureValue]; + for (const message of messages) { + if (message?.id) { + message._nimbusFeature = featureId; + experiments.push(message); + } + } + + // Add Reach messages from unenrolled sibling branches, provided we are + // recording Reach events for this feature. If we are in a rollout, we do + // not have sibling branches. + if (!REACH_EVENT_GROUPS.includes(featureId) || !experimentData) { + continue; + } + + // Check other sibling branches for triggers, add them to the return array + // if found any. The `forReachEvent` label is used to identify those + // branches so that they would only be used to record the Reach event. + const branches = + (await lazy.ExperimentAPI.getAllBranches(experimentData.slug)) || []; + for (const branch of branches) { + let branchValue = branch[featureId].value; + if (!branchValue || branch.slug === experimentData.branch.slug) { + continue; + } + const branchMessages = + branchValue?.template === "multi" && + Array.isArray(branchValue.messages) + ? branchValue.messages + : [branchValue]; + for (const message of branchMessages) { + if (!message?.trigger) { + continue; + } + experiments.push({ + forReachEvent: { sent: false, group: featureId }, + experimentSlug: experimentData.slug, + branchSlug: branch.slug, + ...message, + }); + } + } + } + + return experiments; + }, + + _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchCFRAction) { + if (dispatchCFRAction) { + dispatchCFRAction( + ac.ASRouterUserEvent({ + action: "asrouter_undesired_event", + event, + message_id: "n/a", + event_context: providerId, + }) + ); + } + }, + + /** + * _getMessageLoader - return the right loading function given the provider's type + * + * @param {obj} provider An AS Router provider + * @returns {func} A loading function + */ + _getMessageLoader(provider) { + switch (provider.type) { + case "remote": + return this._remoteLoader; + case "remote-settings": + return this._remoteSettingsLoader; + case "remote-experiments": + return this._experimentsAPILoader; + case "local": + default: + return this._localLoader; + } + }, + + /** + * shouldProviderUpdate - Given the current time, should a provider update its messages? + * + * @param {any} provider An AS Router provider + * @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates + * @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred + * @param {Date} currentTime The time we should check against. (defaults to Date.now()) + * @returns {bool} Should an update happen? + */ + shouldProviderUpdate(provider, currentTime = Date.now()) { + return ( + !(provider.lastUpdated >= 0) || + currentTime - provider.lastUpdated > provider.updateCycleInMs + ); + }, + + async _loadDataForProvider(provider, options) { + const loader = this._getMessageLoader(provider); + let messages = await loader(provider, options); + // istanbul ignore if + if (!messages) { + messages = []; + MessageLoaderUtils.reportError( + new Error( + `Tried to load messages for ${provider.id} but the result was not an Array.` + ) + ); + } + + return { messages }; + }, + + /** + * loadMessagesForProvider - Load messages for a provider, given the provider's type. + * + * @param {obj} provider An AS Router provider + * @param {string} provider.type An AS Router provider type (defaults to "local") + * @param {obj} options.storage A storage object with get() and set() methods for caching. + * @param {func} options.dispatchCFRAction dispatch an action the main AS Store + * @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated) + */ + async loadMessagesForProvider(provider, options) { + let { messages } = await this._loadDataForProvider(provider, options); + // Filter out messages we temporarily want to exclude + if (provider.exclude && provider.exclude.length) { + messages = messages.filter( + message => !provider.exclude.includes(message.id) + ); + } + const lastUpdated = Date.now(); + return { + messages: messages + .map(messageData => { + const message = { + weight: 100, + ...messageData, + groups: messageData.groups || [], + provider: provider.id, + }; + + return message; + }) + .filter(message => message.weight > 0), + lastUpdated, + errors: MessageLoaderUtils.errors, + }; + }, + + /** + * cleanupCache - Removes cached data of removed providers. + * + * @param {Array} providers A list of activer AS Router providers + */ + async cleanupCache(providers, storage) { + const ids = providers.filter(p => p.type === "remote").map(p => p.id); + const cache = await MessageLoaderUtils._remoteLoaderCache(storage); + let dirty = false; + for (let id in cache) { + if (!ids.includes(id)) { + delete cache[id]; + dirty = true; + } + } + if (dirty) { + await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache); + } + }, + + /** + * The locale to use for RemoteL10n. + * + * This may map the app's actual locale into something that RemoteL10n + * supports. + */ + get locale() { + const localeMap = { + "ja-JP-macos": "ja-JP-mac", + + // While it's not a valid locale, "und" is commonly observed on + // Linux platforms. Per l10n team, it's reasonable to fallback to + // "en-US", therefore, we should allow the fetch for it. + und: "en-US", + }; + + const locale = Services.locale.appLocaleAsBCP47; + return localeMap[locale] ?? locale; + }, +}; + +/** + * @class _ASRouter - Keeps track of all messages, UI surfaces, and + * handles blocking, rotation, etc. Inspecting ASRouter.state will + * tell you what the current displayed message is in all UI surfaces. + * + * Note: This is written as a constructor rather than just a plain object + * so that it can be more easily unit tested. + */ +export class _ASRouter { + constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) { + this.initialized = false; + this.clearChildMessages = null; + this.clearChildProviders = null; + this.updateAdminState = null; + this.sendTelemetry = null; + this.dispatchCFRAction = null; + this._storage = null; + this._resetInitialization(); + this._state = { + providers: [], + messageBlockList: [], + messageImpressions: {}, + screenImpressions: {}, + messages: [], + groups: [], + errors: [], + localeInUse: Services.locale.appLocaleAsBCP47, + }; + this._experimentChangedListeners = new Map(); + this._triggerHandler = this._triggerHandler.bind(this); + this._localProviders = localProviders; + this.blockMessageById = this.blockMessageById.bind(this); + this.unblockMessageById = this.unblockMessageById.bind(this); + this.handleMessageRequest = this.handleMessageRequest.bind(this); + this.addImpression = this.addImpression.bind(this); + this.addScreenImpression = this.addScreenImpression.bind(this); + this._handleTargetingError = this._handleTargetingError.bind(this); + this.onPrefChange = this.onPrefChange.bind(this); + this._onLocaleChanged = this._onLocaleChanged.bind(this); + this.isUnblockedMessage = this.isUnblockedMessage.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.forceWNPanel = this.forceWNPanel.bind(this); + this._onExperimentEnrollmentsUpdated = + this._onExperimentEnrollmentsUpdated.bind(this); + this.forcePBWindow = this.forcePBWindow.bind(this); + Services.telemetry.setEventRecordingEnabled(REACH_EVENT_CATEGORY, true); + this.messagesEnabledInAutomation = []; + } + + async onPrefChange(prefName) { + if (lazy.TARGETING_PREFERENCES.includes(prefName)) { + let invalidMessages = []; + // Notify all tabs of messages that have become invalid after pref change + const context = this._getMessagesContext(); + const targetingContext = new lazy.TargetingContext(context); + + for (const msg of this.state.messages.filter(this.isUnblockedMessage)) { + if (!msg.targeting) { + continue; + } + const isMatch = await targetingContext.evalWithDefault(msg.targeting); + if (!isMatch) { + invalidMessages.push(msg.id); + } + } + this.clearChildMessages(invalidMessages); + } else { + // Update message providers and fetch new messages on pref change + this._loadLocalProviders(); + let invalidProviders = await this._updateMessageProviders(); + if (invalidProviders.length) { + this.clearChildProviders(invalidProviders); + } + await this.loadMessagesFromAllProviders(); + // Any change in user prefs can disable or enable groups + await this.setState(state => ({ + groups: state.groups.map(this._checkGroupEnabled), + })); + } + } + + // Fetch and decode the message provider pref JSON, and update the message providers + async _updateMessageProviders() { + lazy.ASRouterPreferences.console.debug("entering updateMessageProviders"); + + const previousProviders = this.state.providers; + const providers = await Promise.all( + [ + // If we have added a `preview` provider, hold onto it + ...previousProviders.filter(p => p.id === "preview"), + // The provider should be enabled and not have a user preference set to false + ...lazy.ASRouterPreferences.providers.filter( + p => + p.enabled && + lazy.ASRouterPreferences.getUserPreference(p.id) !== false + ), + ].map(async _provider => { + // make a copy so we don't modify the source of the pref + const provider = { ..._provider }; + + if (provider.type === "local" && !provider.messages) { + // Get the messages from the local message provider + const localProvider = this._localProviders[provider.localProvider]; + provider.messages = []; + if (localProvider) { + provider.messages = await localProvider.getMessages(); + } + } + if (provider.type === "remote" && provider.url) { + provider.url = provider.url.replace( + /%STARTPAGE_VERSION%/g, + STARTPAGE_VERSION + ); + provider.url = Services.urlFormatter.formatURL(provider.url); + } + if (provider.id === "messaging-experiments") { + // By default, the messaging-experiments provider lacks a featureIds + // property, so fall back to the list of default features. + if (!provider.featureIds) { + provider.featureIds = MESSAGING_EXPERIMENTS_DEFAULT_FEATURES; + } + } + // Reset provider update timestamp to force message refresh + provider.lastUpdated = undefined; + return provider; + }) + ); + + const providerIDs = providers.map(p => p.id); + let invalidProviders = []; + + // Clear old messages for providers that are no longer enabled + for (const prevProvider of previousProviders) { + if (!providerIDs.includes(prevProvider.id)) { + invalidProviders.push(prevProvider.id); + } + } + + return this.setState(prevState => ({ + providers, + // Clear any messages from removed providers + messages: [ + ...prevState.messages.filter(message => + providerIDs.includes(message.provider) + ), + ], + })).then(() => invalidProviders); + } + + get state() { + return this._state; + } + + set state(value) { + throw new Error( + "Do not modify this.state directy. Instead, call this.setState(newState)" + ); + } + + /** + * _resetInitialization - adds the following to the instance: + * .initialized {bool} Has AS Router been initialized? + * .waitForInitialized {Promise} A promise that resolves when initializion is complete + * ._finishInitializing {func} A function that, when called, resolves the .waitForInitialized + * promise and sets .initialized to true. + * @memberof _ASRouter + */ + _resetInitialization() { + this.initialized = false; + this.initializing = false; + this.waitForInitialized = new Promise(resolve => { + this._finishInitializing = () => { + this.initialized = true; + this.initializing = false; + resolve(); + }; + }); + } + + /** + * Check all provided groups are enabled. + * @param groups Set of groups to verify + * @returns bool + */ + hasGroupsEnabled(groups = []) { + return this.state.groups + .filter(({ id }) => groups.includes(id)) + .every(({ enabled }) => enabled); + } + + /** + * Verify that the provider block the message through the `exclude` field + * @param message Message to verify + * @returns bool + */ + isExcludedByProvider(message) { + const provider = this.state.providers.find(p => p.id === message.provider); + if (!provider) { + return true; + } + if (provider.exclude) { + return provider.exclude.includes(message.id); + } + return false; + } + + /** + * Takes a group and sets the correct `enabled` state based on message config + * and user preferences + * + * @param {GroupConfig} group + * @returns {GroupConfig} + */ + _checkGroupEnabled(group) { + return { + ...group, + enabled: + group.enabled && + // And if defined user preferences are true. If multiple prefs are + // defined then at least one has to be enabled. + (Array.isArray(group.userPreferences) + ? group.userPreferences.some(pref => + lazy.ASRouterPreferences.getUserPreference(pref) + ) + : true), + }; + } + + /** + * Fetch all message groups and update Router.state.groups. + * There are two cases to consider: + * 1. The provider needs to update as determined by the update cycle + * 2. Some pref change occured which could invalidate one of the existing + * groups. + */ + async loadAllMessageGroups() { + const provider = this.state.providers.find( + p => + p.id === "message-groups" && MessageLoaderUtils.shouldProviderUpdate(p) + ); + let remoteMessages = null; + if (provider) { + const { messages } = await MessageLoaderUtils._loadDataForProvider( + provider, + { + storage: this._storage, + dispatchCFRAction: this.dispatchCFRAction, + } + ); + remoteMessages = messages; + } + await this.setState(state => ({ + // If fetching remote messages fails we default to existing state.groups. + groups: (remoteMessages || state.groups).map(this._checkGroupEnabled), + })); + } + + /** + * loadMessagesFromAllProviders - Loads messages from all providers if they require updates. + * Checks the .lastUpdated field on each provider to see if updates are needed + * @param toUpdate An optional list of providers to update. This overrides + * the checks to determine which providers to update. + * @memberof _ASRouter + */ + async loadMessagesFromAllProviders(toUpdate = undefined) { + const needsUpdate = Array.isArray(toUpdate) + ? toUpdate + : this.state.providers.filter(provider => + MessageLoaderUtils.shouldProviderUpdate(provider) + ); + lazy.ASRouterPreferences.console.debug( + "entering loadMessagesFromAllProviders" + ); + + await this.loadAllMessageGroups(); + // Don't do extra work if we don't need any updates + if (needsUpdate.length) { + let newState = { messages: [], providers: [] }; + for (const provider of this.state.providers) { + if (needsUpdate.includes(provider)) { + const { messages, lastUpdated, errors } = + await MessageLoaderUtils.loadMessagesForProvider(provider, { + storage: this._storage, + dispatchCFRAction: this.dispatchCFRAction, + }); + newState.providers.push({ ...provider, lastUpdated, errors }); + newState.messages = [...newState.messages, ...messages]; + } else { + // Skip updating this provider's messages if no update is required + let messages = this.state.messages.filter( + msg => msg.provider === provider.id + ); + newState.providers.push(provider); + newState.messages = [...newState.messages, ...messages]; + } + } + + // Some messages have triggers that require us to initalise trigger listeners + const unseenListeners = new Set(lazy.ASRouterTriggerListeners.keys()); + for (const { trigger } of newState.messages) { + if (trigger && lazy.ASRouterTriggerListeners.has(trigger.id)) { + lazy.ASRouterTriggerListeners.get(trigger.id).init( + this._triggerHandler, + trigger.params, + trigger.patterns + ); + unseenListeners.delete(trigger.id); + } + } + // We don't need these listeners, but they may have previously been + // initialised, so uninitialise them + for (const triggerID of unseenListeners) { + lazy.ASRouterTriggerListeners.get(triggerID).uninit(); + } + + await this.setState(newState); + await this.cleanupImpressions(); + } + + await this._fireMessagesLoadedTrigger(); + + return this.state; + } + + async _fireMessagesLoadedTrigger() { + const win = Services.wm.getMostRecentBrowserWindow() ?? null; + const browser = win?.gBrowser?.selectedBrowser ?? null; + // pass skipLoadingMessages to avoid infinite recursion. pass browser and + // window into context so messages that may need a window or browser can + // target accordingly. + await this.sendTriggerMessage( + { + id: "messagesLoaded", + browser, + context: { browser, browserWindow: win }, + }, + true + ); + } + + async _maybeUpdateL10nAttachment() { + const { localeInUse } = this.state.localeInUse; + const newLocale = Services.locale.appLocaleAsBCP47; + if (newLocale !== localeInUse) { + const providers = [...this.state.providers]; + let needsUpdate = false; + providers.forEach(provider => { + if (RS_PROVIDERS_WITH_L10N.includes(provider.id)) { + // Force to refresh the messages as well as the attachment. + provider.lastUpdated = undefined; + needsUpdate = true; + } + }); + if (needsUpdate) { + await this.setState({ + localeInUse: newLocale, + providers, + }); + await this.loadMessagesFromAllProviders(); + } + } + return this.state; + } + + async _onLocaleChanged(subject, topic, data) { + await this._maybeUpdateL10nAttachment(); + } + + observe(aSubject, aTopic, aPrefName) { + switch (aPrefName) { + case USE_REMOTE_L10N_PREF: + CFRPageActions.reloadL10n(); + break; + } + } + + toWaitForInitFunc(func) { + return (...args) => this.waitForInitialized.then(() => func(...args)); + } + + /** + * init - Initializes the MessageRouter. + * + * @param {obj} parameters parameters to initialize ASRouter + * @memberof _ASRouter + */ + async init({ + storage, + sendTelemetry, + clearChildMessages, + clearChildProviders, + updateAdminState, + dispatchCFRAction, + }) { + if (this.initializing || this.initialized) { + return null; + } + this.initializing = true; + this._storage = storage; + this.ALLOWLIST_HOSTS = this._loadAllowHosts(); + this.clearChildMessages = this.toWaitForInitFunc(clearChildMessages); + this.clearChildProviders = this.toWaitForInitFunc(clearChildProviders); + // NOTE: This is only necessary to sync devtools when devtools is active. + this.updateAdminState = this.toWaitForInitFunc(updateAdminState); + this.sendTelemetry = sendTelemetry; + this.dispatchCFRAction = this.toWaitForInitFunc(dispatchCFRAction); + + lazy.ASRouterPreferences.init(); + lazy.ASRouterPreferences.addListener(this.onPrefChange); + lazy.ToolbarBadgeHub.init(this.waitForInitialized, { + handleMessageRequest: this.handleMessageRequest, + addImpression: this.addImpression, + blockMessageById: this.blockMessageById, + unblockMessageById: this.unblockMessageById, + sendTelemetry: this.sendTelemetry, + }); + lazy.ToolbarPanelHub.init(this.waitForInitialized, { + getMessages: this.handleMessageRequest, + sendTelemetry: this.sendTelemetry, + }); + lazy.MomentsPageHub.init(this.waitForInitialized, { + handleMessageRequest: this.handleMessageRequest, + addImpression: this.addImpression, + blockMessageById: this.blockMessageById, + sendTelemetry: this.sendTelemetry, + }); + + this._loadLocalProviders(); + + const messageBlockList = + (await this._storage.get("messageBlockList")) || []; + const messageImpressions = + (await this._storage.get("messageImpressions")) || {}; + const groupImpressions = + (await this._storage.get("groupImpressions")) || {}; + const screenImpressions = + (await this._storage.get("screenImpressions")) || {}; + const previousSessionEnd = + (await this._storage.get("previousSessionEnd")) || 0; + + await this.setState({ + messageBlockList, + groupImpressions, + messageImpressions, + screenImpressions, + previousSessionEnd, + ...(lazy.ASRouterPreferences.specialConditions || {}), + initialized: false, + }); + await this._updateMessageProviders(); + await this.loadMessagesFromAllProviders(); + await MessageLoaderUtils.cleanupCache(this.state.providers, storage); + + lazy.SpecialMessageActions.blockMessageById = this.blockMessageById; + Services.obs.addObserver(this._onLocaleChanged, TOPIC_INTL_LOCALE_CHANGED); + Services.obs.addObserver( + this._onExperimentEnrollmentsUpdated, + TOPIC_EXPERIMENT_ENROLLMENT_CHANGED + ); + Services.prefs.addObserver(USE_REMOTE_L10N_PREF, this); + // sets .initialized to true and resolves .waitForInitialized promise + this._finishInitializing(); + return this.state; + } + + uninit() { + this._storage.set("previousSessionEnd", Date.now()); + + this.clearChildMessages = null; + this.clearChildProviders = null; + this.updateAdminState = null; + this.sendTelemetry = null; + this.dispatchCFRAction = null; + + lazy.ASRouterPreferences.removeListener(this.onPrefChange); + lazy.ASRouterPreferences.uninit(); + lazy.ToolbarPanelHub.uninit(); + lazy.ToolbarBadgeHub.uninit(); + lazy.MomentsPageHub.uninit(); + + // Uninitialise all trigger listeners + for (const listener of lazy.ASRouterTriggerListeners.values()) { + listener.uninit(); + } + Services.obs.removeObserver( + this._onLocaleChanged, + TOPIC_INTL_LOCALE_CHANGED + ); + Services.obs.removeObserver( + this._onExperimentEnrollmentsUpdated, + TOPIC_EXPERIMENT_ENROLLMENT_CHANGED + ); + Services.prefs.removeObserver(USE_REMOTE_L10N_PREF, this); + // If we added any CFR recommendations, they need to be removed + CFRPageActions.clearRecommendations(); + this._resetInitialization(); + } + + setState(callbackOrObj) { + lazy.ASRouterPreferences.console.debug( + "in setState, callbackOrObj = ", + callbackOrObj + ); + lazy.ASRouterPreferences.console.trace(); + const newState = + typeof callbackOrObj === "function" + ? callbackOrObj(this.state) + : callbackOrObj; + this._state = { + ...this.state, + ...newState, + }; + if (lazy.ASRouterPreferences.devtoolsEnabled) { + return this.updateTargetingParameters().then(state => { + this.updateAdminState(state); + return state; + }); + } + return Promise.resolve(this.state); + } + + updateTargetingParameters() { + return this.getTargetingParameters( + lazy.ASRouterTargeting.Environment, + this._getMessagesContext() + ).then(targetingParameters => ({ + ...this.state, + providerPrefs: lazy.ASRouterPreferences.providers, + userPrefs: lazy.ASRouterPreferences.getAllUserPreferences(), + targetingParameters, + errors: this.errors, + devtoolsEnabled: lazy.ASRouterPreferences.devtoolsEnabled, + })); + } + + getMessageById(id) { + return this.state.messages.find(message => message.id === id); + } + + _loadLocalProviders() { + // If we're in ASR debug mode add the local test providers + if (lazy.ASRouterPreferences.devtoolsEnabled) { + this._localProviders = { + ...this._localProviders, + PanelTestProvider: lazy.PanelTestProvider, + }; + } + } + + /** + * Used by ASRouter Admin returns all ASRouterTargeting.Environment + * and ASRouter._getMessagesContext parameters and values + */ + async getTargetingParameters(environment, localContext) { + // Resolve objects that may contain promises. + async function resolve(object) { + if (typeof object === "object" && object !== null) { + if (Array.isArray(object)) { + return Promise.all(object.map(async item => resolve(await item))); + } + + if (object instanceof Date) { + return object; + } + + const target = {}; + const promises = Object.entries(object).map(async ([key, value]) => { + try { + let resolvedValue = await resolve(await value); + return [key, resolvedValue]; + } catch (error) { + lazy.ASRouterPreferences.console.debug( + `getTargetingParameters: Error resolving ${key}: `, + error + ); + throw error; + } + }); + for (const { status, value } of await Promise.allSettled(promises)) { + if (status === "fulfilled") { + const [key, resolvedValue] = value; + target[key] = resolvedValue; + } + } + return target; + } + + return object; + } + + const targetingParameters = { + ...(await resolve(environment)), + ...(await resolve(localContext)), + }; + + return targetingParameters; + } + + _handleTargetingError(error, message) { + console.error(error); + this.dispatchCFRAction( + ac.ASRouterUserEvent({ + message_id: message.id, + action: "asrouter_undesired_event", + event: "TARGETING_EXPRESSION_ERROR", + event_context: {}, + }) + ); + } + + // Return an object containing targeting parameters used to select messages + _getMessagesContext() { + const { messageImpressions, previousSessionEnd, screenImpressions } = + this.state; + + return { + get messageImpressions() { + return messageImpressions; + }, + get previousSessionEnd() { + return previousSessionEnd; + }, + get screenImpressions() { + return screenImpressions; + }, + }; + } + + async evaluateExpression({ expression, context }) { + const targetingContext = new lazy.TargetingContext(context); + let evaluationStatus; + try { + evaluationStatus = { + result: await targetingContext.evalWithDefault(expression), + success: true, + }; + } catch (e) { + evaluationStatus = { result: e.message, success: false }; + } + return Promise.resolve({ evaluationStatus }); + } + + unblockAll() { + return this.setState({ messageBlockList: [] }); + } + + isUnblockedMessage(message) { + const { state } = this; + return ( + !state.messageBlockList.includes(message.id) && + (!message.campaign || + !state.messageBlockList.includes(message.campaign)) && + this.hasGroupsEnabled(message.groups) && + !this.isExcludedByProvider(message) + ); + } + + // Work out if a message can be shown based on its and its provider's frequency caps. + isBelowFrequencyCaps(message) { + const { messageImpressions, groupImpressions } = this.state; + const impressionsForMessage = messageImpressions[message.id]; + + const _belowItemFrequencyCap = this._isBelowItemFrequencyCap( + message, + impressionsForMessage, + MAX_MESSAGE_LIFETIME_CAP + ); + if (!_belowItemFrequencyCap) { + lazy.ASRouterPreferences.console.debug( + `isBelowFrequencyCaps: capped by item: `, + message, + "impressions =", + impressionsForMessage + ); + } + + const _belowGroupFrequencyCaps = message.groups.every(messageGroup => { + const belowThisGroupCap = this._isBelowItemFrequencyCap( + this.state.groups.find(({ id }) => id === messageGroup), + groupImpressions[messageGroup] + ); + + if (!belowThisGroupCap) { + lazy.ASRouterPreferences.console.debug( + `isBelowFrequencyCaps: ${message.id} capped by group ${messageGroup}` + ); + } else { + lazy.ASRouterPreferences.console.debug( + `isBelowFrequencyCaps: ${message.id} allowed by group ${messageGroup}, groupImpressions = `, + groupImpressions + ); + } + + return belowThisGroupCap; + }); + + return _belowItemFrequencyCap && _belowGroupFrequencyCaps; + } + + // Helper for isBelowFrecencyCaps - work out if the frequency cap for the given + // item has been exceeded or not + _isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) { + if (item && item.frequency && impressions && impressions.length) { + if ( + item.frequency.lifetime && + impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap) + ) { + lazy.ASRouterPreferences.console.debug( + `${item.id} capped by lifetime (${item.frequency.lifetime})` + ); + + return false; + } + if (item.frequency.custom) { + const now = Date.now(); + for (const setting of item.frequency.custom) { + let { period } = setting; + const impressionsInPeriod = impressions.filter(t => now - t < period); + if (impressionsInPeriod.length >= setting.cap) { + lazy.ASRouterPreferences.console.debug( + `${item.id} capped by impressions (${impressionsInPeriod.length}) in period (${period}) >= ${setting.cap}` + ); + return false; + } + } + } + } + return true; + } + + async _extraTemplateStrings(originalMessage) { + let extraTemplateStrings; + let localProvider = this._findProvider(originalMessage.provider); + if (localProvider && localProvider.getExtraAttributes) { + extraTemplateStrings = await localProvider.getExtraAttributes(); + } + + return extraTemplateStrings; + } + + _findProvider(providerID) { + return this._localProviders[ + this.state.providers.find(i => i.id === providerID).localProvider + ]; + } + + routeCFRMessage(message, browser, trigger, force = false) { + if (!message) { + return { message: {} }; + } + + // filter out messages we want to exclude from tests + if ( + message.skip_in_tests && + // `this.messagesEnabledInAutomation` should be stubbed in tests + !this.messagesEnabledInAutomation?.includes(message.id) && + (Cu.isInAutomation || + Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") || + Services.env.get("MOZ_AUTOMATION")) + ) { + lazy.log.debug( + `Skipping message ${message.id} because ${message.skip_in_tests}` + ); + return { message: {} }; + } + + switch (message.template) { + case "whatsnew_panel_message": + if (force) { + lazy.ToolbarPanelHub.forceShowMessage(browser, message); + } + break; + case "cfr_doorhanger": + case "milestone_message": + if (force) { + CFRPageActions.forceRecommendation( + browser, + message, + this.dispatchCFRAction + ); + } else { + CFRPageActions.addRecommendation( + browser, + trigger.param && trigger.param.host, + message, + this.dispatchCFRAction + ); + } + break; + case "cfr_urlbar_chiclet": + if (force) { + CFRPageActions.forceRecommendation( + browser, + message, + this.dispatchCFRAction + ); + } else { + CFRPageActions.addRecommendation( + browser, + null, + message, + this.dispatchCFRAction + ); + } + break; + case "toolbar_badge": + lazy.ToolbarBadgeHub.registerBadgeNotificationListener(message, { + force, + }); + break; + case "update_action": + lazy.MomentsPageHub.executeAction(message); + break; + case "infobar": + lazy.InfoBar.showInfoBarMessage( + browser, + message, + this.dispatchCFRAction + ); + break; + case "spotlight": + lazy.Spotlight.showSpotlightDialog( + browser, + message, + this.dispatchCFRAction + ); + break; + case "feature_callout": + // featureCalloutCheck only comes from within FeatureCallout, where it + // is used to request a matching message. It is not a real trigger. + // pdfJsFeatureCalloutCheck is used for PDF.js feature callouts, which + // are managed by the trigger listener itself. + switch (trigger.id) { + case "featureCalloutCheck": + case "pdfJsFeatureCalloutCheck": + case "newtabFeatureCalloutCheck": + break; + default: + lazy.FeatureCalloutBroker.showFeatureCallout(browser, message); + } + break; + case "toast_notification": + lazy.ToastNotification.showToastNotification( + message, + this.dispatchCFRAction + ); + break; + } + + return { message }; + } + + addScreenImpression(screen) { + lazy.ASRouterPreferences.console.debug( + `entering addScreenImpression for ${screen.id}` + ); + + const time = Date.now(); + + let screenImpressions = { ...this.state.screenImpressions }; + screenImpressions[screen.id] = time; + + this.setState({ screenImpressions }); + lazy.ASRouterPreferences.console.debug( + screen.id, + `screen impression added, screenImpressions[screen.id]: `, + screenImpressions[screen.id] + ); + this._storage.set("screenImpressions", screenImpressions); + } + + addImpression(message) { + lazy.ASRouterPreferences.console.debug( + `entering addImpression for ${message.id}` + ); + + const groupsWithFrequency = this.state.groups?.filter( + ({ frequency, id }) => frequency && message.groups?.includes(id) + ); + // We only need to store impressions for messages that have frequency, or + // that have providers that have frequency + if (message.frequency || groupsWithFrequency.length) { + const time = Date.now(); + return this.setState(state => { + const messageImpressions = this._addImpressionForItem( + state.messageImpressions, + message, + "messageImpressions", + time + ); + // Initialize this with state.groupImpressions, and then assign the + // newly-updated copy to it during each iteration so that + // all the changes get captured and either returned or passed into the + // _addImpressionsForItem call on the next iteration. + let { groupImpressions } = state; + for (const group of groupsWithFrequency) { + groupImpressions = this._addImpressionForItem( + groupImpressions, + group, + "groupImpressions", + time + ); + } + + return { messageImpressions, groupImpressions }; + }); + } + return Promise.resolve(); + } + + // Helper for addImpression - calculate the updated impressions object for the given + // item, then store it and return it + _addImpressionForItem(currentImpressions, item, impressionsString, time) { + // The destructuring here is to avoid mutating passed parameters + // (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management) + const impressions = { ...currentImpressions }; + if (item.frequency) { + impressions[item.id] = [...(impressions[item.id] ?? []), time]; + + lazy.ASRouterPreferences.console.debug( + item.id, + "impression added, impressions[item.id]: ", + impressions[item.id] + ); + + this._storage.set(impressionsString, impressions); + } + return impressions; + } + + /** + * getLongestPeriod + * + * @param {obj} item Either an ASRouter message or an ASRouter provider + * @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps. + if the item has no custom frequency caps, null + * @memberof _ASRouter + */ + getLongestPeriod(item) { + if (!item.frequency || !item.frequency.custom) { + return null; + } + return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period; + } + + /** + * cleanupImpressions - this function cleans up obsolete impressions whenever + * messages are refreshed or fetched. It will likely need to be more sophisticated in the future, + * but the current behaviour for when both message impressions and provider impressions are + * cleared is as follows (where `item` is either `message` or `provider`): + * + * 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it + * will be cleared. + * 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older + * than the longest time period will be cleared. + */ + cleanupImpressions() { + return this.setState(state => { + const messageImpressions = this._cleanupImpressionsForItems( + state, + state.messages, + "messageImpressions" + ); + const groupImpressions = this._cleanupImpressionsForItems( + state, + state.groups, + "groupImpressions" + ); + return { messageImpressions, groupImpressions }; + }); + } + + /** _cleanupImpressionsForItems - Helper for cleanupImpressions - calculate the updated + /* impressions object for the given items, then store it and return it + * + * @param {obj} state Reference to ASRouter internal state + * @param {array} items Can be messages, providers or groups that we count impressions for + * @param {string} impressionsString Key name for entry in state where impressions are stored + */ + _cleanupImpressionsForItems(state, items, impressionsString) { + const impressions = { ...state[impressionsString] }; + let needsUpdate = false; + Object.keys(impressions).forEach(id => { + const [item] = items.filter(x => x.id === id); + // Don't keep impressions for items that no longer exist + if (!item || !item.frequency || !Array.isArray(impressions[id])) { + lazy.ASRouterPreferences.console.debug( + "_cleanupImpressionsForItem: removing impressions for deleted or changed item: ", + item + ); + lazy.ASRouterPreferences.console.trace(); + delete impressions[id]; + needsUpdate = true; + return; + } + if (!impressions[id].length) { + return; + } + // If we don't want to store impressions older than the longest period + if (item.frequency.custom && !item.frequency.lifetime) { + lazy.ASRouterPreferences.console.debug( + "_cleanupImpressionsForItem: removing impressions older than longest period for item: ", + item + ); + const now = Date.now(); + impressions[id] = impressions[id].filter( + t => now - t < this.getLongestPeriod(item) + ); + needsUpdate = true; + } + }); + if (needsUpdate) { + this._storage.set(impressionsString, impressions); + } + return impressions; + } + + handleMessageRequest({ + messages: candidates, + triggerId, + triggerParam, + triggerContext, + template, + provider, + ordered = false, + returnAll = false, + }) { + let shouldCache; + lazy.ASRouterPreferences.console.debug( + "in handleMessageRequest, arguments = ", + Array.from(arguments) // eslint-disable-line prefer-rest-params + ); + lazy.ASRouterPreferences.console.trace(); + const messages = + candidates || + this.state.messages.filter(m => { + if (provider && m.provider !== provider) { + lazy.ASRouterPreferences.console.debug(m.id, " filtered by provider"); + return false; + } + if (template && m.template !== template) { + lazy.ASRouterPreferences.console.debug(m.id, " filtered by template"); + return false; + } + if (triggerId && !m.trigger) { + lazy.ASRouterPreferences.console.debug(m.id, " filtered by trigger"); + return false; + } + if (triggerId && m.trigger.id !== triggerId) { + lazy.ASRouterPreferences.console.debug( + m.id, + " filtered by triggerId" + ); + return false; + } + if (!this.isUnblockedMessage(m)) { + lazy.ASRouterPreferences.console.debug( + m.id, + " filtered because blocked" + ); + return false; + } + if (!this.isBelowFrequencyCaps(m)) { + lazy.ASRouterPreferences.console.debug( + m.id, + " filtered because capped" + ); + return false; + } + + if (shouldCache !== false) { + shouldCache = JEXL_PROVIDER_CACHE.has(m.provider); + } + + return true; + }); + + if (!messages.length) { + return returnAll ? messages : null; + } + + const context = this._getMessagesContext(); + + // Find a message that matches the targeting context as well as the trigger context (if one is provided) + // If no trigger is provided, we should find a message WITHOUT a trigger property defined. + return lazy.ASRouterTargeting.findMatchingMessage({ + messages, + trigger: triggerId && { + id: triggerId, + param: triggerParam, + context: triggerContext, + }, + context, + onError: this._handleTargetingError, + ordered, + shouldCache, + returnAll, + }); + } + + setMessageById({ id, ...data }, force, browser) { + return this.routeCFRMessage(this.getMessageById(id), browser, data, force); + } + + blockMessageById(idOrIds) { + lazy.ASRouterPreferences.console.debug( + "blockMessageById called, idOrIds = ", + idOrIds + ); + lazy.ASRouterPreferences.console.trace(); + + const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + + return this.setState(state => { + const messageBlockList = [...state.messageBlockList]; + const messageImpressions = { ...state.messageImpressions }; + + idsToBlock.forEach(id => { + const message = state.messages.find(m => m.id === id); + const idToBlock = message && message.campaign ? message.campaign : id; + if (!messageBlockList.includes(idToBlock)) { + messageBlockList.push(idToBlock); + } + + // When a message is blocked, its impressions should be cleared as well + delete messageImpressions[id]; + }); + + this._storage.set("messageBlockList", messageBlockList); + this._storage.set("messageImpressions", messageImpressions); + return { messageBlockList, messageImpressions }; + }); + } + + unblockMessageById(idOrIds) { + const idsToUnblock = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + + return this.setState(state => { + const messageBlockList = [...state.messageBlockList]; + idsToUnblock + .map(id => state.messages.find(m => m.id === id)) + // Remove all `id`s from the message block list + .forEach(message => { + const idToUnblock = + message && message.campaign ? message.campaign : message.id; + messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1); + }); + + this._storage.set("messageBlockList", messageBlockList); + return { messageBlockList }; + }); + } + + resetGroupsState() { + const newGroupImpressions = {}; + for (let { id } of this.state.groups) { + newGroupImpressions[id] = []; + } + // Update storage + this._storage.set("groupImpressions", newGroupImpressions); + return this.setState(({ groups }) => ({ + groupImpressions: newGroupImpressions, + })); + } + + resetMessageState() { + const newMessageImpressions = {}; + for (let { id } of this.state.messages) { + newMessageImpressions[id] = []; + } + // Update storage + this._storage.set("messageImpressions", newMessageImpressions); + return this.setState(() => ({ + messageImpressions: newMessageImpressions, + })); + } + + resetScreenImpressions() { + const newScreenImpressions = {}; + this._storage.set("screenImpressions", newScreenImpressions); + return this.setState(() => ({ screenImpressions: newScreenImpressions })); + } + + /** + * Edit the ASRouter state directly. For use by the ASRouter devtools. + * Requires browser.newtabpage.activity-stream.asrouter.devtoolsEnabled + * @param {string} key Key of the property to edit, one of: + * | "groupImpressions" + * | "messageImpressions" + * | "screenImpressions" + * | "messageBlockList" + * @param {object|string[]} value New value to set for state[key] + * @returns {Promise<unknown>} The new value in state + */ + async editState(key, value) { + if (!lazy.ASRouterPreferences.devtoolsEnabled) { + throw new Error("Editing state is only allowed in devtools mode"); + } + switch (key) { + case "groupImpressions": + case "messageImpressions": + case "screenImpressions": + if (typeof value !== "object") { + throw new Error("Invalid impression data"); + } + break; + case "messageBlockList": + if (!Array.isArray(value)) { + throw new Error("Invalid message block list"); + } + break; + default: + throw new Error("Invalid state key"); + } + const newState = await this.setState(() => { + this._storage.set(key, value); + return { [key]: value }; + }); + return newState[key]; + } + + _validPreviewEndpoint(url) { + try { + const endpoint = new URL(url); + if (!this.ALLOWLIST_HOSTS[endpoint.host]) { + console.error( + `The preview URL host ${endpoint.host} is not in the list of allowed hosts.` + ); + } + if (endpoint.protocol !== "https:") { + console.error("The URL protocol is not https."); + } + return ( + endpoint.protocol === "https:" && this.ALLOWLIST_HOSTS[endpoint.host] + ); + } catch (e) { + return false; + } + } + + _loadAllowHosts() { + return DEFAULT_ALLOWLIST_HOSTS; + } + + // To be passed to ASRouterTriggerListeners + _triggerHandler(browser, trigger) { + // Disable ASRouterTriggerListeners in kiosk mode. + if (lazy.BrowserHandler.kiosk) { + return Promise.resolve(); + } + return this.sendTriggerMessage({ ...trigger, browser }); + } + + /** Simple wrapper to make test mocking easier + * + * @returns {Promise} resolves when the attribution string has been set + * succesfully. + */ + setAttributionString(attrStr) { + return lazy.MacAttribution.setAttributionString(attrStr); + } + + /** + * forceAttribution - this function should only be called from within about:newtab#asrouter. + * It forces the browser attribution to be set to something specified in asrouter admin + * tools, and reloads the providers in order to get messages that are dependant on this + * attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only + * @param {data} Object an object containing the attribtion data that came from asrouter admin page + */ + async forceAttribution(data) { + // Extract the parameters from data that will make up the referrer url + const attributionData = lazy.AttributionCode.allowedCodeKeys + .map(key => `${key}=${encodeURIComponent(data[key] || "")}`) + .join("&"); + if (AppConstants.platform === "win") { + // The whole attribution data is encoded (again) for windows + await lazy.AttributionCode.writeAttributionFile( + encodeURIComponent(attributionData) + ); + } else if (AppConstants.platform === "macosx") { + await this.setAttributionString(encodeURIComponent(attributionData)); + } + + // Clear cache call is only possible in a testing environment + Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing"); + + // Clear and refresh Attribution, and then fetch the messages again to update + lazy.AttributionCode._clearCache(); + await lazy.AttributionCode.getAttrDataAsync(); + await this._updateMessageProviders(); + return this.loadMessagesFromAllProviders(); + } + + async sendPBNewTabMessage({ tabId, hideDefault }) { + let message = null; + const PromoInfo = { + FOCUS: { enabledPref: "browser.promo.focus.enabled" }, + VPN: { enabledPref: "browser.vpn_promo.enabled" }, + PIN: { enabledPref: "browser.promo.pin.enabled" }, + COOKIE_BANNERS: { enabledPref: "browser.promo.cookiebanners.enabled" }, + }; + await this.loadMessagesFromAllProviders(); + + // If message has hideDefault property set to true + // remove from state all pb_newtab messages with type default + if (hideDefault) { + await this.setState(state => ({ + messages: state.messages.filter( + m => !(m.template === "pb_newtab" && m.type === "default") + ), + })); + } + + // Remove from state pb_newtab messages with PromoType disabled + await this.setState(state => ({ + messages: state.messages.filter( + m => + !( + m.template === "pb_newtab" && + !Services.prefs.getBoolPref( + PromoInfo[m.content?.promoType]?.enabledPref, + true + ) + ) + ), + })); + + const telemetryObject = { tabId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + message = await this.handleMessageRequest({ + template: "pb_newtab", + }); + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + + // Format urls if any are defined + ["infoLinkUrl"].forEach(key => { + if (message?.content?.[key]) { + message.content[key] = Services.urlFormatter.formatURL( + message.content[key] + ); + } + }); + + return { message }; + } + + _recordReachEvent(message) { + const messageGroup = message.forReachEvent.group; + // Events telemetry only accepts understores for the event `object` + const underscored = messageGroup.split("-").join("_"); + const extra = { branches: message.branchSlug }; + Services.telemetry.recordEvent( + REACH_EVENT_CATEGORY, + REACH_EVENT_METHOD, + underscored, + message.experimentSlug, + extra + ); + } + + /** + * Fire a trigger, look for a matching message, and route it to the + * appropriate message handler/messaging surface. + * @param {object} trigger + * @param {string} trigger.id the name of the trigger, e.g. "openURL" + * @param {object} [trigger.param] an object with host, url, type, etc. keys + * whose values are used to match against the message's trigger params + * @param {object} [trigger.context] an object with data about the source of + * the trigger, matched against the message's targeting expression + * @param {MozBrowser} trigger.browser the browser to route messages to + * @param {number} [trigger.tabId] identifier used only for exposure testing + * @param {boolean} [skipLoadingMessages=false] pass true to skip looking for + * new messages. use when calling from loadMessagesFromAllProviders to avoid + * recursion. we call this from loadMessagesFromAllProviders in order to + * fire the messagesLoaded trigger. + * @returns {Promise<object>} + * @resolves {message} an object with the routed message + */ + async sendTriggerMessage( + { tabId, browser, ...trigger }, + skipLoadingMessages = false + ) { + if (!skipLoadingMessages) { + await this.loadMessagesFromAllProviders(); + } + const telemetryObject = { tabId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + // Return all the messages so that it can record the Reach event + const messages = + (await this.handleMessageRequest({ + triggerId: trigger.id, + triggerParam: trigger.param, + triggerContext: trigger.context, + returnAll: true, + })) || []; + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + + // Record the Reach event for all the messages with `forReachEvent`, + // only send the first message without forReachEvent to the target + const nonReachMessages = []; + for (const message of messages) { + if (message.forReachEvent) { + if (!message.forReachEvent.sent) { + this._recordReachEvent(message); + message.forReachEvent.sent = true; + } + } else { + nonReachMessages.push(message); + } + } + + if (nonReachMessages.length) { + let featureId = nonReachMessages[0]._nimbusFeature; + if (featureId) { + lazy.NimbusFeatures[featureId].recordExposureEvent({ once: true }); + } + } + + return this.routeCFRMessage( + nonReachMessages[0] || null, + browser, + trigger, + false + ); + } + + async forceWNPanel(browser) { + let win = browser.ownerGlobal; + await lazy.ToolbarPanelHub.enableToolbarButton(); + + win.PanelUI.showSubView( + "PanelUI-whatsNew", + win.document.getElementById("whats-new-menu-button") + ); + + let panel = win.document.getElementById("customizationui-widget-panel"); + // Set the attribute to keep the panel open + panel.setAttribute("noautohide", true); + } + + async closeWNPanel(browser) { + let win = browser.ownerGlobal; + let panel = win.document.getElementById("customizationui-widget-panel"); + // Set the attribute to allow the panel to close + panel.setAttribute("noautohide", false); + // Removing the button is enough to close the panel. + await lazy.ToolbarPanelHub._hideToolbarButton(win); + } + + async _onExperimentEnrollmentsUpdated() { + const experimentProvider = this.state.providers.find( + p => p.id === "messaging-experiments" + ); + if (!experimentProvider?.enabled) { + return; + } + await this.loadMessagesFromAllProviders([experimentProvider]); + } + + async forcePBWindow(browser, msg) { + const privateBrowserOpener = await new Promise( + ( + resolveOnContentBrowserCreated // wrap this in a promise to give back the right browser + ) => + browser.ownerGlobal.openTrustedLinkIn( + "about:privatebrowsing?debug", + "window", + { + private: true, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal({}), + csp: null, + resolveOnContentBrowserCreated, + opener: "devtools", + } + ) + ); + + lazy.setTimeout(() => { + // setTimeout is necessary to make sure the private browsing window has a chance to open before the message is sent + privateBrowserOpener.browsingContext.currentWindowGlobal + .getActor("AboutPrivateBrowsing") + .sendAsyncMessage("ShowDevToolsMessage", msg); + }, 100); + + return privateBrowserOpener; + } +} + +/** + * ASRouter - singleton instance of _ASRouter that controls all messages + * in the new tab page. + */ +export const ASRouter = new _ASRouter(); diff --git a/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs b/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs new file mode 100644 index 0000000000..e81380b1e2 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs @@ -0,0 +1,64 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { ASRouter } from "resource:///modules/asrouter/ASRouter.sys.mjs"; +import { TelemetryFeed } from "resource://activity-stream/lib/TelemetryFeed.sys.mjs"; +import { ASRouterParentProcessMessageHandler } from "resource:///modules/asrouter/ASRouterParentProcessMessageHandler.sys.mjs"; + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment does not actually rely +// on SpecialMessageActions, and overrides importESModule to be +// a no-op (which can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { SpecialMessageActions } = ChromeUtils.importESModule( + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs" +); + +import { ASRouterPreferences } from "resource:///modules/asrouter/ASRouterPreferences.sys.mjs"; +import { QueryCache } from "resource:///modules/asrouter/ASRouterTargeting.sys.mjs"; +import { ActivityStreamStorage } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"; + +const createStorage = async telemetryFeed => { + // "snippets" is the name of one storage space, but these days it is used + // not for snippet-related data (snippets were removed in bug 1715158), + // but storage for impression or session data for all ASRouter messages. + // + // We keep the name "snippets" to avoid having to do an IndexedDB database + // migration. + const dbStore = new ActivityStreamStorage({ + storeNames: ["sectionPrefs", "snippets"], + telemetry: { + handleUndesiredEvent: e => telemetryFeed.SendASRouterUndesiredEvent(e), + }, + }); + // Accessing the db causes the object stores to be created / migrated. + // This needs to happen before other instances try to access the db, which + // would update only a subset of the stores to the latest version. + try { + await dbStore.db; // eslint-disable-line no-unused-expressions + } catch (e) { + return Promise.reject(e); + } + return dbStore.getDbTable("snippets"); +}; + +export const ASRouterDefaultConfig = () => { + const router = ASRouter; + const telemetry = new TelemetryFeed(); + const messageHandler = new ASRouterParentProcessMessageHandler({ + router, + preferences: ASRouterPreferences, + specialMessageActions: SpecialMessageActions, + queryCache: QueryCache, + sendTelemetry: telemetry.onAction.bind(telemetry), + }); + return { + router, + messageHandler, + createStorage: createStorage.bind(null, telemetry), + }; +}; diff --git a/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs b/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs new file mode 100644 index 0000000000..d0fdbfdae4 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs @@ -0,0 +1,117 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +class ASRouterNewTabHookInstance { + constructor() { + this._newTabMessageHandler = null; + this._parentProcessMessageHandler = null; + this._router = null; + this._clearChildMessages = (...params) => + this._newTabMessageHandler === null + ? Promise.resolve() + : this._newTabMessageHandler.clearChildMessages(...params); + this._clearChildProviders = (...params) => + this._newTabMessageHandler === null + ? Promise.resolve() + : this._newTabMessageHandler.clearChildProviders(...params); + this._updateAdminState = (...params) => + this._newTabMessageHandler === null + ? Promise.resolve() + : this._newTabMessageHandler.updateAdminState(...params); + } + + /** + * Params: + * object - { + * messageHandler: message handler for parent process messages + * { + * handleCFRAction: Responds to CFR action and returns a Promise + * handleTelemetry: Logs telemetry events and returns nothing + * }, + * router: ASRouter instance + * createStorage: function to create DB storage for ASRouter + * } + */ + async initialize({ messageHandler, router, createStorage }) { + this._parentProcessMessageHandler = messageHandler; + this._router = router; + if (!this._router.initialized) { + const storage = await createStorage(); + await this._router.init({ + storage, + sendTelemetry: this._parentProcessMessageHandler.handleTelemetry, + dispatchCFRAction: this._parentProcessMessageHandler.handleCFRAction, + clearChildMessages: this._clearChildMessages, + clearChildProviders: this._clearChildProviders, + updateAdminState: this._updateAdminState, + }); + } + } + + destroy() { + if (this._router?.initialized) { + this.disconnect(); + this._router.uninit(); + } + } + + /** + * Connects new tab message handler to hook. + * Note: Should only ever be called on an initialized instance + * Params: + * newTabMessageHandler - { + * clearChildMessages: clears child messages and returns Promise + * clearChildProviders: clears child providers and returns Promise. + * updateAdminState: updates admin state and returns Promise + * } + * Returns: parentProcessMessageHandler + */ + connect(newTabMessageHandler) { + this._newTabMessageHandler = newTabMessageHandler; + return this._parentProcessMessageHandler; + } + + /** + * Disconnects new tab message handler from hook. + */ + disconnect() { + this._newTabMessageHandler = null; + } +} + +class AwaitSingleton { + constructor() { + this.instance = null; + const initialized = new Promise(resolve => { + this.setInstance = instance => { + this.setInstance = () => {}; + this.instance = instance; + resolve(instance); + }; + }); + this.getInstance = () => initialized; + } +} + +export const ASRouterNewTabHook = (() => { + const singleton = new AwaitSingleton(); + const instance = new ASRouterNewTabHookInstance(); + return { + getInstance: singleton.getInstance, + + /** + * Param: + * params - see ASRouterNewTabHookInstance.init + */ + createInstance: async params => { + await instance.initialize(params); + singleton.setInstance(instance); + }, + + destroy: () => { + instance.destroy(); + }, + }; +})(); diff --git a/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs new file mode 100644 index 0000000000..c2f5fcd884 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs @@ -0,0 +1,171 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { ASRouterPreferences } from "resource:///modules/asrouter/ASRouterPreferences.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "resource:///modules/asrouter/ActorConstants.sys.mjs"; + +export class ASRouterParentProcessMessageHandler { + constructor({ + router, + preferences, + specialMessageActions, + queryCache, + sendTelemetry, + }) { + this._router = router; + this._preferences = preferences; + this._specialMessageActions = specialMessageActions; + this._queryCache = queryCache; + this.handleTelemetry = sendTelemetry; + this.handleMessage = this.handleMessage.bind(this); + this.handleCFRAction = this.handleCFRAction.bind(this); + } + + handleCFRAction({ type, data }, browser) { + switch (type) { + case msg.INFOBAR_TELEMETRY: + case msg.TOOLBAR_BADGE_TELEMETRY: + case msg.TOOLBAR_PANEL_TELEMETRY: + case msg.MOMENTS_PAGE_TELEMETRY: + case msg.DOORHANGER_TELEMETRY: + case msg.SPOTLIGHT_TELEMETRY: + case msg.TOAST_NOTIFICATION_TELEMETRY: { + return this.handleTelemetry({ type, data }); + } + default: { + return this.handleMessage(type, data, { browser }); + } + } + } + + handleMessage(name, data, { id: tabId, browser } = { browser: null }) { + switch (name) { + case msg.AS_ROUTER_TELEMETRY_USER_EVENT: + return this.handleTelemetry({ + type: msg.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); + case msg.BLOCK_MESSAGE_BY_ID: { + ASRouterPreferences.console.debug( + "handleMesssage(): about to block, data = ", + data + ); + ASRouterPreferences.console.trace(); + + // Block the message but don't dismiss it in case the action taken has + // another state that needs to be visible + return this._router + .blockMessageById(data.id) + .then(() => !data.preventDismiss); + } + case msg.USER_ACTION: { + return this._specialMessageActions.handleAction(data, browser); + } + case msg.IMPRESSION: { + return this._router.addImpression(data); + } + case msg.TRIGGER: { + return this._router.sendTriggerMessage({ + ...(data && data.trigger), + tabId, + browser, + }); + } + case msg.PBNEWTAB_MESSAGE_REQUEST: { + return this._router.sendPBNewTabMessage({ + ...data, + tabId, + browser, + }); + } + + // ADMIN Messages + case msg.ADMIN_CONNECT_STATE: { + if (data && data.endpoint) { + return this._router.loadMessagesFromAllProviders(); + } + return this._router.updateTargetingParameters(); + } + case msg.UNBLOCK_MESSAGE_BY_ID: { + return this._router.unblockMessageById(data.id); + } + case msg.UNBLOCK_ALL: { + return this._router.unblockAll(); + } + case msg.BLOCK_BUNDLE: { + return this._router.blockMessageById(data.bundle.map(b => b.id)); + } + case msg.UNBLOCK_BUNDLE: { + return this._router.setState(state => { + const messageBlockList = [...state.messageBlockList]; + for (let message of data.bundle) { + messageBlockList.splice(messageBlockList.indexOf(message.id), 1); + } + this._router._storage.set("messageBlockList", messageBlockList); + return { messageBlockList }; + }); + } + case msg.DISABLE_PROVIDER: { + this._preferences.enableOrDisableProvider(data, false); + return Promise.resolve(); + } + case msg.ENABLE_PROVIDER: { + this._preferences.enableOrDisableProvider(data, true); + return Promise.resolve(); + } + case msg.EVALUATE_JEXL_EXPRESSION: { + return this._router.evaluateExpression(data); + } + case msg.EXPIRE_QUERY_CACHE: { + this._queryCache.expireAll(); + return Promise.resolve(); + } + case msg.FORCE_ATTRIBUTION: { + return this._router.forceAttribution(data); + } + case msg.FORCE_PRIVATE_BROWSING_WINDOW: { + return this._router.forcePBWindow(browser, data.message); + } + case msg.FORCE_WHATSNEW_PANEL: { + return this._router.forceWNPanel(browser); + } + case msg.CLOSE_WHATSNEW_PANEL: { + return this._router.closeWNPanel(browser); + } + case msg.MODIFY_MESSAGE_JSON: { + return this._router.routeCFRMessage(data.content, browser, data, true); + } + case msg.OVERRIDE_MESSAGE: { + return this._router.setMessageById(data, true, browser); + } + case msg.RESET_PROVIDER_PREF: { + this._preferences.resetProviderPref(); + return Promise.resolve(); + } + case msg.SET_PROVIDER_USER_PREF: { + this._preferences.setUserPreference(data.id, data.value); + return Promise.resolve(); + } + case msg.RESET_GROUPS_STATE: { + return this._router + .resetGroupsState(data) + .then(() => this._router.loadMessagesFromAllProviders()); + } + case msg.RESET_MESSAGE_STATE: { + return this._router.resetMessageState(); + } + case msg.RESET_SCREEN_IMPRESSIONS: { + return this._router.resetScreenImpressions(); + } + case msg.EDIT_STATE: { + const [[key, value]] = Object.entries(data); + return this._router.editState(key, value); + } + default: { + return Promise.reject(new Error(`Unknown message received: ${name}`)); + } + } + } +} diff --git a/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs b/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs new file mode 100644 index 0000000000..c7617d80c0 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs @@ -0,0 +1,241 @@ +/* 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/. */ + +const PROVIDER_PREF_BRANCH = + "browser.newtabpage.activity-stream.asrouter.providers."; +const DEVTOOLS_PREF = + "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled"; + +/** + * Use `ASRouterPreferences.console.debug()` and friends from ASRouter files to + * log messages during development. See LOG_LEVELS in ConsoleAPI.jsm for the + * available methods as well as the available values for this pref. + */ +const DEBUG_PREF = "browser.newtabpage.activity-stream.asrouter.debugLogLevel"; + +const FXA_USERNAME_PREF = "services.sync.username"; + +const DEFAULT_STATE = { + _initialized: false, + _providers: null, + _providerPrefBranch: PROVIDER_PREF_BRANCH, + _devtoolsEnabled: null, + _devtoolsPref: DEVTOOLS_PREF, +}; + +const USER_PREFERENCES = { + cfrAddons: "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", + cfrFeatures: + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", +}; + +// Preferences that influence targeting attributes. When these change we need +// to re-evaluate if the message targeting still matches +export const TARGETING_PREFERENCES = [FXA_USERNAME_PREF]; + +export const TEST_PROVIDERS = [ + { + id: "panel_local_testing", + type: "local", + localProvider: "PanelTestProvider", + enabled: true, + }, +]; + +export class _ASRouterPreferences { + constructor() { + Object.assign(this, DEFAULT_STATE); + this._callbacks = new Set(); + + ChromeUtils.defineLazyGetter(this, "console", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + /* eslint-disable mozilla/use-console-createInstance */ + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevel: "error", + maxLogLevelPref: DEBUG_PREF, + prefix: "ASRouter", + }; + return new ConsoleAPI(consoleOptions); + }); + } + + _transformPersonalizedCfrScores(value) { + let result = {}; + try { + result = JSON.parse(value); + } catch (e) { + console.error(e); + } + return result; + } + + _getProviderConfig() { + const prefList = Services.prefs.getChildList(this._providerPrefBranch); + return prefList.reduce((filtered, pref) => { + let value; + try { + value = JSON.parse(Services.prefs.getStringPref(pref, "")); + } catch (e) { + console.error( + `Could not parse ASRouter preference. Try resetting ${pref} in about:config.` + ); + } + if (value) { + filtered.push(value); + } + return filtered; + }, []); + } + + get providers() { + if (!this._initialized || this._providers === null) { + const config = this._getProviderConfig(); + const providers = config.map(provider => Object.freeze(provider)); + if (this.devtoolsEnabled) { + providers.unshift(...TEST_PROVIDERS); + } + this._providers = Object.freeze(providers); + } + + return this._providers; + } + + enableOrDisableProvider(id, value) { + const providers = this._getProviderConfig(); + const config = providers.find(p => p.id === id); + if (!config) { + console.error( + `Cannot set enabled state for '${id}' because the pref ${this._providerPrefBranch}${id} does not exist or is not correctly formatted.` + ); + return; + } + + Services.prefs.setStringPref( + this._providerPrefBranch + id, + JSON.stringify({ ...config, enabled: value }) + ); + } + + resetProviderPref() { + for (const pref of Services.prefs.getChildList(this._providerPrefBranch)) { + Services.prefs.clearUserPref(pref); + } + for (const id of Object.keys(USER_PREFERENCES)) { + Services.prefs.clearUserPref(USER_PREFERENCES[id]); + } + } + + /** + * Bug 1800087 - Migrate the ASRouter message provider prefs' values to the + * current format (provider.bucket -> provider.collection). + * + * TODO (Bug 1800937): Remove migration code after the next watershed release. + */ + _migrateProviderPrefs() { + const prefList = Services.prefs.getChildList(this._providerPrefBranch); + for (const pref of prefList) { + if (!Services.prefs.prefHasUserValue(pref)) { + continue; + } + try { + let value = JSON.parse(Services.prefs.getStringPref(pref, "")); + if (value && "bucket" in value && !("collection" in value)) { + const { bucket, ...rest } = value; + Services.prefs.setStringPref( + pref, + JSON.stringify({ + ...rest, + collection: bucket, + }) + ); + } + } catch (e) { + Services.prefs.clearUserPref(pref); + } + } + } + + get devtoolsEnabled() { + if (!this._initialized || this._devtoolsEnabled === null) { + this._devtoolsEnabled = Services.prefs.getBoolPref( + this._devtoolsPref, + false + ); + } + return this._devtoolsEnabled; + } + + observe(aSubject, aTopic, aPrefName) { + if (aPrefName && aPrefName.startsWith(this._providerPrefBranch)) { + this._providers = null; + } else if (aPrefName === this._devtoolsPref) { + this._providers = null; + this._devtoolsEnabled = null; + } + this._callbacks.forEach(cb => cb(aPrefName)); + } + + getUserPreference(name) { + const prefName = USER_PREFERENCES[name] || name; + return Services.prefs.getBoolPref(prefName, true); + } + + getAllUserPreferences() { + const values = {}; + for (const id of Object.keys(USER_PREFERENCES)) { + values[id] = this.getUserPreference(id); + } + return values; + } + + setUserPreference(providerId, value) { + if (!USER_PREFERENCES[providerId]) { + return; + } + Services.prefs.setBoolPref(USER_PREFERENCES[providerId], value); + } + + addListener(callback) { + this._callbacks.add(callback); + } + + removeListener(callback) { + this._callbacks.delete(callback); + } + + init() { + if (this._initialized) { + return; + } + this._migrateProviderPrefs(); + Services.prefs.addObserver(this._providerPrefBranch, this); + Services.prefs.addObserver(this._devtoolsPref, this); + for (const id of Object.keys(USER_PREFERENCES)) { + Services.prefs.addObserver(USER_PREFERENCES[id], this); + } + for (const targetingPref of TARGETING_PREFERENCES) { + Services.prefs.addObserver(targetingPref, this); + } + this._initialized = true; + } + + uninit() { + if (this._initialized) { + Services.prefs.removeObserver(this._providerPrefBranch, this); + Services.prefs.removeObserver(this._devtoolsPref, this); + for (const id of Object.keys(USER_PREFERENCES)) { + Services.prefs.removeObserver(USER_PREFERENCES[id], this); + } + for (const targetingPref of TARGETING_PREFERENCES) { + Services.prefs.removeObserver(targetingPref, this); + } + } + Object.assign(this, DEFAULT_STATE); + this._callbacks.clear(); + } +} + +export const ASRouterPreferences = new _ASRouterPreferences(); diff --git a/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs new file mode 100644 index 0000000000..a262f8911e --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs @@ -0,0 +1,1308 @@ +/* This Source Code Form is subject to the terms of the Mozilla PublicddonMa + * 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/. */ + +const FXA_ENABLED_PREF = "identity.fxaccounts.enabled"; +const DISTRIBUTION_ID_PREF = "distribution.id"; +const DISTRIBUTION_ID_CHINA_REPACK = "MozillaOnline"; + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils, AppConstants, NewTabUtils and ShellService, and +// overrides importESModule to be a no-op (which can't be done +// for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { NewTabUtils } = ChromeUtils.importESModule( + "resource://gre/modules/NewTabUtils.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { ShellService } = ChromeUtils.importESModule( + "resource:///modules/ShellService.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + ASRouterPreferences: + "resource:///modules/asrouter/ASRouterPreferences.sys.mjs", + AttributionCode: "resource:///modules/AttributionCode.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", + WindowsLaunchOnLogin: "resource://gre/modules/WindowsLaunchOnLogin.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "cfrFeaturesUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "cfrAddonsUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isWhatsNewPanelEnabled", + "browser.messaging-system.whatsNewPanel.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasAccessedFxAPanel", + "identity.fxaccounts.toolbar.accessed", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "clientsDevicesDesktop", + "services.sync.clients.devices.desktop", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "clientsDevicesMobile", + "services.sync.clients.devices.mobile", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "syncNumClients", + "services.sync.numClients", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "devtoolsSelfXSSCount", + "devtools.selfxss.count", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isFxAEnabled", + FXA_ENABLED_PREF, + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isXPIInstallEnabled", + "xpinstall.enabled", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedBookmarks", + "browser.migrate.interactions.bookmarks", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedCSVPasswords", + "browser.migrate.interactions.csvpasswords", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedHistory", + "browser.migrate.interactions.history", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedPasswords", + "browser.migrate.interactions.passwords", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "useEmbeddedMigrationWizard", + "browser.migrate.content-modal.about-welcome-behavior", + "default", + null, + behaviorString => { + return behaviorString === "embedded"; + } +); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + AUS: ["@mozilla.org/updates/update-service;1", "nsIApplicationUpdateService"], + BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"], + TrackingDBService: [ + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService", + ], + UpdateCheckSvc: ["@mozilla.org/updates/update-checker;1", "nsIUpdateChecker"], +}); + +const FXA_USERNAME_PREF = "services.sync.username"; + +const { activityStreamProvider: asProvider } = NewTabUtils; + +const FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL = 4 * 60 * 60 * 1000; // Four hours +const FRECENT_SITES_UPDATE_INTERVAL = 6 * 60 * 60 * 1000; // Six hours +const FRECENT_SITES_IGNORE_BLOCKED = false; +const FRECENT_SITES_NUM_ITEMS = 25; +const FRECENT_SITES_MIN_FRECENCY = 100; + +const CACHE_EXPIRATION = 5 * 60 * 1000; +const jexlEvaluationCache = new Map(); + +/** + * CachedTargetingGetter + * @param property {string} Name of the method + * @param options {any=} Options passed to the method + * @param updateInterval {number?} Update interval for query. Defaults to FRECENT_SITES_UPDATE_INTERVAL + */ +export function CachedTargetingGetter( + property, + options = null, + updateInterval = FRECENT_SITES_UPDATE_INTERVAL, + getter = asProvider +) { + return { + _lastUpdated: 0, + _value: null, + // For testing + expire() { + this._lastUpdated = 0; + this._value = null; + }, + async get() { + const now = Date.now(); + if (now - this._lastUpdated >= updateInterval) { + this._value = await getter[property](options); + this._lastUpdated = now; + } + return this._value; + }, + }; +} + +function CacheListAttachedOAuthClients() { + return { + _lastUpdated: 0, + _value: null, + expire() { + this._lastUpdated = 0; + this._value = null; + }, + get() { + const now = Date.now(); + if (now - this._lastUpdated >= FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL) { + this._value = new Promise(resolve => { + lazy.fxAccounts + .listAttachedOAuthClients() + .then(clients => { + resolve(clients); + }) + .catch(() => resolve([])); + }); + this._lastUpdated = now; + } + return this._value; + }, + }; +} + +function CheckBrowserNeedsUpdate( + updateInterval = FRECENT_SITES_UPDATE_INTERVAL +) { + const checker = { + _lastUpdated: 0, + _value: null, + // For testing. Avoid update check network call. + setUp(value) { + this._lastUpdated = Date.now(); + this._value = value; + }, + expire() { + this._lastUpdated = 0; + this._value = null; + }, + async get() { + const now = Date.now(); + if ( + !AppConstants.MOZ_UPDATER || + now - this._lastUpdated < updateInterval + ) { + return this._value; + } + if (!lazy.AUS.canCheckForUpdates) { + return false; + } + this._lastUpdated = now; + let check = lazy.UpdateCheckSvc.checkForUpdates( + lazy.UpdateCheckSvc.FOREGROUND_CHECK + ); + let result = await check.result; + if (!result.succeeded) { + lazy.ASRouterPreferences.console.error( + "CheckBrowserNeedsUpdate failed :>> ", + result.request + ); + return false; + } + checker._value = !!result.updates.length; + return checker._value; + }, + }; + + return checker; +} + +export const QueryCache = { + expireAll() { + Object.keys(this.queries).forEach(query => { + this.queries[query].expire(); + }); + Object.keys(this.getters).forEach(key => { + this.getters[key].expire(); + }); + }, + queries: { + TopFrecentSites: new CachedTargetingGetter("getTopFrecentSites", { + ignoreBlocked: FRECENT_SITES_IGNORE_BLOCKED, + numItems: FRECENT_SITES_NUM_ITEMS, + topsiteFrecency: FRECENT_SITES_MIN_FRECENCY, + onePerDomain: true, + includeFavicon: false, + }), + TotalBookmarksCount: new CachedTargetingGetter("getTotalBookmarksCount"), + CheckBrowserNeedsUpdate: new CheckBrowserNeedsUpdate(), + RecentBookmarks: new CachedTargetingGetter("getRecentBookmarks"), + ListAttachedOAuthClients: new CacheListAttachedOAuthClients(), + UserMonthlyActivity: new CachedTargetingGetter("getUserMonthlyActivity"), + }, + getters: { + doesAppNeedPin: new CachedTargetingGetter( + "doesAppNeedPin", + null, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + doesAppNeedPrivatePin: new CachedTargetingGetter( + "doesAppNeedPin", + true, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + isDefaultBrowser: new CachedTargetingGetter( + "isDefaultBrowser", + null, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + currentThemes: new CachedTargetingGetter( + "getAddonsByTypes", + ["theme"], + FRECENT_SITES_UPDATE_INTERVAL, + lazy.AddonManager // eslint-disable-line mozilla/valid-lazy + ), + isDefaultHTMLHandler: new CachedTargetingGetter( + "isDefaultHandlerFor", + [".html"], + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + isDefaultPDFHandler: new CachedTargetingGetter( + "isDefaultHandlerFor", + [".pdf"], + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + defaultPDFHandler: new CachedTargetingGetter( + "getDefaultPDFHandler", + null, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + }, +}; + +/** + * sortMessagesByWeightedRank + * + * Each message has an associated weight, which is guaranteed to be strictly + * positive. Sort the messages so that higher weighted messages are more likely + * to come first. + * + * Specifically, sort them so that the probability of message x_1 with weight + * w_1 appearing before message x_2 with weight w_2 is (w_1 / (w_1 + w_2)). + * + * This is equivalent to requiring that x_1 appearing before x_2 is (w_1 / w_2) + * "times" as likely as x_2 appearing before x_1. + * + * See Bug 1484996, Comment 2 for a justification of the method. + * + * @param {Array} messages - A non-empty array of messages to sort, all with + * strictly positive weights + * @returns the sorted array + */ +function sortMessagesByWeightedRank(messages) { + return messages + .map(message => ({ + message, + rank: Math.pow(Math.random(), 1 / message.weight), + })) + .sort((a, b) => b.rank - a.rank) + .map(({ message }) => message); +} + +/** + * getSortedMessages - Given an array of Messages, applies sorting and filtering rules + * in expected order. + * + * @param {Array<Message>} messages + * @param {{}} options + * @param {boolean} options.ordered - Should .order be used instead of random weighted sorting? + * @returns {Array<Message>} + */ +export function getSortedMessages(messages, options = {}) { + let { ordered } = { ordered: false, ...options }; + let result = messages; + + if (!ordered) { + result = sortMessagesByWeightedRank(result); + } + + result.sort((a, b) => { + // Next, sort by priority + if (a.priority > b.priority || (!isNaN(a.priority) && isNaN(b.priority))) { + return -1; + } + if (a.priority < b.priority || (isNaN(a.priority) && !isNaN(b.priority))) { + return 1; + } + + // Sort messages with targeting expressions higher than those with none + if (a.targeting && !b.targeting) { + return -1; + } + if (!a.targeting && b.targeting) { + return 1; + } + + // Next, sort by order *ascending* if ordered = true + if (ordered) { + if (a.order > b.order || (!isNaN(a.order) && isNaN(b.order))) { + return 1; + } + if (a.order < b.order || (isNaN(a.order) && !isNaN(b.order))) { + return -1; + } + } + + return 0; + }); + + return result; +} + +/** + * parseAboutPageURL - Parse a URL string retrieved from about:home and about:new, returns + * its type (web extenstion or custom url) and the parsed url(s) + * + * @param {string} url - A URL string for home page or newtab page + * @returns {Object} { + * isWebExt: boolean, + * isCustomUrl: boolean, + * urls: Array<{url: string, host: string}> + * } + */ +function parseAboutPageURL(url) { + let ret = { + isWebExt: false, + isCustomUrl: false, + urls: [], + }; + if (url.startsWith("moz-extension://")) { + ret.isWebExt = true; + ret.urls.push({ url, host: "" }); + } else { + // The home page URL could be either a single URL or a list of "|" separated URLs. + // Note that it should work with "about:home" and "about:blank", in which case the + // "host" is set as an empty string. + for (const _url of url.split("|")) { + if (!["about:home", "about:newtab", "about:blank"].includes(_url)) { + ret.isCustomUrl = true; + } + try { + const parsedURL = new URL(_url); + const host = parsedURL.hostname.replace(/^www\./i, ""); + ret.urls.push({ url: _url, host }); + } catch (e) {} + } + // If URL parsing failed, just return the given url with an empty host + if (!ret.urls.length) { + ret.urls.push({ url, host: "" }); + } + } + + return ret; +} + +/** + * Get the number of records in autofill storage, e.g. credit cards/addresses. + * + * @param {Object} [data] + * @param {string} [data.collectionName] + * The name used to specify which collection to retrieve records. + * @param {string} [data.searchString] + * The typed string for filtering out the matched records. + * @param {string} [data.info] + * The input autocomplete property's information. + * @returns {Promise<number>} The number of matched records. + * @see FormAutofillParent._getRecords + */ +async function getAutofillRecords(data) { + let actor; + try { + const win = Services.wm.getMostRecentBrowserWindow(); + actor = + win.gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ); + } catch (error) { + // If the actor is not available, we can't get the records. We could import + // the records directly from FormAutofillStorage to avoid the messiness of + // JSActors, but that would import a lot of code for a targeting attribute. + return 0; + } + let records = await actor?.receiveMessage({ + name: "FormAutofill:GetRecords", + data, + }); + return records?.records?.length ?? 0; +} + +// Attribution data can be encoded multiple times so we need this function to +// get a cleartext value. +function decodeAttributionValue(value) { + if (!value) { + return null; + } + + let decodedValue = value; + + while (decodedValue.includes("%")) { + try { + const result = decodeURIComponent(decodedValue); + if (result === decodedValue) { + break; + } + decodedValue = result; + } catch (e) { + break; + } + } + + return decodedValue; +} + +const TargetingGetters = { + get locale() { + return Services.locale.appLocaleAsBCP47; + }, + get localeLanguageCode() { + return ( + Services.locale.appLocaleAsBCP47 && + Services.locale.appLocaleAsBCP47.substr(0, 2) + ); + }, + get browserSettings() { + const { settings } = lazy.TelemetryEnvironment.currentEnvironment; + return { + update: settings.update, + }; + }, + get attributionData() { + // Attribution is determined at startup - so we can use the cached attribution at this point + return lazy.AttributionCode.getCachedAttributionData(); + }, + get currentDate() { + return new Date(); + }, + get profileAgeCreated() { + return lazy.ProfileAge().then(times => times.created); + }, + get profileAgeReset() { + return lazy.ProfileAge().then(times => times.reset); + }, + get usesFirefoxSync() { + return Services.prefs.prefHasUserValue(FXA_USERNAME_PREF); + }, + get isFxAEnabled() { + return lazy.isFxAEnabled; + }, + get isFxASignedIn() { + return new Promise(resolve => { + if (!lazy.isFxAEnabled) { + resolve(false); + } + if (Services.prefs.getStringPref(FXA_USERNAME_PREF, "")) { + resolve(true); + } + lazy.fxAccounts + .getSignedInUser() + .then(data => resolve(!!data)) + .catch(e => resolve(false)); + }); + }, + get sync() { + return { + desktopDevices: lazy.clientsDevicesDesktop, + mobileDevices: lazy.clientsDevicesMobile, + totalDevices: lazy.syncNumClients, + }; + }, + get xpinstallEnabled() { + // This is needed for all add-on recommendations, to know if we allow xpi installs in the first place + return lazy.isXPIInstallEnabled; + }, + get addonsInfo() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + return { addons: {}, isFullData: true }; + } + + return lazy.AddonManager.getActiveAddons(["extension", "service"]).then( + ({ addons, fullData }) => { + const info = {}; + for (const addon of addons) { + info[addon.id] = { + version: addon.version, + type: addon.type, + isSystem: addon.isSystem, + isWebExtension: addon.isWebExtension, + }; + if (fullData) { + Object.assign(info[addon.id], { + name: addon.name, + userDisabled: addon.userDisabled, + installDate: addon.installDate, + }); + } + } + return { addons: info, isFullData: fullData }; + } + ); + }, + get searchEngines() { + const NONE = { installed: [], current: "" }; + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + return Promise.resolve(NONE); + } + return new Promise(resolve => { + // Note: calling init ensures this code is only executed after Search has been initialized + Services.search + .getAppProvidedEngines() + .then(engines => { + resolve({ + current: Services.search.defaultEngine.identifier, + installed: engines.map(engine => engine.identifier), + }); + }) + .catch(() => resolve(NONE)); + }); + }, + get isDefaultBrowser() { + return QueryCache.getters.isDefaultBrowser.get().catch(() => null); + }, + get devToolsOpenedCount() { + return lazy.devtoolsSelfXSSCount; + }, + get topFrecentSites() { + return QueryCache.queries.TopFrecentSites.get().then(sites => + sites.map(site => ({ + url: site.url, + host: new URL(site.url).hostname, + frecency: site.frecency, + lastVisitDate: site.lastVisitDate, + })) + ); + }, + get recentBookmarks() { + return QueryCache.queries.RecentBookmarks.get(); + }, + get pinnedSites() { + return NewTabUtils.pinnedLinks.links.map(site => + site + ? { + url: site.url, + host: new URL(site.url).hostname, + searchTopSite: site.searchTopSite, + } + : {} + ); + }, + get providerCohorts() { + return lazy.ASRouterPreferences.providers.reduce((prev, current) => { + prev[current.id] = current.cohort || ""; + return prev; + }, {}); + }, + get totalBookmarksCount() { + return QueryCache.queries.TotalBookmarksCount.get(); + }, + get firefoxVersion() { + return parseInt(AppConstants.MOZ_APP_VERSION.match(/\d+/), 10); + }, + get region() { + return lazy.Region.home || ""; + }, + get needsUpdate() { + return QueryCache.queries.CheckBrowserNeedsUpdate.get(); + }, + get hasPinnedTabs() { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (win.closed || !win.ownerGlobal.gBrowser) { + continue; + } + if (win.ownerGlobal.gBrowser.visibleTabs.filter(t => t.pinned).length) { + return true; + } + } + + return false; + }, + get hasAccessedFxAPanel() { + return lazy.hasAccessedFxAPanel; + }, + get isWhatsNewPanelEnabled() { + return lazy.isWhatsNewPanelEnabled; + }, + get userPrefs() { + return { + cfrFeatures: lazy.cfrFeaturesUserPref, + cfrAddons: lazy.cfrAddonsUserPref, + }; + }, + get totalBlockedCount() { + return lazy.TrackingDBService.sumAllEvents(); + }, + get blockedCountByType() { + const idToTextMap = new Map([ + [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"], + [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"], + [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"], + [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"], + [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"], + ]); + + const dateTo = new Date(); + const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000); + return lazy.TrackingDBService.getEventsByDateRange(dateFrom, dateTo).then( + eventsByDate => { + let totalEvents = {}; + for (let blockedType of idToTextMap.values()) { + totalEvents[blockedType] = 0; + } + + return eventsByDate.reduce((acc, day) => { + const type = day.getResultByName("type"); + const count = day.getResultByName("count"); + acc[idToTextMap.get(type)] = acc[idToTextMap.get(type)] + count; + return acc; + }, totalEvents); + } + ); + }, + get attachedFxAOAuthClients() { + return this.usesFirefoxSync + ? QueryCache.queries.ListAttachedOAuthClients.get() + : []; + }, + get platformName() { + return AppConstants.platform; + }, + get isChinaRepack() { + return ( + Services.prefs + .getDefaultBranch(null) + .getCharPref(DISTRIBUTION_ID_PREF, "default") === + DISTRIBUTION_ID_CHINA_REPACK + ); + }, + get userId() { + return lazy.ClientEnvironment.userId; + }, + get profileRestartCount() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + return 0; + } + // Counter starts at 1 when a profile is created, substract 1 so the value + // returned matches expectations + return ( + lazy.TelemetrySession.getMetadata("targeting").profileSubsessionCounter - + 1 + ); + }, + get homePageSettings() { + const url = lazy.HomePage.get(); + const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url); + + return { + isWebExt, + isCustomUrl, + urls, + isDefault: lazy.HomePage.isDefault, + isLocked: lazy.HomePage.locked, + }; + }, + get newtabSettings() { + const url = lazy.AboutNewTab.newTabURL; + const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url); + + return { + isWebExt, + isCustomUrl, + isDefault: lazy.AboutNewTab.activityStreamEnabled, + url: urls[0].url, + host: urls[0].host, + }; + }, + get activeNotifications() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + // This might need to hook into the alert service to enumerate relevant + // persistent native notifications. + return false; + } + + let window = lazy.BrowserWindowTracker.getTopWindow(); + + // Technically this doesn't mean we have active notifications, + // but because we use !activeNotifications to check for conflicts, this should return true + if (!window) { + return true; + } + + if ( + window.gURLBar?.view.isOpen || + window.gNotificationBox?.currentNotification || + window.gBrowser.getNotificationBox()?.currentNotification + ) { + return true; + } + + return false; + }, + + get isMajorUpgrade() { + return lazy.BrowserHandler.majorUpgrade; + }, + + get hasActiveEnterprisePolicies() { + return Services.policies.status === Services.policies.ACTIVE; + }, + + get userMonthlyActivity() { + return QueryCache.queries.UserMonthlyActivity.get(); + }, + + get doesAppNeedPin() { + return QueryCache.getters.doesAppNeedPin.get(); + }, + + get doesAppNeedPrivatePin() { + return QueryCache.getters.doesAppNeedPrivatePin.get(); + }, + + get launchOnLoginEnabled() { + if (AppConstants.platform !== "win") { + return false; + } + return lazy.WindowsLaunchOnLogin.getLaunchOnLoginEnabled(); + }, + + /** + * Is this invocation running in background task mode? + * + * @return {boolean} `true` if running in background task mode. + */ + get isBackgroundTaskMode() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + return !!bts?.isBackgroundTaskMode; + }, + + /** + * A non-empty task name if this invocation is running in background + * task mode, or `null` if this invocation is not running in + * background task mode. + * + * @return {string|null} background task name or `null`. + */ + get backgroundTaskName() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + return bts?.backgroundTaskName(); + }, + + get userPrefersReducedMotion() { + let window = Services.appShell.hiddenDOMWindow; + return window?.matchMedia("(prefers-reduced-motion: reduce)")?.matches; + }, + + /** + * Whether or not the user is in the Major Release 2022 holdback study. + */ + get inMr2022Holdback() { + return ( + lazy.NimbusFeatures.majorRelease2022.getVariable("onboarding") === false + ); + }, + + /** + * The distribution id, if any. + * @return {string} + */ + get distributionId() { + return Services.prefs + .getDefaultBranch(null) + .getCharPref("distribution.id", ""); + }, + + /** Where the Firefox View button is shown, if at all. + * @return {string} container of the button if it is shown in the toolbar/overflow menu + * @return {string} `null` if the button has been removed + */ + get fxViewButtonAreaType() { + let button = lazy.CustomizableUI.getWidget("firefox-view-button"); + return button.areaType; + }, + + isDefaultHandler: { + get html() { + return QueryCache.getters.isDefaultHTMLHandler.get(); + }, + get pdf() { + return QueryCache.getters.isDefaultPDFHandler.get(); + }, + }, + + get defaultPDFHandler() { + return QueryCache.getters.defaultPDFHandler.get(); + }, + + get creditCardsSaved() { + return getAutofillRecords({ collectionName: "creditCards" }); + }, + + get addressesSaved() { + return getAutofillRecords({ collectionName: "addresses" }); + }, + + /** + * Has the user ever used the Migration Wizard to migrate bookmarks? + * @return {boolean} `true` if bookmark migration has occurred. + */ + get hasMigratedBookmarks() { + return lazy.hasMigratedBookmarks; + }, + + /** + * Has the user ever used the Migration Wizard to migrate passwords from + * a CSV file? + * @return {boolean} `true` if CSV passwords have been imported via the + * migration wizard. + */ + get hasMigratedCSVPasswords() { + return lazy.hasMigratedCSVPasswords; + }, + + /** + * Has the user ever used the Migration Wizard to migrate history? + * @return {boolean} `true` if history migration has occurred. + */ + get hasMigratedHistory() { + return lazy.hasMigratedHistory; + }, + + /** + * Has the user ever used the Migration Wizard to migrate passwords? + * @return {boolean} `true` if password migration has occurred. + */ + get hasMigratedPasswords() { + return lazy.hasMigratedPasswords; + }, + + /** + * Returns true if the user is configured to use the embedded migration + * wizard in about:welcome by having + * "browser.migrate.content-modal.about-welcome-behavior" be equal to + * "embedded". + * @return {boolean} `true` if the embedded migration wizard is enabled. + */ + get useEmbeddedMigrationWizard() { + return lazy.useEmbeddedMigrationWizard; + }, + + /** + * Whether the user installed Firefox via the RTAMO flow. + * @return {boolean} `true` when RTAMO has been used to download Firefox, + * `false` otherwise. + */ + get isRTAMO() { + const { attributionData } = this; + + return ( + attributionData?.source === "addons.mozilla.org" && + !!decodeAttributionValue(attributionData?.content)?.startsWith("rta:") + ); + }, + + /** + * Whether the user installed via the device migration flow. + * @return {boolean} `true` when the link to download the browser was part + * of guidance for device migration. `false` otherwise. + */ + get isDeviceMigration() { + const { attributionData } = this; + + return attributionData?.campaign === "migration"; + }, + + /** + * The values of the height and width available to the browser to display + * web content. The available height and width are each calculated taking + * into account the presence of menu bars, docks, and other similar OS elements + * @returns {Object} resolution The resolution object containing width and height + * @returns {string} resolution.width The available width of the primary monitor + * @returns {string} resolution.height The available height of the primary monitor + */ + get primaryResolution() { + // Using hidden dom window ensures that we have a window object + // to grab a screen from in certain edge cases such as targeting evaluation + // during first startup before the browser is available, and in MacOS + let window = Services.appShell.hiddenDOMWindow; + return { + width: window?.screen.availWidth, + height: window?.screen.availHeight, + }; + }, + + get archBits() { + let bits = null; + try { + bits = Services.sysinfo.getProperty("archbits", null); + } catch (_e) { + // getProperty can throw if the memsize does not exist + } + if (bits) { + bits = Number(bits); + } + return bits; + }, + + get memoryMB() { + let memory = null; + try { + memory = Services.sysinfo.getProperty("memsize", null); + } catch (_e) { + // getProperty can throw if the memsize does not exist + } + if (memory) { + memory = Number(memory) / 1024 / 1024; + } + return memory; + }, +}; + +export const ASRouterTargeting = { + Environment: TargetingGetters, + + /** + * Snapshot the current targeting environment. + * + * Asynchronous getters are handled. Getters that throw or reject + * are ignored. + * + * Leftward (earlier) targets supercede rightward (later) targets, just like + * `TargetingContext.combineContexts`. + * + * @param {object} options - object containing: + * @param {Array<object>|null} options.targets - + * targeting environments to snapshot; (default: `[ASRouterTargeting.Environment]`) + * @return {object} snapshot of target with `environment` object and `version` integer. + */ + async getEnvironmentSnapshot({ + targets = [ASRouterTargeting.Environment], + } = {}) { + async function resolve(object) { + if (typeof object === "object" && object !== null) { + if (Array.isArray(object)) { + return Promise.all(object.map(async item => resolve(await item))); + } + + if (object instanceof Date) { + return object; + } + + // One promise for each named property. Label promises with property name. + const promises = Object.keys(object).map(async key => { + // Each promise needs to check if we're shutting down when it is evaluated. + if (Services.startup.shuttingDown) { + throw new Error( + "shutting down, so not querying targeting environment" + ); + } + + const value = await resolve(await object[key]); + + return [key, value]; + }); + + const resolved = {}; + for (const result of await Promise.allSettled(promises)) { + // Ignore properties that are rejected. + if (result.status === "fulfilled") { + const [key, value] = result.value; + resolved[key] = value; + } + } + + return resolved; + } + + return object; + } + + // We would like to use `TargetingContext.combineContexts`, but `Proxy` + // instances complicate iterating with `Object.keys`. Instead, merge by + // hand after resolving. + const environment = {}; + for (let target of targets.toReversed()) { + Object.assign(environment, await resolve(target)); + } + + // Should we need to migrate in the future. + const snapshot = { environment, version: 1 }; + + return snapshot; + }, + + isTriggerMatch(trigger = {}, candidateMessageTrigger = {}) { + if (trigger.id !== candidateMessageTrigger.id) { + return false; + } else if ( + !candidateMessageTrigger.params && + !candidateMessageTrigger.patterns + ) { + return true; + } + + if (!trigger.param) { + return false; + } + + return ( + (candidateMessageTrigger.params && + trigger.param.host && + candidateMessageTrigger.params.includes(trigger.param.host)) || + (candidateMessageTrigger.params && + trigger.param.type && + candidateMessageTrigger.params.filter(t => t === trigger.param.type) + .length) || + (candidateMessageTrigger.params && + trigger.param.type && + candidateMessageTrigger.params.filter( + t => (t & trigger.param.type) === t + ).length) || + (candidateMessageTrigger.patterns && + trigger.param.url && + new MatchPatternSet(candidateMessageTrigger.patterns).matches( + trigger.param.url + )) + ); + }, + + /** + * getCachedEvaluation - Return a cached jexl evaluation if available + * + * @param {string} targeting JEXL expression to lookup + * @returns {obj|null} Object with value result or null if not available + */ + getCachedEvaluation(targeting) { + if (jexlEvaluationCache.has(targeting)) { + const { timestamp, value } = jexlEvaluationCache.get(targeting); + if (Date.now() - timestamp <= CACHE_EXPIRATION) { + return { value }; + } + jexlEvaluationCache.delete(targeting); + } + + return null; + }, + + /** + * checkMessageTargeting - Checks is a message's targeting parameters are satisfied + * + * @param {*} message An AS router message + * @param {obj} targetingContext a TargetingContext instance complete with eval environment + * @param {func} onError A function to handle errors (takes two params; error, message) + * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused. + * @returns + */ + async checkMessageTargeting(message, targetingContext, onError, shouldCache) { + lazy.ASRouterPreferences.console.debug( + "in checkMessageTargeting, arguments = ", + Array.from(arguments) // eslint-disable-line prefer-rest-params + ); + + // If no targeting is specified, + if (!message.targeting) { + return true; + } + let result; + try { + if (shouldCache) { + result = this.getCachedEvaluation(message.targeting); + if (result) { + return result.value; + } + } + // Used to report the source of the targeting error in the case of + // undesired events + targetingContext.setTelemetrySource(message.id); + result = await targetingContext.evalWithDefault(message.targeting); + if (shouldCache) { + jexlEvaluationCache.set(message.targeting, { + timestamp: Date.now(), + value: result, + }); + } + } catch (error) { + if (onError) { + onError(error, message); + } + console.error(error); + result = false; + } + return result; + }, + + _isMessageMatch( + message, + trigger, + targetingContext, + onError, + shouldCache = false + ) { + return ( + message && + (trigger + ? this.isTriggerMatch(trigger, message.trigger) + : !message.trigger) && + // If a trigger expression was passed to this function, the message should match it. + // Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time) + this.checkMessageTargeting( + message, + targetingContext, + onError, + shouldCache + ) + ); + }, + + /** + * findMatchingMessage - Given an array of messages, returns one message + * whos targeting expression evaluates to true + * + * @param {Array<Message>} messages An array of AS router messages + * @param {trigger} string A trigger expression if a message for that trigger is desired + * @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above. + * @param {func} onError A function to handle errors (takes two params; error, message) + * @param {func} ordered An optional param when true sort message by order specified in message + * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused. + * @param {boolean} returnAll Should we return all matching messages, not just the first one found. + * @returns {obj|Array<Message>} If returnAll is false, a single message. If returnAll is true, an array of messages. + */ + async findMatchingMessage({ + messages, + trigger = {}, + context = {}, + onError, + ordered = false, + shouldCache = false, + returnAll = false, + }) { + const sortedMessages = getSortedMessages(messages, { ordered }); + lazy.ASRouterPreferences.console.debug( + "in findMatchingMessage, sortedMessages = ", + sortedMessages + ); + const matching = returnAll ? [] : null; + const targetingContext = new lazy.TargetingContext( + lazy.TargetingContext.combineContexts( + context, + this.Environment, + trigger.context || {} + ) + ); + + const isMatch = candidate => + this._isMessageMatch( + candidate, + trigger, + targetingContext, + onError, + shouldCache + ); + + for (const candidate of sortedMessages) { + if (await isMatch(candidate)) { + // If not returnAll, we should return the first message we find that matches. + if (!returnAll) { + return candidate; + } + + matching.push(candidate); + } + } + return matching; + }, +}; diff --git a/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs b/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs new file mode 100644 index 0000000000..d8eaa3994d --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs @@ -0,0 +1,1439 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs", + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + FeatureCalloutBroker: + "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("ASRouterTriggerListeners"); +}); + +const FEW_MINUTES = 15 * 60 * 1000; // 15 mins + +function isPrivateWindow(win) { + return ( + !(win instanceof Ci.nsIDOMWindow) || + win.closed || + lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ); +} + +/** + * Check current location against the list of allowed hosts + * Additionally verify for redirects and check original request URL against + * the list. + * + * @returns {object} - {host, url} pair that matched the list of allowed hosts + */ +function checkURLMatch(aLocationURI, { hosts, matchPatternSet }, aRequest) { + // If checks pass we return a match + let match; + try { + match = { host: aLocationURI.host, url: aLocationURI.spec }; + } catch (e) { + // nsIURI.host can throw for non-nsStandardURL nsIURIs + return false; + } + + // Check current location against allowed hosts + if (hosts.has(match.host)) { + return match; + } + + if (matchPatternSet) { + if (matchPatternSet.matches(match.url)) { + return match; + } + } + + // Nothing else to check, return early + if (!aRequest) { + return false; + } + + // The original URL at the start of the request + const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI; + // We have been redirected + if (originalLocation.spec !== aLocationURI.spec) { + return ( + hosts.has(originalLocation.host) && { + host: originalLocation.host, + url: originalLocation.spec, + } + ); + } + + return false; +} + +function createMatchPatternSet(patterns, flags) { + try { + return new MatchPatternSet(new Set(patterns), flags); + } catch (e) { + console.error(e); + } + return new MatchPatternSet([]); +} + +/** + * A Map from trigger IDs to singleton trigger listeners. Each listener must + * have idempotent `init` and `uninit` methods. + */ +export const ASRouterTriggerListeners = new Map([ + [ + "openArticleURL", + { + id: "openArticleURL", + _initialized: false, + _triggerHandler: null, + _hosts: new Set(), + _matchPatternSet: null, + readerModeEvent: "Reader:UpdateReaderButton", + + init(triggerHandler, hosts, patterns) { + if (!this._initialized) { + this.receiveMessage = this.receiveMessage.bind(this); + lazy.AboutReaderParent.addMessageListener(this.readerModeEvent, this); + this._triggerHandler = triggerHandler; + this._initialized = true; + } + if (patterns) { + this._matchPatternSet = createMatchPatternSet([ + ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), + ...patterns, + ]); + } + if (hosts) { + hosts.forEach(h => this._hosts.add(h)); + } + }, + + receiveMessage({ data, target }) { + if (data && data.isArticle) { + const match = checkURLMatch(target.currentURI, { + hosts: this._hosts, + matchPatternSet: this._matchPatternSet, + }); + if (match) { + this._triggerHandler(target, { id: this.id, param: match }); + } + } + }, + + uninit() { + if (this._initialized) { + lazy.AboutReaderParent.removeMessageListener( + this.readerModeEvent, + this + ); + this._initialized = false; + this._triggerHandler = null; + this._hosts = new Set(); + this._matchPatternSet = null; + } + }, + }, + ], + [ + "openBookmarkedURL", + { + id: "openBookmarkedURL", + _initialized: false, + _triggerHandler: null, + _hosts: new Set(), + bookmarkEvent: "bookmark-icon-updated", + + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, this.bookmarkEvent); + this._triggerHandler = triggerHandler; + this._initialized = true; + } + }, + + observe(subject, topic, data) { + if (topic === this.bookmarkEvent && data === "starred") { + const browser = Services.wm.getMostRecentBrowserWindow(); + if (browser) { + this._triggerHandler(browser.gBrowser.selectedBrowser, { + id: this.id, + }); + } + } + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver(this, this.bookmarkEvent); + this._initialized = false; + this._triggerHandler = null; + this._hosts = new Set(); + } + }, + }, + ], + [ + "frequentVisits", + { + id: "frequentVisits", + _initialized: false, + _triggerHandler: null, + _hosts: null, + _matchPatternSet: null, + _visits: null, + + init(triggerHandler, hosts = [], patterns) { + if (!this._initialized) { + this.onTabSwitch = this.onTabSwitch.bind(this); + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (!isPrivateWindow(win)) { + win.addEventListener("TabSelect", this.onTabSwitch); + win.gBrowser.addTabsProgressListener(this); + } + }, + win => { + if (!isPrivateWindow(win)) { + win.removeEventListener("TabSelect", this.onTabSwitch); + win.gBrowser.removeTabsProgressListener(this); + } + } + ); + this._visits = new Map(); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + if (patterns) { + this._matchPatternSet = createMatchPatternSet([ + ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), + ...patterns, + ]); + } + if (this._hosts) { + hosts.forEach(h => this._hosts.add(h)); + } else { + this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour + } + }, + + /* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only + * if it's been more than FEW_MINUTES since the last visit. + * @param {string} host - Location host of current selected tab + * @returns {boolean} - If the new visit has been recorded + */ + _updateVisits(host) { + const visits = this._visits.get(host); + + if (visits && Date.now() - visits[0] > FEW_MINUTES) { + this._visits.set(host, [Date.now(), ...visits]); + return true; + } + if (!visits) { + this._visits.set(host, [Date.now()]); + return true; + } + + return false; + }, + + onTabSwitch(event) { + if (!event.target.ownerGlobal.gBrowser) { + return; + } + + const { gBrowser } = event.target.ownerGlobal; + const match = checkURLMatch(gBrowser.currentURI, { + hosts: this._hosts, + matchPatternSet: this._matchPatternSet, + }); + if (match) { + this.triggerHandler(gBrowser.selectedBrowser, match); + } + }, + + triggerHandler(aBrowser, match) { + const updated = this._updateVisits(match.host); + + // If the previous visit happend less than FEW_MINUTES ago + // no updates were made, no need to trigger the handler + if (!updated) { + return; + } + + this._triggerHandler(aBrowser, { + id: this.id, + param: match, + context: { + // Remapped to {host, timestamp} because JEXL operators can only + // filter over collections (arrays of objects) + recentVisits: this._visits + .get(match.host) + .map(timestamp => ({ host: match.host, timestamp })), + }, + }); + }, + + onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) { + // Some websites trigger redirect events after they finish loading even + // though the location remains the same. This results in onLocationChange + // events to be fired twice. + const isSameDocument = !!( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + if (aWebProgress.isTopLevel && !isSameDocument) { + const match = checkURLMatch( + aLocationURI, + { hosts: this._hosts, matchPatternSet: this._matchPatternSet }, + aRequest + ); + if (match) { + this.triggerHandler(aBrowser, match); + } + } + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + + this._initialized = false; + this._triggerHandler = null; + this._hosts = null; + this._matchPatternSet = null; + this._visits = null; + } + }, + }, + ], + + /** + * Attach listeners to every browser window to detect location changes, and + * notify the trigger handler whenever we navigate to a URL with a hostname + * we're looking for. + */ + [ + "openURL", + { + id: "openURL", + _initialized: false, + _triggerHandler: null, + _hosts: null, + _matchPatternSet: null, + _visits: null, + + /* + * If the listener is already initialised, `init` will replace the trigger + * handler and add any new hosts to `this._hosts`. + */ + init(triggerHandler, hosts = [], patterns) { + if (!this._initialized) { + this.onLocationChange = this.onLocationChange.bind(this); + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.addTabsProgressListener(this); + } + }, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.removeTabsProgressListener(this); + } + } + ); + + this._visits = new Map(); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + if (patterns) { + this._matchPatternSet = createMatchPatternSet([ + ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), + ...patterns, + ]); + } + if (this._hosts) { + hosts.forEach(h => this._hosts.add(h)); + } else { + this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour + } + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + + this._initialized = false; + this._triggerHandler = null; + this._hosts = null; + this._matchPatternSet = null; + this._visits = null; + } + }, + + onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) { + // Some websites trigger redirect events after they finish loading even + // though the location remains the same. This results in onLocationChange + // events to be fired twice. + const isSameDocument = !!( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + if (aWebProgress.isTopLevel && !isSameDocument) { + const match = checkURLMatch( + aLocationURI, + { hosts: this._hosts, matchPatternSet: this._matchPatternSet }, + aRequest + ); + if (match) { + let visitsCount = (this._visits.get(match.url) || 0) + 1; + this._visits.set(match.url, visitsCount); + this._triggerHandler(aBrowser, { + id: this.id, + param: match, + context: { visitsCount }, + }); + } + } + }, + }, + ], + + /** + * Add an observer notification to notify the trigger handler whenever the user + * saves or updates a login via the login capture doorhanger. + */ + [ + "newSavedLogin", + { + _initialized: false, + _triggerHandler: null, + + /** + * If the listener is already initialised, `init` will replace the trigger + * handler. + */ + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, "LoginStats:NewSavedPassword"); + Services.obs.addObserver(this, "LoginStats:LoginUpdateSaved"); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver(this, "LoginStats:NewSavedPassword"); + Services.obs.removeObserver(this, "LoginStats:LoginUpdateSaved"); + + this._initialized = false; + this._triggerHandler = null; + } + }, + + observe(aSubject, aTopic, aData) { + if (aSubject.currentURI.asciiHost === "accounts.firefox.com") { + // Don't notify about saved logins on the FxA login origin since this + // trigger is used to promote login Sync and getting a recommendation + // to enable Sync during the sign up process is a bad UX. + return; + } + + switch (aTopic) { + case "LoginStats:NewSavedPassword": { + this._triggerHandler(aSubject, { + id: "newSavedLogin", + context: { type: "save" }, + }); + break; + } + case "LoginStats:LoginUpdateSaved": { + this._triggerHandler(aSubject, { + id: "newSavedLogin", + context: { type: "update" }, + }); + break; + } + default: { + throw new Error(`Unexpected observer notification: ${aTopic}`); + } + } + }, + }, + ], + [ + "formAutofill", + { + id: "formAutofill", + _initialized: false, + _triggerHandler: null, + _triggerDelay: 10000, // 10 second delay before triggering + _topic: "formautofill-storage-changed", + _events: ["add", "update", "notifyUsed"] /** @see AutofillRecords */, + _collections: ["addresses", "creditCards"] /** @see AutofillRecords */, + + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, this._topic); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver(this, this._topic); + this._initialized = false; + this._triggerHandler = null; + } + }, + + observe(subject, topic, data) { + const browser = + Services.wm.getMostRecentBrowserWindow()?.gBrowser.selectedBrowser; + if ( + !browser || + topic !== this._topic || + !subject.wrappedJSObject || + // Ignore changes caused by manual edits in the credit card/address + // managers in about:preferences. + browser.contentWindow?.gSubDialog?.dialogs.length + ) { + return; + } + let { sourceSync, collectionName } = subject.wrappedJSObject; + // Ignore changes from sync and changes to untracked collections. + if (sourceSync || !this._collections.includes(collectionName)) { + return; + } + if (this._events.includes(data)) { + let event = data; + let type = collectionName; + if (event === "notifyUsed") { + event = "use"; + } + if (type === "creditCards") { + type = "card"; + } + if (type === "addresses") { + type = "address"; + } + lazy.setTimeout(() => { + if ( + this._initialized && + // Make sure the browser still exists and is still selected. + browser.isConnectedAndReady && + browser === + Services.wm.getMostRecentBrowserWindow()?.gBrowser + .selectedBrowser + ) { + this._triggerHandler(browser, { + id: this.id, + context: { event, type }, + }); + } + }, this._triggerDelay); + } + }, + }, + ], + + [ + "contentBlocking", + { + _initialized: false, + _triggerHandler: null, + _events: [], + _sessionPageLoad: 0, + onLocationChange: null, + + init(triggerHandler, params, patterns) { + params.forEach(p => this._events.push(p)); + + if (!this._initialized) { + Services.obs.addObserver(this, "SiteProtection:ContentBlockingEvent"); + Services.obs.addObserver( + this, + "SiteProtection:ContentBlockingMilestone" + ); + this.onLocationChange = this._onLocationChange.bind(this); + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.addTabsProgressListener(this); + } + }, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.removeTabsProgressListener(this); + } + } + ); + + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver( + this, + "SiteProtection:ContentBlockingEvent" + ); + Services.obs.removeObserver( + this, + "SiteProtection:ContentBlockingMilestone" + ); + lazy.EveryWindow.unregisterCallback(this.id); + this.onLocationChange = null; + this._initialized = false; + } + this._triggerHandler = null; + this._events = []; + this._sessionPageLoad = 0; + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "SiteProtection:ContentBlockingEvent": + const { browser, host, event } = aSubject.wrappedJSObject; + if (this._events.filter(e => (e & event) === e).length) { + this._triggerHandler(browser, { + id: "contentBlocking", + param: { + host, + type: event, + }, + context: { + pageLoad: this._sessionPageLoad, + }, + }); + } + break; + case "SiteProtection:ContentBlockingMilestone": + if (this._events.includes(aSubject.wrappedJSObject.event)) { + this._triggerHandler( + Services.wm.getMostRecentBrowserWindow().gBrowser + .selectedBrowser, + { + id: "contentBlocking", + context: { + pageLoad: this._sessionPageLoad, + }, + param: { + type: aSubject.wrappedJSObject.event, + }, + } + ); + } + break; + } + }, + + _onLocationChange( + aBrowser, + aWebProgress, + aRequest, + aLocationURI, + aFlags + ) { + // Some websites trigger redirect events after they finish loading even + // though the location remains the same. This results in onLocationChange + // events to be fired twice. + const isSameDocument = !!( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + if ( + ["http", "https"].includes(aLocationURI.scheme) && + aWebProgress.isTopLevel && + !isSameDocument + ) { + this._sessionPageLoad += 1; + } + }, + }, + ], + + [ + "captivePortalLogin", + { + id: "captivePortalLogin", + _initialized: false, + _triggerHandler: null, + + _shouldShowCaptivePortalVPNPromo() { + return lazy.BrowserUtils.shouldShowVPNPromo(); + }, + + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, "captive-portal-login-success"); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "captive-portal-login-success": + const browser = Services.wm.getMostRecentBrowserWindow(); + // The check is here rather than in init because some + // folks leave their browsers running for a long time, + // eg from before leaving on a plane trip to after landing + // in the new destination, and the current region may have + // changed since init time. + if (browser && this._shouldShowCaptivePortalVPNPromo()) { + this._triggerHandler(browser.gBrowser.selectedBrowser, { + id: this.id, + }); + } + break; + } + }, + + uninit() { + if (this._initialized) { + this._triggerHandler = null; + this._initialized = false; + Services.obs.removeObserver(this, "captive-portal-login-success"); + } + }, + }, + ], + + [ + "preferenceObserver", + { + id: "preferenceObserver", + _initialized: false, + _triggerHandler: null, + _observedPrefs: [], + + init(triggerHandler, prefs) { + if (!this._initialized) { + this._triggerHandler = triggerHandler; + this._initialized = true; + } + prefs.forEach(pref => { + this._observedPrefs.push(pref); + Services.prefs.addObserver(pref, this); + }); + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "nsPref:changed": + const browser = Services.wm.getMostRecentBrowserWindow(); + if (browser && this._observedPrefs.includes(aData)) { + this._triggerHandler(browser.gBrowser.selectedBrowser, { + id: this.id, + param: { + type: aData, + }, + }); + } + break; + } + }, + + uninit() { + if (this._initialized) { + this._observedPrefs.forEach(pref => + Services.prefs.removeObserver(pref, this) + ); + this._initialized = false; + this._triggerHandler = null; + this._observedPrefs = []; + } + }, + }, + ], + [ + "nthTabClosed", + { + id: "nthTabClosed", + _initialized: false, + _triggerHandler: null, + // Number of tabs the user closed this session + _closedTabs: 0, + + init(triggerHandler) { + this._triggerHandler = triggerHandler; + if (!this._initialized) { + lazy.EveryWindow.registerCallback( + this.id, + win => { + win.addEventListener("TabClose", this); + }, + win => { + win.removeEventListener("TabClose", this); + } + ); + this._initialized = true; + } + }, + handleEvent(event) { + if (this._initialized) { + if (!event.target.ownerGlobal.gBrowser) { + return; + } + const { gBrowser } = event.target.ownerGlobal; + this._closedTabs++; + this._triggerHandler(gBrowser.selectedBrowser, { + id: this.id, + context: { tabsClosedCount: this._closedTabs }, + }); + } + }, + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + this._closedTabs = 0; + } + }, + }, + ], + [ + "activityAfterIdle", + { + id: "activityAfterIdle", + _initialized: false, + _triggerHandler: null, + _idleService: null, + // Optimization - only report idle state after one minute of idle time. + // This represents a minimum idleForMilliseconds of 60000. + _idleThreshold: 60, + _idleSince: null, + _quietSince: null, + _awaitingVisibilityChange: false, + // Fire the trigger 2 seconds after activity resumes to ensure user is + // actively using the browser when it fires. + _triggerDelay: 2000, + _triggerTimeout: null, + // We may get an idle notification immediately after waking from sleep. + // The idle time in such a case will be the amount of time since the last + // user interaction, which was before the computer went to sleep. We want + // to ignore them in that case, so we ignore idle notifications that + // happen within 1 second of the last wake notification. + _wakeDelay: 1000, + _lastWakeTime: null, + _listenedEvents: ["visibilitychange", "TabClose", "TabAttrModified"], + // When the OS goes to sleep or the process is suspended, we want to drop + // the idle time, since the time between sleep and wake is expected to be + // very long (e.g. overnight). Otherwise, this would trigger on the first + // activity after waking/resuming, counting sleep as idle time. This + // basically means each session starts with a fresh idle time. + _observedTopics: [ + "sleep_notification", + "suspend_process_notification", + "wake_notification", + "resume_process_notification", + "mac_app_activate", + ], + + get _isVisible() { + return [...Services.wm.getEnumerator("navigator:browser")].some( + win => !win.closed && !win.document?.hidden + ); + }, + get _soundPlaying() { + return [...Services.wm.getEnumerator("navigator:browser")].some(win => + win.gBrowser?.tabs.some(tab => !tab.closing && tab.soundPlaying) + ); + }, + init(triggerHandler) { + this._triggerHandler = triggerHandler; + // Instantiate this here instead of with a lazy service getter so we can + // stub it in tests (otherwise we'd have to wait up to 6 minutes for an + // idle notification in certain test environments). + if (!this._idleService) { + this._idleService = Cc[ + "@mozilla.org/widget/useridleservice;1" + ].getService(Ci.nsIUserIdleService); + } + if ( + !this._initialized && + !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + this._idleService.addIdleObserver(this, this._idleThreshold); + for (let topic of this._observedTopics) { + Services.obs.addObserver(this, topic); + } + lazy.EveryWindow.registerCallback( + this.id, + win => { + for (let ev of this._listenedEvents) { + win.addEventListener(ev, this); + } + }, + win => { + for (let ev of this._listenedEvents) { + win.removeEventListener(ev, this); + } + } + ); + if (!this._soundPlaying) { + this._quietSince = Date.now(); + } + this._initialized = true; + this.log("Initialized: ", { + idleTime: this._idleService.idleTime, + quietSince: this._quietSince, + }); + } + }, + observe(subject, topic, data) { + if (this._initialized) { + this.log("Heard observer notification: ", { + subject, + topic, + data, + idleTime: this._idleService.idleTime, + idleSince: this._idleSince, + quietSince: this._quietSince, + lastWakeTime: this._lastWakeTime, + }); + switch (topic) { + case "idle": + const now = Date.now(); + // If the idle notification is within 1 second of the last wake + // notification, ignore it. We do this to avoid counting time the + // computer spent asleep as "idle time" + const isImmediatelyAfterWake = + this._lastWakeTime && + now - this._lastWakeTime < this._wakeDelay; + if (!isImmediatelyAfterWake) { + this._idleSince = now - subject.idleTime; + } + break; + case "active": + // Trigger when user returns from being idle. + if (this._isVisible) { + this._onActive(); + this._idleSince = null; + this._lastWakeTime = null; + } else if (this._idleSince) { + // If the window is not visible, we want to wait until it is + // visible before triggering. + this._awaitingVisibilityChange = true; + } + break; + // OS/process notifications + case "wake_notification": + case "resume_process_notification": + case "mac_app_activate": + this._lastWakeTime = Date.now(); + // Fall through to reset idle time. + default: + this._idleSince = null; + } + } + }, + handleEvent(event) { + if (this._initialized) { + switch (event.type) { + case "visibilitychange": + if (this._awaitingVisibilityChange && this._isVisible) { + this._onActive(); + this._idleSince = null; + this._lastWakeTime = null; + this._awaitingVisibilityChange = false; + } + break; + case "TabAttrModified": + // Listen for DOMAudioPlayback* events. + if (!event.detail?.changed?.includes("soundplaying")) { + break; + } + // fall through + case "TabClose": + this.log("Tab sound changed: ", { + event, + idleTime: this._idleService.idleTime, + idleSince: this._idleSince, + quietSince: this._quietSince, + }); + // Maybe update time if a tab closes with sound playing. + if (this._soundPlaying) { + this._quietSince = null; + } else if (!this._quietSince) { + this._quietSince = Date.now(); + } + } + } + }, + _onActive() { + this.log("User is active: ", { + idleTime: this._idleService.idleTime, + idleSince: this._idleSince, + quietSince: this._quietSince, + lastWakeTime: this._lastWakeTime, + }); + if (this._idleSince && this._quietSince) { + const win = Services.wm.getMostRecentBrowserWindow(); + if (win && !isPrivateWindow(win) && !this._triggerTimeout) { + // Time since the most recent user interaction/audio playback, + // reported as the number of milliseconds the user has been idle. + const idleForMilliseconds = + Date.now() - Math.max(this._idleSince, this._quietSince); + this._triggerTimeout = lazy.setTimeout(() => { + this._triggerHandler(win.gBrowser.selectedBrowser, { + id: this.id, + context: { idleForMilliseconds }, + }); + this._triggerTimeout = null; + }, this._triggerDelay); + } + } + }, + uninit() { + if (this._initialized) { + this._idleService.removeIdleObserver(this, this._idleThreshold); + for (let topic of this._observedTopics) { + Services.obs.removeObserver(this, topic); + } + lazy.EveryWindow.unregisterCallback(this.id); + lazy.clearTimeout(this._triggerTimeout); + this._triggerTimeout = null; + this._initialized = false; + this._triggerHandler = null; + this._idleSince = null; + this._quietSince = null; + this._lastWakeTime = null; + this._awaitingVisibilityChange = false; + this.log("Uninitialized"); + } + }, + log(...args) { + lazy.log.debug("Idle trigger :>>", ...args); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }, + ], + [ + "cookieBannerDetected", + { + id: "cookieBannerDetected", + _initialized: false, + _triggerHandler: null, + + init(triggerHandler) { + this._triggerHandler = triggerHandler; + if (!this._initialized) { + lazy.EveryWindow.registerCallback( + this.id, + win => { + win.addEventListener("cookiebannerdetected", this); + }, + win => { + win.removeEventListener("cookiebannerdetected", this); + } + ); + this._initialized = true; + } + }, + handleEvent(event) { + if (this._initialized) { + const win = event.target || Services.wm.getMostRecentBrowserWindow(); + if (!win) { + return; + } + this._triggerHandler(win.gBrowser.selectedBrowser, { + id: this.id, + }); + } + }, + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + } + }, + }, + ], + [ + "cookieBannerHandled", + { + id: "cookieBannerHandled", + _initialized: false, + _triggerHandler: null, + + init(triggerHandler) { + this._triggerHandler = triggerHandler; + if (!this._initialized) { + lazy.EveryWindow.registerCallback( + this.id, + win => { + win.addEventListener("cookiebannerhandled", this); + }, + win => { + win.removeEventListener("cookiebannerhandled", this); + } + ); + this._initialized = true; + } + }, + handleEvent(event) { + if (this._initialized) { + const browser = + event.detail.windowContext.rootFrameLoader?.ownerElement; + const win = browser?.ownerGlobal; + // We only want to show messages in the active browser window. + if ( + win === Services.wm.getMostRecentBrowserWindow() && + browser === win.gBrowser.selectedBrowser + ) { + this._triggerHandler(browser, { id: this.id }); + } + } + }, + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + } + }, + }, + ], + [ + "pdfJsFeatureCalloutCheck", + { + id: "pdfJsFeatureCalloutCheck", + _initialized: false, + _triggerHandler: null, + _callouts: new WeakMap(), + + init(triggerHandler) { + if (!this._initialized) { + this.onLocationChange = this.onLocationChange.bind(this); + this.onStateChange = this.onLocationChange; + lazy.EveryWindow.registerCallback( + this.id, + win => { + this.onBrowserWindow(win); + win.addEventListener("TabSelect", this); + win.addEventListener("TabClose", this); + win.addEventListener("SSTabRestored", this); + win.gBrowser.addTabsProgressListener(this); + }, + win => { + win.removeEventListener("TabSelect", this); + win.removeEventListener("TabClose", this); + win.removeEventListener("SSTabRestored", this); + win.gBrowser.removeTabsProgressListener(this); + } + ); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + for (let key of ChromeUtils.nondeterministicGetWeakMapKeys( + this._callouts + )) { + const item = this._callouts.get(key); + if (item) { + item.callout.endTour(true); + item.cleanup(); + this._callouts.delete(key); + } + } + } + }, + + async showFeatureCalloutTour(win, browser, panelId, context) { + const result = await this._triggerHandler(browser, { + id: "pdfJsFeatureCalloutCheck", + context, + }); + if (result.message.trigger) { + const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout( + { + win, + browser, + pref: { + name: + result.message.content?.tour_pref_name ?? + "browser.pdfjs.feature-tour", + defaultValue: result.message.content?.tour_pref_default_value, + }, + location: "pdfjs", + theme: { preset: "pdfjs", simulateContent: true }, + cleanup: () => { + this._callouts.delete(win); + }, + }, + result.message + ); + if (callout) { + callout.panelId = panelId; + this._callouts.set(win, callout); + } + } + }, + + onLocationChange(browser) { + const tabbrowser = browser.getTabBrowser(); + if (browser !== tabbrowser.selectedBrowser) { + return; + } + const win = tabbrowser.ownerGlobal; + const tab = tabbrowser.selectedTab; + const existingCallout = this._callouts.get(win); + const isPDFJS = + browser.contentPrincipal.originNoSuffix === "resource://pdf.js"; + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isPDFJS) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isPDFJS) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + }, + + handleEvent(event) { + const tab = event.target; + const win = tab.ownerGlobal; + const { gBrowser } = win; + if (!gBrowser) { + return; + } + switch (event.type) { + case "SSTabRestored": + if (tab !== gBrowser.selectedTab) { + return; + } + // fall through + case "TabSelect": { + const browser = gBrowser.getBrowserForTab(tab); + const existingCallout = this._callouts.get(win); + const isPDFJS = + browser.contentPrincipal.originNoSuffix === "resource://pdf.js"; + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isPDFJS) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isPDFJS) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + break; + } + case "TabClose": { + const existingCallout = this._callouts.get(win); + if ( + existingCallout && + existingCallout.panelId === tab.linkedPanel + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + break; + } + } + }, + + onBrowserWindow(win) { + this.onLocationChange(win.gBrowser.selectedBrowser); + }, + }, + ], + [ + "newtabFeatureCalloutCheck", + { + id: "newtabFeatureCalloutCheck", + _initialized: false, + _triggerHandler: null, + _callouts: new WeakMap(), + + init(triggerHandler) { + if (!this._initialized) { + this.onLocationChange = this.onLocationChange.bind(this); + this.onStateChange = this.onLocationChange; + lazy.EveryWindow.registerCallback( + this.id, + win => { + this.onBrowserWindow(win); + win.addEventListener("TabSelect", this); + win.addEventListener("TabClose", this); + win.addEventListener("SSTabRestored", this); + win.gBrowser.addTabsProgressListener(this); + }, + win => { + win.removeEventListener("TabSelect", this); + win.removeEventListener("TabClose", this); + win.removeEventListener("SSTabRestored", this); + win.gBrowser.removeTabsProgressListener(this); + } + ); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + for (let key of ChromeUtils.nondeterministicGetWeakMapKeys( + this._callouts + )) { + const item = this._callouts.get(key); + if (item) { + item.callout.endTour(true); + item.cleanup(); + this._callouts.delete(key); + } + } + } + }, + + async showFeatureCalloutTour(win, browser, panelId, context) { + const result = await this._triggerHandler(browser, { + id: "newtabFeatureCalloutCheck", + context, + }); + if (result.message.trigger) { + const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout( + { + win, + browser, + pref: { + name: + result.message.content?.tour_pref_name ?? + "browser.newtab.feature-tour", + defaultValue: result.message.content?.tour_pref_default_value, + }, + location: "newtab", + theme: { preset: "newtab", simulateContent: true }, + cleanup: () => { + this._callouts.delete(win); + }, + }, + result.message + ); + if (callout) { + callout.panelId = panelId; + this._callouts.set(win, callout); + } + } + }, + + onLocationChange(browser) { + const tabbrowser = browser.getTabBrowser(); + if (browser !== tabbrowser.selectedBrowser) { + return; + } + const win = tabbrowser.ownerGlobal; + const tab = tabbrowser.selectedTab; + const existingCallout = this._callouts.get(win); + const isNewtabOrHome = + browser.currentURI.spec.startsWith("about:home") || + browser.currentURI.spec.startsWith("about:newtab"); + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isNewtabOrHome && tab.linkedPanel) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + }, + + handleEvent(event) { + const tab = event.target; + const win = tab.ownerGlobal; + const { gBrowser } = win; + if (!gBrowser) { + return; + } + switch (event.type) { + case "SSTabRestored": + if (tab !== gBrowser.selectedTab) { + return; + } + // fall through + case "TabSelect": { + const browser = gBrowser.getBrowserForTab(tab); + const existingCallout = this._callouts.get(win); + const isNewtabOrHome = + browser.currentURI.spec.startsWith("about:home") || + browser.currentURI.spec.startsWith("about:newtab"); + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isNewtabOrHome) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + break; + } + case "TabClose": { + const existingCallout = this._callouts.get(win); + if ( + existingCallout && + existingCallout.panelId === tab.linkedPanel + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + break; + } + } + }, + + onBrowserWindow(win) { + this.onLocationChange(win.gBrowser.selectedBrowser); + }, + }, + ], +]); diff --git a/browser/components/asrouter/modules/ActorConstants.sys.mjs b/browser/components/asrouter/modules/ActorConstants.sys.mjs new file mode 100644 index 0000000000..4c996552ab --- /dev/null +++ b/browser/components/asrouter/modules/ActorConstants.sys.mjs @@ -0,0 +1,49 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export const MESSAGE_TYPE_LIST = [ + "BLOCK_MESSAGE_BY_ID", + "USER_ACTION", + "IMPRESSION", + "TRIGGER", + // PB is Private Browsing + "PBNEWTAB_MESSAGE_REQUEST", + "DOORHANGER_TELEMETRY", + "TOOLBAR_BADGE_TELEMETRY", + "TOOLBAR_PANEL_TELEMETRY", + "MOMENTS_PAGE_TELEMETRY", + "INFOBAR_TELEMETRY", + "SPOTLIGHT_TELEMETRY", + "TOAST_NOTIFICATION_TELEMETRY", + "AS_ROUTER_TELEMETRY_USER_EVENT", + + // Admin types + "ADMIN_CONNECT_STATE", + "UNBLOCK_MESSAGE_BY_ID", + "UNBLOCK_ALL", + "BLOCK_BUNDLE", + "UNBLOCK_BUNDLE", + "DISABLE_PROVIDER", + "ENABLE_PROVIDER", + "EVALUATE_JEXL_EXPRESSION", + "EXPIRE_QUERY_CACHE", + "FORCE_ATTRIBUTION", + "FORCE_WHATSNEW_PANEL", + "FORCE_PRIVATE_BROWSING_WINDOW", + "CLOSE_WHATSNEW_PANEL", + "OVERRIDE_MESSAGE", + "MODIFY_MESSAGE_JSON", + "RESET_PROVIDER_PREF", + "SET_PROVIDER_USER_PREF", + "RESET_GROUPS_STATE", + "RESET_MESSAGE_STATE", + "RESET_SCREEN_IMPRESSIONS", + "EDIT_STATE", +]; + +export const MESSAGE_TYPE_HASH = MESSAGE_TYPE_LIST.reduce((hash, value) => { + hash[value] = value; + return hash; +}, {}); diff --git a/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs new file mode 100644 index 0000000000..e0aa49ad49 --- /dev/null +++ b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs @@ -0,0 +1,820 @@ +/* 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/. */ + +const FACEBOOK_CONTAINER_PARAMS = { + existing_addons: [ + "@contain-facebook", + "{bb1b80be-e6b3-40a1-9b6e-9d4073343f0b}", + "{a50d61ca-d27b-437a-8b52-5fd801a0a88b}", + ], + open_urls: ["www.facebook.com", "facebook.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const GOOGLE_TRANSLATE_PARAMS = { + existing_addons: [ + "jid1-93WyvpgvxzGATw@jetpack", + "{087ef4e1-4286-4be6-9aa3-8d6c420ee1db}", + "{4170faaa-ee87-4a0e-b57a-1aec49282887}", + "jid1-TMndP6cdKgxLcQ@jetpack", + "s3google@translator", + "{9c63d15c-b4d9-43bd-b223-37f0a1f22e2a}", + "translator@zoli.bod", + "{8cda9ce6-7893-4f47-ac70-a65215cec288}", + "simple-translate@sienori", + "@translatenow", + "{a79fafce-8da6-4685-923f-7ba1015b8748})", + "{8a802b5a-eeab-11e2-a41d-b0096288709b}", + "jid0-fbHwsGfb6kJyq2hj65KnbGte3yT@jetpack", + "storetranslate.plugin@gmail.com", + "jid1-r2tWDbSkq8AZK1@jetpack", + "{b384b75c-c978-4c4d-b3cf-62a82d8f8f12}", + "jid1-f7dnBeTj8ElpWQ@jetpack", + "{dac8a935-4775-4918-9205-5c0600087dc4}", + "gtranslation2@slam.com", + "{e20e0de5-1667-4df4-bd69-705720e37391}", + "{09e26ae9-e9c1-477c-80a6-99934212f2fe}", + "mgxtranslator@magemagix.com", + "gtranslatewins@mozilla.org", + ], + open_urls: ["translate.google.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const YOUTUBE_ENHANCE_PARAMS = { + existing_addons: [ + "enhancerforyoutube@maximerf.addons.mozilla.org", + "{dc8f61ab-5e98-4027-98ef-bb2ff6060d71}", + "{7b1bf0b6-a1b9-42b0-b75d-252036438bdc}", + "jid0-UVAeBCfd34Kk5usS8A1CBiobvM8@jetpack", + "iridium@particlecore.github.io", + "jid1-ss6kLNCbNz6u0g@jetpack", + "{1cf918d2-f4ea-4b4f-b34e-455283fef19f}", + ], + open_urls: ["www.youtube.com", "youtube.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS = { + existing_addons: [ + "@wikipediacontextmenusearch", + "{ebf47fc8-01d8-4dba-aa04-2118402f4b20}", + "{5737a280-b359-4e26-95b0-adec5915a854}", + "olivier.debroqueville@gmail.com", + "{3923146e-98cb-472b-9c13-f6849d34d6b8}", + ], + open_urls: ["www.wikipedia.org", "wikipedia.org"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const REDDIT_ENHANCEMENT_PARAMS = { + existing_addons: ["jid1-xUfzOsOFlzSOXg@jetpack"], + open_urls: ["www.reddit.com", "reddit.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; + +const CFR_MESSAGES = [ + { + id: "FACEBOOK_CONTAINER_3", + template: "cfr_doorhanger", + groups: ["cfr-message-provider"], + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: FACEBOOK_CONTAINER_PARAMS.sumo_path, + }, + addon: { + id: "954390", + title: "Facebook Container", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.6", + users: "299019", + author: "Mozilla", + amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/", + }, + text: "Stop Facebook from tracking your activity across the web. Use Facebook the way you normally do without annoying ads following you around.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + FACEBOOK_CONTAINER_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + FACEBOOK_CONTAINER_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + FACEBOOK_CONTAINER_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: FACEBOOK_CONTAINER_PARAMS.open_urls }, + }, + { + id: "GOOGLE_TRANSLATE_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: GOOGLE_TRANSLATE_PARAMS.sumo_path, + }, + addon: { + id: "445852", + title: "To Google Translate", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.1", + users: "313474", + author: "Juan Escobar", + amo_url: + "https://addons.mozilla.org/firefox/addon/to-google-translate/", + }, + text: "Instantly translate any webpage text. Simply highlight the text, right-click to open the context menu, and choose a text or aural translation.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + GOOGLE_TRANSLATE_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + GOOGLE_TRANSLATE_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + GOOGLE_TRANSLATE_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: GOOGLE_TRANSLATE_PARAMS.open_urls }, + }, + { + id: "YOUTUBE_ENHANCE_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: YOUTUBE_ENHANCE_PARAMS.sumo_path, + }, + addon: { + id: "700308", + title: "Enhancer for YouTube\u2122", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.8", + users: "357328", + author: "Maxime RF", + amo_url: + "https://addons.mozilla.org/firefox/addon/enhancer-for-youtube/", + }, + text: "Take control of your YouTube experience. Automatically block annoying ads, set playback speed and volume, remove annotations, and more.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + YOUTUBE_ENHANCE_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + YOUTUBE_ENHANCE_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + YOUTUBE_ENHANCE_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: YOUTUBE_ENHANCE_PARAMS.open_urls }, + }, + { + id: "WIKIPEDIA_CONTEXT_MENU_SEARCH_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + exclude: true, + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.sumo_path, + }, + addon: { + id: "659026", + title: "Wikipedia Context Menu Search", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.9", + users: "3095", + author: "Nick Diedrich", + amo_url: + "https://addons.mozilla.org/firefox/addon/wikipedia-context-menu-search/", + }, + text: "Get to a Wikipedia page fast, from anywhere on the web. Just highlight any webpage text and right-click to open the context menu to start a Wikipedia search.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { + id: "openURL", + params: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls, + }, + }, + { + id: "REDDIT_ENHANCEMENT_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + exclude: true, + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: REDDIT_ENHANCEMENT_PARAMS.sumo_path, + }, + addon: { + id: "387429", + title: "Reddit Enhancement Suite", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.6", + users: "258129", + author: "honestbleeps", + amo_url: + "https://addons.mozilla.org/firefox/addon/reddit-enhancement-suite/", + }, + text: "New features include Inline Image Viewer, Never Ending Reddit (never click 'next page' again), Keyboard Navigation, Account Switcher, and User Tagger.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + REDDIT_ENHANCEMENT_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + REDDIT_ENHANCEMENT_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + REDDIT_ENHANCEMENT_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: REDDIT_ENHANCEMENT_PARAMS.open_urls }, + }, + { + id: "DOH_ROLLOUT_CONFIRMATION", + groups: ["cfr-message-provider"], + targeting: ` + "doh-rollout.enabled"|preferenceValue && + !"doh-rollout.disable-heuristics"|preferenceValue && + !"doh-rollout.skipHeuristicsCheck"|preferenceValue && + !"doh-rollout.doorhanger-decision"|preferenceValue + `, + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + persistent_doorhanger: true, + anchor_id: "PanelUI-menu-button", + layout: "icon_and_message", + text: { string_id: "cfr-doorhanger-doh-body" }, + icon: "chrome://global/skin/icons/security.svg", + buttons: { + secondary: [ + { + label: { string_id: "cfr-doorhanger-doh-secondary-button" }, + action: { + type: "DISABLE_DOH", + }, + }, + ], + primary: { + label: { string_id: "cfr-doorhanger-doh-primary-button-2" }, + action: { + type: "ACCEPT_DOH", + }, + }, + }, + bucket_id: "DOH_ROLLOUT_CONFIRMATION", + heading_text: { string_id: "cfr-doorhanger-doh-header" }, + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: "Message from Firefox", + category: "cfrFeatures", + }, + trigger: { + id: "openURL", + patterns: ["*://*/*"], + }, + }, + { + id: "SAVE_LOGIN", + groups: ["cfr-message-provider"], + frequency: { + lifetime: 3, + }, + targeting: + "(!type || type == 'save') && isFxAEnabled == true && usesFirefoxSync == false", + template: "cfr_doorhanger", + content: { + layout: "icon_and_message", + text: "Securely store and sync your passwords to all your devices.", + icon: "chrome://browser/content/aboutlogins/icons/intro-illustration.svg", + icon_class: "cfr-doorhanger-large-icon", + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "general-cfrfeatures", + }, + }, + }, + ], + primary: { + label: { + value: "Turn on Sync", + attributes: { accesskey: "T" }, + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "sync", + entrypoint: "cfr-save-login", + }, + }, + }, + }, + bucket_id: "CFR_SAVE_LOGIN", + heading_text: "Never Lose a Password Again", + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: { + string_id: "cfr-doorhanger-feature-notification", + }, + category: "cfrFeatures", + }, + trigger: { + id: "newSavedLogin", + }, + }, + { + id: "UPDATE_LOGIN", + groups: ["cfr-message-provider"], + frequency: { + lifetime: 3, + }, + targeting: + "type == 'update' && isFxAEnabled == true && usesFirefoxSync == false", + template: "cfr_doorhanger", + content: { + layout: "icon_and_message", + text: "Securely store and sync your passwords to all your devices.", + icon: "chrome://browser/content/aboutlogins/icons/intro-illustration.svg", + icon_class: "cfr-doorhanger-large-icon", + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "general-cfrfeatures", + }, + }, + }, + ], + primary: { + label: { + value: "Turn on Sync", + attributes: { accesskey: "T" }, + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "sync", + entrypoint: "cfr-update-login", + }, + }, + }, + }, + bucket_id: "CFR_UPDATE_LOGIN", + heading_text: "Never Lose a Password Again", + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: { + string_id: "cfr-doorhanger-feature-notification", + }, + category: "cfrFeatures", + }, + trigger: { + id: "newSavedLogin", + }, + }, + { + id: "MILESTONE_MESSAGE", + groups: ["cfr-message-provider"], + template: "milestone_message", + content: { + layout: "short_message", + category: "cfrFeatures", + anchor_id: "tracking-protection-icon-container", + skip_address_bar_notifier: true, + bucket_id: "CFR_MILESTONE_MESSAGE", + heading_text: { string_id: "cfr-doorhanger-milestone-heading2" }, + notification_text: "", + text: "", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-milestone-ok-button" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + event: "PROTECTION", + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-milestone-close-button" }, + action: { type: "CANCEL" }, + event: "DISMISS", + }, + ], + }, + }, + targeting: "pageLoad >= 1", + frequency: { + lifetime: 7, // Length of privacy.contentBlocking.cfr-milestone.milestones pref + }, + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + }, + { + id: "HEARTBEAT_TACTIC_2", + groups: ["cfr-message-provider"], + template: "cfr_urlbar_chiclet", + content: { + layout: "chiclet_open_url", + category: "cfrHeartbeat", + bucket_id: "HEARTBEAT_TACTIC_2", + notification_text: "Improve Firefox", + active_color: "#595e91", + action: { + url: "http://example.com/%VERSION%/", + where: "tabshifted", + }, + }, + targeting: "false", + frequency: { + lifetime: 3, + }, + trigger: { + id: "openURL", + patterns: ["*://*/*"], + }, + }, + { + id: "HOMEPAGE_REMEDIATION_82", + groups: ["cfr-message-provider"], + frequency: { + lifetime: 3, + }, + targeting: + "!homePageSettings.isDefault && homePageSettings.isCustomUrl && homePageSettings.urls[.host == 'google.com']|length > 0 && visitsCount >= 3 && userPrefs.cfrFeatures", + template: "cfr_doorhanger", + content: { + layout: "icon_and_message", + text: "Update your homepage to search Google while also being able to search your Firefox history and bookmarks.", + icon: "chrome://global/skin/icons/search-glass.svg", + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "general-cfrfeatures", + }, + }, + }, + ], + primary: { + label: { + value: "Activate now", + attributes: { + accesskey: "A", + }, + }, + action: { + type: "CONFIGURE_HOMEPAGE", + data: { + homePage: "default", + newtab: "default", + layout: { + search: true, + topsites: false, + highlights: false, + topstories: false, + }, + }, + }, + }, + }, + bucket_id: "HOMEPAGE_REMEDIATION_82", + heading_text: "A better search experience", + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: { + string_id: "cfr-doorhanger-feature-notification", + }, + category: "cfrFeatures", + }, + trigger: { + id: "openURL", + params: ["google.com", "www.google.com"], + }, + }, + { + id: "INFOBAR_ACTION_86", + groups: ["cfr-message-provider"], + targeting: "false", + template: "infobar", + content: { + type: "global", + text: { string_id: "default-browser-notification-message" }, + buttons: [ + { + label: { string_id: "default-browser-notification-button" }, + primary: true, + accessKey: "O", + action: { + type: "SET_DEFAULT_BROWSER", + }, + }, + ], + }, + trigger: { id: "defaultBrowserCheck" }, + }, + { + id: "PREF_OBSERVER_MESSAGE_94", + groups: ["cfr-message-provider"], + targeting: "true", + template: "infobar", + content: { + type: "global", + text: "This is a message triggered when a pref value changes", + buttons: [ + { + label: "OK", + primary: true, + accessKey: "O", + action: { + type: "CANCEL", + }, + }, + ], + }, + trigger: { id: "preferenceObserver", params: ["foo.bar"] }, + }, +]; + +export const CFRMessageProvider = { + getMessages() { + return Promise.resolve(CFR_MESSAGES.filter(msg => !msg.exclude)); + }, +}; diff --git a/browser/components/asrouter/modules/CFRPageActions.sys.mjs b/browser/components/asrouter/modules/CFRPageActions.sys.mjs new file mode 100644 index 0000000000..cf7719d9eb --- /dev/null +++ b/browser/components/asrouter/modules/CFRPageActions.sys.mjs @@ -0,0 +1,1086 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "TrackingDBService", + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService" +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "milestones", + "browser.contentblocking.cfr-milestone.milestones", + "[]", + null, + JSON.parse +); + +const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation"; +const SUMO_BASE_URL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" +); +const ADDONS_API_URL = + "https://services.addons.mozilla.org/api/v4/addons/addon"; + +const DELAY_BEFORE_EXPAND_MS = 1000; +const CATEGORY_ICONS = { + cfrAddons: "webextensions-icon", + cfrFeatures: "recommendations-icon", + cfrHeartbeat: "highlights-icon", +}; + +/** + * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are + * defined in the ExtensionDoorhanger.schema.json. + * + * A recommendation is specific to a browser and host and is active until the + * given browser is closed or the user navigates (within that browser) away from + * the host. + */ +let RecommendationMap = new WeakMap(); + +/** + * A WeakMap from windows to their CFR PageAction. + */ +let PageActionMap = new WeakMap(); + +/** + * We need one PageAction for each window + */ +export class PageAction { + constructor(win, dispatchCFRAction) { + this.window = win; + + this.urlbar = win.gURLBar; // The global URLBar object + this.urlbarinput = win.gURLBar.textbox; // The URLBar DOM node + + this.container = win.document.getElementById( + "contextual-feature-recommendation" + ); + this.button = win.document.getElementById("cfr-button"); + this.label = win.document.getElementById("cfr-label"); + + // This should NOT be use directly to dispatch message-defined actions attached to buttons. + // Please use dispatchUserAction instead. + this._dispatchCFRAction = dispatchCFRAction; + + this._popupStateChange = this._popupStateChange.bind(this); + this._collapse = this._collapse.bind(this); + this._cfrUrlbarButtonClick = this._cfrUrlbarButtonClick.bind(this); + this._executeNotifierAction = this._executeNotifierAction.bind(this); + this.dispatchUserAction = this.dispatchUserAction.bind(this); + + // Saved timeout IDs for scheduled state changes, so they can be cancelled + this.stateTransitionTimeoutIDs = []; + + ChromeUtils.defineLazyGetter(this, "isDarkTheme", () => { + try { + return this.window.document.documentElement.hasAttribute( + "lwt-toolbar-field-brighttext" + ); + } catch (e) { + return false; + } + }); + } + + addImpression(recommendation) { + this._dispatchImpression(recommendation); + // Only send an impression ping upon the first expansion. + // Note that when the user clicks on the "show" button on the asrouter admin + // page (both `bucket_id` and `id` will be set as null), we don't want to send + // the impression ping in that case. + if (!!recommendation.id && !!recommendation.content.bucket_id) { + this._sendTelemetry({ + message_id: recommendation.id, + bucket_id: recommendation.content.bucket_id, + event: "IMPRESSION", + }); + } + } + + reloadL10n() { + lazy.RemoteL10n.reloadL10n(); + } + + async showAddressBarNotifier(recommendation, shouldExpand = false) { + this.container.hidden = false; + + let notificationText = await this.getStrings( + recommendation.content.notification_text + ); + this.label.value = notificationText; + if (notificationText.attributes) { + this.button.setAttribute( + "tooltiptext", + notificationText.attributes.tooltiptext + ); + // For a11y, we want the more descriptive text. + this.container.setAttribute( + "aria-label", + notificationText.attributes.tooltiptext + ); + } + this.container.setAttribute( + "data-cfr-icon", + CATEGORY_ICONS[recommendation.content.category] + ); + if (recommendation.content.active_color) { + this.container.style.setProperty( + "--cfr-active-color", + recommendation.content.active_color + ); + } + + if (recommendation.content.active_text_color) { + this.container.style.setProperty( + "--cfr-active-text-color", + recommendation.content.active_text_color + ); + } + + // Wait for layout to flush to avoid a synchronous reflow then calculate the + // label width. We can safely get the width even though the recommendation is + // collapsed; the label itself remains full width (with its overflow hidden) + let [{ width }] = await this.window.promiseDocumentFlushed(() => + this.label.getClientRects() + ); + this.urlbarinput.style.setProperty("--cfr-label-width", `${width}px`); + + this.container.addEventListener("click", this._cfrUrlbarButtonClick); + // Collapse the recommendation on url bar focus in order to free up more + // space to display and edit the url + this.urlbar.addEventListener("focus", this._collapse); + + if (shouldExpand) { + this._clearScheduledStateChanges(); + + // After one second, expand + this._expand(DELAY_BEFORE_EXPAND_MS); + + this.addImpression(recommendation); + } + + if (notificationText.attributes) { + this.window.A11yUtils.announce({ + raw: notificationText.attributes["a11y-announcement"], + source: this.container, + }); + } + } + + hideAddressBarNotifier() { + this.container.hidden = true; + this._clearScheduledStateChanges(); + this.urlbarinput.removeAttribute("cfr-recommendation-state"); + this.container.removeEventListener("click", this._cfrUrlbarButtonClick); + this.urlbar.removeEventListener("focus", this._collapse); + if (this.currentNotification) { + this.window.PopupNotifications.remove(this.currentNotification); + this.currentNotification = null; + } + } + + _expand(delay) { + if (delay > 0) { + this.stateTransitionTimeoutIDs.push( + this.window.setTimeout(() => { + this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded"); + }, delay) + ); + } else { + // Non-delayed state change overrides any scheduled state changes + this._clearScheduledStateChanges(); + this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded"); + } + } + + _collapse(delay) { + if (delay > 0) { + this.stateTransitionTimeoutIDs.push( + this.window.setTimeout(() => { + if ( + this.urlbarinput.getAttribute("cfr-recommendation-state") === + "expanded" + ) { + this.urlbarinput.setAttribute( + "cfr-recommendation-state", + "collapsed" + ); + } + }, delay) + ); + } else { + // Non-delayed state change overrides any scheduled state changes + this._clearScheduledStateChanges(); + if ( + this.urlbarinput.getAttribute("cfr-recommendation-state") === "expanded" + ) { + this.urlbarinput.setAttribute("cfr-recommendation-state", "collapsed"); + } + } + } + + _clearScheduledStateChanges() { + while (this.stateTransitionTimeoutIDs.length) { + // clearTimeout is safe even with invalid/expired IDs + this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop()); + } + } + + // This is called when the popup closes as a result of interaction _outside_ + // the popup, e.g. by hitting <esc> + _popupStateChange(state) { + if (state === "shown") { + if (this._autoFocus) { + this.window.document.commandDispatcher.advanceFocusIntoSubtree( + this.currentNotification.owner.panel + ); + this._autoFocus = false; + } + } else if (state === "removed") { + if (this.currentNotification) { + this.window.PopupNotifications.remove(this.currentNotification); + this.currentNotification = null; + } + } else if (state === "dismissed") { + const message = RecommendationMap.get(this.currentNotification?.browser); + this._sendTelemetry({ + message_id: message?.id, + bucket_id: message?.content.bucket_id, + event: "DISMISS", + }); + this._collapse(); + } + } + + shouldShowDoorhanger(recommendation) { + if (recommendation.content.layout === "chiclet_open_url") { + return false; + } + + return true; + } + + dispatchUserAction(action) { + this._dispatchCFRAction( + { type: "USER_ACTION", data: action }, + this.window.gBrowser.selectedBrowser + ); + } + + _dispatchImpression(message) { + this._dispatchCFRAction({ type: "IMPRESSION", data: message }); + } + + _sendTelemetry(ping) { + const data = { action: "cfr_user_event", source: "CFR", ...ping }; + if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) { + data.is_private = true; + } + this._dispatchCFRAction({ + type: "DOORHANGER_TELEMETRY", + data, + }); + } + + _blockMessage(messageID) { + this._dispatchCFRAction({ + type: "BLOCK_MESSAGE_BY_ID", + data: { id: messageID }, + }); + } + + maybeLoadCustomElement(win) { + if (!win.customElements.get("remote-text")) { + Services.scriptloader.loadSubScript( + "resource://activity-stream/data/custom-elements/paragraph.js", + win + ); + } + } + + /** + * getStrings - Handles getting the localized strings vs message overrides. + * If string_id is not defined it assumes you passed in an override + * message and it just returns it. + * If subAttribute is provided, the string for it is returned. + * @return A string. One of 1) passed in string 2) a String object with + * attributes property if there are attributes 3) the sub attribute. + */ + async getStrings(string, subAttribute = "") { + if (!string.string_id) { + if (subAttribute) { + if (string.attributes) { + return string.attributes[subAttribute]; + } + + console.error(`String ${string.value} does not contain any attributes`); + return subAttribute; + } + + if (typeof string.value === "string") { + const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers + stringWithAttributes.attributes = string.attributes; + return stringWithAttributes; + } + + return string; + } + + const [localeStrings] = await lazy.RemoteL10n.l10n.formatMessages([ + { + id: string.string_id, + args: string.args, + }, + ]); + + const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers + if (localeStrings.attributes) { + const attributes = localeStrings.attributes.reduce((acc, attribute) => { + acc[attribute.name] = attribute.value; + return acc; + }, {}); + mainString.attributes = attributes; + } + + return subAttribute ? mainString.attributes[subAttribute] : mainString; + } + + async _setAddonRating(document, content) { + const footerFilledStars = this.window.document.getElementById( + "cfr-notification-footer-filled-stars" + ); + const footerEmptyStars = this.window.document.getElementById( + "cfr-notification-footer-empty-stars" + ); + const footerUsers = this.window.document.getElementById( + "cfr-notification-footer-users" + ); + + const rating = content.addon?.rating; + if (rating) { + const MAX_RATING = 5; + const STARS_WIDTH = 16 * MAX_RATING; + const calcWidth = stars => `${(stars / MAX_RATING) * STARS_WIDTH}px`; + const filledWidth = + rating <= MAX_RATING ? calcWidth(rating) : calcWidth(MAX_RATING); + const emptyWidth = + rating <= MAX_RATING ? calcWidth(MAX_RATING - rating) : calcWidth(0); + + footerFilledStars.style.width = filledWidth; + footerEmptyStars.style.width = emptyWidth; + + const ratingString = await this.getStrings( + { + string_id: "cfr-doorhanger-extension-rating", + args: { total: rating }, + }, + "tooltiptext" + ); + footerFilledStars.setAttribute("tooltiptext", ratingString); + footerEmptyStars.setAttribute("tooltiptext", ratingString); + } else { + footerFilledStars.style.width = ""; + footerEmptyStars.style.width = ""; + footerFilledStars.removeAttribute("tooltiptext"); + footerEmptyStars.removeAttribute("tooltiptext"); + } + + const users = content.addon?.users; + if (users) { + footerUsers.setAttribute("value", users); + footerUsers.hidden = false; + } else { + // Prevent whitespace around empty label from affecting other spacing + footerUsers.hidden = true; + footerUsers.removeAttribute("value"); + } + } + + _createElementAndAppend({ type, id }, parent) { + let element = this.window.document.createXULElement(type); + if (id) { + element.setAttribute("id", id); + } + parent.appendChild(element); + return element; + } + + async _renderMilestonePopup(message, browser) { + this.maybeLoadCustomElement(this.window); + + let { content, id } = message; + let { primary, secondary } = content.buttons; + let earliestDate = await lazy.TrackingDBService.getEarliestRecordedDate(); + let timestamp = earliestDate ?? new Date().getTime(); + let panelTitle = ""; + let headerLabel = this.window.document.getElementById( + "cfr-notification-header-label" + ); + let reachedMilestone = 0; + let totalSaved = await lazy.TrackingDBService.sumAllEvents(); + for (let milestone of lazy.milestones) { + if (totalSaved >= milestone) { + reachedMilestone = milestone; + } + } + if (headerLabel.firstChild) { + headerLabel.firstChild.remove(); + } + headerLabel.appendChild( + lazy.RemoteL10n.createElement(this.window.document, "span", { + content: message.content.heading_text, + attributes: { + blockedCount: reachedMilestone, + date: timestamp, + }, + }) + ); + + // Use the message layout as a CSS selector to hide different parts of the + // notification template markup + this.window.document + .getElementById("contextual-feature-recommendation-notification") + .setAttribute("data-notification-category", content.layout); + this.window.document + .getElementById("contextual-feature-recommendation-notification") + .setAttribute("data-notification-bucket", content.bucket_id); + + let primaryBtnString = await this.getStrings(primary.label); + let primaryActionCallback = () => { + this.dispatchUserAction(primary.action); + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event: "CLICK_BUTTON", + }); + + RecommendationMap.delete(browser); + // Invalidate the pref after the user interacts with the button. + // We don't need to show the illustration in the privacy panel. + Services.prefs.clearUserPref( + "browser.contentblocking.cfr-milestone.milestone-shown-time" + ); + }; + + let secondaryBtnString = await this.getStrings(secondary[0].label); + let secondaryActionsCallback = () => { + this.dispatchUserAction(secondary[0].action); + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event: "DISMISS", + }); + RecommendationMap.delete(browser); + }; + + let mainAction = { + label: primaryBtnString, + accessKey: primaryBtnString.attributes.accesskey, + callback: primaryActionCallback, + }; + + let secondaryActions = [ + { + label: secondaryBtnString, + accessKey: secondaryBtnString.attributes.accesskey, + callback: secondaryActionsCallback, + }, + ]; + + // Actually show the notification + this.currentNotification = this.window.PopupNotifications.show( + browser, + POPUP_NOTIFICATION_ID, + panelTitle, + "cfr", + mainAction, + secondaryActions, + { + hideClose: true, + persistWhileVisible: true, + recordTelemetryInPrivateBrowsing: content.show_in_private_browsing, + } + ); + Services.prefs.setIntPref( + "browser.contentblocking.cfr-milestone.milestone-achieved", + reachedMilestone + ); + Services.prefs.setStringPref( + "browser.contentblocking.cfr-milestone.milestone-shown-time", + Date.now().toString() + ); + } + + // eslint-disable-next-line max-statements + async _renderPopup(message, browser) { + this.maybeLoadCustomElement(this.window); + + const { id, content } = message; + + const headerLabel = this.window.document.getElementById( + "cfr-notification-header-label" + ); + const headerLink = this.window.document.getElementById( + "cfr-notification-header-link" + ); + const headerImage = this.window.document.getElementById( + "cfr-notification-header-image" + ); + const footerText = this.window.document.getElementById( + "cfr-notification-footer-text" + ); + const footerLink = this.window.document.getElementById( + "cfr-notification-footer-learn-more-link" + ); + const { primary, secondary } = content.buttons; + let primaryActionCallback; + let persistent = !!content.persistent_doorhanger; + let options = { + persistent, + persistWhileVisible: persistent, + recordTelemetryInPrivateBrowsing: content.show_in_private_browsing, + }; + let panelTitle; + + headerLabel.value = await this.getStrings(content.heading_text); + if (content.info_icon) { + headerLink.setAttribute( + "href", + SUMO_BASE_URL + content.info_icon.sumo_path + ); + headerImage.setAttribute( + "tooltiptext", + await this.getStrings(content.info_icon.label, "tooltiptext") + ); + } + headerLink.onclick = () => + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event: "RATIONALE", + }); + // Use the message layout as a CSS selector to hide different parts of the + // notification template markup + this.window.document + .getElementById("contextual-feature-recommendation-notification") + .setAttribute("data-notification-category", content.layout); + this.window.document + .getElementById("contextual-feature-recommendation-notification") + .setAttribute("data-notification-bucket", content.bucket_id); + + const author = this.window.document.getElementById( + "cfr-notification-author" + ); + if (author.firstChild) { + author.firstChild.remove(); + } + + switch (content.layout) { + case "icon_and_message": + //Clearing content and styles that may have been set by a prior addon_recommendation CFR + this._setAddonRating(this.window.document, content); + author.appendChild( + lazy.RemoteL10n.createElement(this.window.document, "span", { + content: content.text, + }) + ); + primaryActionCallback = () => { + this._blockMessage(id); + this.dispatchUserAction(primary.action); + this.hideAddressBarNotifier(); + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event: "ENABLE", + }); + RecommendationMap.delete(browser); + }; + + let getIcon = () => { + if (content.icon_dark_theme && this.isDarkTheme) { + return content.icon_dark_theme; + } + return content.icon; + }; + + let learnMoreURL = content.learn_more + ? SUMO_BASE_URL + content.learn_more + : null; + + panelTitle = await this.getStrings(content.heading_text); + options = { + popupIconURL: getIcon(), + popupIconClass: content.icon_class, + learnMoreURL, + ...options, + }; + break; + default: + const authorText = await this.getStrings({ + string_id: "cfr-doorhanger-extension-author", + args: { name: content.addon.author }, + }); + panelTitle = await this.getStrings(content.addon.title); + await this._setAddonRating(this.window.document, content); + if (footerText.firstChild) { + footerText.firstChild.remove(); + } + if (footerText.lastChild) { + footerText.lastChild.remove(); + } + + // Main body content of the dropdown + footerText.appendChild( + lazy.RemoteL10n.createElement(this.window.document, "span", { + content: content.text, + }) + ); + + footerLink.value = await this.getStrings({ + string_id: "cfr-doorhanger-extension-learn-more-link", + }); + footerLink.setAttribute("href", content.addon.amo_url); + footerLink.onclick = () => + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event: "LEARN_MORE", + }); + + footerText.appendChild(footerLink); + options = { + popupIconURL: content.addon.icon, + popupIconClass: content.icon_class, + name: authorText, + ...options, + }; + + primaryActionCallback = async () => { + primary.action.data.url = + // eslint-disable-next-line no-use-before-define + await CFRPageActions._fetchLatestAddonVersion(content.addon.id); + this._blockMessage(id); + this.dispatchUserAction(primary.action); + this.hideAddressBarNotifier(); + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event: "INSTALL", + }); + RecommendationMap.delete(browser); + }; + } + + const primaryBtnStrings = await this.getStrings(primary.label); + const mainAction = { + label: primaryBtnStrings, + accessKey: primaryBtnStrings.attributes.accesskey, + callback: primaryActionCallback, + }; + + let _renderSecondaryButtonAction = async (event, button) => { + let label = await this.getStrings(button.label); + let { attributes } = label; + + return { + label, + accessKey: attributes.accesskey, + callback: () => { + if (button.action) { + this.dispatchUserAction(button.action); + } else { + this._blockMessage(id); + this.hideAddressBarNotifier(); + RecommendationMap.delete(browser); + } + + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event, + }); + // We want to collapse if needed when we dismiss + this._collapse(); + }, + }; + }; + + // For each secondary action, define default telemetry event + const defaultSecondaryEvent = ["DISMISS", "BLOCK", "MANAGE"]; + const secondaryActions = await Promise.all( + secondary.map((button, i) => { + return _renderSecondaryButtonAction( + button.event || defaultSecondaryEvent[i], + button + ); + }) + ); + + // If the recommendation button is focused, it was probably activated via + // the keyboard. Therefore, focus the first element in the notification when + // it appears. + // We don't use the autofocus option provided by PopupNotifications.show + // because it doesn't focus the first element; i.e. the user still has to + // press tab once. That's not good enough, especially for screen reader + // users. Instead, we handle this ourselves in _popupStateChange. + this._autoFocus = this.window.document.activeElement === this.container; + + // Actually show the notification + this.currentNotification = this.window.PopupNotifications.show( + browser, + POPUP_NOTIFICATION_ID, + panelTitle, + "cfr", + mainAction, + secondaryActions, + { + ...options, + hideClose: true, + eventCallback: this._popupStateChange, + } + ); + } + + _executeNotifierAction(browser, message) { + switch (message.content.layout) { + case "chiclet_open_url": + this._dispatchCFRAction( + { + type: "USER_ACTION", + data: { + type: "OPEN_URL", + data: { + args: message.content.action.url, + where: message.content.action.where, + }, + }, + }, + this.window + ); + break; + } + + this._blockMessage(message.id); + this.hideAddressBarNotifier(); + RecommendationMap.delete(browser); + } + + /** + * Respond to a user click on the recommendation by showing a doorhanger/ + * popup notification or running the action defined in the message + */ + async _cfrUrlbarButtonClick(event) { + const browser = this.window.gBrowser.selectedBrowser; + if (!RecommendationMap.has(browser)) { + // There's no recommendation for this browser, so the user shouldn't have + // been able to click + this.hideAddressBarNotifier(); + return; + } + const message = RecommendationMap.get(browser); + const { id, content } = message; + + this._sendTelemetry({ + message_id: id, + bucket_id: content.bucket_id, + event: "CLICK_DOORHANGER", + }); + + if (this.shouldShowDoorhanger(message)) { + // The recommendation should remain either collapsed or expanded while the + // doorhanger is showing + this._clearScheduledStateChanges(browser, message); + await this.showPopup(); + } else { + await this._executeNotifierAction(browser, message); + } + } + + _getVisibleElement(idOrEl) { + const element = + typeof idOrEl === "string" + ? idOrEl && this.window.document.getElementById(idOrEl) + : idOrEl; + if (!element) { + return null; // element doesn't exist at all + } + const { visibility, display } = this.window.getComputedStyle(element); + if ( + !this.window.isElementVisible(element) || + visibility !== "visible" || + display === "none" + ) { + // CSS rules like visibility: hidden or display: none. these result in + // element being invisible and unclickable. + return null; + } + let widget = lazy.CustomizableUI.getWidget(idOrEl); + if ( + widget && + (this.window.CustomizationHandler.isCustomizing() || + widget.areaType?.includes("panel")) + ) { + // The element is a customizable widget (a toolbar item, e.g. the + // reload button or the downloads button). Widgets can be in various + // areas, like the overflow panel or the customization palette. + // Widgets in the palette are present in the chrome's DOM during + // customization, but can't be used. + return null; + } + return element; + } + + async showPopup() { + const browser = this.window.gBrowser.selectedBrowser; + const message = RecommendationMap.get(browser); + const { content } = message; + + // A hacky way of setting the popup anchor outside the usual url bar icon box + // See https://searchfox.org/mozilla-central/rev/eb07633057d66ab25f9db4c5900eeb6913da7579/toolkit/modules/PopupNotifications.sys.mjs#44 + browser.cfrpopupnotificationanchor = + this._getVisibleElement(content.anchor_id) || + this._getVisibleElement(content.alt_anchor_id) || + this._getVisibleElement(this.button) || + this._getVisibleElement(this.container); + + await this._renderPopup(message, browser); + } + + async showMilestonePopup() { + const browser = this.window.gBrowser.selectedBrowser; + const message = RecommendationMap.get(browser); + const { content } = message; + + // A hacky way of setting the popup anchor outside the usual url bar icon box + // See https://searchfox.org/mozilla-central/rev/eb07633057d66ab25f9db4c5900eeb6913da7579/toolkit/modules/PopupNotifications.sys.mjs#44 + browser.cfrpopupnotificationanchor = + this.window.document.getElementById(content.anchor_id) || this.container; + + await this._renderMilestonePopup(message, browser); + return true; + } +} + +function isHostMatch(browser, host) { + return ( + browser.documentURI.scheme.startsWith("http") && + browser.documentURI.host === host + ); +} + +export const CFRPageActions = { + // For testing purposes + RecommendationMap, + PageActionMap, + + /** + * To be called from browser.js on a location change, passing in the browser + * that's been updated + */ + updatePageActions(browser) { + const win = browser.ownerGlobal; + const pageAction = PageActionMap.get(win); + if (!pageAction || browser !== win.gBrowser.selectedBrowser) { + return; + } + if (RecommendationMap.has(browser)) { + const recommendation = RecommendationMap.get(browser); + if ( + !recommendation.content.skip_address_bar_notifier && + (isHostMatch(browser, recommendation.host) || + // If there is no host associated we assume we're back on a tab + // that had a CFR message so we should show it again + !recommendation.host) + ) { + // The browser has a recommendation specified with this host, so show + // the page action + pageAction.showAddressBarNotifier(recommendation); + } else if (!recommendation.content.persistent_doorhanger) { + if (recommendation.retain) { + // Keep the recommendation first time the user navigates away just in + // case they will go back to the previous page + pageAction.hideAddressBarNotifier(); + recommendation.retain = false; + } else { + // The user has navigated away from the specified host in the given + // browser, so the recommendation is no longer valid and should be removed + RecommendationMap.delete(browser); + pageAction.hideAddressBarNotifier(); + } + } + } else { + // There's no recommendation specified for this browser, so hide the page action + pageAction.hideAddressBarNotifier(); + } + }, + + /** + * Fetch the URL to the latest add-on xpi so the recommendation can download it. + * @param id The add-on ID + * @return A string for the URL that was fetched + */ + async _fetchLatestAddonVersion(id) { + let url = null; + try { + const response = await fetch(`${ADDONS_API_URL}/${id}/`, { + credentials: "omit", + }); + if (response.status !== 204 && response.ok) { + const json = await response.json(); + url = json.current_version.files[0].url; + } + } catch (e) { + console.error( + "Failed to get the latest add-on version for this recommendation" + ); + } + return url; + }, + + /** + * Force a recommendation to be shown. Should only happen via the Admin page. + * @param browser The browser for the recommendation + * @param recommendation The recommendation to show + * @param dispatchCFRAction A function to dispatch resulting actions to + * @return Did adding the recommendation succeed? + */ + async forceRecommendation(browser, recommendation, dispatchCFRAction) { + if (!browser) { + return false; + } + // If we are forcing via the Admin page, the browser comes in a different format + const win = browser.ownerGlobal; + const { id, content } = recommendation; + RecommendationMap.set(browser, { + id, + content, + retain: true, + }); + if (!PageActionMap.has(win)) { + PageActionMap.set(win, new PageAction(win, dispatchCFRAction)); + } + + if (content.skip_address_bar_notifier) { + if (recommendation.template === "milestone_message") { + await PageActionMap.get(win).showMilestonePopup(); + PageActionMap.get(win).addImpression(recommendation); + } else { + await PageActionMap.get(win).showPopup(); + PageActionMap.get(win).addImpression(recommendation); + } + } else { + await PageActionMap.get(win).showAddressBarNotifier(recommendation, true); + } + return true; + }, + + /** + * Add a recommendation specific to the given browser and host. + * @param browser The browser for the recommendation + * @param host The host for the recommendation + * @param recommendation The recommendation to show + * @param dispatchCFRAction A function to dispatch resulting actions to + * @return Did adding the recommendation succeed? + */ + async addRecommendation(browser, host, recommendation, dispatchCFRAction) { + if (!browser) { + return false; + } + const win = browser.ownerGlobal; + if ( + browser !== win.gBrowser.selectedBrowser || + // We can have recommendations without URL restrictions + (host && !isHostMatch(browser, host)) + ) { + return false; + } + if (RecommendationMap.has(browser)) { + // Don't replace an existing message + return false; + } + const { id, content } = recommendation; + if ( + !content.show_in_private_browsing && + lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ) { + return false; + } + RecommendationMap.set(browser, { + id, + host, + content, + retain: true, + }); + if (!PageActionMap.has(win)) { + PageActionMap.set(win, new PageAction(win, dispatchCFRAction)); + } + + if (content.skip_address_bar_notifier) { + if (recommendation.template === "milestone_message") { + await PageActionMap.get(win).showMilestonePopup(); + PageActionMap.get(win).addImpression(recommendation); + } else { + // Tracking protection messages + await PageActionMap.get(win).showPopup(); + PageActionMap.get(win).addImpression(recommendation); + } + } else { + // Doorhanger messages + await PageActionMap.get(win).showAddressBarNotifier(recommendation, true); + } + return true; + }, + + /** + * Clear all recommendations and hide all PageActions + */ + clearRecommendations() { + // WeakMaps aren't iterable so we have to test all existing windows + for (const win of Services.wm.getEnumerator("navigator:browser")) { + if (win.closed || !PageActionMap.has(win)) { + continue; + } + PageActionMap.get(win).hideAddressBarNotifier(); + } + // WeakMaps don't have a `clear` method + PageActionMap = new WeakMap(); + RecommendationMap = new WeakMap(); + this.PageActionMap = PageActionMap; + this.RecommendationMap = RecommendationMap; + }, + + /** + * Reload the l10n Fluent files for all PageActions + */ + reloadL10n() { + for (const win of Services.wm.getEnumerator("navigator:browser")) { + if (win.closed || !PageActionMap.has(win)) { + continue; + } + PageActionMap.get(win).reloadL10n(); + } + }, +}; diff --git a/browser/components/asrouter/modules/FeatureCallout.sys.mjs b/browser/components/asrouter/modules/FeatureCallout.sys.mjs new file mode 100644 index 0000000000..01998662f6 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCallout.sys.mjs @@ -0,0 +1,2100 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs", + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + PageEventManager: "resource:///modules/asrouter/PageEventManager.sys.mjs", +}); + +const TRANSITION_MS = 500; +const CONTAINER_ID = "feature-callout"; +const CONTENT_BOX_ID = "multi-stage-message-root"; +const BUNDLE_SRC = + "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js"; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("FeatureCallout"); +}); + +/** + * Feature Callout fetches messages relevant to a given source and displays them + * in the parent page pointing to the element they describe. + */ +export class FeatureCallout { + /** + * @typedef {Object} FeatureCalloutOptions + * @property {Window} win window in which messages will be rendered. + * @property {{name: String, defaultValue?: String}} [pref] optional pref used + * to track progress through a given feature tour. for example: + * { + * name: "browser.pdfjs.feature-tour", + * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }', + * } + * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional) + * @property {String} [location] string to pass as the page when requesting + * messages from ASRouter and sending telemetry. + * @property {String} context either "chrome" or "content". "chrome" is used + * when the callout is shown in the browser chrome, and "content" is used + * when the callout is shown in a content page like Firefox View. + * @property {MozBrowser} [browser] <browser> element responsible for the + * feature callout. for content pages, this is the browser element that the + * callout is being shown in. for chrome, this is the active browser. + * @property {Function} [listener] callback to be invoked on various callout + * events to keep the broker informed of the callout's state. + * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets + */ + + /** @param {FeatureCalloutOptions} options */ + constructor({ + win, + pref, + location, + context, + browser, + listener, + theme = {}, + } = {}) { + this.win = win; + this.doc = win.document; + this.browser = browser || this.win.docShell.chromeEventHandler; + this.config = null; + this.loadingConfig = false; + this.message = null; + if (pref?.name) { + this.pref = pref; + } + this._featureTourProgress = null; + this.currentScreen = null; + this.renderObserver = null; + this.savedFocus = null; + this.ready = false; + this._positionListenersRegistered = false; + this._panelConflictListenersRegistered = false; + this.AWSetup = false; + this.location = location; + this.context = context; + this.listener = listener; + this._initTheme(theme); + + this._handlePrefChange = this._handlePrefChange.bind(this); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "cfrFeaturesUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true + ); + this.setupFeatureTourProgress(); + + // When the window is focused, ensure tour is synced with tours in any other + // instances of the parent page. This does not apply when the Callout is + // shown in the browser chrome. + if (this.context !== "chrome") { + this.win.addEventListener("visibilitychange", this); + } + + this.win.addEventListener("unload", this); + } + + setupFeatureTourProgress() { + if (this.featureTourProgress) { + return; + } + if (this.pref?.name) { + this._handlePrefChange(null, null, this.pref.name); + Services.prefs.addObserver(this.pref.name, this._handlePrefChange); + } + } + + teardownFeatureTourProgress() { + if (this.pref?.name) { + Services.prefs.removeObserver(this.pref.name, this._handlePrefChange); + } + this._featureTourProgress = null; + } + + get featureTourProgress() { + return this._featureTourProgress; + } + + /** + * Get the page event manager and instantiate it if necessary. Only used by + * _attachPageEventListeners, since we don't want to do this unnecessary work + * if a message with page event listeners hasn't loaded. Other consumers + * should use `this._pageEventManager?.property` instead. + */ + get _loadPageEventManager() { + if (!this._pageEventManager) { + this._pageEventManager = new lazy.PageEventManager(this.win); + } + return this._pageEventManager; + } + + _addPositionListeners() { + if (!this._positionListenersRegistered) { + this.win.addEventListener("resize", this); + this._positionListenersRegistered = true; + } + } + + _removePositionListeners() { + if (this._positionListenersRegistered) { + this.win.removeEventListener("resize", this); + this._positionListenersRegistered = false; + } + } + + _addPanelConflictListeners() { + if (!this._panelConflictListenersRegistered) { + this.win.addEventListener("popupshowing", this); + this.win.gURLBar.controller.addQueryListener(this); + this._panelConflictListenersRegistered = true; + } + } + + _removePanelConflictListeners() { + if (this._panelConflictListenersRegistered) { + this.win.removeEventListener("popupshowing", this); + this.win.gURLBar.controller.removeQueryListener(this); + this._panelConflictListenersRegistered = false; + } + } + + /** + * Close the tour when the urlbar is opened in the chrome. Set up by + * gURLBar.controller.addQueryListener in _addPanelConflictListeners. + */ + onViewOpen() { + this.endTour(); + } + + _handlePrefChange(subject, topic, prefName) { + switch (prefName) { + case this.pref?.name: + try { + this._featureTourProgress = JSON.parse( + Services.prefs.getStringPref( + this.pref.name, + this.pref.defaultValue ?? null + ) + ); + } catch (error) { + this._featureTourProgress = null; + } + if (topic === "nsPref:changed") { + this._maybeAdvanceScreens(); + } + break; + } + } + + _maybeAdvanceScreens() { + if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) { + return; + } + + // If we have more than one screen, it means that we're displaying a feature + // tour, and transitions are handled based on the value of a tour progress + // pref. Otherwise, just show the feature callout. If a pref change results + // from an event in a Spotlight message, initialize the feature callout with + // the next message in the tour. + if ( + this.config?.screens.length === 1 || + this.currentScreen === "spotlight" + ) { + this.showFeatureCallout(); + return; + } + + let prefVal = this.featureTourProgress; + // End the tour according to the tour progress pref or if the user disabled + // contextual feature recommendations. + if (prefVal.complete || !this.cfrFeaturesUserPref) { + this.endTour(); + } else if (prefVal.screen !== this.currentScreen?.id) { + // Pref changes only matter to us insofar as they let us advance an + // ongoing tour. If the tour was closed and the pref changed later, e.g. + // by editing the pref directly, we don't want to start up the tour again. + // This is more important in the chrome, which is always open. + if (this.context === "chrome" && !this.currentScreen) { + return; + } + this.ready = false; + this._container?.classList.toggle( + "hidden", + this._container?.localName !== "panel" + ); + this._pageEventManager?.emit({ + type: "touradvance", + target: this._container, + }); + const onFadeOut = async () => { + // If the initial message was deployed from outside by ASRouter as a + // result of a trigger, we can't continue it through _loadConfig, since + // that effectively requests a message with a `featureCalloutCheck` + // trigger. So we need to load up the same message again, merely + // changing the startScreen index. Just check that the next screen and + // the current screen are both within the message's screens array. + let nextMessage = null; + if ( + this.context === "chrome" && + this.message?.trigger.id !== "featureCalloutCheck" + ) { + if ( + this.config?.screens.some(s => s.id === this.currentScreen?.id) && + this.config.screens.some(s => s.id === prefVal.screen) + ) { + nextMessage = this.message; + } + } + this._container?.remove(); + this.renderObserver?.disconnect(); + this._removePositionListeners(); + this._removePanelConflictListeners(); + this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); + if (nextMessage) { + const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage( + nextMessage + ); + if (!isMessageUnblocked) { + this.endTour(); + return; + } + } + let updated = await this._updateConfig(nextMessage); + if (!updated && !this.currentScreen) { + this.endTour(); + return; + } + let rendering = await this._renderCallout(); + if (!rendering) { + this.endTour(); + } + }; + if (this._container?.localName === "panel") { + this._container.removeEventListener("popuphiding", this); + this._container.addEventListener("popuphidden", onFadeOut, { + once: true, + }); + this._container.hidePopup(true); + } else { + this.win.setTimeout(onFadeOut, TRANSITION_MS); + } + } + } + + handleEvent(event) { + switch (event.type) { + case "focus": { + if (!this._container) { + return; + } + // If focus has fired on the feature callout window itself, or on something + // contained in that window, ignore it, as we can't possibly place the focus + // on it after the callout is closd. + if ( + event.target === this._container || + (Node.isInstance(event.target) && + this._container.contains(event.target)) + ) { + return; + } + // Save this so that if the next focus event is re-entering the popup, + // then we'll put the focus back here where the user left it once we exit + // the feature callout series. + if (this.doc.activeElement) { + let element = this.doc.activeElement; + this.savedFocus = { + element, + focusVisible: element.matches(":focus-visible"), + }; + } else { + this.savedFocus = null; + } + break; + } + + case "keypress": { + if (event.key !== "Escape") { + return; + } + if (!this._container) { + return; + } + let focusedElement = + this.context === "chrome" + ? Services.focus.focusedElement + : this.doc.activeElement; + // If the window has a focused element, let it handle the ESC key instead. + if ( + !focusedElement || + focusedElement === this.doc.body || + (focusedElement === this.browser && this.theme.simulateContent) || + this._container.contains(focusedElement) + ) { + this.win.AWSendEventTelemetry?.({ + event: "DISMISS", + event_context: { + source: `KEY_${event.key}`, + page: this.location, + }, + message_id: this.config?.id.toUpperCase(), + }); + this._dismiss(); + event.preventDefault(); + } + break; + } + + case "visibilitychange": + this._maybeAdvanceScreens(); + break; + + case "resize": + case "toggle": + this.win.requestAnimationFrame(() => this._positionCallout()); + break; + + case "popupshowing": + // If another panel is showing, close the tour. + if ( + event.target !== this._container && + event.target.localName === "panel" && + event.target.id !== "ctrlTab-panel" && + event.target.ownerGlobal === this.win + ) { + this.endTour(); + } + break; + + case "popuphiding": + if (event.target === this._container) { + this.endTour(); + } + break; + + case "unload": + try { + this.teardownFeatureTourProgress(); + } catch (error) {} + break; + + default: + } + } + + async _addCalloutLinkElements() { + for (const path of [ + "browser/newtab/onboarding.ftl", + "browser/spotlight.ftl", + "branding/brand.ftl", + "toolkit/branding/brandings.ftl", + "browser/newtab/asrouter.ftl", + "browser/featureCallout.ftl", + ]) { + this.win.MozXULElement.insertFTLIfNeeded(path); + } + + const addChromeSheet = href => { + try { + this.win.windowUtils.loadSheetUsingURIString( + href, + Ci.nsIDOMWindowUtils.AUTHOR_SHEET + ); + } catch (error) { + // the sheet was probably already loaded. I don't think there's a way to + // check for this via JS, but the method checks and throws if it's + // already loaded, so we can just treat the error as expected. + } + }; + const addStylesheet = href => { + if (this.win.isChromeWindow) { + // for chrome, load the stylesheet using a special method to make sure + // it's loaded synchronously before the first paint & position. + return addChromeSheet(href); + } + if (this.doc.querySelector(`link[href="${href}"]`)) { + return null; + } + const link = this.doc.head.appendChild(this.doc.createElement("link")); + link.rel = "stylesheet"; + link.href = href; + return null; + }; + // Update styling to be compatible with about:welcome bundle + await addStylesheet( + "chrome://browser/content/aboutwelcome/aboutwelcome.css" + ); + } + + /** + * @typedef { + * | "topleft" + * | "topright" + * | "bottomleft" + * | "bottomright" + * | "leftcenter" + * | "rightcenter" + * | "topcenter" + * | "bottomcenter" + * } PopupAttachmentPoint + * + * @see nsMenuPopupFrame + * + * Each attachment point corresponds to an attachment point on the edge of a + * frame. For example, "topleft" corresponds to the frame's top left corner, + * and "rightcenter" corresponds to the center of the right edge of the frame. + */ + + /** + * @typedef {Object} PanelPosition Specifies how the callout panel should be + * positioned relative to the anchor element, by providing which point on + * the callout should be aligned with which point on the anchor element. + * @property {PopupAttachmentPoint} anchor_attachment + * @property {PopupAttachmentPoint} callout_attachment + * @property {Number} [offset_x] Offset in pixels to apply to the callout + * position in the horizontal direction. + * @property {Number} [offset_y] The same in the vertical direction. + * + * This is used when you want the callout to be displayed as a <panel> + * element. A panel is critical when the callout is displayed in the browser + * chrome, anchored to an element whose position on the screen is dynamic, + * such as a button. When the anchor moves, the panel will automatically move + * with it. Also, when the elements are aligned so that the callout would + * extend beyond the edge of the screen, the panel will automatically flip + * itself to the other side of the anchor element. This requires specifying + * both an anchor attachment point and a callout attachment point. For + * example, to get the callout to appear under a button, with its arrow on the + * right side of the callout: + * { anchor_attachment: "bottomcenter", callout_attachment: "topright" } + */ + + /** + * @typedef { + * | "top" + * | "bottom" + * | "end" + * | "start" + * | "top-end" + * | "top-start" + * | "top-center-arrow-end" + * | "top-center-arrow-start" + * } HTMLArrowPosition + * + * @see FeatureCallout._positionCallout() + * The position of the callout arrow relative to the callout container. Only + * used for HTML callouts, typically in content pages. If the position + * contains a dash, the value before the dash refers to which edge of the + * feature callout the arrow points from. The value after the dash describes + * where along that edge the arrow sits, with middle as the default. + */ + + /** + * @typedef {Object} PositionOverride CSS properties to override + * the callout's position relative to the anchor element. Although the + * callout is not actually a child of the anchor element, this allows + * absolute positioning of the callout relative to the anchor element. In + * other words, { top: "0px", left: "0px" } will position the callout in the + * top left corner of the anchor element, in the same way these properties + * would position a child element. + * @property {String} [top] + * @property {String} [left] + * @property {String} [right] + * @property {String} [bottom] + */ + + /** + * @typedef {Object} AnchorConfig + * @property {String} selector CSS selector for the anchor node. + * @property {PanelPosition} [panel_position] Used to show the callout in a + * XUL panel. Only works in chrome documents, like the main browser window. + * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in + * an HTML div container. Mutually exclusive with panel_position. + * @property {PositionOverride} [absolute_position] Only used for HTML + * callouts, i.e. when panel_position is not specified. Allows absolute + * positioning of the callout relative to the anchor element. + * @property {Boolean} [hide_arrow] Whether to hide the arrow. + * @property {Boolean} [no_open_on_anchor] Whether to set the [open] style on + * the anchor element when the callout is shown. False to set it, true to + * not set it. This only works for panel callouts. Not all elements have an + * [open] style. Buttons do, for example. It's usually similar to :active. + * @property {Number} [arrow_width] The desired width of the arrow in a number + * of pixels. 33.94113 by default (this corresponds to 24px edges). + */ + + /** + * @typedef {Object} Anchor + * @property {String} selector + * @property {PanelPosition} [panel_position] + * @property {HTMLArrowPosition} [arrow_position] + * @property {PositionOverride} [absolute_position] + * @property {Boolean} [hide_arrow] + * @property {Boolean} [no_open_on_anchor] + * @property {Number} [arrow_width] + * @property {Element} element The anchor node resolved from the selector. + * @property {String} [panel_position_string] The panel_position joined into a + * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup. + */ + + /** + * Return the first visible anchor element for the current screen. Screens can + * specify multiple anchors in an array, and the first one that is visible + * will be used. If none are visible, return null. + * @returns {Anchor|null} + */ + _getAnchor() { + /** @type {AnchorConfig[]} */ + const anchors = Array.isArray(this.currentScreen?.anchors) + ? this.currentScreen.anchors + : []; + for (let anchor of anchors) { + if (!anchor || typeof anchor !== "object") { + lazy.log.debug( + `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}` + ); + continue; + } + const { selector, arrow_position, panel_position } = anchor; + let panel_position_string; + if (panel_position) { + panel_position_string = this._getPanelPositionString(panel_position); + // if the positionString doesn't match the format we expect, don't + // render the callout. + if (!panel_position_string && !arrow_position) { + lazy.log.debug( + `In ${ + this.location + }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify( + panel_position + )}` + ); + continue; + } + } + if ( + arrow_position && + !this._HTMLArrowPositions.includes(arrow_position) + ) { + lazy.log.debug( + `In ${ + this.location + }: Invalid arrow_position config. Expected one of ${JSON.stringify( + this._HTMLArrowPositions + )}, got: ${arrow_position}` + ); + continue; + } + const element = selector && this.doc.querySelector(selector); + if (!element) { + continue; // Element doesn't exist at all. + } + const isVisible = () => { + if ( + this.context === "chrome" && + typeof this.win.isElementVisible === "function" + ) { + // In chrome windows, we can use the isElementVisible function to + // check that the element has non-zero width and height. If it was + // hidden, it would most likely have zero width and/or height. + if (!this.win.isElementVisible(element)) { + return false; + } + } + // CSS rules like visibility: hidden or display: none. These result in + // element being invisible and unclickable. + const style = this.win.getComputedStyle(element); + return style?.visibility === "visible" && style?.display !== "none"; + }; + if (!isVisible()) { + continue; + } + if ( + this.context === "chrome" && + element.id && + anchor.selector.includes(`#${element.id}`) + ) { + let widget = lazy.CustomizableUI.getWidget(element.id); + if ( + widget && + (this.win.CustomizationHandler.isCustomizing() || + widget.areaType?.includes("panel")) + ) { + // The element is a customizable widget (a toolbar item, e.g. the + // reload button or the downloads button). Widgets can be in various + // areas, like the overflow panel or the customization palette. + // Widgets in the palette are present in the chrome's DOM during + // customization, but can't be used. + continue; + } + } + return { ...anchor, panel_position_string, element }; + } + return null; + } + + /** @see PopupAttachmentPoint */ + _popupAttachmentPoints = [ + "topleft", + "topright", + "bottomleft", + "bottomright", + "leftcenter", + "rightcenter", + "topcenter", + "bottomcenter", + ]; + + /** + * Return a string representing the position of the panel relative to the + * anchor element. Passed to XULPopupElement::openPopup. The string is of the + * form "anchor_attachment callout_attachment". + * + * @param {PanelPosition} panelPosition + * @returns {String|null} A string like "bottomcenter topright", or null if + * the panelPosition object is invalid. + */ + _getPanelPositionString(panelPosition) { + const { anchor_attachment, callout_attachment } = panelPosition; + if ( + !this._popupAttachmentPoints.includes(anchor_attachment) || + !this._popupAttachmentPoints.includes(callout_attachment) + ) { + return null; + } + let positionString = `${anchor_attachment} ${callout_attachment}`; + return positionString; + } + + /** + * Set/override methods on a panel element. Can be used to override methods on + * the custom element class, or to add additional methods. + * + * @param {MozPanel} panel The panel to set methods for + */ + _setPanelMethods(panel) { + // This method is optionally called by MozPanel::_setSideAttribute, though + // it does not exist on the class. + panel.setArrowPosition = function setArrowPosition(event) { + if (!this.hasAttribute("show-arrow")) { + return; + } + let { alignmentPosition, alignmentOffset, popupAlignment } = event; + let positionParts = alignmentPosition?.match( + /^(before|after|start|end)_(before|after|start|end)$/ + ); + if (!positionParts) { + return; + } + // Hide the arrow if the `flip` behavior has caused the panel to + // offset relative to its anchor, since the arrow would no longer + // point at the true anchor. This differs from an arrow that is + // intentionally hidden by the user in message. + if (this.getAttribute("hide-arrow") !== "permanent") { + if (alignmentOffset) { + this.setAttribute("hide-arrow", "temporary"); + } else { + this.removeAttribute("hide-arrow"); + } + } + let arrowPosition = "top"; + switch (positionParts[1]) { + case "start": + case "end": { + // Inline arrow, i.e. arrow is on one of the left/right edges. + let isRTL = + this.ownerGlobal.getComputedStyle(this).direction === "rtl"; + let isRight = isRTL ^ (positionParts[1] === "start"); + let side = isRight ? "end" : "start"; + arrowPosition = `inline-${side}`; + if (popupAlignment?.includes("center")) { + arrowPosition = `inline-${side}`; + } else if (positionParts[2] === "before") { + arrowPosition = `inline-${side}-top`; + } else if (positionParts[2] === "after") { + arrowPosition = `inline-${side}-bottom`; + } + break; + } + case "before": + case "after": { + // Block arrow, i.e. arrow is on one of the top/bottom edges. + let side = positionParts[1] === "before" ? "bottom" : "top"; + arrowPosition = side; + if (popupAlignment?.includes("center")) { + arrowPosition = side; + } else if (positionParts[2] === "end") { + arrowPosition = `${side}-end`; + } else if (positionParts[2] === "start") { + arrowPosition = `${side}-start`; + } + break; + } + } + this.setAttribute("arrow-position", arrowPosition); + }; + } + + _createContainer() { + const anchor = this._getAnchor(); + // Don't render the callout if none of the anchors is visible. + if (!anchor) { + return false; + } + + const { autohide, padding } = this.currentScreen.content; + const { + panel_position_string, + hide_arrow, + no_open_on_anchor, + arrow_width, + } = anchor; + const needsPanel = "MozXULElement" in this.win && !!panel_position_string; + + if (this._container) { + if (needsPanel ^ (this._container?.localName === "panel")) { + this._container.remove(); + } + } + + if (!this._container?.parentElement) { + if (needsPanel) { + let fragment = this.win.MozXULElement.parseXULToFragment(`<panel + class="panel-no-padding" + orient="vertical" + ignorekeys="true" + noautofocus="true" + flip="slide" + type="arrow" + position="${panel_position_string}" + ${hide_arrow ? "" : 'show-arrow=""'} + ${autohide ? "" : 'noautohide="true"'} + ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""} + />`); + this._container = fragment.firstElementChild; + this._setPanelMethods(this._container); + } else { + this._container = this.doc.createElement("div"); + this._container?.classList.add("hidden"); + } + this._container.classList.add("featureCallout", "callout-arrow"); + if (hide_arrow) { + this._container.setAttribute("hide-arrow", "permanent"); + } else { + this._container.removeAttribute("hide-arrow"); + } + this._container.id = CONTAINER_ID; + this._container.setAttribute( + "aria-describedby", + `#${CONTAINER_ID} .welcome-text` + ); + this._container.tabIndex = 0; + if (arrow_width) { + this._container.style.setProperty("--arrow-width", `${arrow_width}px`); + } else { + this._container.style.removeProperty("--arrow-width"); + } + if (padding) { + this._container.style.setProperty("--callout-padding", `${padding}px`); + } else { + this._container.style.removeProperty("--callout-padding"); + } + let contentBox = this.doc.createElement("div"); + contentBox.id = CONTENT_BOX_ID; + contentBox.classList.add("onboardingContainer"); + // This value is reported as the "page" in about:welcome telemetry + contentBox.dataset.page = this.location; + this._applyTheme(); + if (needsPanel && this.win.isChromeWindow) { + this.doc.getElementById("mainPopupSet").appendChild(this._container); + } else { + this.doc.body.prepend(this._container); + } + const makeArrow = classPrefix => { + const arrowRotationBox = this.doc.createElement("div"); + arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`); + const arrow = this.doc.createElement("div"); + arrow.classList.add("arrow", `${classPrefix}-arrow`); + arrowRotationBox.appendChild(arrow); + return arrowRotationBox; + }; + this._container.appendChild(makeArrow("shadow")); + this._container.appendChild(contentBox); + this._container.appendChild(makeArrow("background")); + } + return this._container; + } + + /** @see HTMLArrowPosition */ + _HTMLArrowPositions = [ + "top", + "bottom", + "end", + "start", + "top-end", + "top-start", + "top-center-arrow-end", + "top-center-arrow-start", + ]; + + /** + * Set callout's position relative to parent element + */ + _positionCallout() { + const container = this._container; + const anchor = this._getAnchor(); + if (!container || !anchor) { + this.endTour(); + return; + } + const parentEl = anchor.element; + const { doc } = this; + const arrowPosition = anchor.arrow_position || "top"; + const arrowWidth = anchor.arrow_width || 33.94113; + const arrowHeight = arrowWidth / 2; + const overlapAmount = 5; + let overlap = overlapAmount - arrowHeight; + // Is the document layout right to left? + const RTL = this.doc.dir === "rtl"; + const customPosition = anchor.absolute_position; + + const getOffset = el => { + const rect = el.getBoundingClientRect(); + return { + left: rect.left + this.win.scrollX, + right: rect.right + this.win.scrollX, + top: rect.top + this.win.scrollY, + bottom: rect.bottom + this.win.scrollY, + }; + }; + + const centerVertically = () => { + let topOffset = + (container.getBoundingClientRect().height - + parentEl.getBoundingClientRect().height) / + 2; + container.style.top = `${getOffset(parentEl).top - topOffset}px`; + }; + + /** + * Horizontally align a top/bottom-positioned callout according to the + * passed position. + * @param {String} position one of... + * - "center": for use with top/bottom. arrow is in the center, and the + * center of the callout aligns with the parent center. + * - "center-arrow-start": for use with center-arrow-top-start. arrow is + * on the start (left) side of the callout, and the callout is aligned + * so that the arrow points to the center of the parent element. + * - "center-arrow-end": for use with center-arrow-top-end. arrow is on + * the end, and the arrow points to the center of the parent. + * - "start": currently unused. align the callout's starting edge with the + * parent's starting edge. + * - "end": currently unused. same as start but for the ending edge. + */ + const alignHorizontally = position => { + switch (position) { + case "center": { + const sideOffset = + (parentEl.getBoundingClientRect().width - + container.getBoundingClientRect().width) / + 2; + const containerSide = RTL + ? doc.documentElement.clientWidth - + getOffset(parentEl).right + + sideOffset + : getOffset(parentEl).left + sideOffset; + container.style[RTL ? "right" : "left"] = `${Math.max( + containerSide, + 0 + )}px`; + break; + } + case "end": + case "start": { + const containerSide = + RTL ^ (position === "end") + ? parentEl.getBoundingClientRect().left + + parentEl.getBoundingClientRect().width - + container.getBoundingClientRect().width + : parentEl.getBoundingClientRect().left; + container.style.left = `${Math.max(containerSide, 0)}px`; + break; + } + case "center-arrow-end": + case "center-arrow-start": { + const parentRect = parentEl.getBoundingClientRect(); + const containerWidth = container.getBoundingClientRect().width; + const containerSide = + RTL ^ position.endsWith("end") + ? parentRect.left + + parentRect.width / 2 + + 12 + + arrowWidth / 2 - + containerWidth + : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2; + const maxContainerSide = + doc.documentElement.clientWidth - containerWidth; + container.style.left = `${Math.min( + maxContainerSide, + Math.max(containerSide, 0) + )}px`; + } + } + }; + + // Remember not to use HTML-only properties/methods like offsetHeight. Try + // to use getBoundingClientRect() instead, which is available on XUL + // elements. This is necessary to support feature callout in chrome, which + // is still largely XUL-based. + const positioners = { + // availableSpace should be the space between the edge of the page in the + // assumed direction and the edge of the parent (with the callout being + // intended to fit between those two edges) while needed space should be + // the space necessary to fit the callout container. + top: { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center"); + }, + }, + bottom: { + availableSpace() { + return getOffset(parentEl).top; + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element below the callout + let containerTop = + getOffset(parentEl).top - + container.getBoundingClientRect().height + + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center"); + }, + }, + right: { + availableSpace() { + return getOffset(parentEl).left; + }, + neededSpace: container.getBoundingClientRect().width - overlap, + position() { + // Point to an element to the right of the callout + let containerLeft = + getOffset(parentEl).left - + container.getBoundingClientRect().width + + overlap; + container.style.left = `${Math.max(0, containerLeft)}px`; + if ( + container.getBoundingClientRect().height <= + parentEl.getBoundingClientRect().height + ) { + container.style.top = `${getOffset(parentEl).top}px`; + } else { + centerVertically(); + } + }, + }, + left: { + availableSpace() { + return doc.documentElement.clientWidth - getOffset(parentEl).right; + }, + neededSpace: container.getBoundingClientRect().width - overlap, + position() { + // Point to an element to the left of the callout + let containerLeft = + getOffset(parentEl).left + + parentEl.getBoundingClientRect().width - + overlap; + container.style.left = `${Math.max(0, containerLeft)}px`; + if ( + container.getBoundingClientRect().height <= + parentEl.getBoundingClientRect().height + ) { + container.style.top = `${getOffset(parentEl).top}px`; + } else { + centerVertically(); + } + }, + }, + "top-start": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the start of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("start"); + }, + }, + "top-end": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the end of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("end"); + }, + }, + "top-center-arrow-start": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the start of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center-arrow-start"); + }, + }, + "top-center-arrow-end": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the end of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center-arrow-end"); + }, + }, + }; + + const clearPosition = () => { + Object.keys(positioners).forEach(position => { + container.style[position] = "unset"; + }); + container.removeAttribute("arrow-position"); + }; + + const setArrowPosition = position => { + let val; + switch (position) { + case "bottom": + val = "bottom"; + break; + case "left": + val = "inline-start"; + break; + case "right": + val = "inline-end"; + break; + case "top-start": + case "top-center-arrow-start": + val = RTL ? "top-end" : "top-start"; + break; + case "top-end": + case "top-center-arrow-end": + val = RTL ? "top-start" : "top-end"; + break; + case "top": + default: + val = "top"; + break; + } + + container.setAttribute("arrow-position", val); + }; + + const addValueToPixelValue = (value, pixelValue) => { + return `${parseFloat(pixelValue) + value}px`; + }; + + const subtractPixelValueFromValue = (pixelValue, value) => { + return `${value - parseFloat(pixelValue)}px`; + }; + + const overridePosition = () => { + // We override _every_ positioner here, because we want to manually set + // all container.style.positions in every positioner's "position" function + // regardless of the actual arrow position + + // Note: We override the position functions with new functions here, but + // they don't actually get executed until the respective position + // functions are called and this function is not executed unless the + // message has a custom position property. + + // We're positioning relative to a parent element's bounds, if that parent + // element exists. + + for (const position in positioners) { + if (!Object.prototype.hasOwnProperty.call(positioners, position)) { + continue; + } + + positioners[position].position = () => { + if (customPosition.top) { + container.style.top = addValueToPixelValue( + parentEl.getBoundingClientRect().top, + customPosition.top + ); + } + + if (customPosition.left) { + const leftPosition = addValueToPixelValue( + parentEl.getBoundingClientRect().left, + customPosition.left + ); + + if (RTL) { + container.style.right = leftPosition; + } else { + container.style.left = leftPosition; + } + } + + if (customPosition.right) { + const rightPosition = subtractPixelValueFromValue( + customPosition.right, + parentEl.getBoundingClientRect().right - + container.getBoundingClientRect().width + ); + + if (RTL) { + container.style.right = rightPosition; + } else { + container.style.left = rightPosition; + } + } + + if (customPosition.bottom) { + container.style.top = subtractPixelValueFromValue( + customPosition.bottom, + parentEl.getBoundingClientRect().bottom - + container.getBoundingClientRect().height + ); + } + }; + } + }; + + const calloutFits = position => { + // Does callout element fit in this position relative + // to the parent element without going off screen? + + // Only consider which edge of the callout the arrow points from, + // not the alignment of the arrow along the edge of the callout + let [edgePosition] = position.split("-"); + return ( + positioners[edgePosition].availableSpace() > + positioners[edgePosition].neededSpace + ); + }; + + const choosePosition = () => { + let position = arrowPosition; + if (!this._HTMLArrowPositions.includes(position)) { + // Configured arrow position is not valid + position = null; + } + if (["start", "end"].includes(position)) { + // position here is referencing the direction that the callout container + // is pointing to, and therefore should be the _opposite_ side of the + // arrow eg. if arrow is at the "end" in LTR layouts, the container is + // pointing at an element to the right of itself, while in RTL layouts + // it is pointing to the left of itself + position = RTL ^ (position === "start") ? "left" : "right"; + } + // If we're overriding the position, we don't need to sort for available space + if (customPosition || (position && calloutFits(position))) { + return position; + } + let sortedPositions = ["top", "bottom", "left", "right"] + .filter(p => p !== position) + .filter(calloutFits) + .sort((a, b) => { + return ( + positioners[b].availableSpace() - positioners[b].neededSpace > + positioners[a].availableSpace() - positioners[a].neededSpace + ); + }); + // If the callout doesn't fit in any position, use the configured one. + // The callout will be adjusted to overlap the parent element so that + // the former doesn't go off screen. + return sortedPositions[0] || position; + }; + + clearPosition(container); + + if (customPosition) { + overridePosition(); + } + + let finalPosition = choosePosition(); + if (finalPosition) { + positioners[finalPosition].position(); + setArrowPosition(finalPosition); + } + + container.classList.remove("hidden"); + } + + /** Expose top level functions expected by the aboutwelcome bundle. */ + _setupWindowFunctions() { + if (this.AWSetup) { + return; + } + + const handleActorMessage = + lazy.AboutWelcomeParent.prototype.onContentMessage.bind({}); + const getActionHandler = name => data => + handleActorMessage(`AWPage:${name}`, data, this.doc); + + const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT"); + const AWSendEventTelemetry = data => { + if (this.config?.metrics !== "block") { + return telemetryMessageHandler(data); + } + return null; + }; + this._windowFuncs = { + AWGetFeatureConfig: () => this.config, + AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"), + // Do not send telemetry if message config sets metrics as 'block'. + AWSendEventTelemetry, + AWSendToDeviceEmailsSupported: getActionHandler( + "SEND_TO_DEVICE_EMAILS_SUPPORTED" + ), + AWSendToParent: (name, data) => getActionHandler(name)(data), + AWFinish: () => this.endTour(), + AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"), + }; + for (const [name, func] of Object.entries(this._windowFuncs)) { + this.win[name] = func; + } + + this.AWSetup = true; + } + + /** Clean up the functions defined above. */ + _clearWindowFunctions() { + if (this.AWSetup) { + this.AWSetup = false; + + for (const name of Object.keys(this._windowFuncs)) { + delete this.win[name]; + } + } + } + + /** + * Emit an event to the broker, if one is present. + * @param {String} name + * @param {any} data + */ + _emitEvent(name, data) { + this.listener?.(this.win, name, data); + } + + endTour(skipFadeOut = false) { + // We don't want focus events that happen during teardown to affect + // this.savedFocus + this.win.removeEventListener("focus", this, { + capture: true, + passive: true, + }); + this.win.removeEventListener("keypress", this, { capture: true }); + this._pageEventManager?.emit({ + type: "tourend", + target: this._container, + }); + this._container?.removeEventListener("popuphiding", this); + this._pageEventManager?.clear(); + + // Delete almost everything to get this ready to show a different message. + this.teardownFeatureTourProgress(); + this.pref = null; + this.ready = false; + this.message = null; + this.content = null; + this.currentScreen = null; + // wait for fade out transition + this._container?.classList.toggle( + "hidden", + this._container?.localName !== "panel" + ); + this._clearWindowFunctions(); + const onFadeOut = () => { + this._container?.remove(); + this.renderObserver?.disconnect(); + this._removePositionListeners(); + this._removePanelConflictListeners(); + this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); + // Put the focus back to the last place the user focused outside of the + // featureCallout windows. + if (this.savedFocus) { + this.savedFocus.element.focus({ + focusVisible: this.savedFocus.focusVisible, + }); + } + this.savedFocus = null; + this._emitEvent("end"); + }; + if (this._container?.localName === "panel") { + this._container.addEventListener("popuphidden", onFadeOut, { + once: true, + }); + this._container.hidePopup(!skipFadeOut); + } else if (this._container) { + this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS); + } else { + onFadeOut(); + } + } + + _dismiss() { + let action = this.currentScreen?.content.dismiss_button?.action; + if (action?.type) { + this.win.AWSendToParent("SPECIAL_ACTION", action); + if (!action.dismiss) { + return; + } + } + this.endTour(); + } + + async _addScriptsAndRender() { + const reactSrc = "resource://activity-stream/vendor/react.js"; + const domSrc = "resource://activity-stream/vendor/react-dom.js"; + // Add React script + const getReactReady = () => { + return new Promise(resolve => { + let reactScript = this.doc.createElement("script"); + reactScript.src = reactSrc; + this.doc.head.appendChild(reactScript); + reactScript.addEventListener("load", resolve); + }); + }; + // Add ReactDom script + const getDomReady = () => { + return new Promise(resolve => { + let domScript = this.doc.createElement("script"); + domScript.src = domSrc; + this.doc.head.appendChild(domScript); + domScript.addEventListener("load", resolve); + }); + }; + // Load React, then React Dom + if (!this.doc.querySelector(`[src="${reactSrc}"]`)) { + await getReactReady(); + } + if (!this.doc.querySelector(`[src="${domSrc}"]`)) { + await getDomReady(); + } + // Load the bundle to render the content as configured. + this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); + let bundleScript = this.doc.createElement("script"); + bundleScript.src = BUNDLE_SRC; + this.doc.head.appendChild(bundleScript); + } + + _observeRender(container) { + this.renderObserver?.observe(container, { childList: true }); + } + + /** + * Update the internal config with a new message. If a message is not + * provided, try requesting one from ASRouter. The message content is stored + * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome + * bundle will use that function to get the content when it executes. + * @param {Object} [message] ASRouter message. Omit to request a new one. + * @returns {Promise<boolean>} true if a message is loaded, false if not. + */ + async _updateConfig(message) { + if (this.loadingConfig) { + return false; + } + + this.message = message || (await this._loadConfig()); + + switch (this.message.template) { + case "feature_callout": + break; + case "spotlight": + // Special handling for spotlight messages, which can be configured as a + // kind of introduction to a feature tour. + this.currentScreen = "spotlight"; + // fall through + default: + return false; + } + + this.config = this.message.content; + + // Set the default start screen. + let newScreen = this.config?.screens?.[this.config?.startScreen || 0]; + // If we have a feature tour in progress, try to set the start screen to + // whichever screen is configured in the feature tour pref. + if ( + this.config.screens && + this.config?.tour_pref_name && + this.config.tour_pref_name === this.pref?.name && + this.featureTourProgress + ) { + const newIndex = this.config.screens.findIndex( + screen => screen.id === this.featureTourProgress.screen + ); + if (newIndex !== -1) { + newScreen = this.config.screens[newIndex]; + if (newScreen?.id !== this.currentScreen?.id) { + // This is how we tell the bundle to render the correct screen. + this.config.startScreen = newIndex; + } + } + } + if (newScreen?.id === this.currentScreen?.id) { + return false; + } + + this.currentScreen = newScreen; + return true; + } + + /** + * Request a message from ASRouter, targeting the `browser` and `page` values + * passed to the constructor. + * @returns {Promise<Object>} the requested message. + */ + async _loadConfig() { + this.loadingConfig = true; + await lazy.ASRouter.waitForInitialized; + let result = await lazy.ASRouter.sendTriggerMessage({ + browser: this.browser, + // triggerId and triggerContext + id: "featureCalloutCheck", + context: { source: this.location }, + }); + this.loadingConfig = false; + return result.message; + } + + /** + * Try to render the callout in the current document. + * @returns {Promise<Boolean>} whether the callout was rendered. + */ + async _renderCallout() { + this._setupWindowFunctions(); + await this._addCalloutLinkElements(); + let container = this._createContainer(); + if (container) { + // This results in rendering the Feature Callout + await this._addScriptsAndRender(); + this._observeRender(container.querySelector(`#${CONTENT_BOX_ID}`)); + if (container.localName === "div") { + this._addPositionListeners(); + } + return true; + } + return false; + } + + /** + * For each member of the screen's page_event_listeners array, add a listener. + * @param {Array<PageEventListenerConfig>} listeners + * + * @typedef {Object} PageEventListenerConfig + * @property {PageEventListenerParams} params Event listener parameters + * @property {PageEventListenerAction} action Sent when the event fires + * + * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs + * @property {String} type Event type string e.g. `click` + * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]` + * @property {PageEventListenerOptions} [options] addEventListener options + * + * @typedef {Object} PageEventListenerOptions + * @property {Boolean} [capture] Use event capturing phase? + * @property {Boolean} [once] Remove listener after first event? + * @property {Boolean} [preventDefault] Prevent default action? + * @property {Number} [interval] Used only for `timeout` and `interval` event + * types. These don't set up real event listeners, but instead invoke the + * action on a timer. + * + * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent + * @property {String} [type] Action type, e.g. `OPEN_URL` + * @property {Object} [data] Extra data, properties depend on action type + * @property {Boolean} [dismiss] Dismiss screen after performing action? + * @property {Boolean} [reposition] Reposition screen after performing action? + */ + _attachPageEventListeners(listeners) { + listeners?.forEach(({ params, action }) => + this._loadPageEventManager[params.options?.once ? "once" : "on"]( + params, + event => { + this._handlePageEventAction(action, event); + if (params.options?.preventDefault) { + event.preventDefault?.(); + } + } + ) + ); + } + + /** + * Perform an action in response to a page event. + * @param {PageEventListenerAction} action + * @param {Event} event Triggering event + */ + _handlePageEventAction(action, event) { + const page = this.location; + const message_id = this.config?.id.toUpperCase(); + const source = + typeof event.target === "string" + ? event.target + : this._getUniqueElementIdentifier(event.target); + if (action.type) { + this.win.AWSendEventTelemetry?.({ + event: "PAGE_EVENT", + event_context: { + action: action.type, + reason: event.type?.toUpperCase(), + source, + page, + }, + message_id, + }); + this.win.AWSendToParent("SPECIAL_ACTION", action); + } + if (action.dismiss) { + this.win.AWSendEventTelemetry?.({ + event: "DISMISS", + event_context: { source: `PAGE_EVENT:${source}`, page }, + message_id, + }); + this._dismiss(); + } + if (action.reposition) { + this.win.requestAnimationFrame(() => this._positionCallout()); + } + } + + /** + * For a given element, calculate a unique string that identifies it. + * @param {Element} target Element to calculate the selector for + * @returns {String} Computed event target selector, e.g. `button#next` + */ + _getUniqueElementIdentifier(target) { + let source; + if (Element.isInstance(target)) { + source = target.localName; + if (target.className) { + source += `.${[...target.classList].join(".")}`; + } + if (target.id) { + source += `#${target.id}`; + } + if (target.attributes.length) { + source += `${[...target.attributes] + .filter(attr => ["is", "role", "open"].includes(attr.name)) + .map(attr => `[${attr.name}="${attr.value}"]`) + .join("")}`; + } + if (this.doc.querySelectorAll(source).length > 1) { + let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`); + if (uniqueAncestor) { + source = `${this._getUniqueElementIdentifier( + uniqueAncestor + )} > ${source}`; + } + } + } + return source; + } + + /** + * Get the element that should be initially focused. Prioritize the primary + * button, then the secondary button, then any additional button, excluding + * pseudo-links and the dismiss button. If no button is found, focus the first + * input element. If no affirmative action is found, focus the first button, + * which is probably the dismiss button. If no button is found, focus the + * container itself. + * @returns {Element|null} The element to focus when the callout is shown. + */ + getInitialFocus() { + if (!this._container) { + return null; + } + return ( + this._container.querySelector( + ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" + ) || + this._container.querySelector( + ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" + ) || + this._container.querySelector( + "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)" + ) || + this._container.querySelector("input:not(:disabled, [hidden])") || + this._container.querySelector( + "button:not(:disabled, [hidden], .text-link, .cta-link)" + ) || + this._container + ); + } + + /** + * Show a feature callout message, either by requesting one from ASRouter or + * by showing a message passed as an argument. + * @param {Object} [message] optional message to show instead of requesting one + * @returns {Promise<Boolean>} true if a message was shown + */ + async showFeatureCallout(message) { + let updated = await this._updateConfig(message); + + if (!updated || !this.config?.screens?.length) { + return !!this.currentScreen; + } + + if (!this.renderObserver) { + this.renderObserver = new this.win.MutationObserver(() => { + // Check if the Feature Callout screen has loaded for the first time + if (!this.ready && this._container.querySelector(".screen")) { + const onRender = () => { + this.ready = true; + this._pageEventManager?.clear(); + this._attachPageEventListeners( + this.currentScreen?.content?.page_event_listeners + ); + this.getInitialFocus()?.focus(); + this.win.addEventListener("keypress", this, { capture: true }); + if (this._container.localName === "div") { + this.win.addEventListener("focus", this, { + capture: true, // get the event before retargeting + passive: true, + }); + this._positionCallout(); + } else { + this._container.classList.remove("hidden"); + } + }; + if ( + this._container.localName === "div" && + this.doc.activeElement && + !this.savedFocus + ) { + let element = this.doc.activeElement; + this.savedFocus = { + element, + focusVisible: element.matches(":focus-visible"), + }; + } + // Once the screen element is added to the DOM, wait for the + // animation frame after next to ensure that _positionCallout + // has access to the rendered screen with the correct height + if (this._container.localName === "div") { + this.win.requestAnimationFrame(() => { + this.win.requestAnimationFrame(onRender); + }); + } else if (this._container.localName === "panel") { + const anchor = this._getAnchor(); + if (!anchor) { + this.endTour(); + return; + } + const position = anchor.panel_position_string; + this._container.addEventListener("popupshown", onRender, { + once: true, + }); + this._container.addEventListener("popuphiding", this); + this._addPanelConflictListeners(); + this._container.openPopup(anchor.element, { position }); + } + } + }); + } + + this._pageEventManager?.clear(); + this.ready = false; + this._container?.remove(); + this.renderObserver?.disconnect(); + + if (!this.cfrFeaturesUserPref) { + this.endTour(); + return false; + } + + let rendering = (await this._renderCallout()) && !!this.currentScreen; + if (!rendering) { + this.endTour(); + } + + if (this.message.template) { + lazy.ASRouter.addImpression(this.message); + } + return rendering; + } + + /** + * @typedef {Object} FeatureCalloutTheme An object with a set of custom color + * schemes and/or a preset key. If both are provided, the preset will be + * applied first, then the custom themes will override the preset values. + * @property {String} [preset] Key of {@link FeatureCallout.themePresets} + * @property {ColorScheme} [light] Custom light scheme + * @property {ColorScheme} [dark] Custom dark scheme + * @property {ColorScheme} [hcm] Custom high contrast scheme + * @property {ColorScheme} [all] Custom scheme that will be applied in all + * cases, but overridden by the other schemes if they are present. This is + * useful if the values are already controlled by the browser theme. + * @property {Boolean} [simulateContent] Set to true if the feature callout + * exists in the browser chrome but is meant to be displayed over the + * content area to appear as if it is part of the page. This will cause the + * styles to use a media query targeting the content instead of the chrome, + * so that if the browser theme doesn't match the content color scheme, the + * callout will correctly follow the content scheme. This is currently used + * for the feature callouts displayed over the PDF.js viewer. + */ + + /** + * @typedef {Object} ColorScheme An object with key-value pairs, with keys + * from {@link FeatureCallout.themePropNames}, mapped to CSS color values + */ + + /** + * Combine the preset and custom themes into a single object and store it. + * @param {FeatureCalloutTheme} theme + */ + _initTheme(theme) { + /** @type {FeatureCalloutTheme} */ + this.theme = Object.assign( + {}, + FeatureCallout.themePresets[theme.preset], + theme + ); + } + + /** + * Apply all the theme colors to the feature callout's root element as CSS + * custom properties in inline styles. These custom properties are consumed by + * _feature-callout-theme.scss, which is bundled with the other styles that + * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}. + */ + _applyTheme() { + if (this._container) { + // This tells the stylesheets to use -moz-content-prefers-color-scheme + // instead of prefers-color-scheme, in order to follow the content color + // scheme instead of the chrome color scheme, in case of a mismatch when + // the feature callout exists in the chrome but is meant to look like it's + // part of the content of a page in a browser tab (like PDF.js). + this._container.classList.toggle( + "simulateContent", + !!this.theme.simulateContent + ); + for (const type of ["light", "dark", "hcm"]) { + const scheme = this.theme[type]; + for (const name of FeatureCallout.themePropNames) { + this._setThemeVariable( + `--fc-${name}-${type}`, + scheme?.[name] || this.theme.all?.[name] + ); + } + } + } + } + + /** + * Set or remove a CSS custom property on the feature callout container + * @param {String} name Name of the CSS custom property + * @param {String|void} [value] Value of the property, or omit to remove it + */ + _setThemeVariable(name, value) { + if (value) { + this._container.style.setProperty(name, value); + } else { + this._container.style.removeProperty(name); + } + } + + /** A list of all the theme properties that can be set */ + static themePropNames = [ + "background", + "color", + "border", + "accent-color", + "button-background", + "button-color", + "button-border", + "button-background-hover", + "button-color-hover", + "button-border-hover", + "button-background-active", + "button-color-active", + "button-border-active", + "primary-button-background", + "primary-button-color", + "primary-button-border", + "primary-button-background-hover", + "primary-button-color-hover", + "primary-button-border-hover", + "primary-button-background-active", + "primary-button-color-active", + "primary-button-border-active", + "link-color", + "link-color-hover", + "link-color-active", + ]; + + /** @type {Object<String, FeatureCalloutTheme>} */ + static themePresets = { + // For themed system pages like New Tab and Firefox View. Themed content + // colors inherit from the user's theme through contentTheme.js. + "themed-content": { + all: { + background: "var(--newtab-background-color-secondary)", + color: "var(--newtab-text-primary-color, var(--in-content-page-color))", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)", + "accent-color": "var(--in-content-primary-button-background)", + "button-background": "color-mix(in srgb, transparent 93%, #000)", + "button-color": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border": "transparent", + "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", + "button-color-hover": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border-hover": "transparent", + "button-background-active": "color-mix(in srgb, transparent 80%, #000)", + "button-color-active": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border-active": "transparent", + "primary-button-background": + "var(--in-content-primary-button-background)", + "primary-button-color": "var(--in-content-primary-button-text-color)", + "primary-button-border": + "var(--in-content-primary-button-border-color)", + "primary-button-background-hover": + "var(--in-content-primary-button-background-hover)", + "primary-button-color-hover": + "var(--in-content-primary-button-text-color-hover)", + "primary-button-border-hover": + "var(--in-content-primary-button-border-hover)", + "primary-button-background-active": + "var(--in-content-primary-button-background-active)", + "primary-button-color-active": + "var(--in-content-primary-button-text-color-active)", + "primary-button-border-active": + "var(--in-content-primary-button-border-active)", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + dark: { + border: + "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)", + "button-background": "color-mix(in srgb, transparent 80%, #000)", + "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", + "button-background-active": "color-mix(in srgb, transparent 55%, #000)", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "-moz-dialogtext", + "accent-color": "LinkText", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "ButtonText", + "button-color-hover": "ButtonFace", + "button-border-hover": "ButtonText", + "button-background-active": "ButtonText", + "button-color-active": "ButtonFace", + "button-border-active": "ButtonText", + }, + }, + // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css + pdfjs: { + all: { + background: "#FFF", + color: "rgb(12, 12, 13)", + border: "#CFCFD8", + "accent-color": "#0A84FF", + "button-background": "rgb(215, 215, 219)", + "button-color": "rgb(12, 12, 13)", + "button-border": "transparent", + "button-background-hover": "rgb(221, 222, 223)", + "button-color-hover": "rgb(12, 12, 13)", + "button-border-hover": "transparent", + "button-background-active": "rgb(221, 222, 223)", + "button-color-active": "rgb(12, 12, 13)", + "button-border-active": "transparent", + // use default primary button colors in _feature-callout-theme.scss + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + dark: { + background: "#1C1B22", + color: "#F9F9FA", + border: "#3A3944", + "button-background": "rgb(74, 74, 79)", + "button-color": "#F9F9FA", + "button-background-hover": "rgb(102, 102, 103)", + "button-color-hover": "#F9F9FA", + "button-background-active": "rgb(102, 102, 103)", + "button-color-active": "#F9F9FA", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "CanvasText", + "accent-color": "Highlight", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "Highlight", + "button-color-hover": "CanvasText", + "button-border-hover": "Highlight", + "button-background-active": "Highlight", + "button-color-active": "CanvasText", + "button-border-active": "Highlight", + }, + }, + newtab: { + all: { + background: "var(--newtab-background-color-secondary, #FFF)", + color: "var(--newtab-text-primary-color, WindowText)", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)", + "accent-color": "#0061e0", + "button-background": "color-mix(in srgb, transparent 93%, #000)", + "button-color": "var(--newtab-text-primary-color, WindowText)", + "button-border": "transparent", + "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", + "button-color-hover": "var(--newtab-text-primary-color, WindowText)", + "button-border-hover": "transparent", + "button-background-active": "color-mix(in srgb, transparent 80%, #000)", + "button-color-active": "var(--newtab-text-primary-color, WindowText)", + "button-border-active": "transparent", + // use default primary button colors in _feature-callout-theme.scss + "link-color": "rgb(0, 97, 224)", + "link-color-hover": "rgb(0, 97, 224)", + "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)", + "link-color-visited": "rgb(0, 97, 224)", + }, + dark: { + "accent-color": "rgb(0, 221, 255)", + background: "var(--newtab-background-color-secondary, #42414D)", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)", + "button-background": "color-mix(in srgb, transparent 80%, #000)", + "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", + "button-background-active": "color-mix(in srgb, transparent 55%, #000)", + "link-color": "rgb(0, 221, 255)", + "link-color-hover": "rgb(0,221,255)", + "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)", + "link-color-visited": "rgb(0, 221, 255)", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "-moz-dialogtext", + "accent-color": "SelectedItem", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "ButtonText", + "button-color-hover": "ButtonFace", + "button-border-hover": "ButtonText", + "button-background-active": "ButtonText", + "button-color-active": "ButtonFace", + "button-border-active": "ButtonText", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + }, + // These colors are intended to inherit the user's theme properties from the + // main chrome window, for callouts to be anchored to chrome elements. + // Specific schemes aren't necessary since the theme and frontend + // stylesheets handle these variables' values. + chrome: { + all: { + background: "var(--arrowpanel-background)", + color: "var(--arrowpanel-color)", + border: "var(--arrowpanel-border-color)", + "accent-color": "var(--focus-outline-color)", + "button-background": "var(--button-bgcolor)", + "button-color": "var(--button-color)", + "button-border": "transparent", + "button-background-hover": "var(--button-hover-bgcolor)", + "button-color-hover": "var(--button-color)", + "button-border-hover": "transparent", + "button-background-active": "var(--button-active-bgcolor)", + "button-color-active": "var(--button-color)", + "button-border-active": "transparent", + "primary-button-background": "var(--button-primary-bgcolor)", + "primary-button-color": "var(--button-primary-color)", + "primary-button-border": "transparent", + "primary-button-background-hover": + "var(--button-primary-hover-bgcolor)", + "primary-button-color-hover": "var(--button-primary-color)", + "primary-button-border-hover": "transparent", + "primary-button-background-active": + "var(--button-primary-active-bgcolor)", + "primary-button-color-active": "var(--button-primary-color)", + "primary-button-border-active": "transparent", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + }, + }; +} diff --git a/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs new file mode 100644 index 0000000000..7ede6c9bf8 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs @@ -0,0 +1,215 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", +}); + +/** + * @typedef {Object} FeatureCalloutOptions + * @property {Window} win window in which messages will be rendered. + * @property {{name: String, defaultValue?: String}} [pref] optional pref used + * to track progress through a given feature tour. for example: + * { + * name: "browser.pdfjs.feature-tour", + * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }', + * } + * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional) + * @property {String} [location] string to pass as the page when requesting + * messages from ASRouter and sending telemetry. + * @property {MozBrowser} [browser] <browser> element responsible for the + * feature callout. for content pages, this is the browser element that the + * callout is being shown in. for chrome, this is the active browser. + * @property {Function} [cleanup] callback to be invoked when the callout is + * removed or the window is unloaded. + * @property {FeatureCalloutTheme} [theme] optional dynamic color theme. + */ + +/** @typedef {import("resource:///modules/asrouter/FeatureCallout.sys.mjs").FeatureCalloutTheme} FeatureCalloutTheme */ + +/** + * @typedef {Object} FeatureCalloutItem + * @property {lazy.FeatureCallout} callout instance of FeatureCallout. + * @property {Function} [cleanup] cleanup callback. + * @property {Boolean} showing whether the callout is currently showing. + */ + +export class _FeatureCalloutBroker { + /** + * Make a new FeatureCallout instance and store it in the callout map. Also + * add an unload listener to the window to clean up the callout when the + * window is unloaded. + * @param {FeatureCalloutOptions} config + */ + makeFeatureCallout(config) { + const { win, pref, location, browser, theme } = config; + // Use an AbortController to clean up the unload listener in case the + // callout is cleaned up before the window is unloaded. + const controller = new AbortController(); + const cleanup = () => { + this.#calloutMap.delete(win); + controller.abort(); + config.cleanup?.(); + }; + this.#calloutMap.set(win, { + callout: new lazy.FeatureCallout({ + win, + pref, + location, + context: "chrome", + browser, + listener: this.handleFeatureCalloutCallback.bind(this), + theme, + }), + cleanup, + showing: false, + }); + win.addEventListener("unload", cleanup, { signal: controller.signal }); + } + + /** + * Show a feature callout message. For use by ASRouter, to be invoked when a + * trigger has matched to a feature_callout message. + * @param {MozBrowser} browser <browser> element associated with the trigger. + * @param {Object} message feature_callout message from ASRouter. + * @see {@link FeatureCalloutMessages.sys.mjs} + * @returns {Promise<Boolean>} whether the callout was shown. + */ + async showFeatureCallout(browser, message) { + // Only show one callout at a time, across all windows. + if (this.isCalloutShowing) { + return false; + } + const win = browser.ownerGlobal; + // Avoid showing feature callouts if a dialog or panel is showing. + if ( + win.gDialogBox?.dialog || + [...win.document.querySelectorAll("panel")].some(p => p.state === "open") + ) { + return false; + } + const currentCallout = this.#calloutMap.get(win); + // If a custom callout was previously showing, but is no longer showing, + // tear down the FeatureCallout instance. We avoid tearing them down when + // they stop showing because they may be shown again, and we want to avoid + // the overhead of creating a new FeatureCallout instance. But the custom + // callout instance may be incompatible with the new ASRouter message, so + // we tear it down and create a new one. + if (currentCallout && currentCallout.callout.location !== "chrome") { + currentCallout.cleanup(); + } + let item = this.#calloutMap.get(win); + let callout = item?.callout; + if (item) { + // If a callout previously showed in this instance, but the new message's + // tour_pref_name is different, update the old instance's tour properties. + callout.teardownFeatureTourProgress(); + if (message.content.tour_pref_name) { + callout.pref = { + name: message.content.tour_pref_name, + defaultValue: message.content.tour_pref_default_value, + }; + callout.setupFeatureTourProgress(); + } else { + callout.pref = null; + } + } else { + const options = { + win, + location: "chrome", + browser, + theme: { preset: "chrome" }, + }; + if (message.content.tour_pref_name) { + options.pref = { + name: message.content.tour_pref_name, + defaultValue: message.content.tour_pref_default_value, + }; + } + this.makeFeatureCallout(options); + item = this.#calloutMap.get(win); + callout = item.callout; + } + // Set this to true for now so that we can't be interrupted by another + // invocation. We'll set it to false below if it ended up not showing. + item.showing = true; + item.showing = await callout.showFeatureCallout(message).catch(() => { + item.cleanup(); + return false; + }); + return item.showing; + } + + /** + * Make a new FeatureCallout instance specific to a special location, tearing + * down the existing generic FeatureCallout if it exists, and (if no message + * is passed) requesting a feature callout message to show. Does nothing if a + * callout is already in progress. This allows the PDF.js feature tour, which + * simulates content, to be shown in the chrome window without interfering + * with chrome feature callouts. + * @param {FeatureCalloutOptions} config + * @param {Object} message feature_callout message from ASRouter. + * @see {@link FeatureCalloutMessages.sys.mjs} + * @returns {FeatureCalloutItem|null} the callout item, if one was created. + */ + showCustomFeatureCallout(config, message) { + if (this.isCalloutShowing) { + return null; + } + const { win, pref, location } = config; + const currentCallout = this.#calloutMap.get(win); + if (currentCallout && currentCallout.location !== location) { + currentCallout.cleanup(); + } + let item = this.#calloutMap.get(win); + let callout = item?.callout; + if (item) { + callout.teardownFeatureTourProgress(); + callout.pref = pref; + if (pref) { + callout.setupFeatureTourProgress(); + } + } else { + this.makeFeatureCallout(config); + item = this.#calloutMap.get(win); + callout = item.callout; + } + item.showing = true; + // In this case, callers are not necessarily async, so we don't await. + callout + .showFeatureCallout(message) + .then(showing => { + item.showing = showing; + }) + .catch(() => { + item.cleanup(); + item.showing = false; + }); + /** @type {FeatureCalloutItem} */ + return item; + } + + handleFeatureCalloutCallback(win, event, data) { + switch (event) { + case "end": + const item = this.#calloutMap.get(win); + if (item) { + item.showing = false; + } + break; + } + } + + /** @returns {Boolean} whether a callout is currently showing. */ + get isCalloutShowing() { + return [...this.#calloutMap.values()].some(({ showing }) => showing); + } + + /** @type {Map<Window, FeatureCalloutItem>} */ + #calloutMap = new Map(); +} + +export const FeatureCalloutBroker = new _FeatureCalloutBroker(); diff --git a/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs new file mode 100644 index 0000000000..38c9a8d848 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs @@ -0,0 +1,1299 @@ +/* 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/. */ + +// Eventually, make this a messaging system +// provider instead of adding these message +// into OnboardingMessageProvider.sys.mjs +const FIREFOX_VIEW_PREF = "browser.firefox-view.feature-tour"; +const PDFJS_PREF = "browser.pdfjs.feature-tour"; +// Empty screens are included as placeholders to ensure step +// indicator shows the correct number of total steps in the tour +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; + +// Generate a JEXL targeting string based on the `complete` property being true +// in a given Feature Callout tour progress preference value (which is JSON). +const matchIncompleteTargeting = (prefName, defaultValue = false) => { + // regExpMatch() is a JEXL filter expression. Here we check if 'complete' + // exists in the pref's value, and returns true if the tour is incomplete. + const prefVal = `'${prefName}' | preferenceValue`; + // prefVal might be null if the preference doesn't exist. in this case, don't + // try to pipe into regExpMatch. + const completeMatch = `${prefVal} | regExpMatch('(?<=complete":)(.*)(?=})')`; + return `((${prefVal}) ? ((${completeMatch}) ? (${completeMatch}[1] != "true") : ${String( + defaultValue + )}) : ${String(defaultValue)})`; +}; + +// Generate a JEXL targeting string based on the current screen id found in a +// given Feature Callout tour progress preference. +const matchCurrentScreenTargeting = (prefName, screenIdRegEx = ".*") => { + // regExpMatch() is a JEXL filter expression. Here we check if 'screen' exists + // in the pref's value, and if it matches the screenIdRegEx. Returns + // null otherwise. + const prefVal = `'${prefName}' | preferenceValue`; + const screenMatch = `${prefVal} | regExpMatch('(?<=screen"\s*:)\s*"(${screenIdRegEx})(?="\s*,)')`; + const screenValMatches = `(${screenMatch}) ? !!(${screenMatch}[1]) : false`; + return `(${screenValMatches})`; +}; + +/** + * add24HourImpressionJEXLTargeting - + * Creates a "hasn't been viewed in > 24 hours" + * JEXL string and adds it to each message specified + * + * @param {array} messageIds - IDs of messages that the targeting string will be added to + * @param {string} prefix - The prefix of messageIDs that will used to create the JEXL string + * @param {array} messages - The array of messages that will be edited + * @returns {array} - The array of messages with the appropriate targeting strings edited + */ +function add24HourImpressionJEXLTargeting( + messageIds, + prefix, + uneditedMessages +) { + let noImpressionsIn24HoursString = uneditedMessages + .filter(message => message.id.startsWith(prefix)) + .map( + message => + // If the last impression is null or if epoch time + // of the impression is < current time - 24hours worth of MS + `(messageImpressions.${message.id}[messageImpressions.${ + message.id + } | length - 1] == null || messageImpressions.${ + message.id + }[messageImpressions.${message.id} | length - 1] < ${ + Date.now() - ONE_DAY_IN_MS + })` + ) + .join(" && "); + + // We're appending the string here instead of using + // template strings to avoid a recursion error from + // using the 'messages' variable within itself + return uneditedMessages.map(message => { + if (messageIds.includes(message.id)) { + message.targeting += `&& ${noImpressionsIn24HoursString}`; + } + + return message; + }); +} + +// Exporting the about:firefoxview messages as a method here +// acts as a safety guard against mutations of the original objects +const MESSAGES = () => { + let messages = [ + { + id: "FIREFOX_VIEW_SPOTLIGHT", + template: "spotlight", + content: { + id: "FIREFOX_VIEW_PROMO", + template: "multistage", + modal: "tab", + tour_pref_name: FIREFOX_VIEW_PREF, + screens: [ + { + id: "DEFAULT_MODAL_UI", + content: { + title: { + fontSize: "32px", + fontWeight: 400, + string_id: "firefoxview-spotlight-promo-title", + }, + subtitle: { + fontSize: "15px", + fontWeight: 400, + marginBlock: "10px", + marginInline: "40px", + string_id: "firefoxview-spotlight-promo-subtitle", + }, + logo: { height: "48px" }, + primary_button: { + label: { + string_id: "firefoxview-spotlight-promo-primarybutton", + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_1", + complete: false, + }), + }, + }, + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "firefoxview-spotlight-promo-secondarybutton", + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + navigate: true, + }, + }, + }, + }, + ], + }, + priority: 3, + trigger: { + id: "featureCalloutCheck", + }, + frequency: { + // Add the highest possible cap to ensure impressions are recorded while allowing the Spotlight to sync across windows/tabs with Firefox View open + lifetime: 100, + }, + targeting: `!inMr2022Holdback && source == "about:firefoxview" && + !'browser.newtabpage.activity-stream.asrouter.providers.cfr'|preferenceIsUserSet && + 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && + ${matchCurrentScreenTargeting( + FIREFOX_VIEW_PREF, + "FIREFOX_VIEW_SPOTLIGHT" + )} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`, + }, + { + id: "FIREFOX_VIEW_FEATURE_TOUR", + template: "feature_callout", + content: { + id: "FIREFOX_VIEW_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: FIREFOX_VIEW_PREF, + screens: [ + { + id: "FEATURE_CALLOUT_1", + anchors: [ + { + selector: "#tab-pickup-container", + arrow_position: "top", + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-firefox-view-tab-pickup-title", + }, + subtitle: { + string_id: "callout-firefox-view-tab-pickup-subtitle", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + height: "128px", + }, + primary_button: { + label: { + string_id: "callout-primary-advance-button-label", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + page_event_listeners: [ + { + params: { + type: "toggle", + selectors: "#tab-pickup-container", + }, + action: { reposition: true }, + }, + ], + }, + }, + { + id: "FEATURE_CALLOUT_2", + anchors: [ + { + selector: "#recently-closed-tabs-container", + arrow_position: "bottom", + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-firefox-view-recently-closed-title", + }, + subtitle: { + string_id: "callout-firefox-view-recently-closed-subtitle", + }, + primary_button: { + label: { + string_id: "callout-primary-complete-button-label", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + page_event_listeners: [ + { + params: { + type: "toggle", + selectors: "#recently-closed-tabs-container", + }, + action: { reposition: true }, + }, + ], + }, + }, + ], + }, + priority: 3, + targeting: `!inMr2022Holdback && source == "about:firefoxview" && ${matchCurrentScreenTargeting( + FIREFOX_VIEW_PREF, + "FEATURE_CALLOUT_[0-9]" + )} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`, + trigger: { id: "featureCalloutCheck" }, + }, + { + id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER", + template: "feature_callout", + content: { + id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER", + anchors: [ + { + selector: "#tab-pickup-container", + arrow_position: "top", + }, + ], + content: { + position: "callout", + title: { + string_id: + "continuous-onboarding-firefox-view-tab-pickup-title", + }, + subtitle: { + string_id: + "continuous-onboarding-firefox-view-tab-pickup-subtitle", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + height: "128px", + }, + primary_button: { + label: { + string_id: "mr1-onboarding-get-started-primary-button-label", + }, + style: "secondary", + action: { + type: "CLICK_ELEMENT", + navigate: true, + data: { + selector: + "#tab-pickup-container button.primary:not(#error-state-button)", + }, + }, + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "toggle", + selectors: "#tab-pickup-container", + }, + action: { reposition: true }, + }, + ], + }, + }, + ], + }, + priority: 2, + targeting: `!inMr2022Holdback && source == "about:firefoxview" && "browser.firefox-view.view-count" | preferenceValue > 2 + && (("identity.fxaccounts.enabled" | preferenceValue == false) || !(("services.sync.engine.tabs" | preferenceValue == true) && ("services.sync.username" | preferenceValue))) && (!messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] || messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] < currentDate|date - ${ONE_DAY_IN_MS})`, + frequency: { + lifetime: 1, + }, + trigger: { id: "featureCalloutCheck" }, + }, + { + id: "PDFJS_FEATURE_TOUR_A", + template: "feature_callout", + content: { + id: "PDFJS_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: PDFJS_PREF, + screens: [ + { + id: "FEATURE_CALLOUT_1_A", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "51px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-edit-title", + }, + subtitle: { + string_id: "callout-pdfjs-edit-body-a", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-edit-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2_A", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + { + id: "FEATURE_CALLOUT_2_A", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "23px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-draw-title", + }, + subtitle: { + string_id: "callout-pdfjs-draw-body-a", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-draw-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + ], + }, + priority: 1, + targeting: `source == "open" && ${matchCurrentScreenTargeting( + PDFJS_PREF, + "FEATURE_CALLOUT_[0-9]_A" + )} && ${matchIncompleteTargeting(PDFJS_PREF)}`, + trigger: { id: "pdfJsFeatureCalloutCheck" }, + }, + { + id: "PDFJS_FEATURE_TOUR_B", + template: "feature_callout", + content: { + id: "PDFJS_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: PDFJS_PREF, + screens: [ + { + id: "FEATURE_CALLOUT_1_B", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "51px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-edit-title", + }, + subtitle: { + string_id: "callout-pdfjs-edit-body-b", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-edit-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2_B", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + { + id: "FEATURE_CALLOUT_2_B", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "23px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-draw-title", + }, + subtitle: { + string_id: "callout-pdfjs-draw-body-b", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-draw-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + ], + }, + priority: 1, + targeting: `source == "open" && ${matchCurrentScreenTargeting( + PDFJS_PREF, + "FEATURE_CALLOUT_[0-9]_B" + )} && ${matchIncompleteTargeting(PDFJS_PREF)}`, + trigger: { id: "pdfJsFeatureCalloutCheck" }, + }, + { + // "Callout 1" in the Fakespot Figma spec + id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + title_logo: { + imageURL: + "chrome://browser/content/shopping/assets/shopping.svg", + alignment: "top", + }, + title: { + string_id: "shopping-callout-closed-opted-in-subtitle", + marginInline: "3px 40px", + fontWeight: "inherit", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "24px 0", + marginInline: "0 24px", + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is not enabled; User is opted in; First time closing sidebar; Has not seen either on-closed callout before; Has not opted out of CFRs. + targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT|length && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT|length`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + }, + { + // "Callout 3" in the Fakespot Figma spec + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + title_logo: { + imageURL: + "chrome://browser/content/shopping/assets/shopping.svg", + }, + title: { + string_id: "shopping-callout-closed-not-opted-in-title", + marginInline: "3px 40px", + }, + subtitle: { + string_id: "shopping-callout-closed-not-opted-in-subtitle", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "24px 0", + marginInline: "0 24px", + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is not enabled; User is not opted in; First time closing sidebar; Has not seen either on-closed callout before; Has not opted out of CFRs. + targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue != 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT|length && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT|length`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + }, + { + // "callout 2" in the Fakespot Figma spec + id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + title: { string_id: "shopping-callout-pdp-opted-in-title" }, + subtitle: { string_id: "shopping-callout-pdp-opted-in-subtitle" }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/ratingLight.avif", + darkModeImageURL: + "chrome://browser/content/shopping/assets/ratingDark.avif", + height: "216px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "24px 0", + marginInline: "0 24px", + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is not enabled; User is opted in; Has not opted out of CFRs; Has seen either on-closed callout before, but not within the last 24hrs or in this session. + targeting: `!isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && ((currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT[messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT | length - 1] | date) / 3600000 > 24 || (currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT[messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT | length - 1] | date) / 3600000 > 24)`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + }, + { + // "Callout 1" in the Fakespot Figma spec, but + // targeting not opted-in users only for rediscoverability experiment 2. + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-closed-not-opted-in-revised-title", + }, + subtitle: { + string_id: + "shopping-callout-closed-not-opted-in-revised-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg", + height: "214px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: + "shopping-callout-closed-not-opted-in-revised-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is enabled; User is not opted in; First time closing sidebar; Has not opted out of CFRs. + targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue != 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + { + // "Callout 3" in the Fakespot Figma spec, but + // displayed if auto-open version of "callout 1" was seen already and 24 hours have passed. + id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-not-opted-in-reminder-title", + fontSize: "20px", + letterSpacing: "0", + }, + subtitle: { + string_id: "shopping-callout-not-opted-in-reminder-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/reviewsVisualCallout.svg", + alt: { + string_id: "shopping-callout-not-opted-in-reminder-img-alt", + }, + height: "214px", + }, + dismiss_button: { + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [], + }, + dismiss: true, + }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: + "shopping-callout-not-opted-in-reminder-close-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [], + }, + dismiss: true, + }, + }, + secondary_button: { + label: { + string_id: + "shopping-callout-not-opted-in-reminder-open-button", + marginBlock: "0 -8px", + }, + style: "primary", + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.shopping.experience2023.active", + value: true, + }, + }, + }, + ], + }, + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + tiles: { + type: "multiselect", + style: { + flexDirection: "column", + alignItems: "flex-start", + }, + data: [ + { + id: "checkbox-dont-show-again", + type: "checkbox", + defaultValue: false, + style: { + alignItems: "center", + }, + label: { + string_id: + "shopping-callout-not-opted-in-reminder-ignore-checkbox", + }, + icon: { + style: { + width: "16px", + height: "16px", + marginInline: "0 8px", + }, + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.shopping-callouts-1-block", + value: true, + }, + }, + }, + }, + ], + }, + }, + }, + ], + }, + priority: 2, + // Auto-open feature flag is enabled; User is not opted in; Has not opted out of CFRs; Has seen callout 1 before, but not within the last 5 days. + targeting: + "!isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 0 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !'messaging-system-action.shopping-callouts-1-block' | preferenceValue && (currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN[messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN | length - 1] | date) / 3600000 > 24", + trigger: { + id: "shoppingProductPageWithSidebarClosed", + }, + frequency: { + custom: [ + { + cap: 1, + period: 432000000, + }, + ], + lifetime: 3, + }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + { + // "Callout 4" in the Fakespot Figma spec, for rediscoverability experiment 2. + id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-disabled-auto-open-title", + }, + subtitle: { + string_id: "shopping-callout-disabled-auto-open-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg", + height: "214px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: "shopping-callout-disabled-auto-open-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is enabled; User disabled auto-open behavior; User is opted in; Has not opted out of CFRs. + targeting: `'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.autoOpen.userEnabled' | preferenceValue == false && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + trigger: { + id: "preferenceObserver", + params: ["browser.shopping.experience2023.autoOpen.userEnabled"], + }, + frequency: { lifetime: 1 }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + { + // "Callout 5" in the Fakespot Figma spec, for rediscoverability experiment 2. + id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-opted-out-title", + }, + subtitle: { + string_id: "shopping-callout-opted-out-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg", + height: "214px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: "shopping-callout-opted-out-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is enabled; User has opted out; Has not opted out of CFRs. + targeting: `'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 2 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + trigger: { + id: "preferenceObserver", + params: ["browser.shopping.experience2023.optedIn"], + }, + frequency: { lifetime: 1 }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + + // cookie banner reduction onboarding + { + id: "CFR_COOKIEBANNER", + groups: ["cfr"], + template: "feature_callout", + content: { + id: "CFR_COOKIEBANNER", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "COOKIEBANNER_CALLOUT", + anchors: [ + { + selector: "#tracking-protection-icon-container", + panel_position: { + callout_attachment: "topleft", + anchor_attachment: "bottomcenter", + }, + }, + ], + content: { + position: "callout", + autohide: true, + title: { + string_id: "cookie-banner-blocker-onboarding-header", + paddingInline: "12px 0", + }, + subtitle: { + string_id: "cookie-banner-blocker-onboarding-body", + paddingInline: "34px 0", + }, + title_logo: { + alignment: "top", + height: "20px", + width: "20px", + imageURL: + "chrome://browser/skin/controlcenter/3rdpartycookies-blocked.svg", + }, + dismiss_button: { + size: "small", + action: { dismiss: true }, + }, + additional_button: { + label: { + string_id: "cookie-banner-blocker-onboarding-learn-more", + marginInline: "34px 0", + }, + style: "link", + alignment: "start", + action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/cookie-banner-reduction", + where: "tabshifted", + }, + }, + }, + }, + }, + ], + }, + frequency: { + lifetime: 1, + }, + skip_in_tests: "it's not tested in automation", + trigger: { + id: "cookieBannerHandled", + }, + targeting: `'cookiebanners.ui.desktop.enabled'|preferenceValue == true && 'cookiebanners.ui.desktop.showCallout'|preferenceValue == true && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + }, + ]; + messages = add24HourImpressionJEXLTargeting( + ["FIREFOX_VIEW_TAB_PICKUP_REMINDER"], + "FIREFOX_VIEW", + messages + ); + return messages; +}; + +export const FeatureCalloutMessages = { + getMessages() { + return MESSAGES(); + }, +}; diff --git a/browser/components/asrouter/modules/InfoBar.sys.mjs b/browser/components/asrouter/modules/InfoBar.sys.mjs new file mode 100644 index 0000000000..a287b650a5 --- /dev/null +++ b/browser/components/asrouter/modules/InfoBar.sys.mjs @@ -0,0 +1,169 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", +}); + +class InfoBarNotification { + constructor(message, dispatch) { + this._dispatch = dispatch; + this.dispatchUserAction = this.dispatchUserAction.bind(this); + this.buttonCallback = this.buttonCallback.bind(this); + this.infobarCallback = this.infobarCallback.bind(this); + this.message = message; + this.notification = null; + } + + /** + * Show the infobar notification and send an impression ping + * + * @param {object} browser Browser reference for the currently selected tab + */ + async showNotification(browser) { + let { content } = this.message; + let { gBrowser } = browser.ownerGlobal; + let doc = gBrowser.ownerDocument; + let notificationContainer; + if (content.type === "global") { + notificationContainer = browser.ownerGlobal.gNotificationBox; + } else { + notificationContainer = gBrowser.getNotificationBox(browser); + } + + let priority = content.priority || notificationContainer.PRIORITY_SYSTEM; + + this.notification = await notificationContainer.appendNotification( + this.message.id, + { + label: this.formatMessageConfig(doc, content.text), + image: content.icon || "chrome://branding/content/icon64.png", + priority, + eventCallback: this.infobarCallback, + }, + content.buttons.map(b => this.formatButtonConfig(b)) + ); + + this.addImpression(); + } + + formatMessageConfig(doc, content) { + let docFragment = doc.createDocumentFragment(); + // notificationbox will only `appendChild` for documentFragments + docFragment.appendChild( + lazy.RemoteL10n.createElement(doc, "span", { content }) + ); + + return docFragment; + } + + formatButtonConfig(button) { + let btnConfig = { callback: this.buttonCallback, ...button }; + // notificationbox will set correct data-l10n-id attributes if passed in + // using the l10n-id key. Otherwise the `button.label` text is used. + if (button.label.string_id) { + btnConfig["l10n-id"] = button.label.string_id; + } + + return btnConfig; + } + + addImpression() { + // Record an impression in ASRouter for frequency capping + this._dispatch({ type: "IMPRESSION", data: this.message }); + // Send a user impression telemetry ping + this.sendUserEventTelemetry("IMPRESSION"); + } + + /** + * Called when one of the infobar buttons is clicked + */ + buttonCallback(notificationBox, btnDescription, target) { + this.dispatchUserAction( + btnDescription.action, + target.ownerGlobal.gBrowser.selectedBrowser + ); + let isPrimary = target.classList.contains("primary"); + let eventName = isPrimary + ? "CLICK_PRIMARY_BUTTON" + : "CLICK_SECONDARY_BUTTON"; + this.sendUserEventTelemetry(eventName); + } + + dispatchUserAction(action, selectedBrowser) { + this._dispatch({ type: "USER_ACTION", data: action }, selectedBrowser); + } + + /** + * Called when interacting with the toolbar (but not through the buttons) + */ + infobarCallback(eventType) { + if (eventType === "removed") { + this.notification = null; + // eslint-disable-next-line no-use-before-define + InfoBar._activeInfobar = null; + } else if (this.notification) { + this.sendUserEventTelemetry("DISMISSED"); + this.notification = null; + // eslint-disable-next-line no-use-before-define + InfoBar._activeInfobar = null; + } + } + + sendUserEventTelemetry(event) { + const ping = { + message_id: this.message.id, + event, + }; + this._dispatch({ + type: "INFOBAR_TELEMETRY", + data: { action: "infobar_user_event", ...ping }, + }); + } +} + +export const InfoBar = { + _activeInfobar: null, + + maybeLoadCustomElement(win) { + if (!win.customElements.get("remote-text")) { + Services.scriptloader.loadSubScript( + "resource://activity-stream/data/custom-elements/paragraph.js", + win + ); + } + }, + + maybeInsertFTL(win) { + win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); + win.MozXULElement.insertFTLIfNeeded( + "browser/defaultBrowserNotification.ftl" + ); + }, + + async showInfoBarMessage(browser, message, dispatch) { + // Prevent stacking multiple infobars + if (this._activeInfobar) { + return null; + } + + const win = browser?.ownerGlobal; + + if (!win || lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return null; + } + + this.maybeLoadCustomElement(win); + this.maybeInsertFTL(win); + + let notification = new InfoBarNotification(message, dispatch); + await notification.showNotification(browser); + this._activeInfobar = true; + + return notification; + }, +}; diff --git a/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs b/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs new file mode 100644 index 0000000000..5960ab92cc --- /dev/null +++ b/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs @@ -0,0 +1,37 @@ +/* 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 file is used to define constants related to messaging experiments. It is + * imported by both ASRouter as well as import-rollouts.js, a node script that + * imports Nimbus rollouts into tree. It doesn't have access to any Firefox + * APIs, XPCOM, etc. and should be kept that way. + */ + +/** + * These are the Nimbus feature IDs that correspond to messaging experiments. + * Other Nimbus features contain specific variables whose keys are enumerated in + * FeatureManifest.yaml. Conversely, messaging experiment features contain + * actual messages, with the usual message keys like `template` and `targeting`. + * @see FeatureManifest.yaml + */ +export const MESSAGING_EXPERIMENTS_DEFAULT_FEATURES = [ + "cfr", + "fxms-message-1", + "fxms-message-2", + "fxms-message-3", + "fxms-message-4", + "fxms-message-5", + "fxms-message-6", + "fxms-message-7", + "fxms-message-8", + "fxms-message-9", + "fxms-message-10", + "fxms-message-11", + "infobar", + "moments-page", + "pbNewtab", + "spotlight", + "featureCallout", +]; diff --git a/browser/components/asrouter/modules/MomentsPageHub.sys.mjs b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs new file mode 100644 index 0000000000..84fee3b517 --- /dev/null +++ b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs @@ -0,0 +1,171 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setInterval: "resource://gre/modules/Timer.sys.mjs", + clearInterval: "resource://gre/modules/Timer.sys.mjs", +}); + +// Frequency at which to check for new messages +const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000; +const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; + +// For the "reach" event of Messaging Experiments +const REACH_EVENT_CATEGORY = "messaging_experiments"; +const REACH_EVENT_METHOD = "reach"; +// Note it's not "moments-page" as Telemetry Events only accepts understores +// for the event `object` +const REACH_EVENT_OBJECT = "moments_page"; + +export class _MomentsPageHub { + constructor() { + this.id = "moments-page-hub"; + this.state = {}; + this.checkHomepageOverridePref = this.checkHomepageOverridePref.bind(this); + this._initialized = false; + } + + async init( + waitForInitialized, + { handleMessageRequest, addImpression, blockMessageById, sendTelemetry } + ) { + if (this._initialized) { + return; + } + + this._initialized = true; + this._handleMessageRequest = handleMessageRequest; + this._addImpression = addImpression; + this._blockMessageById = blockMessageById; + this._sendTelemetry = sendTelemetry; + + // Need to wait for ASRouter to initialize before trying to fetch messages + await waitForInitialized; + + this.messageRequest({ + triggerId: "momentsUpdate", + template: "update_action", + }); + + const _intervalId = lazy.setInterval( + () => this.checkHomepageOverridePref(), + SYSTEM_TICK_INTERVAL + ); + this.state = { _intervalId }; + } + + _sendPing(ping) { + this._sendTelemetry({ + type: "MOMENTS_PAGE_TELEMETRY", + data: { action: "moments_user_event", ...ping }, + }); + } + + sendUserEventTelemetry(message) { + this._sendPing({ + message_id: message.id, + bucket_id: message.id, + event: "MOMENTS_PAGE_SET", + }); + } + + /** + * If we don't have `expire` defined with the message it could be because + * it depends on user dependent parameters. Since the message matched + * targeting we calculate `expire` based on the current timestamp and the + * `expireDelta` which defines for how long it should be available. + * @param expireDelta {number} - Offset in milliseconds from the current date + */ + getExpirationDate(expireDelta) { + return Date.now() + expireDelta; + } + + executeAction(message) { + const { id, data } = message.content.action; + switch (id) { + case "moments-wnp": + const { url, expireDelta } = data; + let { expire } = data; + if (!expire) { + expire = this.getExpirationDate(expireDelta); + } + // In order to reset this action we can dispatch a new message that + // will overwrite the prev value with an expiration date from the past. + Services.prefs.setStringPref( + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ message_id: message.id, url, expire }) + ); + // Add impression and block immediately after taking the action + this.sendUserEventTelemetry(message); + this._addImpression(message); + this._blockMessageById(message.id); + break; + } + } + + _recordReachEvent(message) { + const extra = { branches: message.branchSlug }; + Services.telemetry.recordEvent( + REACH_EVENT_CATEGORY, + REACH_EVENT_METHOD, + REACH_EVENT_OBJECT, + message.experimentSlug, + extra + ); + } + + async messageRequest({ triggerId, template }) { + const telemetryObject = { triggerId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + const messages = await this._handleMessageRequest({ + triggerId, + template, + returnAll: true, + }); + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + + // Record the "reach" event for all the messages with `forReachEvent`, + // only execute action for the first message without forReachEvent. + const nonReachMessages = []; + for (const message of messages) { + if (message.forReachEvent) { + if (!message.forReachEvent.sent) { + this._recordReachEvent(message); + message.forReachEvent.sent = true; + } + } else { + nonReachMessages.push(message); + } + } + if (nonReachMessages.length) { + this.executeAction(nonReachMessages[0]); + } + } + + /** + * Pref is set via Remote Settings message. We want to continously + * monitor new messages that come in to ensure the one with the + * highest priority is set. + */ + checkHomepageOverridePref() { + this.messageRequest({ + triggerId: "momentsUpdate", + template: "update_action", + }); + } + + uninit() { + lazy.clearInterval(this.state._intervalId); + this.state = {}; + this._initialized = false; + } +} + +/** + * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate + * message requests and render messages. + */ +export const MomentsPageHub = new _MomentsPageHub(); diff --git a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs new file mode 100644 index 0000000000..6164e3e72a --- /dev/null +++ b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs @@ -0,0 +1,1414 @@ +/* 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/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils and AppConstants, and overrides importESModule +// to be a no-op (which can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +import { FeatureCalloutMessages } from "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "usesFirefoxSync", + "services.sync.username" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "mobileDevices", + "services.sync.clients.devices.mobile", + 0 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hidePrivatePin", + "browser.startup.upgradeDialog.pinPBM.disabled", + false +); + +const L10N = new Localization([ + "branding/brand.ftl", + "browser/newtab/onboarding.ftl", + "toolkit/branding/brandings.ftl", + "toolkit/branding/accounts.ftl", +]); + +const HOMEPAGE_PREF = "browser.startup.homepage"; +const NEWTAB_PREF = "browser.newtabpage.enabled"; +const FOURTEEN_DAYS_IN_MS = 14 * 24 * 60 * 60 * 1000; + +const BASE_MESSAGES = () => [ + { + id: "FXA_ACCOUNTS_BADGE", + template: "toolbar_badge", + content: { + delay: 10000, // delay for 10 seconds + target: "fxa-toolbar-menu-button", + }, + targeting: "false", + trigger: { id: "toolbarBadgeUpdate" }, + }, + { + id: "MILESTONE_MESSAGE_87", + groups: ["cfr"], + content: { + text: "", + layout: "short_message", + buttons: { + primary: { + event: "PROTECTION", + label: { + string_id: "cfr-doorhanger-milestone-ok-button", + }, + action: { + type: "OPEN_PROTECTION_REPORT", + }, + }, + secondary: [ + { + event: "DISMISS", + label: { + string_id: "cfr-doorhanger-milestone-close-button", + }, + action: { + type: "CANCEL", + }, + }, + ], + }, + category: "cfrFeatures", + anchor_id: "tracking-protection-icon-container", + bucket_id: "CFR_MILESTONE_MESSAGE", + heading_text: { + string_id: "cfr-doorhanger-milestone-heading2", + }, + notification_text: "", + skip_address_bar_notifier: true, + }, + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + template: "milestone_message", + frequency: { + lifetime: 7, + }, + targeting: "pageLoad >= 4 && userPrefs.cfrFeatures", + }, + { + id: "FX_MR_106_UPGRADE", + template: "spotlight", + targeting: "true", + content: { + template: "multistage", + id: "FX_MR_106_UPGRADE", + transitions: true, + modal: "tab", + screens: [ + { + id: "UPGRADE_PIN_FIREFOX", + content: { + position: "split", + split_narrow_bkg_position: "-155px", + image_alt_text: { + string_id: "mr2022-onboarding-pin-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-pintaskbar.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-existing-pin-header", + }, + subtitle: { + string_id: "mr2022-onboarding-existing-pin-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-pin-primary-button-label", + }, + action: { + navigate: true, + type: "PIN_FIREFOX_TO_TASKBAR", + }, + }, + checkbox: { + label: { + string_id: "mr2022-onboarding-existing-pin-checkbox-label", + }, + defaultValue: true, + action: { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + data: { + privatePin: true, + }, + }, + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + ], + }, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_SET_DEFAULT", + content: { + position: "split", + split_narrow_bkg_position: "-60px", + image_alt_text: { + string_id: "mr2022-onboarding-default-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-settodefault.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-set-default-title", + }, + subtitle: { + string_id: "mr2022-onboarding-set-default-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-set-default-primary-button-label", + }, + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_IMPORT_SETTINGS_EMBEDDED", + content: { + tiles: { type: "migration-wizard" }, + position: "split", + split_narrow_bkg_position: "-42px", + image_alt_text: { + string_id: "mr2022-onboarding-import-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-import.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + progress_bar: true, + hide_secondary_section: "responsive", + migrate_start: { + action: {}, + }, + migrate_close: { + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_MOBILE_DOWNLOAD", + content: { + position: "split", + split_narrow_bkg_position: "-160px", + image_alt_text: { + string_id: "mr2022-onboarding-mobile-download-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-mobilecrosspromo.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + progress_bar: true, + logo: {}, + title: { + string_id: + "onboarding-mobile-download-security-and-privacy-title", + }, + subtitle: { + string_id: + "onboarding-mobile-download-security-and-privacy-subtitle", + }, + hero_image: { + url: "chrome://activity-stream/content/data/content/assets/mobile-download-qr-existing-user.svg", + }, + cta_paragraph: { + text: { + string_id: "mr2022-onboarding-mobile-download-cta-text", + string_name: "download-label", + }, + action: { + type: "OPEN_URL", + data: { + args: "https://www.mozilla.org/firefox/mobile/get-app/?utm_medium=firefox-desktop&utm_source=onboarding-modal&utm_campaign=mr2022&utm_content=existing-global", + where: "tab", + }, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_PIN_PRIVATE_WINDOW", + content: { + position: "split", + split_narrow_bkg_position: "-100px", + image_alt_text: { + string_id: "mr2022-onboarding-pin-private-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-pinprivate.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-upgrade-onboarding-pin-private-window-header", + }, + subtitle: { + string_id: + "mr2022-upgrade-onboarding-pin-private-window-subtitle", + }, + primary_button: { + label: { + string_id: + "mr2022-upgrade-onboarding-pin-private-window-primary-button-label", + }, + action: { + type: "PIN_FIREFOX_TO_TASKBAR", + data: { + privatePin: true, + }, + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_DATA_RECOMMENDATION", + content: { + position: "split", + split_narrow_bkg_position: "-80px", + image_alt_text: { + string_id: "mr2022-onboarding-privacy-segmentation-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-privacysegmentation.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-privacy-segmentation-title", + }, + subtitle: { + string_id: "mr2022-onboarding-privacy-segmentation-subtitle", + }, + cta_paragraph: { + text: { + string_id: "mr2022-onboarding-privacy-segmentation-text-cta", + }, + }, + primary_button: { + label: { + string_id: + "mr2022-onboarding-privacy-segmentation-button-primary-label", + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.dataFeatureRecommendations.enabled", + value: true, + }, + }, + navigate: true, + }, + }, + additional_button: { + label: { + string_id: + "mr2022-onboarding-privacy-segmentation-button-secondary-label", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.dataFeatureRecommendations.enabled", + value: false, + }, + }, + navigate: true, + }, + }, + }, + }, + { + id: "UPGRADE_GRATITUDE", + content: { + position: "split", + progress_bar: "true", + split_narrow_bkg_position: "-228px", + image_alt_text: { + string_id: "mr2022-onboarding-gratitude-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-gratitude-title", + }, + subtitle: { + string_id: "mr2022-onboarding-gratitude-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-gratitude-primary-button-label", + }, + action: { + type: "OPEN_FIREFOX_VIEW", + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-gratitude-secondary-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + }, + { + id: "FX_100_UPGRADE", + template: "spotlight", + targeting: "false", + content: { + template: "multistage", + id: "FX_100_UPGRADE", + transitions: true, + screens: [ + { + id: "UPGRADE_PIN_FIREFOX", + content: { + logo: { + imageURL: + "chrome://activity-stream/content/data/content/assets/heart.webp", + height: "73px", + }, + has_noodles: true, + title: { + fontSize: "36px", + string_id: "fx100-upgrade-thanks-header", + }, + title_style: "fancy shine", + background: + "url('chrome://activity-stream/content/data/content/assets/confetti.svg') top / 100% no-repeat var(--in-content-page-background)", + subtitle: { + string_id: "fx100-upgrade-thanks-keep-body", + }, + primary_button: { + label: { + string_id: "fx100-thank-you-pin-primary-button-label", + }, + action: { + navigate: true, + type: "PIN_FIREFOX_TO_TASKBAR", + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + }, + { + id: "PB_NEWTAB_FOCUS_PROMO", + type: "default", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "FOCUS", + promoHeader: "fluent:about-private-browsing-focus-promo-header-c", + promoImageLarge: "chrome://browser/content/assets/focus-promo.png", + promoLinkText: "fluent:about-private-browsing-focus-promo-cta", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-focus-promo-text-c", + promoTitleEnabled: true, + promoButton: { + action: { + type: "SHOW_SPOTLIGHT", + data: { + content: { + id: "FOCUS_PROMO", + template: "multistage", + modal: "tab", + backdrop: "transparent", + screens: [ + { + id: "DEFAULT_MODAL_UI", + content: { + logo: { + imageURL: + "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: { + string_id: "spotlight-focus-promo-title", + }, + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fus%2Fapp%2Ffirefox-focus-privacy-browser%2Fid1055677337", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.focus", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/focus-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + priority: 2, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + // Exclude the next 2 messages: 1) Klar for en 2) Klar for de + targeting: + "!(region in [ 'DE', 'AT', 'CH'] && localeLanguageCode == 'en') && localeLanguageCode != 'de'", + }, + { + id: "PB_NEWTAB_KLAR_PROMO", + type: "default", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "FOCUS", + promoHeader: "fluent:about-private-browsing-focus-promo-header-c", + promoImageLarge: "chrome://browser/content/assets/focus-promo.png", + promoLinkText: "Download Firefox Klar", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: + "Firefox Klar clears your history every time while blocking ads and trackers.", + promoTitleEnabled: true, + promoButton: { + action: { + type: "SHOW_SPOTLIGHT", + data: { + content: { + id: "KLAR_PROMO", + template: "multistage", + modal: "tab", + backdrop: "transparent", + screens: [ + { + id: "DEFAULT_MODAL_UI", + order: 0, + content: { + logo: { + imageURL: + "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: "Get Firefox Klar", + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fde%2Fapp%2Fklar-by-firefox%2Fid1073435754", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.klar", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/klar-qr-code.svg", + alt_text: "Scan the QR code to get Firefox Klar", + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + priority: 2, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: "region in [ 'DE', 'AT', 'CH'] && localeLanguageCode == 'en'", + }, + { + id: "PB_NEWTAB_KLAR_PROMO_DE", + type: "default", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "FOCUS", + promoHeader: "fluent:about-private-browsing-focus-promo-header-c", + promoImageLarge: "chrome://browser/content/assets/focus-promo.png", + promoLinkText: "fluent:about-private-browsing-focus-promo-cta", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-focus-promo-text-c", + promoTitleEnabled: true, + promoButton: { + action: { + type: "SHOW_SPOTLIGHT", + data: { + content: { + id: "FOCUS_PROMO", + template: "multistage", + modal: "tab", + backdrop: "transparent", + screens: [ + { + id: "DEFAULT_MODAL_UI", + content: { + logo: { + imageURL: + "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: { + string_id: "spotlight-focus-promo-title", + }, + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fde%2Fapp%2Fklar-by-firefox%2Fid1073435754", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.klar", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/klar-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + priority: 2, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: "localeLanguageCode == 'de'", + }, + { + id: "PB_NEWTAB_PIN_PROMO", + template: "pb_newtab", + type: "default", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "PIN", + promoHeader: "fluent:about-private-browsing-pin-promo-header", + promoImageLarge: + "chrome://browser/content/assets/private-promo-asset.svg", + promoLinkText: "fluent:about-private-browsing-pin-promo-link-text", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-pin-promo-title", + promoTitleEnabled: true, + promoButton: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.privateWindowSeparation.enabled", + value: true, + }, + }, + }, + { + type: "PIN_FIREFOX_TO_TASKBAR", + data: { + privatePin: true, + }, + }, + { + type: "BLOCK_MESSAGE", + data: { + id: "PB_NEWTAB_PIN_PROMO", + }, + }, + { + type: "OPEN_ABOUT_PAGE", + data: { args: "privatebrowsing", where: "current" }, + }, + ], + }, + }, + }, + }, + priority: 3, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: "!inMr2022Holdback && doesAppNeedPrivatePin", + }, + { + id: "PB_NEWTAB_COOKIE_BANNERS_PROMO", + template: "pb_newtab", + type: "default", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "COOKIE_BANNERS", + promoHeader: "fluent:about-private-browsing-cookie-banners-promo-heading", + promoImageLarge: + "chrome://browser/content/assets/cookie-banners-begone.svg", + promoLinkText: "fluent:about-private-browsing-learn-more-link", + promoLinkType: "link", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-cookie-banners-promo-body", + promoTitleEnabled: true, + promoButton: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/cookie-banner-reduction", + where: "tabshifted", + }, + }, + { + type: "BLOCK_MESSAGE", + data: { + id: "PB_NEWTAB_COOKIE_BANNERS_PROMO", + }, + }, + ], + }, + }, + }, + }, + priority: 4, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: `'cookiebanners.service.mode.privateBrowsing'|preferenceValue != 0 || 'cookiebanners.service.mode'|preferenceValue != 0`, + }, + { + id: "INFOBAR_LAUNCH_ON_LOGIN", + groups: ["cfr"], + template: "infobar", + content: { + type: "global", + text: { + string_id: "launch-on-login-infobar-message", + }, + buttons: [ + { + label: { + string_id: "launch-on-login-learnmore", + }, + supportPage: "make-firefox-automatically-open-when-you-start", + action: { + type: "CANCEL", + }, + }, + { + label: { string_id: "launch-on-login-infobar-reject-button" }, + action: { + type: "CANCEL", + }, + }, + { + label: { string_id: "launch-on-login-infobar-confirm-button" }, + primary: true, + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", + value: true, + }, + }, + }, + { + type: "CONFIRM_LAUNCH_ON_LOGIN", + }, + ], + }, + }, + }, + ], + }, + frequency: { + lifetime: 1, + }, + trigger: { id: "defaultBrowserCheck" }, + targeting: `source == 'newtab' + && 'browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt'|preferenceValue == false + && 'browser.startup.windowsLaunchOnLogin.enabled'|preferenceValue == true && isDefaultBrowser && !activeNotifications + && !launchOnLoginEnabled`, + }, + { + id: "INFOBAR_LAUNCH_ON_LOGIN_FINAL", + groups: ["cfr"], + template: "infobar", + content: { + type: "global", + text: { + string_id: "launch-on-login-infobar-final-message", + }, + buttons: [ + { + label: { + string_id: "launch-on-login-learnmore", + }, + supportPage: "make-firefox-automatically-open-when-you-start", + action: { + type: "CANCEL", + }, + }, + { + label: { string_id: "launch-on-login-infobar-final-reject-button" }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", + value: true, + }, + }, + }, + }, + { + label: { string_id: "launch-on-login-infobar-confirm-button" }, + primary: true, + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", + value: true, + }, + }, + }, + { + type: "CONFIRM_LAUNCH_ON_LOGIN", + }, + ], + }, + }, + }, + ], + }, + frequency: { + lifetime: 1, + }, + trigger: { id: "defaultBrowserCheck" }, + targeting: `source == 'newtab' + && 'browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt'|preferenceValue == false + && 'browser.startup.windowsLaunchOnLogin.enabled'|preferenceValue == true && isDefaultBrowser && !activeNotifications + && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1] + && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1] < + currentDate|date - ${FOURTEEN_DAYS_IN_MS} + && !launchOnLoginEnabled`, + }, + { + id: "FOX_DOODLE_SET_DEFAULT", + template: "spotlight", + groups: ["eco"], + skip_in_tests: "it fails unrelated tests", + content: { + backdrop: "transparent", + id: "FOX_DOODLE_SET_DEFAULT", + screens: [ + { + id: "FOX_DOODLE_SET_DEFAULT_SCREEN", + content: { + logo: { + height: "125px", + imageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-waving.gif", + reducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-waving-static.png", + }, + title: { + fontSize: "22px", + fontWeight: 590, + letterSpacing: 0, + paddingInline: "24px", + paddingBlock: "4px 0", + string_id: "fox-doodle-pin-headline", + }, + subtitle: { + fontSize: "15px", + letterSpacing: 0, + lineHeight: "1.4", + marginBlock: "8px 16px", + paddingInline: "24px", + string_id: "fox-doodle-pin-body", + }, + primary_button: { + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + label: { + paddingBlock: "0", + paddingInline: "16px", + marginBlock: "4px 0", + string_id: "fox-doodle-pin-primary", + }, + }, + secondary_button: { + action: { + navigate: true, + }, + label: { + marginBlock: "0 -20px", + string_id: "fox-doodle-pin-secondary", + }, + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + }, + }, + ], + template: "multistage", + transitions: true, + }, + frequency: { + lifetime: 2, + }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && !isDefaultBrowser && !willShowDefaultPrompt && (currentDate|date - profileAgeCreated|date) / 86400000 >= 28 && userPrefs.cfrFeatures == true", + trigger: { + id: "defaultBrowserCheck", + }, + }, + { + id: "TAIL_FOX_SET_DEFAULT", + template: "spotlight", + groups: ["eco"], + skip_in_tests: "it fails unrelated tests", + content: { + backdrop: "transparent", + id: "TAIL_FOX_SET_DEFAULT_CONTENT", + screens: [ + { + id: "TAIL_FOX_SET_DEFAULT_SCREEN", + content: { + logo: { + height: "140px", + imageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-tail.png", + reducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-tail.png", + }, + title: { + fontSize: "22px", + fontWeight: 590, + letterSpacing: 0, + paddingInline: "24px", + paddingBlock: "4px 0", + string_id: "tail-fox-spotlight-title", + }, + subtitle: { + fontSize: "15px", + letterSpacing: 0, + lineHeight: "1.4", + marginBlock: "8px 16px", + paddingInline: "24px", + string_id: "tail-fox-spotlight-subtitle", + }, + primary_button: { + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + label: { + paddingBlock: "0", + paddingInline: "16px", + marginBlock: "4px 0", + string_id: "tail-fox-spotlight-primary-button", + }, + }, + secondary_button: { + action: { + navigate: true, + }, + label: { + marginBlock: "0 -20px", + string_id: "tail-fox-spotlight-secondary-button", + }, + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + }, + }, + ], + template: "multistage", + transitions: true, + }, + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && !isDefaultBrowser && !willShowDefaultPrompt && (currentDate|date - profileAgeCreated|date) / 86400000 <= 28 && (currentDate|date - profileAgeCreated|date) / 86400000 >= 7 && userPrefs.cfrFeatures == true", + trigger: { + id: "defaultBrowserCheck", + }, + }, +]; + +// Eventually, move Feature Callout messages to their own provider +const ONBOARDING_MESSAGES = () => + BASE_MESSAGES().concat(FeatureCalloutMessages.getMessages()); + +export const OnboardingMessageProvider = { + async getExtraAttributes() { + const [header, button_label] = await L10N.formatMessages([ + { id: "onboarding-welcome-header" }, + { id: "onboarding-start-browsing-button-label" }, + ]); + return { header: header.value, button_label: button_label.value }; + }, + async getMessages() { + const messages = await this.translateMessages(await ONBOARDING_MESSAGES()); + return messages; + }, + async getUntranslatedMessages() { + // This is helpful for jsonSchema testing - since we are localizing in the provider + const messages = await ONBOARDING_MESSAGES(); + return messages; + }, + async translateMessages(messages) { + let translatedMessages = []; + for (const msg of messages) { + let translatedMessage = { ...msg }; + + // If the message has no content, do not attempt to translate it + if (!translatedMessage.content) { + translatedMessages.push(translatedMessage); + continue; + } + + // Translate any secondary buttons separately + if (msg.content.secondary_button) { + const [secondary_button_string] = await L10N.formatMessages([ + { id: msg.content.secondary_button.label.string_id }, + ]); + translatedMessage.content.secondary_button.label = + secondary_button_string.value; + } + if (msg.content.header) { + const [header_string] = await L10N.formatMessages([ + { id: msg.content.header.string_id }, + ]); + translatedMessage.content.header = header_string.value; + } + translatedMessages.push(translatedMessage); + } + return translatedMessages; + }, + async _doesAppNeedPin(privateBrowsing = false) { + const needPin = await lazy.ShellService.doesAppNeedPin(privateBrowsing); + return needPin; + }, + async _doesAppNeedDefault() { + let checkDefault = Services.prefs.getBoolPref( + "browser.shell.checkDefaultBrowser", + false + ); + let isDefault = await lazy.ShellService.isDefaultBrowser(); + return checkDefault && !isDefault; + }, + _shouldShowPrivacySegmentationScreen() { + // Fall back to pref: browser.privacySegmentation.preferences.show + return lazy.NimbusFeatures.majorRelease2022.getVariable( + "feltPrivacyShowPreferencesSection" + ); + }, + _doesHomepageNeedReset() { + return ( + Services.prefs.prefHasUserValue(HOMEPAGE_PREF) || + Services.prefs.prefHasUserValue(NEWTAB_PREF) + ); + }, + + async getUpgradeMessage() { + let message = (await OnboardingMessageProvider.getMessages()).find( + ({ id }) => id === "FX_MR_106_UPGRADE" + ); + + let { content } = message; + // Helper to find screens and remove them where applicable. + function removeScreens(check) { + const { screens } = content; + for (let i = 0; i < screens?.length; i++) { + if (check(screens[i])) { + screens.splice(i--, 1); + } + } + } + + // Helper to prepare mobile download screen content + function prepareMobileDownload() { + let mobileContent = content.screens.find( + screen => screen.id === "UPGRADE_MOBILE_DOWNLOAD" + )?.content; + + if (!mobileContent) { + return; + } + if (!lazy.BrowserUtils.sendToDeviceEmailsSupported()) { + // If send to device emails are not supported for a user's locale, + // remove the send to device link and update the screen text + delete mobileContent.cta_paragraph.action; + mobileContent.cta_paragraph.text = { + string_id: "mr2022-onboarding-no-mobile-download-cta-text", + }; + } + // Update CN specific QRCode url + if (AppConstants.isChinaRepack()) { + mobileContent.hero_image.url = `${mobileContent.hero_image.url.slice( + 0, + mobileContent.hero_image.url.indexOf(".svg") + )}-cn.svg`; + } + } + + let pinScreen = content.screens?.find( + screen => screen.id === "UPGRADE_PIN_FIREFOX" + ); + const needPin = await this._doesAppNeedPin(); + const needDefault = await this._doesAppNeedDefault(); + const needPrivatePin = + !lazy.hidePrivatePin && (await this._doesAppNeedPin(true)); + const showSegmentation = this._shouldShowPrivacySegmentationScreen(); + + //If a user has Firefox as default remove import screen + if (!needDefault) { + removeScreens(screen => + screen.id?.startsWith("UPGRADE_IMPORT_SETTINGS_EMBEDDED") + ); + } + + // If already pinned, convert "pin" screen to "welcome" with desired action. + let removeDefault = !needDefault; + // If user doesn't need pin, update screen to set "default" or "get started" configuration + if (!needPin && pinScreen) { + // don't need to show the checkbox + delete pinScreen.content.checkbox; + + removeDefault = true; + let primary = pinScreen.content.primary_button; + if (needDefault) { + pinScreen.id = "UPGRADE_ONLY_DEFAULT"; + pinScreen.content.subtitle = { + string_id: "mr2022-onboarding-existing-set-default-only-subtitle", + }; + primary.label.string_id = + "mr2022-onboarding-set-default-primary-button-label"; + + // The "pin" screen will now handle "default" so remove other "default." + primary.action.type = "SET_DEFAULT_BROWSER"; + } else { + pinScreen.id = "UPGRADE_GET_STARTED"; + pinScreen.content.subtitle = { + string_id: "mr2022-onboarding-get-started-primary-subtitle", + }; + primary.label = { + string_id: "mr2022-onboarding-get-started-primary-button-label", + }; + delete primary.action.type; + } + } + + // If a user has Firefox private pinned remove pin private window screen + // We also remove standalone pin private window screen if a user doesn't have + // Firefox pinned in which case the option is shown as checkbox with UPGRADE_PIN_FIREFOX screen + if (!needPrivatePin || needPin) { + removeScreens(screen => + screen.id?.startsWith("UPGRADE_PIN_PRIVATE_WINDOW") + ); + } + + if (!showSegmentation) { + removeScreens(screen => + screen.id?.startsWith("UPGRADE_DATA_RECOMMENDATION") + ); + } + + //If privatePin, remove checkbox from pinscreen + if (!needPrivatePin) { + delete content.screens?.find( + screen => screen.id === "UPGRADE_PIN_FIREFOX" + )?.content?.checkbox; + } + + if (removeDefault) { + removeScreens(screen => screen.id?.startsWith("UPGRADE_SET_DEFAULT")); + } + + // Remove mobile download screen if user has sync enabled + if (lazy.usesFirefoxSync && lazy.mobileDevices > 0) { + removeScreens(screen => screen.id === "UPGRADE_MOBILE_DOWNLOAD"); + } else { + prepareMobileDownload(); + } + + return message; + }, +}; diff --git a/browser/components/asrouter/modules/PageEventManager.sys.mjs b/browser/components/asrouter/modules/PageEventManager.sys.mjs new file mode 100644 index 0000000000..44f1293385 --- /dev/null +++ b/browser/components/asrouter/modules/PageEventManager.sys.mjs @@ -0,0 +1,135 @@ +/* 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/. */ + +/** + * Methods for setting up and tearing down page event listeners. These are used + * to dismiss Feature Callouts when the callout's anchor element is clicked. + */ +export class PageEventManager { + /** + * A set of parameters defining a page event listener. + * @typedef {Object} PageEventListenerParams + * @property {String} type Event type string e.g. `click` + * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]` + * @property {PageEventListenerOptions} [options] addEventListener options + * + * @typedef {Object} PageEventListenerOptions + * @property {Boolean} [capture] Use event capturing phase? + * @property {Boolean} [once] Remove listener after first event? + * @property {Boolean} [preventDefault] Inverted value for `passive` option + * @property {Number} [interval] Used only for `timeout` and `interval` event + * types. These don't set up real event listeners, but instead invoke the + * action on a timer. + * + * @typedef {Object} PageEventListener + * @property {Function} callback Function to call when event is triggered + * @property {AbortController} controller Handle for aborting the listener + * + * @typedef {Object} PageEvent + * @property {String} type Event type string e.g. `click` + * @property {Element} [target] Event target + */ + + /** + * Maps event listener params to their PageEventListeners, so they can be + * called and cancelled. + * @type {Map<PageEventListenerParams, PageEventListener>} + */ + _listeners = new Map(); + + /** + * @param {Window} win Window containing the document to listen to + */ + constructor(win) { + this.win = win; + this.doc = win.document; + } + + /** + * Adds a page event listener. + * @param {PageEventListenerParams} params + * @param {Function} callback Function to call when event is triggered + */ + on(params, callback) { + if (this._listeners.has(params)) { + return; + } + const { type, selectors, options = {} } = params; + const listener = { callback }; + if (selectors) { + const controller = new AbortController(); + const opt = { + capture: !!options.capture, + passive: !options.preventDefault, + signal: controller.signal, + }; + const targets = this.doc.querySelectorAll(selectors); + for (const target of targets) { + target.addEventListener(type, callback, opt); + } + listener.controller = controller; + } else if (["timeout", "interval"].includes(type) && options.interval) { + let interval; + const abort = () => this.win.clearInterval(interval); + const onInterval = () => { + callback({ type, target: type }); + if (type === "timeout") { + abort(); + } + }; + interval = this.win.setInterval(onInterval, options.interval); + listener.callback = onInterval; + listener.controller = { abort }; + } + this._listeners.set(params, listener); + } + + /** + * Removes a page event listener. + * @param {PageEventListenerParams} params + */ + off(params) { + const listener = this._listeners.get(params); + if (!listener) { + return; + } + listener.controller?.abort(); + this._listeners.delete(params); + } + + /** + * Adds a page event listener that is removed after the first event. + * @param {PageEventListenerParams} params + * @param {Function} callback Function to call when event is triggered + */ + once(params, callback) { + const wrappedCallback = (...args) => { + this.off(params); + callback(...args); + }; + this.on(params, wrappedCallback); + } + + /** + * Removes all page event listeners. + */ + clear() { + for (const listener of this._listeners.values()) { + listener.controller?.abort(); + } + this._listeners.clear(); + } + + /** + * Calls matching page event listeners. A way to dispatch a "fake" event. + * @param {PageEvent} event + */ + emit(event) { + for (const [params, listener] of this._listeners) { + if (params.type === event.type) { + listener.callback(event); + } + } + } +} diff --git a/browser/components/asrouter/modules/PanelTestProvider.sys.mjs b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs new file mode 100644 index 0000000000..7a7ff1e1fc --- /dev/null +++ b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs @@ -0,0 +1,771 @@ +/* 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/. */ + +const TWO_DAYS = 2 * 24 * 3600 * 1000; + +const MESSAGES = () => [ + { + id: "WNP_THANK_YOU", + template: "update_action", + content: { + action: { + id: "moments-wnp", + data: { + url: "https://www.mozilla.org/%LOCALE%/etc/firefox/retention/thank-you-a/", + expireDelta: TWO_DAYS, + }, + }, + }, + trigger: { id: "momentsUpdate" }, + }, + { + id: "WHATS_NEW_FINGERPRINTER_COUNTER_ALT", + template: "whatsnew_panel_message", + order: 6, + content: { + bucket_id: "WHATS_NEW_72", + published_date: 1574776601000, + title: "Title", + icon_url: + "chrome://activity-stream/content/data/content/assets/protection-report-icon.png", + icon_alt: { string_id: "cfr-badge-reader-label-newfeature" }, + body: "Message body", + link_text: "Click here", + cta_url: "about:blank", + cta_type: "OPEN_PROTECTION_REPORT", + }, + targeting: `firefoxVersion >= 72`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "WHATS_NEW_70_1", + template: "whatsnew_panel_message", + order: 3, + content: { + bucket_id: "WHATS_NEW_70_1", + published_date: 1560969794394, + title: "Protection Is Our Focus", + icon_url: + "chrome://activity-stream/content/data/content/assets/whatsnew-send-icon.png", + icon_alt: "Firefox Send Logo", + body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", + cta_url: "https://blog.mozilla.org/", + cta_type: "OPEN_URL", + }, + targeting: `firefoxVersion > 69`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "WHATS_NEW_70_2", + template: "whatsnew_panel_message", + order: 1, + content: { + bucket_id: "WHATS_NEW_70_1", + published_date: 1560969794394, + title: "Another thing new in Firefox 70", + body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", + link_text: "Learn more on our blog", + cta_url: "https://blog.mozilla.org/", + cta_type: "OPEN_URL", + }, + targeting: `firefoxVersion > 69`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "WHATS_NEW_SEARCH_SHORTCUTS_84", + template: "whatsnew_panel_message", + order: 2, + content: { + bucket_id: "WHATS_NEW_SEARCH_SHORTCUTS_84", + published_date: 1560969794394, + title: "Title", + icon_url: "chrome://global/skin/icons/check.svg", + icon_alt: "", + body: "Message content", + cta_url: + "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/search-shortcuts", + cta_type: "OPEN_URL", + link_text: "Click here", + }, + targeting: "firefoxVersion >= 84", + trigger: { + id: "whatsNewPanelOpened", + }, + }, + { + id: "WHATS_NEW_PIONEER_82", + template: "whatsnew_panel_message", + order: 1, + content: { + bucket_id: "WHATS_NEW_PIONEER_82", + published_date: 1603152000000, + title: "Put your data to work for a better internet", + body: "Contribute your data to Mozilla's Pioneer program to help researchers understand pressing technology issues like misinformation, data privacy, and ethical AI.", + cta_url: "about:blank", + cta_where: "tab", + cta_type: "OPEN_ABOUT_PAGE", + link_text: "Join Pioneer", + }, + targeting: "firefoxVersion >= 82", + trigger: { + id: "whatsNewPanelOpened", + }, + }, + { + id: "WHATS_NEW_MEDIA_SESSION_82", + template: "whatsnew_panel_message", + order: 3, + content: { + bucket_id: "WHATS_NEW_MEDIA_SESSION_82", + published_date: 1603152000000, + title: "Title", + body: "Message content", + cta_url: + "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/media-keyboard-control", + cta_type: "OPEN_URL", + link_text: "Click here", + }, + targeting: "firefoxVersion >= 82", + trigger: { + id: "whatsNewPanelOpened", + }, + }, + { + id: "WHATS_NEW_69_1", + template: "whatsnew_panel_message", + order: 1, + content: { + bucket_id: "WHATS_NEW_69_1", + published_date: 1557346235089, + title: "Something new in Firefox 69", + body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", + link_text: "Learn more on our blog", + cta_url: "https://blog.mozilla.org/", + cta_type: "OPEN_URL", + }, + targeting: `firefoxVersion > 68`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "PERSONALIZED_CFR_MESSAGE", + template: "cfr_doorhanger", + groups: ["cfr"], + content: { + layout: "icon_and_message", + category: "cfrFeatures", + bucket_id: "PERSONALIZED_CFR_MESSAGE", + notification_text: "Personalized CFR Recommendation", + heading_text: { string_id: "cfr-doorhanger-bookmark-fxa-header" }, + info_icon: { + label: { + attributes: { + tooltiptext: { string_id: "cfr-doorhanger-fxa-close-btn-tooltip" }, + }, + }, + sumo_path: "https://example.com", + }, + text: { string_id: "cfr-doorhanger-bookmark-fxa-body" }, + icon: "chrome://branding/content/icon64.png", + icon_class: "cfr-doorhanger-large-icon", + persistent_doorhanger: true, + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-milestone-ok-button" }, + action: { + type: "OPEN_URL", + data: { + args: "https://send.firefox.com/login/?utm_source=activity-stream&entrypoint=activity-stream-cfr-pdf", + where: "tabshifted", + }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfrfeatures" }, + }, + }, + ], + }, + }, + targeting: "scores.PERSONALIZED_CFR_MESSAGE.score > scoreThreshold", + trigger: { + id: "openURL", + patterns: ["*://*/*.pdf"], + }, + }, + { + id: "MULTISTAGE_SPOTLIGHT_MESSAGE", + groups: ["panel-test-provider"], + template: "spotlight", + content: { + id: "MULTISTAGE_SPOTLIGHT_MESSAGE", + template: "multistage", + backdrop: "transparent", + transitions: true, + screens: [ + { + id: "AW_PIN_FIREFOX", + content: { + has_noodles: true, + title: { + string_id: "onboarding-easy-setup-security-and-privacy-title", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + reducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/glyph-pin-16.svg", + darkModeReducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/firefox.svg", + alt: "sample alt text", + }, + hero_text: { + string_id: "fx100-thank-you-hero-text", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-pin-primary-button-label", + }, + action: { + navigate: true, + type: "PIN_FIREFOX_TO_TASKBAR", + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + dismiss_button: { + action: { + dismiss: true, + }, + }, + }, + }, + { + id: "AW_SET_DEFAULT", + content: { + has_noodles: true, + logo: { + imageURL: "chrome://browser/content/logos/vpn-promo-logo.svg", + height: "100px", + }, + title: { + fontSize: "36px", + fontWeight: 276, + string_id: "mr2022-onboarding-set-default-title", + }, + subtitle: { + string_id: "mr2022-onboarding-set-default-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-set-default-primary-button-label", + }, + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + { + id: "BACKGROUND_IMAGE", + content: { + background: "#000", + text_color: "light", + progress_bar: true, + logo: { + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif", + }, + title: "A dialog with a background", + subtitle: + "The text color is configurable and a progress bar style step indicator is used", + primary_button: { + label: "Continue", + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + { + id: "BACKGROUND_COLOR", + content: { + background: "white", + progress_bar: true, + logo: { + height: "200px", + imageURL: "", + }, + title: { + fontSize: "36px", + fontWeight: 276, + raw: "Peace of mind.", + }, + title_style: "fancy shine", + text_color: "dark", + subtitle: "Using progress bar style step indicator", + primary_button: { + label: "Continue", + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + frequency: { lifetime: 3 }, + trigger: { id: "defaultBrowserCheck" }, + }, + { + id: "PB_FOCUS_PROMO", + groups: ["panel-test-provider"], + template: "spotlight", + content: { + template: "multistage", + backdrop: "transparent", + screens: [ + { + id: "PBM_FIREFOX_FOCUS", + order: 0, + content: { + logo: { + imageURL: "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: { + string_id: "spotlight-focus-promo-title", + }, + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + dismiss: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fus%2Fapp%2Ffirefox-focus-privacy-browser%2Fid1055677337", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.focus", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + email_link: { + action: { + data: { + args: "https://mozilla.org", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/focus-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + email: { + link_text: "Email yourself a link", + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + trigger: { id: "defaultBrowserCheck" }, + }, + { + id: "PB_NEWTAB_VPN_PROMO", + template: "pb_newtab", + content: { + promoEnabled: true, + promoType: "VPN", + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-description-private-window", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitleEnabled: false, + promoLinkType: "button", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + promoSectionStyle: "below-search", + promoHeader: "fluent:about-private-browsing-get-privacy", + promoTitle: "fluent:about-private-browsing-hide-activity-1", + promoTitleEnabled: true, + promoImageLarge: "chrome://browser/content/assets/moz-vpn.svg", + promoButton: { + action: { + type: "OPEN_URL", + data: { + args: "https://vpn.mozilla.org/", + }, + }, + }, + }, + groups: ["panel-test-provider"], + targeting: "region != 'CN' && !hasActiveEnterprisePolicies", + frequency: { lifetime: 3 }, + }, + { + id: "PB_PIN_PROMO", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "PIN", + promoHeader: "Private browsing freedom in one click", + promoImageLarge: + "chrome://browser/content/assets/private-promo-asset.svg", + promoLinkText: "Pin To Taskbar", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: + "No saved cookies or history, right from your desktop. Browse like no one’s watching.", + promoTitleEnabled: true, + promoButton: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.privateWindowSeparation.enabled", + value: true, + }, + }, + }, + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "BLOCK_MESSAGE", + data: { + id: "PB_PIN_PROMO", + }, + }, + { + type: "OPEN_ABOUT_PAGE", + data: { args: "privatebrowsing", where: "current" }, + }, + ], + }, + }, + }, + }, + priority: 3, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: + "region != 'CN' && !hasActiveEnterprisePolicies && doesAppNeedPin", + }, + { + id: "TEST_TOAST_NOTIFICATION1", + weight: 100, + template: "toast_notification", + content: { + title: { + string_id: "cfr-doorhanger-bookmark-fxa-header", + }, + body: "Body", + image_url: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif", + launch_url: "https://mozilla.org", + requireInteraction: true, + actions: [ + { + action: "dismiss", + title: "Dismiss", + windowsSystemActivationType: true, + }, + { + action: "snooze", + title: "Snooze", + windowsSystemActivationType: true, + }, + { action: "callback", title: "Callback" }, + ], + tag: "test_toast_notification", + }, + groups: ["panel-test-provider"], + targeting: "!hasActiveEnterprisePolicies", + trigger: { id: "backgroundTaskMessage" }, + frequency: { lifetime: 3 }, + }, + { + id: "TEST_TOAST_NOTIFICATION2", + weight: 100, + template: "toast_notification", + content: { + title: "Launch action on toast click and on action button click", + body: "Body", + image_url: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif", + launch_action: { + type: "OPEN_URL", + data: { args: "https://mozilla.org", where: "window" }, + }, + requireInteraction: true, + actions: [ + { + action: "dismiss", + title: "Dismiss", + windowsSystemActivationType: true, + }, + { + action: "snooze", + title: "Snooze", + windowsSystemActivationType: true, + }, + { + action: "private", + title: "Private Window", + launch_action: { type: "OPEN_PRIVATE_BROWSER_WINDOW" }, + }, + ], + tag: "test_toast_notification", + }, + groups: ["panel-test-provider"], + targeting: "!hasActiveEnterprisePolicies", + trigger: { id: "backgroundTaskMessage" }, + frequency: { lifetime: 3 }, + }, + { + id: "MR2022_BACKGROUND_UPDATE_TOAST_NOTIFICATION", + weight: 100, + template: "toast_notification", + content: { + title: { + string_id: "mr2022-background-update-toast-title", + }, + body: { + string_id: "mr2022-background-update-toast-text", + }, + image_url: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/673d2808-e5d8-41b9-957e-f60d53233b97.png", + requireInteraction: true, + actions: [ + { + action: "open", + title: { + string_id: "mr2022-background-update-toast-primary-button-label", + }, + }, + { + action: "snooze", + windowsSystemActivationType: true, + title: { + string_id: "mr2022-background-update-toast-secondary-button-label", + }, + }, + ], + tag: "mr2022_background_update", + }, + groups: ["panel-test-provider"], + targeting: "!hasActiveEnterprisePolicies", + trigger: { id: "backgroundTaskMessage" }, + frequency: { lifetime: 3 }, + }, + { + id: "IMPORT_SETTINGS_EMBEDDED", + groups: ["panel-test-provider"], + template: "spotlight", + content: { + template: "multistage", + backdrop: "transparent", + screens: [ + { + id: "IMPORT_SETTINGS_EMBEDDED", + content: { + logo: {}, + tiles: { type: "migration-wizard" }, + progress_bar: true, + migrate_start: { + action: {}, + }, + migrate_close: { + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + ], + }, + }, + { + id: "TEST_FEATURE_TOUR", + template: "feature_callout", + groups: [], + content: { + id: "TEST_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FEATURE_CALLOUT_1", + anchors: [ + { + selector: "#PanelUI-menu-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + }, + ], + content: { + position: "callout", + title: { raw: "Panel Feature Callout" }, + subtitle: { raw: "Hello!" }, + secondary_button: { + label: { raw: "Advance" }, + action: { navigate: true }, + }, + submenu_button: { + submenu: [ + { + type: "action", + label: { raw: "Item 1" }, + action: { navigate: true }, + id: "item1", + }, + { + type: "action", + label: { raw: "Item 2" }, + action: { navigate: true }, + id: "item2", + }, + { + type: "menu", + label: { raw: "Menu 1" }, + submenu: [ + { + type: "action", + label: { raw: "Item 3" }, + action: { navigate: true }, + id: "item3", + }, + { + type: "action", + label: { raw: "Item 4" }, + action: { navigate: true }, + id: "item4", + }, + ], + id: "menu1", + }, + ], + attached_to: "secondary_button", + }, + dismiss_button: { + action: { dismiss: true }, + }, + }, + }, + ], + }, + }, +]; + +export const PanelTestProvider = { + getMessages() { + return Promise.resolve( + MESSAGES().map(message => ({ + ...message, + targeting: `providerCohorts.panel_local_testing == "SHOW_TEST"`, + })) + ); + }, +}; diff --git a/browser/components/asrouter/modules/RemoteL10n.sys.mjs b/browser/components/asrouter/modules/RemoteL10n.sys.mjs new file mode 100644 index 0000000000..1df10fbd72 --- /dev/null +++ b/browser/components/asrouter/modules/RemoteL10n.sys.mjs @@ -0,0 +1,249 @@ +/* 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/. */ + +/** + * The downloaded Fluent file is located in this sub-directory of the local + * profile directory. + */ +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +/** + * All supported locales for remote l10n + * + * This is used by ASRouter.sys.mjs to check if the locale is supported before + * issuing the request for remote fluent files to RemoteSettings. + * + * Note: + * * this is generated based on "browser/locales/all-locales" as l10n doesn't + * provide an API to fetch that list + * + * * this list doesn't include "en-US", though "en-US" is well supported and + * `_RemoteL10n.isLocaleSupported()` will handle it properly + */ +const ALL_LOCALES = new Set([ + "ach", + "af", + "an", + "ar", + "ast", + "az", + "be", + "bg", + "bn", + "bo", + "br", + "brx", + "bs", + "ca", + "ca-valencia", + "cak", + "ckb", + "cs", + "cy", + "da", + "de", + "dsb", + "el", + "en-CA", + "en-GB", + "eo", + "es-AR", + "es-CL", + "es-ES", + "es-MX", + "et", + "eu", + "fa", + "ff", + "fi", + "fr", + "fy-NL", + "ga-IE", + "gd", + "gl", + "gn", + "gu-IN", + "he", + "hi-IN", + "hr", + "hsb", + "hu", + "hy-AM", + "hye", + "ia", + "id", + "is", + "it", + "ja", + "ja-JP-mac", + "ka", + "kab", + "kk", + "km", + "kn", + "ko", + "lij", + "lo", + "lt", + "ltg", + "lv", + "meh", + "mk", + "mr", + "ms", + "my", + "nb-NO", + "ne-NP", + "nl", + "nn-NO", + "oc", + "pa-IN", + "pl", + "pt-BR", + "pt-PT", + "rm", + "ro", + "ru", + "scn", + "si", + "sk", + "sl", + "son", + "sq", + "sr", + "sv-SE", + "szl", + "ta", + "te", + "th", + "tl", + "tr", + "trs", + "uk", + "ur", + "uz", + "vi", + "wo", + "xh", + "zh-CN", + "zh-TW", +]); + +export class _RemoteL10n { + constructor() { + this._l10n = null; + } + + createElement(doc, elem, options = {}) { + let node; + if (options.content && options.content.string_id) { + node = doc.createElement("remote-text"); + } else { + node = doc.createElementNS("http://www.w3.org/1999/xhtml", elem); + } + if (options.classList) { + node.classList.add(options.classList); + } + this.setString(node, options); + + return node; + } + + // If `string_id` is present it means we are relying on fluent for translations. + // Otherwise, we have a vanilla string. + setString(el, { content, attributes = {} }) { + if (content && content.string_id) { + for (let [fluentId, value] of Object.entries(attributes)) { + el.setAttribute(`fluent-variable-${fluentId}`, value); + } + el.setAttribute("fluent-remote-id", content.string_id); + } else { + el.textContent = content; + } + } + + /** + * Creates a new DOMLocalization instance with the Fluent file from Remote Settings. + * + * Note: it will use the local Fluent file in any of following cases: + * * the remote Fluent file is not available + * * it was told to use the local Fluent file + */ + _createDOML10n() { + /* istanbul ignore next */ + let useRemoteL10n = Services.prefs.getBoolPref(USE_REMOTE_L10N_PREF, true); + if (useRemoteL10n && !L10nRegistry.getInstance().hasSource("cfr")) { + const appLocale = Services.locale.appLocaleAsBCP47; + const l10nFluentDir = PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "settings", + "main", + "ms-language-packs" + ); + let cfrIndexedFileSource = new L10nFileSource( + "cfr", + "app", + [appLocale], + `file://${l10nFluentDir}/`, + { + addResourceOptions: { + allowOverrides: true, + }, + }, + [`file://${l10nFluentDir}/browser/newtab/asrouter.ftl`] + ); + L10nRegistry.getInstance().registerSources([cfrIndexedFileSource]); + } else if (!useRemoteL10n && L10nRegistry.getInstance().hasSource("cfr")) { + L10nRegistry.getInstance().removeSources(["cfr"]); + } + + return new DOMLocalization( + [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ], + false + ); + } + + get l10n() { + if (!this._l10n) { + this._l10n = this._createDOML10n(); + } + return this._l10n; + } + + reloadL10n() { + this._l10n = null; + } + + isLocaleSupported(locale) { + return locale === "en-US" || ALL_LOCALES.has(locale); + } + + /** + * Format given `localizableText`. + * + * Format `localizableText` if it is an object using any `string_id` field, + * otherwise return `localizableText` unmodified. + * + * @param {object|string} `localizableText` to format. + * @return {string} formatted text. + */ + async formatLocalizableText(localizableText) { + if (typeof localizableText !== "string") { + // It's more useful to get an error than passing through an object without + // a `string_id` field. + let value = await this.l10n.formatValue(localizableText.string_id); + return value; + } + return localizableText; + } +} + +export const RemoteL10n = new _RemoteL10n(); diff --git a/browser/components/asrouter/modules/Spotlight.sys.mjs b/browser/components/asrouter/modules/Spotlight.sys.mjs new file mode 100644 index 0000000000..65453a4397 --- /dev/null +++ b/browser/components/asrouter/modules/Spotlight.sys.mjs @@ -0,0 +1,78 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutWelcomeTelemetry: + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "AWTelemetry", + () => new lazy.AboutWelcomeTelemetry() +); + +export const Spotlight = { + sendUserEventTelemetry(event, message, dispatch) { + const ping = { + message_id: message.content.id, + event, + }; + dispatch({ + type: "SPOTLIGHT_TELEMETRY", + data: { action: "spotlight_user_event", ...ping }, + }); + }, + + defaultDispatch(message) { + if (message.type === "SPOTLIGHT_TELEMETRY") { + const { message_id, event } = message.data; + lazy.AWTelemetry.sendTelemetry({ message_id, event }); + } + }, + + /** + * Shows spotlight tab or window modal specific to the given browser + * @param browser The browser for spotlight display + * @param message Message containing content to show + * @param dispatchCFRAction A function to dispatch resulting actions + * @return boolean value capturing if spotlight was displayed + */ + async showSpotlightDialog(browser, message, dispatch = this.defaultDispatch) { + const win = browser?.ownerGlobal; + if (!win || win.gDialogBox.isOpen) { + return false; + } + const spotlight_url = "chrome://browser/content/spotlight.html"; + + const dispatchCFRAction = + // This also blocks CFR impressions, which is fine for current use cases. + message.content?.metrics === "block" ? () => {} : dispatch; + + // This handles `IMPRESSION` events used by ASRouter for frequency caps. + // AboutWelcome handles `IMPRESSION` events for telemetry. + this.sendUserEventTelemetry("IMPRESSION", message, dispatchCFRAction); + dispatchCFRAction({ type: "IMPRESSION", data: message }); + + if (message.content?.modal === "tab") { + let { closedPromise } = win.gBrowser.getTabDialogBox(browser).open( + spotlight_url, + { + features: "resizable=no", + allowDuplicateDialogs: false, + }, + message.content + ); + await closedPromise; + } else { + await win.gDialogBox.open(spotlight_url, message.content); + } + + // If dismissed report telemetry and exit + this.sendUserEventTelemetry("DISMISS", message, dispatchCFRAction); + return true; + }, +}; diff --git a/browser/components/asrouter/modules/ToastNotification.sys.mjs b/browser/components/asrouter/modules/ToastNotification.sys.mjs new file mode 100644 index 0000000000..136225cf61 --- /dev/null +++ b/browser/components/asrouter/modules/ToastNotification.sys.mjs @@ -0,0 +1,138 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"], +}); + +export const ToastNotification = { + // Allow testing to stub the alerts service. + get AlertsService() { + return lazy.AlertsService; + }, + + sendUserEventTelemetry(event, message, dispatch) { + const ping = { + message_id: message.id, + event, + }; + dispatch({ + type: "TOAST_NOTIFICATION_TELEMETRY", + data: { action: "toast_notification_user_event", ...ping }, + }); + }, + + /** + * Show a toast notification. + * @param message Message containing content to show. + * @param dispatch A function to dispatch resulting actions. + * @return boolean value capturing if toast notification was displayed. + */ + async showToastNotification(message, dispatch) { + let { content } = message; + let title = await lazy.RemoteL10n.formatLocalizableText(content.title); + let body = await lazy.RemoteL10n.formatLocalizableText(content.body); + + // The only link between background task message experiment and user + // re-engagement via the notification is the associated "tag". Said tag is + // usually controlled by the message content, but for message experiments, + // we want to avoid a missing tag and to ensure a deterministic tag for + // easier analysis, including across branches. + let { tag } = content; + + let experimentMetadata = + lazy.ExperimentAPI.getExperimentMetaData({ + featureId: "backgroundTaskMessage", + }) || {}; + + if ( + experimentMetadata?.active && + experimentMetadata?.slug && + experimentMetadata?.branch?.slug + ) { + // Like `my-experiment:my-branch`. + tag = `${experimentMetadata?.slug}:${experimentMetadata?.branch?.slug}`; + } + + // There are two events named `IMPRESSION` the first one refers to telemetry + // while the other refers to ASRouter impressions used for the frequency cap + this.sendUserEventTelemetry("IMPRESSION", message, dispatch); + dispatch({ type: "IMPRESSION", data: message }); + + let alert = Cc["@mozilla.org/alert-notification;1"].createInstance( + Ci.nsIAlertNotification + ); + let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + alert.init( + tag, + content.image_url + ? Services.urlFormatter.formatURL(content.image_url) + : content.image_url, + title, + body, + true /* aTextClickable */, + content.data, + null /* aDir */, + null /* aLang */, + null /* aData */, + systemPrincipal, + null /* aInPrivateBrowsing */, + content.requireInteraction + ); + + if (content.actions) { + let actions = Cu.cloneInto(content.actions, {}); + for (let action of actions) { + if (action.title) { + action.title = await lazy.RemoteL10n.formatLocalizableText( + action.title + ); + } + if (action.launch_action) { + action.opaqueRelaunchData = JSON.stringify(action.launch_action); + delete action.launch_action; + } + } + alert.actions = actions; + } + + // Populate `opaqueRelaunchData`, prefering `launch_action` if given, + // falling back to `launch_url` if given. + let relaunchAction = content.launch_action; + if (!relaunchAction && content.launch_url) { + relaunchAction = { + type: "OPEN_URL", + data: { + args: content.launch_url, + where: "tab", + }, + }; + } + if (relaunchAction) { + alert.opaqueRelaunchData = JSON.stringify(relaunchAction); + } + + let shownPromise = Promise.withResolvers(); + let obs = (subject, topic, data) => { + if (topic === "alertshow") { + shownPromise.resolve(); + } + }; + + this.AlertsService.showAlert(alert, obs); + + await shownPromise; + + return true; + }, +}; diff --git a/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs new file mode 100644 index 0000000000..7832ae9456 --- /dev/null +++ b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs @@ -0,0 +1,308 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + requestIdleCallback: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs", +}); + +let notificationsByWindow = new WeakMap(); + +export class _ToolbarBadgeHub { + constructor() { + this.id = "toolbar-badge-hub"; + this.state = {}; + this.prefs = { + WHATSNEW_TOOLBAR_PANEL: "browser.messaging-system.whatsNewPanel.enabled", + }; + this.removeAllNotifications = this.removeAllNotifications.bind(this); + this.removeToolbarNotification = this.removeToolbarNotification.bind(this); + this.addToolbarNotification = this.addToolbarNotification.bind(this); + this.registerBadgeToAllWindows = this.registerBadgeToAllWindows.bind(this); + this._sendPing = this._sendPing.bind(this); + this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this); + + this._handleMessageRequest = null; + this._addImpression = null; + this._blockMessageById = null; + this._sendTelemetry = null; + this._initialized = false; + } + + async init( + waitForInitialized, + { + handleMessageRequest, + addImpression, + blockMessageById, + unblockMessageById, + sendTelemetry, + } + ) { + if (this._initialized) { + return; + } + + this._initialized = true; + this._handleMessageRequest = handleMessageRequest; + this._blockMessageById = blockMessageById; + this._unblockMessageById = unblockMessageById; + this._addImpression = addImpression; + this._sendTelemetry = sendTelemetry; + // Need to wait for ASRouter to initialize before trying to fetch messages + await waitForInitialized; + this.messageRequest({ + triggerId: "toolbarBadgeUpdate", + template: "toolbar_badge", + }); + // Listen for pref changes that could trigger new badges + Services.prefs.addObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this); + } + + observe(aSubject, aTopic, aPrefName) { + switch (aPrefName) { + case this.prefs.WHATSNEW_TOOLBAR_PANEL: + this.messageRequest({ + triggerId: "toolbarBadgeUpdate", + template: "toolbar_badge", + }); + break; + } + } + + maybeInsertFTL(win) { + win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); + } + + executeAction({ id, data, message_id }) { + switch (id) { + case "show-whatsnew-button": + lazy.ToolbarPanelHub.enableToolbarButton(); + lazy.ToolbarPanelHub.enableAppmenuButton(); + break; + } + } + + _clearBadgeTimeout() { + if (this.state.showBadgeTimeoutId) { + lazy.clearTimeout(this.state.showBadgeTimeoutId); + } + } + + removeAllNotifications(event) { + if (event) { + // ignore right clicks + if ( + (event.type === "mousedown" || event.type === "click") && + event.button !== 0 + ) { + return; + } + // ignore keyboard access that is not one of the usual accessor keys + if ( + event.type === "keypress" && + event.key !== " " && + event.key !== "Enter" + ) { + return; + } + + event.target.removeEventListener( + "mousedown", + this.removeAllNotifications + ); + event.target.removeEventListener("keypress", this.removeAllNotifications); + // If we have an event it means the user interacted with the badge + // we should send telemetry + if (this.state.notification) { + this.sendUserEventTelemetry("CLICK", this.state.notification); + } + } + // Will call uninit on every window + lazy.EveryWindow.unregisterCallback(this.id); + if (this.state.notification) { + this._blockMessageById(this.state.notification.id); + } + this._clearBadgeTimeout(); + this.state = {}; + } + + removeToolbarNotification(toolbarButton) { + // Remove it from the element that displays the badge + toolbarButton + .querySelector(".toolbarbutton-badge") + .classList.remove("feature-callout"); + toolbarButton.removeAttribute("badged"); + // Remove id used for for aria-label badge description + const notificationDescription = toolbarButton.querySelector( + "#toolbarbutton-notification-description" + ); + if (notificationDescription) { + notificationDescription.remove(); + toolbarButton.removeAttribute("aria-labelledby"); + toolbarButton.removeAttribute("aria-describedby"); + } + } + + addToolbarNotification(win, message) { + const document = win.browser.ownerDocument; + if (message.content.action) { + this.executeAction({ ...message.content.action, message_id: message.id }); + } + let toolbarbutton = document.getElementById(message.content.target); + if (toolbarbutton) { + const badge = toolbarbutton.querySelector(".toolbarbutton-badge"); + badge.classList.add("feature-callout"); + toolbarbutton.setAttribute("badged", true); + // If we have additional aria-label information for the notification + // we add this content to the hidden `toolbarbutton-text` node. + // We then use `aria-labelledby` to link this description to the button + // that received the notification badge. + if (message.content.badgeDescription) { + // Insert strings as soon as we know we're showing them + this.maybeInsertFTL(win); + toolbarbutton.setAttribute( + "aria-labelledby", + `toolbarbutton-notification-description ${message.content.target}` + ); + // Because tooltiptext is different to the label, it gets duplicated as + // the description. Setting `describedby` to the same value as + // `labelledby` will be detected by the a11y code and the description + // will be removed. + toolbarbutton.setAttribute( + "aria-describedby", + `toolbarbutton-notification-description ${message.content.target}` + ); + const descriptionEl = document.createElement("span"); + descriptionEl.setAttribute( + "id", + "toolbarbutton-notification-description" + ); + descriptionEl.hidden = true; + document.l10n.setAttributes( + descriptionEl, + message.content.badgeDescription.string_id + ); + toolbarbutton.appendChild(descriptionEl); + } + // `mousedown` event required because of the `onmousedown` defined on + // the button that prevents `click` events from firing + toolbarbutton.addEventListener("mousedown", this.removeAllNotifications); + // `keypress` event required for keyboard accessibility + toolbarbutton.addEventListener("keypress", this.removeAllNotifications); + this.state = { notification: { id: message.id } }; + + // Impression should be added when the badge becomes visible + this._addImpression(message); + // Send a telemetry ping when adding the notification badge + this.sendUserEventTelemetry("IMPRESSION", message); + + return toolbarbutton; + } + + return null; + } + + registerBadgeToAllWindows(message) { + if (message.template === "update_action") { + this.executeAction({ ...message.content.action, message_id: message.id }); + // No badge to set only an action to execute + return; + } + + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (notificationsByWindow.has(win)) { + // nothing to do + return; + } + const el = this.addToolbarNotification(win, message); + notificationsByWindow.set(win, el); + }, + win => { + const el = notificationsByWindow.get(win); + if (el) { + this.removeToolbarNotification(el); + } + notificationsByWindow.delete(win); + } + ); + } + + registerBadgeNotificationListener(message, options = {}) { + // We need to clear any existing notifications and only show + // the one set by devtools + if (options.force) { + this.removeAllNotifications(); + // When debugging immediately show the badge + this.registerBadgeToAllWindows(message); + return; + } + + if (message.content.delay) { + this.state.showBadgeTimeoutId = lazy.setTimeout(() => { + lazy.requestIdleCallback(() => this.registerBadgeToAllWindows(message)); + }, message.content.delay); + } else { + this.registerBadgeToAllWindows(message); + } + } + + async messageRequest({ triggerId, template }) { + const telemetryObject = { triggerId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + const message = await this._handleMessageRequest({ + triggerId, + template, + }); + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + if (message) { + this.registerBadgeNotificationListener(message); + } + } + + _sendPing(ping) { + this._sendTelemetry({ + type: "TOOLBAR_BADGE_TELEMETRY", + data: { action: "badge_user_event", ...ping }, + }); + } + + sendUserEventTelemetry(event, message) { + const win = Services.wm.getMostRecentWindow("navigator:browser"); + // Only send pings for non private browsing windows + if ( + win && + !lazy.PrivateBrowsingUtils.isBrowserPrivate( + win.ownerGlobal.gBrowser.selectedBrowser + ) + ) { + this._sendPing({ + message_id: message.id, + event, + }); + } + } + + uninit() { + this._clearBadgeTimeout(); + this.state = {}; + this._initialized = false; + notificationsByWindow = new WeakMap(); + Services.prefs.removeObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this); + } +} + +/** + * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate + * message requests and render messages. + */ +export const ToolbarBadgeHub = new _ToolbarBadgeHub(); diff --git a/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs b/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs new file mode 100644 index 0000000000..519bca8a89 --- /dev/null +++ b/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs @@ -0,0 +1,544 @@ +/* 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/. */ + +const lazy = {}; + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// XPCOMUtils. That environment overrides importESModule to be a no-op +// (which can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(lazy, { + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "TrackingDBService", + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService" +); + +const idToTextMap = new Map([ + [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"], + [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"], + [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"], + [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"], + [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"], +]); + +const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled"; +const PROTECTIONS_PANEL_INFOMSG_PREF = + "browser.protections_panel.infoMessage.seen"; + +const TOOLBAR_BUTTON_ID = "whats-new-menu-button"; +const APPMENU_BUTTON_ID = "appMenu-whatsnew-button"; + +const BUTTON_STRING_ID = "cfr-whatsnew-button"; +const WHATS_NEW_PANEL_SELECTOR = "PanelUI-whatsNew-message-container"; + +export class _ToolbarPanelHub { + constructor() { + this.triggerId = "whatsNewPanelOpened"; + this._showAppmenuButton = this._showAppmenuButton.bind(this); + this._hideAppmenuButton = this._hideAppmenuButton.bind(this); + this._showToolbarButton = this._showToolbarButton.bind(this); + this._hideToolbarButton = this._hideToolbarButton.bind(this); + + this.state = {}; + this._initialized = false; + } + + async init(waitForInitialized, { getMessages, sendTelemetry }) { + if (this._initialized) { + return; + } + + this._initialized = true; + this._getMessages = getMessages; + this._sendTelemetry = sendTelemetry; + // Wait for ASRouter messages to become available in order to know + // if we can show the What's New panel + await waitForInitialized; + // Enable the application menu button so that the user can access + // the panel outside of the toolbar button + await this.enableAppmenuButton(); + + this.state = { + protectionPanelMessageSeen: Services.prefs.getBoolPref( + PROTECTIONS_PANEL_INFOMSG_PREF, + false + ), + }; + } + + uninit() { + this._initialized = false; + lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID); + lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID); + } + + get messages() { + return this._getMessages({ + template: "whatsnew_panel_message", + triggerId: "whatsNewPanelOpened", + returnAll: true, + }); + } + + toggleWhatsNewPref(event) { + // Checkbox onclick handler gets called before the checkbox state gets toggled, + // so we have to call it with the opposite value. + let newValue = !event.target.checked; + Services.prefs.setBoolPref(WHATSNEW_ENABLED_PREF, newValue); + + this.sendUserEventTelemetry( + event.target.ownerGlobal, + "WNP_PREF_TOGGLE", + // Message id is not applicable in this case, the notification state + // is not related to a particular message + { id: "n/a" }, + { value: { prefValue: newValue } } + ); + } + + maybeInsertFTL(win) { + win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); + win.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); + win.MozXULElement.insertFTLIfNeeded("toolkit/branding/accounts.ftl"); + } + + maybeLoadCustomElement(win) { + if (!win.customElements.get("remote-text")) { + Services.scriptloader.loadSubScript( + "resource://activity-stream/data/custom-elements/paragraph.js", + win + ); + } + } + + // Turns on the Appmenu (hamburger menu) button for all open windows and future windows. + async enableAppmenuButton() { + if ((await this.messages).length) { + lazy.EveryWindow.registerCallback( + APPMENU_BUTTON_ID, + this._showAppmenuButton, + this._hideAppmenuButton + ); + } + } + + // Removes the button from the Appmenu. + // Only used in tests. + disableAppmenuButton() { + lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID); + } + + // Turns on the Toolbar button for all open windows and future windows. + async enableToolbarButton() { + if ((await this.messages).length) { + lazy.EveryWindow.registerCallback( + TOOLBAR_BUTTON_ID, + this._showToolbarButton, + this._hideToolbarButton + ); + } + } + + // When the panel is hidden we want to run some cleanup + _onPanelHidden(win) { + const panelContainer = win.document.getElementById( + "customizationui-widget-panel" + ); + // When the panel is hidden we want to remove any toolbar buttons that + // might have been added as an entry point to the panel + const removeToolbarButton = () => { + lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID); + }; + if (!panelContainer) { + return; + } + panelContainer.addEventListener("popuphidden", removeToolbarButton, { + once: true, + }); + } + + // Newer messages first and use `order` field to decide between messages + // with the same timestamp + _sortWhatsNewMessages(m1, m2) { + // Sort by published_date in descending order. + if (m1.content.published_date === m2.content.published_date) { + // Ascending order + return m1.order - m2.order; + } + if (m1.content.published_date > m2.content.published_date) { + return -1; + } + return 1; + } + + // Render what's new messages into the panel. + async renderMessages(win, doc, containerId, options = {}) { + // Set the checked status of the footer checkbox + let value = Services.prefs.getBoolPref(WHATSNEW_ENABLED_PREF); + let checkbox = win.document.getElementById("panelMenu-toggleWhatsNew"); + + checkbox.checked = value; + + this.maybeLoadCustomElement(win); + const messages = + (options.force && options.messages) || + (await this.messages).sort(this._sortWhatsNewMessages); + const container = lazy.PanelMultiView.getViewNode(doc, containerId); + + if (messages) { + // Targeting attribute state might have changed making new messages + // available and old messages invalid, we need to refresh + this.removeMessages(win, containerId); + let previousDate = 0; + // Get and store any variable part of the message content + this.state.contentArguments = await this._contentArguments(); + for (let message of messages) { + container.appendChild( + this._createMessageElements(win, doc, message, previousDate) + ); + previousDate = message.content.published_date; + } + } + + this._onPanelHidden(win); + + // Panel impressions are not associated with one particular message + // but with a set of messages. We concatenate message ids and send them + // back for every impression. + const eventId = { + id: messages + .map(({ id }) => id) + .sort() + .join(","), + }; + // Check `mainview` attribute to determine if the panel is shown as a + // subview (inside the application menu) or as a toolbar dropdown. + // https://searchfox.org/mozilla-central/rev/07f7390618692fa4f2a674a96b9b677df3a13450/browser/components/customizableui/PanelMultiView.jsm#1268 + const mainview = win.PanelUI.whatsNewPanel.hasAttribute("mainview"); + this.sendUserEventTelemetry(win, "IMPRESSION", eventId, { + value: { view: mainview ? "toolbar_dropdown" : "application_menu" }, + }); + } + + removeMessages(win, containerId) { + const doc = win.document; + const messageNodes = lazy.PanelMultiView.getViewNode( + doc, + containerId + ).querySelectorAll(".whatsNew-message"); + for (const messageNode of messageNodes) { + messageNode.remove(); + } + } + + /** + * Dispatch the action defined in the message and user telemetry event. + */ + _dispatchUserAction(win, message) { + let url; + try { + // Set platform specific path variables for SUMO articles + url = Services.urlFormatter.formatURL(message.content.cta_url); + } catch (e) { + console.error(e); + url = message.content.cta_url; + } + lazy.SpecialMessageActions.handleAction( + { + type: message.content.cta_type, + data: { + args: url, + where: message.content.cta_where || "tabshifted", + }, + }, + win.browser + ); + + this.sendUserEventTelemetry(win, "CLICK", message); + } + + /** + * Attach event listener to dispatch message defined action. + */ + _attachCommandListener(win, element, message) { + // Add event listener for `mouseup` not to overlap with the + // `mousedown` & `click` events dispatched from PanelMultiView.sys.mjs + // https://searchfox.org/mozilla-central/rev/7531325c8660cfa61bf71725f83501028178cbb9/browser/components/customizableui/PanelMultiView.jsm#1830-1837 + element.addEventListener("mouseup", () => { + this._dispatchUserAction(win, message); + }); + element.addEventListener("keyup", e => { + if (e.key === "Enter" || e.key === " ") { + this._dispatchUserAction(win, message); + } + }); + } + + _createMessageElements(win, doc, message, previousDate) { + const { content } = message; + const messageEl = lazy.RemoteL10n.createElement(doc, "div"); + messageEl.classList.add("whatsNew-message"); + + // Only render date if it is different from the one rendered before. + if (content.published_date !== previousDate) { + messageEl.appendChild( + lazy.RemoteL10n.createElement(doc, "p", { + classList: "whatsNew-message-date", + content: new Date(content.published_date).toLocaleDateString( + "default", + { + month: "long", + day: "numeric", + year: "numeric", + } + ), + }) + ); + } + + const wrapperEl = lazy.RemoteL10n.createElement(doc, "div"); + wrapperEl.doCommand = () => this._dispatchUserAction(win, message); + wrapperEl.classList.add("whatsNew-message-body"); + messageEl.appendChild(wrapperEl); + + if (content.icon_url) { + wrapperEl.classList.add("has-icon"); + const iconEl = lazy.RemoteL10n.createElement(doc, "img"); + iconEl.src = content.icon_url; + iconEl.classList.add("whatsNew-message-icon"); + if (content.icon_alt && content.icon_alt.string_id) { + doc.l10n.setAttributes(iconEl, content.icon_alt.string_id); + } else { + iconEl.setAttribute("alt", content.icon_alt); + } + wrapperEl.appendChild(iconEl); + } + + wrapperEl.appendChild(this._createMessageContent(win, doc, content)); + + if (content.link_text) { + const anchorEl = lazy.RemoteL10n.createElement(doc, "a", { + classList: "text-link", + content: content.link_text, + }); + anchorEl.doCommand = () => this._dispatchUserAction(win, message); + wrapperEl.appendChild(anchorEl); + } + + // Attach event listener on entire message container + this._attachCommandListener(win, messageEl, message); + + return messageEl; + } + + /** + * Return message title (optional subtitle) and body + */ + _createMessageContent(win, doc, content) { + const wrapperEl = new win.DocumentFragment(); + + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "h2", { + classList: "whatsNew-message-title", + content: content.title, + attributes: this.state.contentArguments, + }) + ); + + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "p", { + content: content.body, + classList: "whatsNew-message-content", + attributes: this.state.contentArguments, + }) + ); + + return wrapperEl; + } + + _createHeroElement(win, doc, message) { + this.maybeLoadCustomElement(win); + + const messageEl = lazy.RemoteL10n.createElement(doc, "div"); + messageEl.setAttribute("id", "protections-popup-message"); + messageEl.classList.add("whatsNew-hero-message"); + const wrapperEl = lazy.RemoteL10n.createElement(doc, "div"); + wrapperEl.classList.add("whatsNew-message-body"); + messageEl.appendChild(wrapperEl); + + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "h2", { + classList: "whatsNew-message-title", + content: message.content.title, + }) + ); + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "p", { + classList: "protections-popup-content", + content: message.content.body, + }) + ); + + if (message.content.link_text) { + let linkEl = lazy.RemoteL10n.createElement(doc, "a", { + classList: "text-link", + content: message.content.link_text, + }); + linkEl.disabled = true; + wrapperEl.appendChild(linkEl); + this._attachCommandListener(win, linkEl, message); + } else { + this._attachCommandListener(win, wrapperEl, message); + } + + return messageEl; + } + + async _contentArguments() { + const { defaultEngine } = Services.search; + // Between now and 6 weeks ago + const dateTo = new Date(); + const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000); + const eventsByDate = await lazy.TrackingDBService.getEventsByDateRange( + dateFrom, + dateTo + ); + // Make sure we set all types of possible values to 0 because they might + // be referenced by fluent strings + let totalEvents = { blockedCount: 0 }; + for (let blockedType of idToTextMap.values()) { + totalEvents[blockedType] = 0; + } + // Count all events in the past 6 weeks. Returns an object with: + // `blockedCount` total number of blocked resources + // {tracker|cookie|social...} breakdown by event type as defined by `idToTextMap` + totalEvents = eventsByDate.reduce((acc, day) => { + const type = day.getResultByName("type"); + const count = day.getResultByName("count"); + acc[idToTextMap.get(type)] = (acc[idToTextMap.get(type)] || 0) + count; + acc.blockedCount += count; + return acc; + }, totalEvents); + return { + // Keys need to match variable names used in asrouter.ftl + // `earliestDate` will be either 6 weeks ago or when tracking recording + // started. Whichever is more recent. + earliestDate: Math.max( + new Date(await lazy.TrackingDBService.getEarliestRecordedDate()), + dateFrom + ), + ...totalEvents, + // Passing in `undefined` as string for the Fluent variable name + // in order to match and select the message that does not require + // the variable. + searchEngineName: defaultEngine ? defaultEngine.name : "undefined", + }; + } + + async _showAppmenuButton(win) { + this.maybeInsertFTL(win); + await this._showElement( + win.browser.ownerDocument, + APPMENU_BUTTON_ID, + BUTTON_STRING_ID + ); + } + + _hideAppmenuButton(win, windowClosed) { + // No need to do something if the window is going away + if (!windowClosed) { + this._hideElement(win.browser.ownerDocument, APPMENU_BUTTON_ID); + } + } + + _showToolbarButton(win) { + const document = win.browser.ownerDocument; + this.maybeInsertFTL(win); + return this._showElement(document, TOOLBAR_BUTTON_ID, BUTTON_STRING_ID); + } + + _hideToolbarButton(win) { + this._hideElement(win.browser.ownerDocument, TOOLBAR_BUTTON_ID); + } + + _showElement(document, id, string_id) { + const el = lazy.PanelMultiView.getViewNode(document, id); + document.l10n.setAttributes(el, string_id); + el.hidden = false; + } + + _hideElement(document, id) { + const el = lazy.PanelMultiView.getViewNode(document, id); + if (el) { + el.hidden = true; + } + } + + _sendPing(ping) { + this._sendTelemetry({ + type: "TOOLBAR_PANEL_TELEMETRY", + data: { action: "whats-new-panel_user_event", ...ping }, + }); + } + + sendUserEventTelemetry(win, event, message, options = {}) { + // Only send pings for non private browsing windows + if ( + win && + !lazy.PrivateBrowsingUtils.isBrowserPrivate( + win.ownerGlobal.gBrowser.selectedBrowser + ) + ) { + this._sendPing({ + message_id: message.id, + event, + event_context: options.value, + }); + } + } + + /** + * @param {object} [browser] MessageChannel target argument as a response to a + * user action. No message is shown if undefined. + * @param {object[]} messages Messages selected from devtools page + */ + forceShowMessage(browser, messages) { + if (!browser) { + return; + } + const win = browser.ownerGlobal; + const doc = browser.ownerDocument; + this.removeMessages(win, WHATS_NEW_PANEL_SELECTOR); + this.renderMessages(win, doc, WHATS_NEW_PANEL_SELECTOR, { + force: true, + messages: Array.isArray(messages) ? messages : [messages], + }); + win.PanelUI.panel.addEventListener("popuphidden", event => + this.removeMessages(event.target.ownerGlobal, WHATS_NEW_PANEL_SELECTOR) + ); + } +} + +/** + * ToolbarPanelHub - singleton instance of _ToolbarPanelHub that can initiate + * message requests and render messages. + */ +export const ToolbarPanelHub = new _ToolbarPanelHub(); diff --git a/browser/components/asrouter/moz.build b/browser/components/asrouter/moz.build new file mode 100644 index 0000000000..558ccbeb9b --- /dev/null +++ b/browser/components/asrouter/moz.build @@ -0,0 +1,67 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Messaging System") + +FINAL_TARGET_FILES.actors += [ + "actors/ASRouterChild.sys.mjs", + "actors/ASRouterParent.sys.mjs", +] + +EXTRA_JS_MODULES.asrouter += [ + "modules/ActorConstants.sys.mjs", + "modules/ASRouter.sys.mjs", + "modules/ASRouterDefaultConfig.sys.mjs", + "modules/ASRouterNewTabHook.sys.mjs", + "modules/ASRouterParentProcessMessageHandler.sys.mjs", + "modules/ASRouterPreferences.sys.mjs", + "modules/ASRouterTargeting.sys.mjs", + "modules/ASRouterTriggerListeners.sys.mjs", + "modules/CFRMessageProvider.sys.mjs", + "modules/CFRPageActions.sys.mjs", + "modules/FeatureCallout.sys.mjs", + "modules/FeatureCalloutBroker.sys.mjs", + "modules/FeatureCalloutMessages.sys.mjs", + "modules/InfoBar.sys.mjs", + "modules/MessagingExperimentConstants.sys.mjs", + "modules/MomentsPageHub.sys.mjs", + "modules/OnboardingMessageProvider.sys.mjs", + "modules/PageEventManager.sys.mjs", + "modules/PanelTestProvider.sys.mjs", + "modules/RemoteL10n.sys.mjs", + "modules/Spotlight.sys.mjs", + "modules/ToastNotification.sys.mjs", + "modules/ToolbarBadgeHub.sys.mjs", + "modules/ToolbarPanelHub.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += [ + "tests/browser/browser.toml", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "tests/xpcshell/xpcshell.toml", +] + +TESTING_JS_MODULES += [ + "content-src/schemas/FxMSCommon.schema.json", + "content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json", + "content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json", + "content-src/templates/CFR/templates/InfoBar.schema.json", + "content-src/templates/OnboardingMessage/Spotlight.schema.json", + "content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json", + "content-src/templates/OnboardingMessage/UpdateAction.schema.json", + "content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json", + "content-src/templates/PBNewtab/NewtabPromoMessage.schema.json", + "content-src/templates/ToastNotification/ToastNotification.schema.json", + "tests/InflightAssetsMessageProvider.sys.mjs", + "tests/NimbusRolloutMessageProvider.sys.mjs", +] + +SPHINX_TREES["docs"] = "docs" diff --git a/browser/components/asrouter/package-lock.json b/browser/components/asrouter/package-lock.json new file mode 100644 index 0000000000..58bddccf28 --- /dev/null +++ b/browser/components/asrouter/package-lock.json @@ -0,0 +1,11917 @@ +{ + "name": "ASRouter", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "ASRouter", + "version": "1.0.0", + "license": "MPL-2.0", + "dependencies": { + "@fluent/bundle": "0.17.1", + "@fluent/react": "0.15.0", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-redux": "7.2.6", + "redux": "4.1.2" + }, + "devDependencies": { + "@babel/preset-react": "7.16.0", + "@jsdevtools/coverage-istanbul-loader": "^3.0.5", + "babel-loader": "8.2.3", + "babel-plugin-jsm-to-esmodules": "0.6.0", + "chai": "4.3.4", + "chai-json-schema": "1.5.1", + "enzyme": "3.11.0", + "enzyme-adapter-react-16": "1.15.6", + "karma": "6.3.8", + "karma-chai": "0.1.0", + "karma-coverage-istanbul-reporter": "3.0.3", + "karma-firefox-launcher": "2.1.2", + "karma-json-reporter": "1.2.1", + "karma-mocha": "2.0.1", + "karma-mocha-reporter": "2.2.5", + "karma-sinon": "1.0.5", + "karma-sourcemap-loader": "0.3.8", + "karma-webpack": "5.0.0", + "mocha": "9.1.3", + "npm-run-all": "4.1.5", + "raw-loader": "4.0.2", + "sass": "1.43.4", + "sinon": "12.0.1", + "stream-browserify": "3.0.0", + "webpack": "5.56.0", + "webpack-cli": "4.9.1", + "yamscripts": "0.1.0" + }, + "engines": { + "//": "when changing node versions, also edit .nvmrc", + "firefox": ">=45.0 <=*", + "node": "16.19.*", + "npm": "8.19.3" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", + "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.7.tgz", + "integrity": "sha512-6AMnjCoC8wjqBzDHkuqpa7jAKwvMo4dC+lr/TFBz+ucfulO1XMpDnwWPGBNwClOKZ8h6xn5N81W/R5OrcKtCbQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", + "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz", + "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz", + "integrity": "sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.16.0.tgz", + "integrity": "sha512-d31IFW2bLRB28uL1WoElyro8RH5l6531XfxMtCeCmp6RVAF1uTfxxUA0LH1tXl+psZdwfmIbwoG4U5VwgbhtLw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-validator-option": "^7.14.5", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-react-jsx": "^7.16.0", + "@babel/plugin-transform-react-jsx-development": "^7.16.0", + "@babel/plugin-transform-react-pure-annotations": "^7.16.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.7.tgz", + "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", + "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@fluent/bundle": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz", + "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==", + "engines": { + "node": ">=12.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@fluent/react": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@fluent/react/-/react-0.15.0.tgz", + "integrity": "sha512-qUMfaHman+UciOELQc5hnFAv0VerUR6+9gEBCRk9RR66XS13syt91ZElNOTHWe2Ofv70cxAGaJ5Yff4MRPg5Ow==", + "dependencies": { + "@fluent/sequence": "^0.8.0", + "cached-iterable": "^0.3.0" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@fluent/bundle": ">=0.16.0 <0.18.0", + "react": ">=16.8.0" + } + }, + "node_modules/@fluent/sequence": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@fluent/sequence/-/sequence-0.8.0.tgz", + "integrity": "sha512-eV5QlEEVV/wR3AFQLXO67x4yPRPQXyqke0c8yucyMSeW36B3ecZyVFlY1UprzrfFV8iPJB4TAehDy/dLGbvQ1Q==", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@fluent/bundle": ">= 0.13.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/coverage-istanbul-loader": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@jsdevtools/coverage-istanbul-loader/-/coverage-istanbul-loader-3.0.5.tgz", + "integrity": "sha512-EUCPEkaRPvmHjWAAZkWMT7JDzpw7FKB00WTISaiXsbNOd5hCHg77XLA8sLYLFDo1zepYLo2w7GstN8YBqRXZfA==", + "dev": true, + "dependencies": { + "convert-source-map": "^1.7.0", + "istanbul-lib-instrument": "^4.0.3", + "loader-utils": "^2.0.0", + "merge-source-map": "^1.1.0", + "schema-utils": "^2.7.0" + } + }, + "node_modules/@jsdevtools/coverage-istanbul-loader/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.3.tgz", + "integrity": "sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", + "dev": true + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.1.tgz", + "integrity": "sha512-18PLWRzhy9glDQp3+wOgfLYRWlhgX0azxgJ63rdpoUHyrC9z0f5CkFburjQx4uD7ZCruw85ZtMt6K+L+R8fLJQ==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz", + "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==", + "dev": true + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz", + "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + }, + "node_modules/@types/react": { + "version": "18.2.46", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.46.tgz", + "integrity": "sha512-nNCvVBcZlvX4NU1nRRNV/mFl1nNRuTuslAJglQsq+8ldXe5Xv0Wd2f7WTE3jOxhLH2BFfiZGC6GCp+kHQbgG+w==", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + }, + "node_modules/@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/airbnb-prop-types": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz", + "integrity": "sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==", + "dev": true, + "dependencies": { + "array.prototype.find": "^2.1.1", + "function.prototype.name": "^1.1.2", + "is-regex": "^1.1.0", + "object-is": "^1.1.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.2", + "prop-types": "^15.7.2", + "prop-types-exact": "^1.2.0", + "react-is": "^16.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0-alpha" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.filter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", + "integrity": "sha512-VizNcj/RGJiUyQBgzwxzE5oHdeuXY5hSbbmKMlphj1cy1Vl7Pn2asCGbSrru6hSQjmCzqTBPVWAF/whmEOVHbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.find": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz", + "integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-loader": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.3.tgz", + "integrity": "sha512-n4Zeta8NC3QAsuyiizu0GkmRcQ6clkV9WFUnUf1iXP//IeSKbWjofW3UHyZVwlOB4y039YQKefawyTn64Zwbuw==", + "dev": true, + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^1.4.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-loader/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/babel-loader/node_modules/loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/babel-plugin-jsm-to-esmodules": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jsm-to-esmodules/-/babel-plugin-jsm-to-esmodules-0.6.0.tgz", + "integrity": "sha512-463Yuq2sLkjoGHl5vPYUQQONnDjxnmxZuhsR1swL5N76hDFGyYZAVd6HoS4E02jBF8bORpS4aFmdr1XjEZ0buQ==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cached-iterable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz", + "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==", + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001574", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz", + "integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chai-json-schema": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/chai-json-schema/-/chai-json-schema-1.5.1.tgz", + "integrity": "sha512-TR/xPDxRhqwFFCWg1HgL8nNWbpNfUwaib6pBN++QKpnd0t+o3+MBvAn5CM1mpdUMaM76oJAtUjGKdjGad01lIA==", + "dev": true, + "dependencies": { + "jsonpointer.js": "0.4.0", + "tv4": "^1.3.0" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "chai": ">= 1.6.1 < 5" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dev": true, + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true + }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true + }, + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.620", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.620.tgz", + "integrity": "sha512-a2fcSHOHrqBJsPNXtf6ZCEZpXrFCcbK1FBxfX3txoqWzNgtEDG1f3M59M98iwxhRW4iMKESnSjbJ310/rkrp0g==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/enzyme": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", + "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", + "dev": true, + "dependencies": { + "array.prototype.flat": "^1.2.3", + "cheerio": "^1.0.0-rc.3", + "enzyme-shallow-equal": "^1.0.1", + "function.prototype.name": "^1.1.2", + "has": "^1.0.3", + "html-element-map": "^1.2.0", + "is-boolean-object": "^1.0.1", + "is-callable": "^1.1.5", + "is-number-object": "^1.0.4", + "is-regex": "^1.0.5", + "is-string": "^1.0.5", + "is-subset": "^0.1.1", + "lodash.escape": "^4.0.1", + "lodash.isequal": "^4.5.0", + "object-inspect": "^1.7.0", + "object-is": "^1.0.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.1", + "object.values": "^1.1.1", + "raf": "^3.4.1", + "rst-selector-parser": "^2.2.3", + "string.prototype.trim": "^1.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/enzyme-adapter-react-16": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz", + "integrity": "sha512-yFlVJCXh8T+mcQo8M6my9sPgeGzj85HSHi6Apgf1Cvq/7EL/J9+1JoJmJsRxZgyTvPMAqOEpRSu/Ii/ZpyOk0g==", + "dev": true, + "dependencies": { + "enzyme-adapter-utils": "^1.14.0", + "enzyme-shallow-equal": "^1.0.4", + "has": "^1.0.3", + "object.assign": "^4.1.2", + "object.values": "^1.1.2", + "prop-types": "^15.7.2", + "react-is": "^16.13.1", + "react-test-renderer": "^16.0.0-0", + "semver": "^5.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "peerDependencies": { + "enzyme": "^3.0.0", + "react": "^16.0.0-0", + "react-dom": "^16.0.0-0" + } + }, + "node_modules/enzyme-adapter-react-16/node_modules/react-test-renderer": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.14.0.tgz", + "integrity": "sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg==", + "dev": true, + "dependencies": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.8.6", + "scheduler": "^0.19.1" + }, + "peerDependencies": { + "react": "^16.14.0" + } + }, + "node_modules/enzyme-adapter-react-16/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/enzyme-adapter-utils": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz", + "integrity": "sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ==", + "dev": true, + "dependencies": { + "airbnb-prop-types": "^2.16.0", + "function.prototype.name": "^1.1.5", + "has": "^1.0.3", + "object.assign": "^4.1.4", + "object.fromentries": "^2.0.5", + "prop-types": "^15.8.1", + "semver": "^5.7.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "peerDependencies": { + "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0" + } + }, + "node_modules/enzyme-adapter-utils/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/enzyme-shallow-equal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz", + "integrity": "sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg==", + "dev": true, + "dependencies": { + "has": "^1.0.3", + "object-is": "^1.1.5" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "node_modules/es-module-lexer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", + "dev": true + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true, + "engines": { + "node": ">=4.x" + } + }, + "node_modules/has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/html-element-map": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", + "integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==", + "dev": true, + "dependencies": { + "array.prototype.filter": "^1.0.0", + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/istanbul-lib-source-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", + "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "rimraf": "^2.6.3", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer.js": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/jsonpointer.js/-/jsonpointer.js-0.4.0.tgz", + "integrity": "sha512-2bf/1crAmPpsmj1I6rDT6W0SOErkrNBpb555xNWcMVWYrX6VnXpG0GRMQ2shvOHwafpfse8q0gnzPFYVH6Tqdg==", + "dev": true + }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, + "node_modules/karma": { + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.8.tgz", + "integrity": "sha512-10wBBU9S0lBHhbCNfmmbWQaY5C1bXlKdnvzN2QKThujCI/+DKaezrI08l6bfTlpJ92VsEboq3zYKpXwK6DOi3A==", + "dev": true, + "dependencies": { + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "colors": "^1.4.0", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.3.0", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.2.0", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/karma-chai": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/karma-chai/-/karma-chai-0.1.0.tgz", + "integrity": "sha512-mqKCkHwzPMhgTYca10S90aCEX9+HjVjjrBFAsw36Zj7BlQNbokXXCAe6Ji04VUMsxcY5RLP7YphpfO06XOubdg==", + "dev": true, + "peerDependencies": { + "chai": "*", + "karma": ">=0.10.9" + } + }, + "node_modules/karma-coverage-istanbul-reporter": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-3.0.3.tgz", + "integrity": "sha512-wE4VFhG/QZv2Y4CdAYWDbMmcAHeS926ZIji4z+FkB2aF/EposRb6DP6G5ncT/wXhqUfAb/d7kZrNKPonbvsATw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^3.0.6", + "istanbul-reports": "^3.0.2", + "minimatch": "^3.0.4" + }, + "funding": { + "url": "https://github.com/sponsors/mattlewis92" + } + }, + "node_modules/karma-firefox-launcher": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.2.tgz", + "integrity": "sha512-VV9xDQU1QIboTrjtGVD4NCfzIH7n01ZXqy/qpBhnOeGVOkG5JYPEm8kuSd7psHE6WouZaQ9Ool92g8LFweSNMA==", + "dev": true, + "dependencies": { + "is-wsl": "^2.2.0", + "which": "^2.0.1" + } + }, + "node_modules/karma-json-reporter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/karma-json-reporter/-/karma-json-reporter-1.2.1.tgz", + "integrity": "sha512-ASmvranNhUN0ctSuAZKeWISW9Nf4AteMcVy8rJVjS7Qk+qWgssag/nw+yivHWKDROztVFn7TdamHOETMPCkvgA==", + "dev": true, + "peerDependencies": { + "karma": ">=0.9" + } + }, + "node_modules/karma-mocha": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-2.0.1.tgz", + "integrity": "sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.3" + } + }, + "node_modules/karma-mocha-reporter": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz", + "integrity": "sha512-Hr6nhkIp0GIJJrvzY8JFeHpQZNseuIakGac4bpw8K1+5F0tLb6l7uvXRa8mt2Z+NVwYgCct4QAfp2R2QP6o00w==", + "dev": true, + "dependencies": { + "chalk": "^2.1.0", + "log-symbols": "^2.1.0", + "strip-ansi": "^4.0.0" + }, + "peerDependencies": { + "karma": ">=0.13" + } + }, + "node_modules/karma-sinon": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/karma-sinon/-/karma-sinon-1.0.5.tgz", + "integrity": "sha512-wrkyAxJmJbn75Dqy17L/8aILJWFm7znd1CE8gkyxTBFnjMSOe2XTJ3P30T8SkxWZHmoHX0SCaUJTDBEoXs25Og==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + }, + "peerDependencies": { + "karma": ">=0.10", + "sinon": "*" + } + }, + "node_modules/karma-sourcemap-loader": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.8.tgz", + "integrity": "sha512-zorxyAakYZuBcHRJE+vbrK2o2JXLFWK8VVjiT/6P+ltLBUGUvqTEkUiQ119MGdOrK7mrmxXHZF1/pfT6GgIZ6g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2" + } + }, + "node_modules/karma-webpack": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.0.tgz", + "integrity": "sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "webpack-merge": "^4.1.5" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-source-map": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", + "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "dev": true, + "dependencies": { + "source-map": "^0.6.1" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mocha": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.1.3.tgz", + "integrity": "sha512-Xcpl9FqXOAYqI3j79pEtHBBnQgVXIhpULjGQa7DVb0Po+VzmSIK9kanAiWLHoRR/dbZ2qpdPshuXr8l1VaHCzw==", + "dev": true, + "dependencies": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.2", + "debug": "4.3.2", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.1.7", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "3.0.4", + "ms": "2.1.3", + "nanoid": "3.1.25", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "workerpool": "6.1.5", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/mocha/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/mocha/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/chokidar": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/mocha/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/mocha/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/mocha/node_modules/debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mocha/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.1.25", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz", + "integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dev": true, + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-to-regexp/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-exact": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", + "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", + "dev": true, + "dependencies": { + "has": "^1.0.3", + "object.assign": "^4.1.0", + "reflect.ownkeys": "^0.2.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "engines": { + "node": ">=0.9" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", + "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/raw-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/react": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", + "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", + "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + }, + "peerDependencies": { + "react": "^16.13.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-redux": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/reflect.ownkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", + "integrity": "sha512-qOLsBKHCpSOFKK1NUOCGC5VyeufB6lEsFe92AL2bhIJsacZS1qdoOZSbPk3MYKuT2cFlRDnulKXuuElIrMjGUg==", + "dev": true + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rst-selector-parser": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", + "integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==", + "dev": true, + "dependencies": { + "lodash.flattendeep": "^4.4.0", + "nearley": "^2.7.10" + } + }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/sass": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz", + "integrity": "sha512-/ptG7KE9lxpGSYiXn7Ar+lKOv37xfWsZRtFYal2QHNigyVQDx685VFT/h7ejVr+R8w7H4tmUgtulsKl5YpveOg==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sinon": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-12.0.1.tgz", + "integrity": "sha512-iGu29Xhym33ydkAT+aNQFBINakjq69kKO6ByPvTsm3yyIACfyQttRTP03aBP/I8GfhFmLzrnKwNNkr0ORb1udg==", + "deprecated": "16.1.1", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^8.1.0", + "@sinonjs/samsam": "^6.0.2", + "diff": "^5.0.0", + "nise": "^5.1.0", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/socket.io": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", + "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", + "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "dev": true, + "dependencies": { + "ws": "~8.11.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", + "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.37.tgz", + "integrity": "sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.56.0.tgz", + "integrity": "sha512-pJ7esw2AGkpZL0jqsEAKnDEfRZdrc9NVjAWA+d1mFkwj68ng9VQ6+Wnrl+kS5dlDHvrat5ASK5vd7wp6I7f53Q==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.0", + "@types/estree": "^0.0.50", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.4.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.8.3", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.4", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.2.0", + "webpack-sources": "^3.2.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.9.1.tgz", + "integrity": "sha512-JYRFVuyFpzDxMDB+v/nanUdQYcZtqFPGzmlW4s+UkPMFhSpfRNmf1z4AwYcHJVdvEFAM7FFCQdNTpsBYhDLusQ==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.1.0", + "@webpack-cli/info": "^1.4.0", + "@webpack-cli/serve": "^1.6.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "execa": "^5.0.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-cli/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-merge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", + "integrity": "sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==", + "dev": true, + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/workerpool": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.5.tgz", + "integrity": "sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "bin": { + "json2yaml": "bin/json2yaml", + "yaml2json": "bin/yaml2json" + } + }, + "node_modules/yamljs/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/yamscripts": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yamscripts/-/yamscripts-0.1.0.tgz", + "integrity": "sha512-i4ThS58KwsK83qSrrc8YZiBqgdl3WewWcWZ4fPdrh7A+qiRU9kXMcIKzngOC7VpJ2nTsWvHG6TcK3JHXpBxACA==", + "dev": true, + "dependencies": { + "colors": "^1.3.2", + "fs-extra": "^7.0.0", + "minimist": "^1.2.0", + "yamljs": "^0.3.0" + }, + "bin": { + "yamscripts": "bin/yamscripts.js" + } + }, + "node_modules/yamscripts/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + }, + "dependencies": { + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + } + }, + "@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true + }, + "@babel/core": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", + "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "requires": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true + }, + "@babel/helpers": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.7.tgz", + "integrity": "sha512-6AMnjCoC8wjqBzDHkuqpa7jAKwvMo4dC+lr/TFBz+ucfulO1XMpDnwWPGBNwClOKZ8h6xn5N81W/R5OrcKtCbQ==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true + }, + "@babel/plugin-syntax-jsx": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", + "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz", + "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + } + }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz", + "integrity": "sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/preset-react": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.16.0.tgz", + "integrity": "sha512-d31IFW2bLRB28uL1WoElyro8RH5l6531XfxMtCeCmp6RVAF1uTfxxUA0LH1tXl+psZdwfmIbwoG4U5VwgbhtLw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-validator-option": "^7.14.5", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-react-jsx": "^7.16.0", + "@babel/plugin-transform-react-jsx-development": "^7.16.0", + "@babel/plugin-transform-react-pure-annotations": "^7.16.0" + } + }, + "@babel/runtime": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.7.tgz", + "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, + "@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + } + }, + "@babel/traverse": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", + "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, + "@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true + }, + "@fluent/bundle": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.17.1.tgz", + "integrity": "sha512-CRFNT9QcSFAeFDneTF59eyv3JXFGhIIN4boUO2y22YmsuuKLyDk+N1I/NQUYz9Ab63e6V7T6vItoZIG/2oOOuw==" + }, + "@fluent/react": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@fluent/react/-/react-0.15.0.tgz", + "integrity": "sha512-qUMfaHman+UciOELQc5hnFAv0VerUR6+9gEBCRk9RR66XS13syt91ZElNOTHWe2Ofv70cxAGaJ5Yff4MRPg5Ow==", + "requires": { + "@fluent/sequence": "^0.8.0", + "cached-iterable": "^0.3.0" + } + }, + "@fluent/sequence": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@fluent/sequence/-/sequence-0.8.0.tgz", + "integrity": "sha512-eV5QlEEVV/wR3AFQLXO67x4yPRPQXyqke0c8yucyMSeW36B3ecZyVFlY1UprzrfFV8iPJB4TAehDy/dLGbvQ1Q==", + "requires": {} + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@jsdevtools/coverage-istanbul-loader": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@jsdevtools/coverage-istanbul-loader/-/coverage-istanbul-loader-3.0.5.tgz", + "integrity": "sha512-EUCPEkaRPvmHjWAAZkWMT7JDzpw7FKB00WTISaiXsbNOd5hCHg77XLA8sLYLFDo1zepYLo2w7GstN8YBqRXZfA==", + "dev": true, + "requires": { + "convert-source-map": "^1.7.0", + "istanbul-lib-instrument": "^4.0.3", + "loader-utils": "^2.0.0", + "merge-source-map": "^1.1.0", + "schema-utils": "^2.7.0" + }, + "dependencies": { + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + } + } + }, + "@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/samsam": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.3.tgz", + "integrity": "sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", + "dev": true + }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.1.tgz", + "integrity": "sha512-18PLWRzhy9glDQp3+wOgfLYRWlhgX0azxgJ63rdpoUHyrC9z0f5CkFburjQx4uD7ZCruw85ZtMt6K+L+R8fLJQ==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz", + "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==", + "dev": true + }, + "@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/node": { + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz", + "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + }, + "@types/react": { + "version": "18.2.46", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.46.tgz", + "integrity": "sha512-nNCvVBcZlvX4NU1nRRNV/mFl1nNRuTuslAJglQsq+8ldXe5Xv0Wd2f7WTE3jOxhLH2BFfiZGC6GCp+kHQbgG+w==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + }, + "@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true + }, + "@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "requires": {} + }, + "@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "requires": { + "envinfo": "^7.7.3" + } + }, + "@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "requires": {} + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "requires": {} + }, + "airbnb-prop-types": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz", + "integrity": "sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==", + "dev": true, + "requires": { + "array.prototype.find": "^2.1.1", + "function.prototype.name": "^1.1.2", + "is-regex": "^1.1.0", + "object-is": "^1.1.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.2", + "prop-types": "^15.7.2", + "prop-types-exact": "^1.2.0", + "react-is": "^16.13.1" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + } + }, + "array.prototype.filter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", + "integrity": "sha512-VizNcj/RGJiUyQBgzwxzE5oHdeuXY5hSbbmKMlphj1cy1Vl7Pn2asCGbSrru6hSQjmCzqTBPVWAF/whmEOVHbw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + } + }, + "array.prototype.find": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz", + "integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + } + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true + }, + "babel-loader": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.3.tgz", + "integrity": "sha512-n4Zeta8NC3QAsuyiizu0GkmRcQ6clkV9WFUnUf1iXP//IeSKbWjofW3UHyZVwlOB4y039YQKefawyTn64Zwbuw==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^1.4.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "dependencies": { + "json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + } + } + }, + "babel-plugin-jsm-to-esmodules": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jsm-to-esmodules/-/babel-plugin-jsm-to-esmodules-0.6.0.tgz", + "integrity": "sha512-463Yuq2sLkjoGHl5vPYUQQONnDjxnmxZuhsR1swL5N76hDFGyYZAVd6HoS4E02jBF8bORpS4aFmdr1XjEZ0buQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "browserslist": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, + "cached-iterable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/cached-iterable/-/cached-iterable-0.3.0.tgz", + "integrity": "sha512-MDqM6TpBVebZD4UDtmlFp8EjVtRcsB6xt9aRdWymjk0fWVUUGgmt/V7o0H0gkI2Tkvv8B0ucjidZm4mLosdlWw==" + }, + "call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + } + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001574", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz", + "integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==", + "dev": true + }, + "chai": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + } + }, + "chai-json-schema": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/chai-json-schema/-/chai-json-schema-1.5.1.tgz", + "integrity": "sha512-TR/xPDxRhqwFFCWg1HgL8nNWbpNfUwaib6pBN++QKpnd0t+o3+MBvAn5CM1mpdUMaM76oJAtUjGKdjGad01lIA==", + "dev": true, + "requires": { + "jsonpointer.js": "0.4.0", + "tv4": "^1.3.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "requires": { + "get-func-name": "^2.0.2" + } + }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dev": true, + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "requires": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true + }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true + }, + "date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true + }, + "di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true + }, + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true + }, + "discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true + }, + "dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "requires": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.4.620", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.620.tgz", + "integrity": "sha512-a2fcSHOHrqBJsPNXtf6ZCEZpXrFCcbK1FBxfX3txoqWzNgtEDG1f3M59M98iwxhRW4iMKESnSjbJ310/rkrp0g==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true + }, + "engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dev": true, + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + } + }, + "engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==", + "dev": true + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true + }, + "envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true + }, + "enzyme": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", + "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", + "dev": true, + "requires": { + "array.prototype.flat": "^1.2.3", + "cheerio": "^1.0.0-rc.3", + "enzyme-shallow-equal": "^1.0.1", + "function.prototype.name": "^1.1.2", + "has": "^1.0.3", + "html-element-map": "^1.2.0", + "is-boolean-object": "^1.0.1", + "is-callable": "^1.1.5", + "is-number-object": "^1.0.4", + "is-regex": "^1.0.5", + "is-string": "^1.0.5", + "is-subset": "^0.1.1", + "lodash.escape": "^4.0.1", + "lodash.isequal": "^4.5.0", + "object-inspect": "^1.7.0", + "object-is": "^1.0.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.1", + "object.values": "^1.1.1", + "raf": "^3.4.1", + "rst-selector-parser": "^2.2.3", + "string.prototype.trim": "^1.2.1" + } + }, + "enzyme-adapter-react-16": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz", + "integrity": "sha512-yFlVJCXh8T+mcQo8M6my9sPgeGzj85HSHi6Apgf1Cvq/7EL/J9+1JoJmJsRxZgyTvPMAqOEpRSu/Ii/ZpyOk0g==", + "dev": true, + "requires": { + "enzyme-adapter-utils": "^1.14.0", + "enzyme-shallow-equal": "^1.0.4", + "has": "^1.0.3", + "object.assign": "^4.1.2", + "object.values": "^1.1.2", + "prop-types": "^15.7.2", + "react-is": "^16.13.1", + "react-test-renderer": "^16.0.0-0", + "semver": "^5.7.0" + }, + "dependencies": { + "react-test-renderer": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.14.0.tgz", + "integrity": "sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.8.6", + "scheduler": "^0.19.1" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "enzyme-adapter-utils": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz", + "integrity": "sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ==", + "dev": true, + "requires": { + "airbnb-prop-types": "^2.16.0", + "function.prototype.name": "^1.1.5", + "has": "^1.0.3", + "object.assign": "^4.1.4", + "object.fromentries": "^2.0.5", + "prop-types": "^15.8.1", + "semver": "^5.7.1" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "enzyme-shallow-equal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz", + "integrity": "sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg==", + "dev": true, + "requires": { + "has": "^1.0.3", + "object-is": "^1.1.5" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + } + }, + "es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "es-module-lexer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", + "dev": true + }, + "es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + } + }, + "es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "follow-redirects": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "dev": true + }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + } + }, + "functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, + "get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3" + } + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3" + } + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "dev": true + }, + "has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2" + } + }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "html-element-map": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", + "integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==", + "dev": true, + "requires": { + "array.prototype.filter": "^1.0.0", + "call-bind": "^1.0.2" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "dependencies": { + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + } + } + }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + } + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true + }, + "is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "requires": { + "has-bigints": "^1.0.1" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true + }, + "is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, + "is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "requires": { + "which-typed-array": "^1.1.11" + } + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + } + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "requires": { + "semver": "^7.5.3" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "istanbul-lib-source-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", + "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "rimraf": "^2.6.3", + "source-map": "^0.6.1" + }, + "dependencies": { + "istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsonpointer.js": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/jsonpointer.js/-/jsonpointer.js-0.4.0.tgz", + "integrity": "sha512-2bf/1crAmPpsmj1I6rDT6W0SOErkrNBpb555xNWcMVWYrX6VnXpG0GRMQ2shvOHwafpfse8q0gnzPFYVH6Tqdg==", + "dev": true + }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, + "karma": { + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.8.tgz", + "integrity": "sha512-10wBBU9S0lBHhbCNfmmbWQaY5C1bXlKdnvzN2QKThujCI/+DKaezrI08l6bfTlpJ92VsEboq3zYKpXwK6DOi3A==", + "dev": true, + "requires": { + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "colors": "^1.4.0", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.3.0", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.2.0", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + } + }, + "karma-chai": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/karma-chai/-/karma-chai-0.1.0.tgz", + "integrity": "sha512-mqKCkHwzPMhgTYca10S90aCEX9+HjVjjrBFAsw36Zj7BlQNbokXXCAe6Ji04VUMsxcY5RLP7YphpfO06XOubdg==", + "dev": true, + "requires": {} + }, + "karma-coverage-istanbul-reporter": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-3.0.3.tgz", + "integrity": "sha512-wE4VFhG/QZv2Y4CdAYWDbMmcAHeS926ZIji4z+FkB2aF/EposRb6DP6G5ncT/wXhqUfAb/d7kZrNKPonbvsATw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^3.0.6", + "istanbul-reports": "^3.0.2", + "minimatch": "^3.0.4" + } + }, + "karma-firefox-launcher": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.2.tgz", + "integrity": "sha512-VV9xDQU1QIboTrjtGVD4NCfzIH7n01ZXqy/qpBhnOeGVOkG5JYPEm8kuSd7psHE6WouZaQ9Ool92g8LFweSNMA==", + "dev": true, + "requires": { + "is-wsl": "^2.2.0", + "which": "^2.0.1" + } + }, + "karma-json-reporter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/karma-json-reporter/-/karma-json-reporter-1.2.1.tgz", + "integrity": "sha512-ASmvranNhUN0ctSuAZKeWISW9Nf4AteMcVy8rJVjS7Qk+qWgssag/nw+yivHWKDROztVFn7TdamHOETMPCkvgA==", + "dev": true, + "requires": {} + }, + "karma-mocha": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-2.0.1.tgz", + "integrity": "sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==", + "dev": true, + "requires": { + "minimist": "^1.2.3" + } + }, + "karma-mocha-reporter": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz", + "integrity": "sha512-Hr6nhkIp0GIJJrvzY8JFeHpQZNseuIakGac4bpw8K1+5F0tLb6l7uvXRa8mt2Z+NVwYgCct4QAfp2R2QP6o00w==", + "dev": true, + "requires": { + "chalk": "^2.1.0", + "log-symbols": "^2.1.0", + "strip-ansi": "^4.0.0" + } + }, + "karma-sinon": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/karma-sinon/-/karma-sinon-1.0.5.tgz", + "integrity": "sha512-wrkyAxJmJbn75Dqy17L/8aILJWFm7znd1CE8gkyxTBFnjMSOe2XTJ3P30T8SkxWZHmoHX0SCaUJTDBEoXs25Og==", + "dev": true, + "requires": {} + }, + "karma-sourcemap-loader": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.8.tgz", + "integrity": "sha512-zorxyAakYZuBcHRJE+vbrK2o2JXLFWK8VVjiT/6P+ltLBUGUvqTEkUiQ119MGdOrK7mrmxXHZF1/pfT6GgIZ6g==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2" + } + }, + "karma-webpack": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.0.tgz", + "integrity": "sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA==", + "dev": true, + "requires": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "webpack-merge": "^4.1.5" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true + }, + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + } + }, + "log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "requires": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true + }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true + }, + "merge-source-map": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", + "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true + }, + "mocha": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.1.3.tgz", + "integrity": "sha512-Xcpl9FqXOAYqI3j79pEtHBBnQgVXIhpULjGQa7DVb0Po+VzmSIK9kanAiWLHoRR/dbZ2qpdPshuXr8l1VaHCzw==", + "dev": true, + "requires": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.2", + "debug": "4.3.2", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.1.7", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "3.0.4", + "ms": "2.1.3", + "nanoid": "3.1.25", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "workerpool": "6.1.5", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "chokidar": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nanoid": { + "version": "3.1.25", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz", + "integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q==", + "dev": true + }, + "nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "requires": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + } + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + } + } + }, + "node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + } + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + }, + "dependencies": { + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + } + } + }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true + }, + "object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + } + }, + "object.entries": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "requires": { + "entities": "^4.4.0" + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dev": true, + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + } + } + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + } + } + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "prop-types-exact": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", + "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", + "dev": true, + "requires": { + "has": "^1.0.3", + "object.assign": "^4.1.0", + "reflect.ownkeys": "^0.2.0" + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "requires": { + "performance-now": "^2.1.0" + } + }, + "railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true + }, + "randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "requires": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + } + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "raw-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", + "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "react": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", + "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "react-dom": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", + "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-redux": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "requires": { + "resolve": "^1.9.0" + } + }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, + "reflect.ownkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", + "integrity": "sha512-qOLsBKHCpSOFKK1NUOCGC5VyeufB6lEsFe92AL2bhIJsacZS1qdoOZSbPk3MYKuT2cFlRDnulKXuuElIrMjGUg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "requires": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rst-selector-parser": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", + "integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==", + "dev": true, + "requires": { + "lodash.flattendeep": "^4.4.0", + "nearley": "^2.7.10" + } + }, + "safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sass": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz", + "integrity": "sha512-/ptG7KE9lxpGSYiXn7Ar+lKOv37xfWsZRtFYal2QHNigyVQDx685VFT/h7ejVr+R8w7H4tmUgtulsKl5YpveOg==", + "dev": true, + "requires": { + "chokidar": ">=3.0.0 <4.0.0" + } + }, + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true + }, + "shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "sinon": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-12.0.1.tgz", + "integrity": "sha512-iGu29Xhym33ydkAT+aNQFBINakjq69kKO6ByPvTsm3yyIACfyQttRTP03aBP/I8GfhFmLzrnKwNNkr0ORb1udg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^8.1.0", + "@sinonjs/samsam": "^6.0.2", + "diff": "^5.0.0", + "nise": "^5.1.0", + "supports-color": "^7.2.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "socket.io": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", + "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-adapter": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", + "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "dev": true, + "requires": { + "ws": "~8.11.0" + } + }, + "socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true + }, + "stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "requires": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "requires": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "terser": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", + "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + } + }, + "terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + } + } + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "requires": { + "rimraf": "^3.0.0" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true + }, + "tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "dev": true + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + } + }, + "ua-parser-js": { + "version": "0.7.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.37.tgz", + "integrity": "sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==", + "dev": true + }, + "unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + } + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true + }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true + }, + "watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "webpack": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.56.0.tgz", + "integrity": "sha512-pJ7esw2AGkpZL0jqsEAKnDEfRZdrc9NVjAWA+d1mFkwj68ng9VQ6+Wnrl+kS5dlDHvrat5ASK5vd7wp6I7f53Q==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.0", + "@types/estree": "^0.0.50", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.4.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.8.3", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.4", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.2.0", + "webpack-sources": "^3.2.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "webpack-cli": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.9.1.tgz", + "integrity": "sha512-JYRFVuyFpzDxMDB+v/nanUdQYcZtqFPGzmlW4s+UkPMFhSpfRNmf1z4AwYcHJVdvEFAM7FFCQdNTpsBYhDLusQ==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.1.0", + "@webpack-cli/info": "^1.4.0", + "@webpack-cli/serve": "^1.6.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "execa": "^5.0.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + } + } + } + }, + "webpack-merge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", + "integrity": "sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + } + }, + "wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "workerpool": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.5.tgz", + "integrity": "sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "requires": {} + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + } + } + }, + "yamscripts": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yamscripts/-/yamscripts-0.1.0.tgz", + "integrity": "sha512-i4ThS58KwsK83qSrrc8YZiBqgdl3WewWcWZ4fPdrh7A+qiRU9kXMcIKzngOC7VpJ2nTsWvHG6TcK3JHXpBxACA==", + "dev": true, + "requires": { + "colors": "^1.3.2", + "fs-extra": "^7.0.0", + "minimist": "^1.2.0", + "yamljs": "^0.3.0" + }, + "dependencies": { + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + } + } + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + }, + "yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "requires": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + } + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/browser/components/asrouter/package.json b/browser/components/asrouter/package.json new file mode 100644 index 0000000000..8bd8da7b54 --- /dev/null +++ b/browser/components/asrouter/package.json @@ -0,0 +1,82 @@ +{ + "name": "ASRouter", + "description": "Task running for ASRouter", + "version": "1.0.0", + "author": "Mozilla (https://mozilla.org/)", + "dependencies": { + "@fluent/bundle": "0.17.1", + "@fluent/react": "0.15.0", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-redux": "7.2.6", + "redux": "4.1.2" + }, + "devDependencies": { + "@babel/preset-react": "7.16.0", + "@jsdevtools/coverage-istanbul-loader": "^3.0.5", + "babel-loader": "8.2.3", + "babel-plugin-jsm-to-esmodules": "0.6.0", + "chai": "4.3.4", + "chai-json-schema": "1.5.1", + "enzyme": "3.11.0", + "enzyme-adapter-react-16": "1.15.6", + "karma": "6.3.8", + "karma-chai": "0.1.0", + "karma-coverage-istanbul-reporter": "3.0.3", + "karma-firefox-launcher": "2.1.2", + "karma-json-reporter": "1.2.1", + "karma-mocha": "2.0.1", + "karma-mocha-reporter": "2.2.5", + "karma-sinon": "1.0.5", + "karma-sourcemap-loader": "0.3.8", + "karma-webpack": "5.0.0", + "mocha": "9.1.3", + "npm-run-all": "4.1.5", + "raw-loader": "4.0.2", + "sass": "1.43.4", + "sinon": "12.0.1", + "stream-browserify": "3.0.0", + "webpack": "5.56.0", + "webpack-cli": "4.9.1", + "yamscripts": "0.1.0" + }, + "engines": { + "firefox": ">=45.0 <=*", + "//": "when changing node versions, also edit .nvmrc", + "node": "16.19.*", + "npm": "8.19.3" + }, + "license": "MPL-2.0", + "config": { + "mc_root": "../../..", + "asrouter_path": "browser/components/asrouter" + }, + "scripts": { + "bundle": "npm-run-all bundle:*", + "bundle:admin": "webpack-cli --config webpack.asrouter-admin.config.js", + "bundle:css": "sass content-src:content --no-source-map", + "watchmc": "npm-run-all --parallel watchmc:*", + "watchmc:bundle": "npm run bundle:admin -- --env development -w", + "watchmc:css": "npm run bundle:css -- --source-map --embed-sources --embed-source-map -w", + "testmc": "npm-run-all testmc:*", + "testmc:lint": "npm run lint", + "testmc:build": "npm run bundle:admin", + "testmc:unit": "karma start karma.mc.config.js", + "tddmc": "karma start karma.mc.config.js --tdd", + "debugcoverage": "open logs/coverage/lcov-report/index.html", + "lint": "npm-run-all lint:*", + "lint:codespell": "(cd $npm_package_config_mc_root && ./mach lint -l codespell $npm_package_config_asrouter_path)", + "lint:eslint": "(cd $npm_package_config_mc_root && ./mach lint -l eslint $npm_package_config_asrouter_path)", + "lint:license": "(cd $npm_package_config_mc_root && ./mach lint -l license $npm_package_config_asrouter_path)", + "lint:stylelint": "(cd $npm_package_config_mc_root && ./mach lint -l stylelint $npm_package_config_asrouter_path)", + "test": "npm run testmc", + "tdd": "npm run tddmc", + "fix": "npm-run-all fix:*", + "fix:eslint": "npm run lint:eslint -- --fix", + "fix:stylelint": "npm run lint:stylelint -- --fix", + "import-rollouts": "node ./bin/import-rollouts.js", + "help": "yamscripts help", + "yamscripts": "yamscripts compile", + "__": "# NOTE: THESE SCRIPTS ARE COMPILED!!! EDIT yamscripts.yml instead!!!" + } +} diff --git a/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs new file mode 100644 index 0000000000..e92b210c12 --- /dev/null +++ b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs @@ -0,0 +1,340 @@ +/* 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 file is generated by: +// https://github.com/mozilla/messaging-system-inflight-assets/tree/master/scripts/export-all.py + +export const InflightAssetsMessageProvider = { + getMessages() { + return [ + { + id: "MILESTONE_MESSAGE", + groups: ["cfr"], + content: { + anchor_id: "tracking-protection-icon-box", + bucket_id: "CFR_MILESTONE_MESSAGE", + buttons: { + primary: { + action: { + type: "OPEN_PROTECTION_REPORT", + }, + event: "PROTECTION", + label: { + string_id: "cfr-doorhanger-milestone-ok-button", + }, + }, + secondary: [ + { + label: { + string_id: "cfr-doorhanger-milestone-close-button", + }, + action: { + type: "CANCEL", + }, + event: "DISMISS", + }, + ], + }, + category: "cfrFeatures", + heading_text: { + string_id: "cfr-doorhanger-milestone-heading", + }, + layout: "short_message", + notification_text: "", + skip_address_bar_notifier: true, + text: "", + }, + frequency: { + lifetime: 7, + }, + targeting: + "pageLoad >= 4 && firefoxVersion < 87 && userPrefs.cfrFeatures", + template: "milestone_message", + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + }, + { + id: "MILESTONE_MESSAGE_87", + groups: ["cfr"], + content: { + anchor_id: "tracking-protection-icon-box", + bucket_id: "CFR_MILESTONE_MESSAGE", + buttons: { + primary: { + action: { + type: "OPEN_PROTECTION_REPORT", + }, + event: "PROTECTION", + label: { + string_id: "cfr-doorhanger-milestone-ok-button", + }, + }, + secondary: [ + { + label: { + string_id: "cfr-doorhanger-milestone-close-button", + }, + action: { + type: "CANCEL", + }, + event: "DISMISS", + }, + ], + }, + category: "cfrFeatures", + heading_text: { + string_id: "cfr-doorhanger-milestone-heading2", + }, + layout: "short_message", + notification_text: "", + skip_address_bar_notifier: true, + text: "", + }, + frequency: { + lifetime: 7, + }, + targeting: + "pageLoad >= 4 && firefoxVersion >= 87 && userPrefs.cfrFeatures", + template: "milestone_message", + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + }, + { + id: "DOH_ROLLOUT_CONFIRMATION_89", + groups: ["cfr"], + targeting: + "profileAgeCreated < 1572480000000 && ( 'doh-rollout.enabled'|preferenceValue || 'doh-rollout.self-enabled'|preferenceValue || 'doh-rollout.ru.enabled'|preferenceValue || 'doh-rollout.ua.enabled'|preferenceValue ) && !( 'doh-rollout.disable-heuristics'|preferenceValue || 'doh-rollout.skipHeuristicsCheck'|preferenceValue || 'doh-rollout.doorhanger-decision'|preferenceValue ) && firefoxVersion >= 89", + template: "infobar", + content: { + priority: 3, + type: "global", + text: { + string_id: "cfr-doorhanger-doh-body", + }, + buttons: [ + { + label: { + string_id: "cfr-doorhanger-doh-primary-button-2", + }, + action: { + type: "ACCEPT_DOH", + }, + primary: true, + }, + { + label: { + string_id: "cfr-doorhanger-doh-secondary-button", + }, + action: { + type: "DISABLE_DOH", + }, + }, + { + label: { + string_id: "notification-learnmore-default-label", + }, + supportPage: "dns-over-https", + callback: null, + action: { + type: "CANCEL", + }, + }, + ], + bucket_id: "DOH_ROLLOUT_CONFIRMATION_89", + category: "cfrFeatures", + }, + frequency: { + lifetime: 3, + }, + trigger: { + id: "openURL", + patterns: ["*://*/*"], + }, + }, + { + id: "INFOBAR_DEFAULT_AND_PIN_87", + groups: ["cfr"], + content: { + category: "cfrFeatures", + bucket_id: "INFOBAR_DEFAULT_AND_PIN_87", + text: { + string_id: "default-browser-notification-message", + }, + type: "global", + buttons: [ + { + label: { + string_id: "default-browser-notification-button", + }, + action: { + type: "PIN_AND_DEFAULT", + }, + primary: true, + accessKey: "P", + }, + ], + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "infobar", + frequency: { + lifetime: 2, + custom: [ + { + period: 3024000000, + cap: 1, + }, + ], + }, + targeting: + "((firefoxVersion >= 87 && firefoxVersion < 89) || (firefoxVersion >= 89 && source == 'startup')) && !isDefaultBrowser && !'browser.shell.checkDefaultBrowser'|preferenceValue && isMajorUpgrade != true && platformName != 'linux' && ((currentDate|date - profileAgeCreated) / 604800000) >= 5 && !activeNotifications && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && ((currentDate|date - profileAgeCreated) / 604800000) < 15", + }, + { + id: "CFR_FULL_VIDEO_SUPPORT_EN", + groups: ["cfr"], + targeting: + "firefoxVersion < 88 && firefoxVersion != 78 && localeLanguageCode in ['en', 'fr', 'de', 'ru', 'zh', 'es', 'it', 'pl']", + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + persistent_doorhanger: true, + anchor_id: "PanelUI-menu-button", + layout: "icon_and_message", + text: { + string_id: "cfr-doorhanger-video-support-body", + }, + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + ], + primary: { + label: { + string_id: "cfr-doorhanger-video-support-primary-button", + }, + action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/kb/update-firefox-latest-release", + where: "tabshifted", + }, + }, + }, + }, + bucket_id: "CFR_FULL_VIDEO_SUPPORT_EN", + heading_text: { + string_id: "cfr-doorhanger-video-support-header", + }, + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: "Message from Firefox", + category: "cfrFeatures", + }, + frequency: { + lifetime: 3, + }, + trigger: { + id: "openURL", + patterns: ["https://*/Amazon-Video/*", "https://*/Prime-Video/*"], + params: [ + "www.hulu.com", + "hulu.com", + "www.netflix.com", + "netflix.com", + "www.disneyplus.com", + "disneyplus.com", + "www.hbomax.com", + "hbomax.com", + "www.sho.com", + "sho.com", + "www.directv.com", + "directv.com", + "www.starzplay.com", + "starzplay.com", + "www.sling.com", + "sling.com", + "www.facebook.com", + "facebook.com", + ], + }, + }, + { + id: "WNP_MOMENTS_12", + groups: ["moments-pages"], + content: { + action: { + data: { + expire: 1640908800000, + url: "https://www.mozilla.org/firefox/welcome/12", + }, + id: "moments-wnp", + }, + bucket_id: "WNP_MOMENTS_12", + }, + targeting: + 'localeLanguageCode == "en" && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 1 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', + template: "update_action", + trigger: { + id: "momentsUpdate", + }, + }, + { + id: "WNP_MOMENTS_13", + groups: ["moments-pages"], + content: { + action: { + data: { + expire: 1640908800000, + url: "https://www.mozilla.org/firefox/welcome/13", + }, + id: "moments-wnp", + }, + bucket_id: "WNP_MOMENTS_13", + }, + targeting: + '(localeLanguageCode in ["en", "de", "fr", "nl", "it", "ms"] || locale == "es-ES") && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 0 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', + template: "update_action", + trigger: { + id: "momentsUpdate", + }, + }, + { + id: "WNP_MOMENTS_14", + groups: ["moments-pages"], + content: { + action: { + data: { + expire: 1668470400000, + url: "https://www.mozilla.org/firefox/welcome/14", + }, + id: "moments-wnp", + }, + bucket_id: "WNP_MOMENTS_14", + }, + targeting: + 'localeLanguageCode in ["en", "de", "fr"] && region in ["AT", "BE", "CA", "CH", "DE", "ES", "FI", "FR", "GB", "IE", "IT", "MY", "NL", "NZ", "SE", "SG", "US"] && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', + template: "update_action", + trigger: { + id: "momentsUpdate", + }, + }, + ]; + }, +}; diff --git a/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs new file mode 100644 index 0000000000..5bfbec9557 --- /dev/null +++ b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs @@ -0,0 +1,199 @@ +/* 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 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 [ + { + // Nimbus slug: fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout:treatment-a + // Version range: 116+ + // Recipe: https://experimenter.services.mozilla.com/nimbus/fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout/summary#treatment-a + id: "fox-doodle-set-to-default-early-day-user-de-fr-it:A", + groups: ["eco"], + content: { + id: "fox-doodle-set-to-default-early-day-user-de-fr-it:A", + screens: [ + { + id: "SET_DEFAULT", + content: { + logo: { + height: "140px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + reducedMotionImageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + }, + title: { + raw: { + $l10n: { + id: "fox-doodle-trackers-title", + text: "Keep pesky trackers off your tail", + comment: + "This title is displayed together with the picture of a running fox with a long tail. In English, this is a figure of speech meaning 'stop something from following you'. If the localization of this message is challenging, consider using a simplified alternative as a reference for translation: 'Keep unwanted trackers away'.", + }, + }, + fontSize: "22px", + fontWeight: 590, + paddingBlock: "4px 0", + letterSpacing: 0, + paddingInline: "24px", + }, + subtitle: { + raw: { + $l10n: { + id: "fox-doodle-trackers-subtitle", + text: "Say goodbye to annoying ad trackers and settle into a safer, speedy internet experience.", + comment: "", + }, + }, + fontSize: "15px", + lineHeight: "1.4", + marginBlock: "8px 16px", + letterSpacing: 0, + paddingInline: "24px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + raw: { + $l10n: { + id: "fox-doodle-set-default-driving-primary-button-label", + text: "Open my links with Firefox", + comment: "", + }, + }, + marginBlock: "4px 0", + paddingBlock: "0", + paddingInline: "16px", + }, + action: { + type: "SET_DEFAULT_BROWSER", + navigate: true, + }, + }, + secondary_button: { + label: { + raw: { + $l10n: { + id: "fox-doodle-driving-secondary-button-label", + text: "Not now", + comment: "", + }, + }, + marginBlock: "0 -20px", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + priority: 1, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && !isMajorUpgrade && !activeNotifications && (((currentDate|date) - (profileAgeCreated|date)) / 3600000 >= 6) && !isDefaultBrowser", + }, + { + // Nimbus slug: fox-doodle-set-to-default-early-day-user-en-treatment-a-rollout:treatment-a + // Version range: 116+ + // Recipe: https://experimenter.services.mozilla.com/nimbus/fox-doodle-set-to-default-early-day-user-en-treatment-a-rollout/summary#treatment-a + id: "fox-doodle-set-to-default-early-day-user:A", + groups: ["eco"], + content: { + id: "fox-doodle-set-to-default-early-day-user:A", + screens: [ + { + id: "SET_DEFAULT", + content: { + logo: { + height: "140px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + reducedMotionImageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + }, + title: { + raw: "Keep pesky trackers off your tail", + fontSize: "22px", + fontWeight: 590, + paddingBlock: "4px 0", + letterSpacing: 0, + paddingInline: "24px", + }, + subtitle: { + raw: "Say goodbye to annoying ad trackers and settle into a safer, speedy internet experience.", + fontSize: "15px", + lineHeight: "1.4", + marginBlock: "8px 16px", + letterSpacing: 0, + paddingInline: "24px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + raw: "Open my links with Firefox", + marginBlock: "4px 0", + paddingBlock: "0", + paddingInline: "16px", + }, + action: { + type: "SET_DEFAULT_BROWSER", + navigate: true, + }, + }, + secondary_button: { + label: { + raw: "Not now", + marginBlock: "0 -20px", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + priority: 1, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && !isMajorUpgrade && !activeNotifications && (((currentDate|date) - (profileAgeCreated|date)) / 3600000 >= 6) && !isDefaultBrowser", + }, + ]; + }, +}; diff --git a/browser/components/asrouter/tests/browser/browser.toml b/browser/components/asrouter/tests/browser/browser.toml new file mode 100644 index 0000000000..7bed40373d --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser.toml @@ -0,0 +1,44 @@ +[DEFAULT] +support-files = [ + "../../../newtab/test/browser/blue_page.html", + "head.js", +] + +["browser_asrouter_bug1761522.js"] + +["browser_asrouter_bug1800087.js"] + +["browser_asrouter_cfr.js"] +https_first_disabled = true + +["browser_asrouter_experimentsAPILoader.js"] + +["browser_asrouter_group_frequency.js"] +https_first_disabled = true + +["browser_asrouter_group_userprefs.js"] +skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1643036 + +["browser_asrouter_infobar.js"] + +["browser_asrouter_momentspagehub.js"] +tags = "remote-settings" + +["browser_asrouter_targeting.js"] + +["browser_asrouter_toast_notification.js"] + +["browser_asrouter_toolbarbadge.js"] +tags = "remote-settings" +skip-if = ["a11y_checks"] # Bug 1854515 and 1858041 to investigate intermittent a11y_checks results (fails on Autoland, passes on Try) + +["browser_feature_callout_in_chrome.js"] +skip-if = [ + "os == 'mac' && debug", # Bug 1804349 + "win11_2009", # Bug 1804349 +] + +["browser_feature_callout_panel.js"] + +["browser_trigger_listeners.js"] +https_first_disabled = true diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js new file mode 100644 index 0000000000..19fcb63131 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js @@ -0,0 +1,234 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ASRouter, MessageLoaderUtils } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); +const { RemoteL10n } = ChromeUtils.importESModule( + "resource:///modules/asrouter/RemoteL10n.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +// This pref is used to override the Remote Settings server URL in tests. +// See SERVER_URL in services/settings/Utils.jsm for more details. +const RS_SERVER_PREF = "services.settings.server"; + +const FLUENT_CONTENT = "asrouter-test-string = Test Test Test\n"; + +async function serveRemoteSettings() { + const server = new HttpServer(); + server.start(-1); + + const baseURL = `http://localhost:${server.identity.primaryPort}/`; + const attachmentUuid = crypto.randomUUID(); + const attachment = new TextEncoder().encode(FLUENT_CONTENT); + + // Serve an index so RS knows where to fetch images from. + server.registerPathHandler("/v1/", (request, response) => { + response.write( + JSON.stringify({ + capabilities: { + attachments: { + base_url: `${baseURL}cdn`, + }, + }, + }) + ); + }); + + // Serve the ms-language-packs record for cfr-v1-ja-JP-mac, pointing to an attachment. + server.registerPathHandler( + "/v1/buckets/main/collections/ms-language-packs/records/cfr-v1-ja-JP-mac", + (request, response) => { + response.setStatusLine(null, 200, "OK"); + response.setHeader( + "Content-type", + "application/json; charset=utf-8", + false + ); + response.write( + JSON.stringify({ + permissions: {}, + data: { + attachment: { + hash: "f9aead2693c4ff95c2764df72b43fdf5b3490ed06414588843848f991136040b", + size: attachment.buffer.byteLength, + filename: "asrouter.ftl", + location: `main-workspace/ms-language-packs/${attachmentUuid}`, + }, + id: "cfr-v1-ja-JP-mac", + last_modified: Date.now(), + }, + }) + ); + } + ); + + // Serve the attachment for ms-language-packs/cfr-va-ja-JP-mac. + server.registerPathHandler( + `/cdn/main-workspace/ms-language-packs/${attachmentUuid}`, + (request, response) => { + const stream = Cc[ + "@mozilla.org/io/arraybuffer-input-stream;1" + ].createInstance(Ci.nsIArrayBufferInputStream); + stream.setData(attachment.buffer, 0, attachment.buffer.byteLength); + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-type", "application/octet-stream"); + response.bodyOutputStream.writeFrom(stream, attachment.buffer.byteLength); + } + ); + + // Serve the list of changed collections. cfr must have changed, otherwise we + // won't attempt to fetch the cfr records (and then won't fetch + // ms-language-packs). + server.registerPathHandler( + "/v1/buckets/monitor/collections/changes/changeset", + (request, response) => { + const now = Date.now(); + response.setStatusLine(null, 200, "OK"); + response.setHeader( + "Content-type", + "application/json; charset=utf-8", + false + ); + response.write( + JSON.stringify({ + timestamp: now, + changes: [ + { + host: `localhost:${server.identity.primaryPort}`, + last_modified: now, + bucket: "main", + collection: "cfr", + }, + ], + metadata: {}, + }) + ); + } + ); + + const message = await PanelTestProvider.getMessages().then(msgs => + msgs.find(msg => msg.id === "PERSONALIZED_CFR_MESSAGE") + ); + + // Serve the "changed" cfr entries. If there are no changes, then ASRouter + // won't re-fetch ms-language-packs. + server.registerPathHandler( + "/v1/buckets/main/collections/cfr/changeset", + (request, response) => { + const now = Date.now(); + response.setStatusLine(null, 200, "OK"); + response.setHeader( + "Content-type", + "application/json; charset=utf-8", + false + ); + response.write( + JSON.stringify({ + timestamp: now, + changes: [message], + metadata: {}, + }) + ); + } + ); + + await SpecialPowers.pushPrefEnv({ + set: [[RS_SERVER_PREF, `${baseURL}v1`]], + }); + + return async () => { + await new Promise(resolve => server.stop(() => resolve())); + await SpecialPowers.popPrefEnv(); + }; +} + +add_task(async function test_asrouter() { + const MS_LANGUAGE_PACKS_DIR = PathUtils.join( + PathUtils.localProfileDir, + "settings", + "main", + "ms-language-packs" + ); + const sandbox = sinon.createSandbox(); + const stop = await serveRemoteSettings(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.cfr", + JSON.stringify({ + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + updateCyleInMs: 3600000, + }), + ], + ], + }); + const localeService = Services.locale; + RemoteSettings("cfr").verifySignature = false; + + registerCleanupFunction(async () => { + RemoteSettings("cfr").verifySignature = true; + Services.locale = localeService; + await SpecialPowers.popPrefEnv(); + await stop(); + sandbox.restore(); + await IOUtils.remove(MS_LANGUAGE_PACKS_DIR, { recursive: true }); + RemoteL10n.reloadL10n(); + }); + + // We can't stub Services.locale.appLocaleAsBCP47 directly because its an + // XPCOM_Native object. + const fakeLocaleService = new Proxy(localeService, { + get(obj, prop) { + if (prop === "appLocaleAsBCP47") { + return "ja-JP-macos"; + } + return obj[prop]; + }, + }); + + const localeSpy = sandbox.spy(MessageLoaderUtils, "locale", ["get"]); + Services.locale = fakeLocaleService; + + const cfrProvider = ASRouter.state.providers.find(p => p.id === "cfr"); + await ASRouter.loadMessagesFromAllProviders([cfrProvider]); + + Assert.equal( + Services.locale.appLocaleAsBCP47, + "ja-JP-macos", + "Locale service returns ja-JP-macos" + ); + Assert.ok(localeSpy.get.called, "MessageLoaderUtils.locale getter called"); + Assert.ok( + localeSpy.get.alwaysReturned("ja-JP-mac"), + "MessageLoaderUtils.locale getter returned expected locale ja-JP-mac" + ); + + const path = PathUtils.join( + MS_LANGUAGE_PACKS_DIR, + "browser", + "newtab", + "asrouter.ftl" + ); + Assert.ok(await IOUtils.exists(path), "asrouter.ftl was downloaded"); + Assert.equal( + await IOUtils.readUTF8(path), + FLUENT_CONTENT, + "asrouter.ftl content matches expected" + ); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js b/browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js new file mode 100644 index 0000000000..ce4e673742 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js @@ -0,0 +1,48 @@ +/* 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/. */ + +"use strict"; + +// TODO (Bug 1800937): Remove this whole test along with the migration code +// after the next watershed release. + +const { ASRouterNewTabHook } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterNewTabHook.sys.mjs" +); +const { ASRouterDefaultConfig } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs" +); + +add_setup(() => ASRouterNewTabHook.destroy()); + +// Test that the old pref format is migrated correctly to the new format. +// provider.bucket -> provider.collection +add_task(async function test_newtab_asrouter() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.cfr", + JSON.stringify({ + id: "cfr", + enabled: true, + type: "local", + bucket: "cfr", // The pre-migration property name is bucket. + updateCyleInMs: 3600000, + }), + ], + ], + }); + + await ASRouterNewTabHook.createInstance(ASRouterDefaultConfig()); + const hook = await ASRouterNewTabHook.getInstance(); + const router = hook._router; + if (!router.initialized) { + await router.waitForInitialized; + } + + // Test that the pref's bucket is migrated to collection. + let cfrProvider = router.state.providers.find(p => p.id === "cfr"); + Assert.equal(cfrProvider.collection, "cfr", "The collection name is correct"); + Assert.ok(!cfrProvider.bucket, "The bucket name is removed"); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js new file mode 100644 index 0000000000..0cab79994e --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js @@ -0,0 +1,932 @@ +/* eslint-disable @microsoft/sdl/no-insecure-url */ +const { ASRouterTriggerListeners } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +const { TelemetryFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/TelemetryFeed.sys.mjs" +); + +const createDummyRecommendation = ({ + action, + category, + heading_text, + layout, + skip_address_bar_notifier, + show_in_private_browsing, + template, +}) => { + let recommendation = { + template, + groups: ["mochitest-group"], + content: { + layout: layout || "addon_recommendation", + category, + anchor_id: "page-action-buttons", + skip_address_bar_notifier, + show_in_private_browsing, + heading_text: heading_text || "Mochitest", + info_icon: { + label: { attributes: { tooltiptext: "Why am I seeing this" } }, + sumo_path: "extensionrecommendations", + }, + icon: "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg", + icon_dark_theme: + "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg", + learn_more: "extensionrecommendations", + addon: { + id: "addon-id", + title: "Addon name", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + author: "Author name", + amo_url: "https://example.com", + }, + descriptionDetails: { steps: [] }, + text: "Mochitest", + buttons: { + primary: { + label: { + value: "OK", + attributes: { accesskey: "O" }, + }, + action: { + type: action.type, + data: {}, + }, + }, + secondary: [ + { + label: { + value: "Cancel", + attributes: { accesskey: "C" }, + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + value: "Cancel 1", + attributes: { accesskey: "A" }, + }, + }, + { + label: { + value: "Cancel 2", + attributes: { accesskey: "B" }, + }, + }, + ], + }, + }, + }; + recommendation.content.notification_text = new String("Mochitest"); // eslint-disable-line + recommendation.content.notification_text.attributes = { + tooltiptext: "Mochitest tooltip", + "a11y-announcement": "Mochitest announcement", + }; + return recommendation; +}; + +function checkCFRAddonsElements(notification) { + Assert.ok(notification.hidden === false, "Panel should be visible"); + Assert.equal( + notification.getAttribute("data-notification-category"), + "addon_recommendation", + "Panel have correct data attribute" + ); + Assert.ok( + notification.querySelector("#cfr-notification-footer-text-and-addon-info"), + "Panel should have addon info container" + ); + Assert.ok( + notification.querySelector("#cfr-notification-footer-filled-stars"), + "Panel should have addon rating info" + ); + Assert.ok( + notification.querySelector("#cfr-notification-author"), + "Panel should have author info" + ); +} + +function checkCFRTrackingProtectionMilestone(notification) { + Assert.ok(notification.hidden === false, "Panel should be visible"); + Assert.ok( + notification.getAttribute("data-notification-category") === "short_message", + "Panel have correct data attribute" + ); +} + +function clearNotifications() { + for (let notification of PopupNotifications._currentNotifications) { + notification.remove(); + } + + // Clicking the primary action also removes the notification + Assert.equal( + PopupNotifications._currentNotifications.length, + 0, + "Should have removed the notification" + ); +} + +function trigger_cfr_panel( + browser, + trigger, + { + action = { type: "CANCEL" }, + heading_text, + category = "cfrAddons", + layout, + skip_address_bar_notifier = false, + use_single_secondary_button = false, + show_in_private_browsing = false, + template = "cfr_doorhanger", + } = {} +) { + // a fake action type will result in the action being ignored + const recommendation = createDummyRecommendation({ + action, + category, + heading_text, + layout, + skip_address_bar_notifier, + show_in_private_browsing, + template, + }); + if (category !== "cfrAddons") { + delete recommendation.content.addon; + } + if (use_single_secondary_button) { + recommendation.content.buttons.secondary = [ + recommendation.content.buttons.secondary[0], + ]; + } + + clearNotifications(); + return CFRPageActions.addRecommendation( + browser, + trigger, + recommendation, + // Use the real AS dispatch method to trigger real notifications + ASRouter.dispatchCFRAction + ); +} + +add_setup(async function () { + // Store it in order to restore to the original value + const { _fetchLatestAddonVersion } = CFRPageActions; + // Prevent fetching the real addon url and making a network request + CFRPageActions._fetchLatestAddonVersion = x => "http://example.com"; + + registerCleanupFunction(() => { + CFRPageActions._fetchLatestAddonVersion = _fetchLatestAddonVersion; + clearNotifications(); + CFRPageActions.clearRecommendations(); + }); +}); + +add_task(async function test_cfr_notification_show() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.telemetry" + ); + // Reset fog to clear pings here for private window test later. + Services.fog.testResetFOG(); + }); + + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.telemetry", + true + ); + + Services.fog.testResetFOG(); + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal(Glean.messagingSystem.source.testGetValue(), "CFR"); + }); + + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const response = await trigger_cfr_panel(browser, "example.com"); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + const oldFocus = document.activeElement; + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Open the panel + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .hidden === false, + "Panel should be visible" + ); + Assert.equal( + document.activeElement, + oldFocus, + "Focus didn't move when panel was shown" + ); + + // Check there is a primary button and click it. It will trigger the callback. + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .button + ); + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; + + // Clicking the primary action also removes the notification + Assert.equal( + PopupNotifications._currentNotifications.length, + 0, + "Should have removed the notification" + ); + + Assert.ok(pingSubmitted, "Recorded an event"); +}); + +add_task(async function test_cfr_notification_show() { + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + let response = await trigger_cfr_panel(browser, "example.com", { + heading_text: "First Message", + }); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + // Try adding another message + response = await trigger_cfr_panel(browser, "example.com", { + heading_text: "Second Message", + }); + Assert.equal( + response, + false, + "Should return false if second call did not add the message" + ); + + // Open the panel + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .hidden === false, + "Panel should be visible" + ); + + Assert.equal( + document.getElementById("cfr-notification-header-label").value, + "First Message", + "The first message should be visible" + ); + + // Check there is a primary button and click it. It will trigger the callback. + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .button + ); + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; + + // Clicking the primary action also removes the notification + Assert.equal( + PopupNotifications._currentNotifications.length, + 0, + "Should have removed the notification" + ); +}); + +add_task(async function test_cfr_notification_minimize() { + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + let response = await trigger_cfr_panel(browser, "example.com"); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + await BrowserTestUtils.waitForCondition( + () => gURLBar.hasAttribute("cfr-recommendation-state"), + "Wait for the notification to show up and have a state" + ); + Assert.ok( + gURLBar.getAttribute("cfr-recommendation-state") === "expanded", + "CFR recomendation state is correct" + ); + + gURLBar.focus(); + + await BrowserTestUtils.waitForCondition( + () => gURLBar.getAttribute("cfr-recommendation-state") === "collapsed", + "After urlbar focus the CFR notification should collapse" + ); + + // Open the panel and click to dismiss to ensure cleanup + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Open the panel + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; +}); + +add_task(async function test_cfr_notification_minimize_2() { + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + let response = await trigger_cfr_panel(browser, "example.com"); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + await BrowserTestUtils.waitForCondition( + () => gURLBar.hasAttribute("cfr-recommendation-state"), + "Wait for the notification to show up and have a state" + ); + Assert.ok( + gURLBar.getAttribute("cfr-recommendation-state") === "expanded", + "CFR recomendation state is correct" + ); + + // Open the panel and click to dismiss to ensure cleanup + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Open the panel + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .secondaryButton, + "There should be a cancel button" + ); + + // Click the Not Now button + document + .getElementById("contextual-feature-recommendation-notification") + .secondaryButton.click(); + + await hidePanel; + + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification"), + "The notification should not dissapear" + ); + + await BrowserTestUtils.waitForCondition( + () => gURLBar.getAttribute("cfr-recommendation-state") === "collapsed", + "Clicking the secondary button should collapse the notification" + ); + + clearNotifications(); + CFRPageActions.clearRecommendations(); +}); + +add_task(async function test_cfr_addon_install() { + // addRecommendation checks that scheme starts with http and host matches + const browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const response = await trigger_cfr_panel(browser, "example.com", { + action: { type: "INSTALL_ADDON_FROM_URL" }, + }); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Open the panel + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .hidden === false, + "Panel should be visible" + ); + checkCFRAddonsElements( + document.getElementById("contextual-feature-recommendation-notification") + ); + + // Check there is a primary button and click it. It will trigger the callback. + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .button + ); + const hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; + + await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + + let [notification] = PopupNotifications.panel.childNodes; + // Trying to install the addon will trigger a progress popup or an error popup if + // running the test multiple times in a row + Assert.ok( + notification.id === "addon-progress-notification" || + notification.id === "addon-install-failed-notification", + "Should try to install the addon" + ); + + clearNotifications(); +}); + +add_task( + async function test_cfr_tracking_protection_milestone_notification_remove() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.contentblocking.cfr-milestone.milestone-achieved", 1000], + [ + "browser.newtabpage.activity-stream.asrouter.providers.cfr", + `{"id":"cfr","enabled":true,"type":"local","localProvider":"CFRMessageProvider","updateCycleInMs":3600000}`, + ], + ], + }); + + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + event: "ContentBlockingMilestone", + }, + }, + "SiteProtection:ContentBlockingMilestone" + ); + + await showPanel; + + const notification = document.getElementById( + "contextual-feature-recommendation-notification" + ); + + checkCFRTrackingProtectionMilestone(notification); + + Assert.ok(notification.secondaryButton); + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + + notification.secondaryButton.click(); + await hidePanel; + await SpecialPowers.popPrefEnv(); + clearNotifications(); + } +); + +add_task(async function test_cfr_addon_and_features_show() { + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + // Trigger Feature CFR + let response = await trigger_cfr_panel(browser, "example.com"); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + let showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Open the panel + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + const notification = document.getElementById( + "contextual-feature-recommendation-notification" + ); + checkCFRAddonsElements(notification); + + // Check there is a primary button and click it. It will trigger the callback. + Assert.ok(notification.button); + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; + + // Clicking the primary action also removes the notification + Assert.equal( + PopupNotifications._currentNotifications.length, + 0, + "Should have removed the notification" + ); + + // Trigger Addon CFR + response = await trigger_cfr_panel(browser, "example.com", { + action: { type: "PIN_CURRENT_TAB" }, + category: "cfrAddons", + }); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Open the panel + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + Assert.ok( + document.getElementById("contextual-feature-recommendation-notification") + .hidden === false, + "Panel should be visible" + ); + checkCFRAddonsElements( + document.getElementById("contextual-feature-recommendation-notification") + ); + + // Check there is a primary button and click it. It will trigger the callback. + Assert.ok(notification.button); + hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; + + // Clicking the primary action also removes the notification + Assert.equal( + PopupNotifications._currentNotifications.length, + 0, + "Should have removed the notification" + ); +}); + +add_task(async function test_onLocationChange_cb() { + let count = 0; + const triggerHandler = () => ++count; + const TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/blue_page.html"; + const browser = gBrowser.selectedBrowser; + + await ASRouterTriggerListeners.get("openURL").init(triggerHandler, [ + "example.com", + ]); + + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + Assert.equal(count, 1, "Count navigation to example.com"); + + // Anchor scroll triggers a location change event with the same document + // https://searchfox.org/mozilla-central/rev/8848b9741fc4ee4e9bc3ae83ea0fc048da39979f/uriloader/base/nsIWebProgressListener.idl#400-403 + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/#foo"); + await BrowserTestUtils.waitForLocationChange( + gBrowser, + "http://example.com/#foo" + ); + + Assert.equal(count, 1, "It should ignore same page navigation"); + + BrowserTestUtils.startLoadingURIString(browser, TEST_URL); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + + Assert.equal(count, 2, "We moved to a new document"); + + registerCleanupFunction(() => { + ASRouterTriggerListeners.get("openURL").uninit(); + }); +}); + +add_task(async function test_matchPattern() { + let count = 0; + const triggerHandler = () => ++count; + const frequentVisitsTrigger = ASRouterTriggerListeners.get("frequentVisits"); + await frequentVisitsTrigger.init(triggerHandler, [], ["*://*.example.com/"]); + + const browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + await BrowserTestUtils.waitForCondition( + () => frequentVisitsTrigger._visits.get("example.com").length === 1, + "Registered pattern matched the current location" + ); + + BrowserTestUtils.startLoadingURIString(browser, "about:config"); + await BrowserTestUtils.browserLoaded(browser, false, "about:config"); + + await BrowserTestUtils.waitForCondition( + () => frequentVisitsTrigger._visits.get("example.com").length === 1, + "Navigated to a new page but not a match" + ); + + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + await BrowserTestUtils.waitForCondition( + () => frequentVisitsTrigger._visits.get("example.com").length === 1, + "Navigated to a location that matches the pattern but within 15 mins" + ); + + BrowserTestUtils.startLoadingURIString(browser, "http://www.example.com/"); + await BrowserTestUtils.browserLoaded( + browser, + false, + "http://www.example.com/" + ); + + await BrowserTestUtils.waitForCondition( + () => frequentVisitsTrigger._visits.get("www.example.com").length === 1, + "www.example.com is a different host that also matches the pattern." + ); + await BrowserTestUtils.waitForCondition( + () => frequentVisitsTrigger._visits.get("example.com").length === 1, + "www.example.com is a different host that also matches the pattern." + ); + + registerCleanupFunction(() => { + ASRouterTriggerListeners.get("frequentVisits").uninit(); + }); +}); + +add_task(async function test_providerNames() { + const providersBranch = + "browser.newtabpage.activity-stream.asrouter.providers."; + const cfrProviderPrefs = Services.prefs.getChildList(providersBranch); + for (const prefName of cfrProviderPrefs) { + const prefValue = JSON.parse(Services.prefs.getStringPref(prefName)); + if (prefValue && prefValue.id) { + Assert.equal( + prefValue.id, + prefName.slice(providersBranch.length), + "Provider id and pref name do not match" + ); + } + } +}); + +add_task(async function test_cfr_notification_keyboard() { + // addRecommendation checks that scheme starts with http and host matches + const browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const response = await trigger_cfr_panel(browser, "example.com"); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + // Open the panel with the keyboard. + // Toolbar buttons aren't always focusable; toolbar keyboard navigation + // makes them focusable on demand. Therefore, we must force focus. + const button = document.getElementById("contextual-feature-recommendation"); + button.setAttribute("tabindex", "-1"); + button.focus(); + button.removeAttribute("tabindex"); + + let focused = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "focus", + true + ); + EventUtils.synthesizeKey(" "); + await focused; + Assert.ok(true, "Focus inside panel after button pressed"); + + let hidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + EventUtils.synthesizeKey("KEY_Escape"); + await hidden; + Assert.ok(true, "Panel hidden after Escape pressed"); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Need to dismiss the notification to clear the RecommendationMap + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + const hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; +}); + +add_task(function test_updateCycleForProviders() { + Services.prefs + .getChildList("browser.newtabpage.activity-stream.asrouter.providers.") + .forEach(provider => { + const prefValue = JSON.parse(Services.prefs.getStringPref(provider, "")); + if (prefValue && prefValue.type === "remote-settings") { + Assert.ok(prefValue.updateCycleInMs); + } + }); +}); + +add_task(async function test_heartbeat_tactic_2() { + clearNotifications(); + registerCleanupFunction(() => { + // Remove the tab opened by clicking the heartbeat message + gBrowser.removeCurrentTab(); + clearNotifications(); + }); + + const msg = (await CFRMessageProvider.getMessages()).find( + m => m.id === "HEARTBEAT_TACTIC_2" + ); + const shown = await CFRPageActions.addRecommendation( + gBrowser.selectedBrowser, + null, + { + ...msg, + id: `HEARTBEAT_MOCHITEST_${Date.now()}`, + groups: ["mochitest-group"], + targeting: true, + }, + // Use the real AS dispatch method to trigger real notifications + ASRouter.dispatchCFRAction + ); + + Assert.ok(shown, "Heartbeat CFR added"); + + // Wait for visibility change + BrowserTestUtils.waitForCondition( + () => document.getElementById("contextual-feature-recommendation"), + "Heartbeat button exists" + ); + + let newTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + Services.urlFormatter.formatURL(msg.content.action.url), + true + ); + + document.getElementById("contextual-feature-recommendation").click(); + + await newTabPromise; +}); + +add_task(async function test_cfr_doorhanger_in_private_window() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.telemetry" + ); + }); + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.telemetry", + true + ); + + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal(Glean.messagingSystem.source.testGetValue(), "CFR"); + Assert.equal( + Glean.messagingSystem.messageId.testGetValue(), + "n/a", + "Omitted message_id consistent with CFR telemetry policy" + ); + Assert.equal( + Glean.messagingSystem.clientId.testGetValue(), + undefined, + "Omitted client_id consistent with CFR telemetry policy" + ); + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "http://example.com/" + ); + const browser = tab.linkedBrowser; + + const response1 = await trigger_cfr_panel(browser, "example.com"); + Assert.ok( + !response1, + "CFR should not be shown in a private window if show_in_private_browsing is false" + ); + + const response2 = await trigger_cfr_panel(browser, "example.com", { + show_in_private_browsing: true, + }); + Assert.ok( + response2, + "CFR should be shown in a private window if show_in_private_browsing is true" + ); + + const shownPromise = BrowserTestUtils.waitForEvent( + win.PopupNotifications.panel, + "popupshown" + ); + win.document.getElementById("contextual-feature-recommendation").click(); + await shownPromise; + + const hiddenPromise = BrowserTestUtils.waitForEvent( + win.PopupNotifications.panel, + "popuphidden" + ); + const button = win.document.getElementById( + "contextual-feature-recommendation-notification" + )?.button; + Assert.ok(button, "CFR doorhanger button found"); + button.click(); + await hiddenPromise; + + Assert.ok(pingSubmitted, "Submitted a CFR messaging system ping"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js b/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js new file mode 100644 index 0000000000..14f4dda54f --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js @@ -0,0 +1,505 @@ +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule( + "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs" +); +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { ExperimentFakes, ExperimentTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { ExperimentManager } = ChromeUtils.importESModule( + "resource://nimbus/lib/ExperimentManager.sys.mjs" +); +const { TelemetryFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/TelemetryFeed.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const MESSAGE_CONTENT = { + id: "xman_test_message", + groups: [], + content: { + text: "This is a test CFR", + addon: { + id: "954390", + icon: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png", + title: "Facebook Container", + users: "1455872", + author: "Mozilla", + rating: "4.5", + amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/", + }, + buttons: { + primary: { + label: { + string_id: "cfr-doorhanger-extension-ok-button", + }, + action: { + data: { + url: "about:blank", + }, + type: "INSTALL_ADDON_FROM_URL", + }, + }, + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + data: { + origin: "CFR", + category: "general-cfraddons", + }, + type: "OPEN_PREFERENCES_PAGE", + }, + }, + ], + }, + category: "cfrAddons", + layout: "short_message", + bucket_id: "CFR_M1", + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + heading_text: "Welcome to the experiment", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + }, + trigger: { + id: "openURL", + params: [ + "www.facebook.com", + "facebook.com", + "www.instagram.com", + "instagram.com", + "www.whatsapp.com", + "whatsapp.com", + "web.whatsapp.com", + "www.messenger.com", + "messenger.com", + ], + }, + template: "cfr_doorhanger", + frequency: { + lifetime: 3, + }, + targeting: "true", +}; + +const getExperiment = async feature => { + let recipe = ExperimentFakes.recipe( + // In tests by default studies/experiments are turned off. We turn them on + // to run the test and rollback at the end. Cleanup causes unenrollment so + // for cases where the test runs multiple times we need unique ids. + `test_xman_${feature}_${Date.now()}`, + { + id: "xman_test_message", + bucketConfig: { + count: 100, + start: 0, + total: 100, + namespace: "mochitest", + randomizationUnit: "normandy_id", + }, + } + ); + recipe.branches[0].features[0].featureId = feature; + recipe.branches[0].features[0].value = MESSAGE_CONTENT; + recipe.branches[1].features[0].featureId = feature; + recipe.branches[1].features[0].value = MESSAGE_CONTENT; + recipe.featureIds = [feature]; + await ExperimentTestUtils.validateExperiment(recipe); + return recipe; +}; + +const getCFRExperiment = async () => { + return getExperiment("cfr"); +}; + +const getLegacyCFRExperiment = async () => { + let recipe = ExperimentFakes.recipe(`test_xman_cfr_${Date.now()}`, { + id: "xman_test_message", + bucketConfig: { + count: 100, + start: 0, + total: 100, + namespace: "mochitest", + randomizationUnit: "normandy_id", + }, + }); + + delete recipe.branches[0].features; + delete recipe.branches[1].features; + recipe.branches[0].feature = { + featureId: "cfr", + value: MESSAGE_CONTENT, + }; + recipe.branches[1].feature = { + featureId: "cfr", + value: MESSAGE_CONTENT, + }; + return recipe; +}; + +const client = RemoteSettings("nimbus-desktop-experiments"); + +// no `add_task` because we want to run this setup before each test not before +// the entire test suite. +async function setup(experiment) { + // Store the experiment in RS local db to bypass synchronization. + await client.db.importChanges({}, Date.now(), [experiment], { clear: true }); + await SpecialPowers.pushPrefEnv({ + set: [ + ["app.shield.optoutstudies.enabled", true], + ["datareporting.healthreport.uploadEnabled", true], + [ + "browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments", + `{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","updateCycleInMs":0}`, + ], + ], + }); +} + +async function cleanup() { + await client.db.clear(); + await SpecialPowers.popPrefEnv(); + // Reload the provider + await ASRouter._updateMessageProviders(); +} + +/** + * Assert that a message is (or optionally is not) present in the ASRouter + * messages list, optionally waiting for it to be present/not present. + * @param {string} id message id + * @param {boolean} [found=true] expect the message to be found + * @param {boolean} [wait=true] check for the message until found/not found + * @returns {Promise<Message|null>} resolves with the message, if found + */ +async function assertMessageInState(id, found = true, wait = true) { + if (wait) { + await BrowserTestUtils.waitForCondition( + () => !!ASRouter.state.messages.find(m => m.id === id) === found, + `Message ${id} should ${found ? "" : "not"} be found in ASRouter state` + ); + } + const message = ASRouter.state.messages.find(m => m.id === id); + Assert.equal( + !!message, + found, + `Message ${id} should ${found ? "" : "not"} be found` + ); + return message || null; +} + +add_task(async function test_loading_experimentsAPI() { + const experiment = await getCFRExperiment(); + await setup(experiment); + // Fetch the new recipe from RS + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId: "cfr" }), + "ExperimentAPI should return an experiment" + ); + + const telemetryFeedInstance = new TelemetryFeed(); + Assert.ok( + telemetryFeedInstance.isInCFRCohort, + "Telemetry should return true" + ); + + await assertMessageInState("xman_test_message"); + + await cleanup(); +}); + +add_task(async function test_loading_fxms_message_1_feature() { + const experiment = await getExperiment("fxms-message-1"); + await setup(experiment); + // Fetch the new recipe from RS + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId: "fxms-message-1" }), + "ExperimentAPI should return an experiment" + ); + + await assertMessageInState("xman_test_message"); + + await cleanup(); +}); + +add_task(async function test_loading_experimentsAPI_legacy() { + const experiment = await getLegacyCFRExperiment(); + await setup(experiment); + // Fetch the new recipe from RS + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId: "cfr" }), + "ExperimentAPI should return an experiment" + ); + + const telemetryFeedInstance = new TelemetryFeed(); + Assert.ok( + telemetryFeedInstance.isInCFRCohort, + "Telemetry should return true" + ); + + await assertMessageInState("xman_test_message"); + + await cleanup(); +}); + +add_task(async function test_loading_experimentsAPI_rollout() { + const rollout = await getCFRExperiment(); + rollout.isRollout = true; + rollout.branches.pop(); + + await setup(rollout); + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition(() => + ExperimentAPI.getRolloutMetaData({ featureId: "cfr" }) + ); + + await assertMessageInState("xman_test_message"); + + await cleanup(); +}); + +add_task(async function test_exposure_ping() { + // Reset this check to allow sending multiple exposure pings in tests + NimbusFeatures.cfr._didSendExposureEvent = false; + const experiment = await getCFRExperiment(); + await setup(experiment); + Services.telemetry.clearScalars(); + // Fetch the new recipe from RS + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId: "cfr" }), + "ExperimentAPI should return an experiment" + ); + + await assertMessageInState("xman_test_message"); + + const exposureSpy = sinon.spy(ExperimentAPI, "recordExposureEvent"); + + await ASRouter.sendTriggerMessage({ + tabId: 1, + browser: gBrowser.selectedBrowser, + id: "openURL", + param: { host: "messenger.com" }, + }); + + Assert.ok(exposureSpy.callCount === 1, "Should send exposure ping"); + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "telemetry.event_counts", + "normandy#expose#nimbus_experiment", + 1 + ); + + exposureSpy.restore(); + await cleanup(); +}); + +add_task(async function test_exposure_ping_legacy() { + // Reset this check to allow sending multiple exposure pings in tests + NimbusFeatures.cfr._didSendExposureEvent = false; + const experiment = await getLegacyCFRExperiment(); + await setup(experiment); + Services.telemetry.clearScalars(); + // Fetch the new recipe from RS + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId: "cfr" }), + "ExperimentAPI should return an experiment" + ); + + await assertMessageInState("xman_test_message"); + + const exposureSpy = sinon.spy(ExperimentAPI, "recordExposureEvent"); + + await ASRouter.sendTriggerMessage({ + tabId: 1, + browser: gBrowser.selectedBrowser, + id: "openURL", + param: { host: "messenger.com" }, + }); + + Assert.ok(exposureSpy.callCount === 1, "Should send exposure ping"); + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "telemetry.event_counts", + "normandy#expose#nimbus_experiment", + 1 + ); + + exposureSpy.restore(); + await cleanup(); +}); + +add_task(async function test_forceEnrollUpdatesMessages() { + const experiment = await getCFRExperiment(); + + await setup(experiment); + await SpecialPowers.pushPrefEnv({ + set: [["nimbus.debug", true]], + }); + + await assertMessageInState("xman_test_message", false, false); + + await RemoteSettingsExperimentLoader.optInToExperiment({ + slug: experiment.slug, + branch: experiment.branches[0].slug, + }); + + await assertMessageInState("xman_test_message"); + + await ExperimentManager.unenroll(`optin-${experiment.slug}`, "cleanup"); + await SpecialPowers.popPrefEnv(); + await cleanup(); +}); + +add_task(async function test_update_on_enrollments_changed() { + // Check that the message is not already present + await assertMessageInState("xman_test_message", false, false); + + const experiment = await getCFRExperiment(); + let enrollmentChanged = TestUtils.topicObserved("nimbus:enrollments-updated"); + await setup(experiment); + await RemoteSettingsExperimentLoader.updateRecipes(); + + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId: "cfr" }), + "ExperimentAPI should return an experiment" + ); + await enrollmentChanged; + + await assertMessageInState("xman_test_message"); + + await cleanup(); +}); + +add_task(async function test_emptyMessage() { + const experiment = ExperimentFakes.recipe(`empty_${Date.now()}`, { + id: "empty", + branches: [ + { + slug: "a", + ratio: 1, + features: [ + { + featureId: "cfr", + value: {}, + }, + ], + }, + ], + bucketConfig: { + start: 0, + count: 100, + total: 100, + namespace: "mochitest", + randomizationUnit: "normandy_id", + }, + }); + + await setup(experiment); + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId: "cfr" }), + "ExperimentAPI should return an experiment" + ); + + await ASRouter._updateMessageProviders(); + + const experimentsProvider = ASRouter.state.providers.find( + p => p.id === "messaging-experiments" + ); + + // Clear all messages + ASRouter.setState(state => ({ + messages: [], + })); + + await ASRouter.loadMessagesFromAllProviders([experimentsProvider]); + + Assert.deepEqual( + ASRouter.state.messages, + [], + "ASRouter should have loaded zero messages" + ); + + await cleanup(); +}); + +add_task(async function test_multiMessageTreatment() { + const featureId = "cfr"; + // Add an array of two messages to the first branch + const messages = [ + { ...MESSAGE_CONTENT, id: "multi-message-1" }, + { ...MESSAGE_CONTENT, id: "multi-message-2" }, + ]; + const recipe = ExperimentFakes.recipe(`multi-message_${Date.now()}`, { + id: `multi-message`, + bucketConfig: { + count: 100, + start: 0, + total: 100, + namespace: "mochitest", + randomizationUnit: "normandy_id", + }, + branches: [ + { + slug: "control", + ratio: 1, + features: [{ featureId, value: { template: "multi", messages } }], + }, + ], + }); + await ExperimentTestUtils.validateExperiment(recipe); + + await setup(recipe); + await RemoteSettingsExperimentLoader.updateRecipes(); + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI.getExperiment({ featureId }), + "ExperimentAPI should return an experiment" + ); + + await BrowserTestUtils.waitForCondition( + () => + messages + .map(m => ASRouter.state.messages.find(n => n.id === m.id)) + .every(Boolean), + "Experiment message found in ASRouter state" + ); + Assert.ok(true, "Experiment message found in ASRouter state"); + + await cleanup(); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js b/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js new file mode 100644 index 0000000000..58f47ae6bf --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js @@ -0,0 +1,188 @@ +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +/** + * Load and modify a message for the test. + */ +add_setup(async function () { + const initialMsgCount = ASRouter.state.messages.length; + const heartbeatMsg = (await CFRMessageProvider.getMessages()).find( + m => m.id === "HEARTBEAT_TACTIC_2" + ); + const testMessage = { + ...heartbeatMsg, + groups: ["messaging-experiments"], + targeting: "true", + // Ensure no overlap due to frequency capping with other tests + id: `HEARTBEAT_MESSAGE_${Date.now()}`, + }; + const client = RemoteSettings("cfr"); + await client.db.importChanges({}, Date.now(), [testMessage], { + clear: true, + }); + + // Force the CFR provider cache to 0 by modifying updateCycleInMs + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.cfr", + `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`, + ], + ], + }); + + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + await BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.length > initialMsgCount, + "Should load the extra heartbeat message" + ); + + BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.find(m => m.id === testMessage.id), + "Wait to load the message" + ); + + const msg = ASRouter.state.messages.find(m => m.id === testMessage.id); + Assert.equal(msg.targeting, "true"); + Assert.equal(msg.groups[0], "messaging-experiments"); + + registerCleanupFunction(async () => { + await client.db.clear(); + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + await BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.length === initialMsgCount, + "Should reset messages" + ); + await SpecialPowers.popPrefEnv(); + }); +}); + +/** + * Test group frequency capping. + * Message has a lifetime frequency of 3 but it's group has a lifetime frequency + * of 2. It should only show up twice. + * We update the provider to remove any daily limitations so it should show up + * on every new tab load. + */ +add_task(async function test_heartbeat_tactic_2() { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const TEST_URL = "http://example.com"; + const msg = ASRouter.state.messages.find(m => + m.groups.includes("messaging-experiments") + ); + Assert.ok(msg, "Message found"); + const groupConfiguration = { + id: "messaging-experiments", + enabled: true, + frequency: { lifetime: 2 }, + }; + const client = RemoteSettings("message-groups"); + await client.db.importChanges({}, Date.now(), [groupConfiguration], { + clear: true, + }); + + // Force the WNPanel provider cache to 0 by modifying updateCycleInMs + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.message-groups", + `{"id":"message-groups","enabled":true,"type":"remote-settings","collection":"message-groups","updateCycleInMs":0}`, + ], + ], + }); + + await BrowserTestUtils.waitForCondition(async () => { + const msgs = await client.get(); + return msgs.find(m => m.id === groupConfiguration.id); + }, "Wait for RS message"); + + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadAllMessageGroups(); + + let groupState = await BrowserTestUtils.waitForCondition( + () => ASRouter.state.groups.find(g => g.id === groupConfiguration.id), + "Wait for group config to load" + ); + Assert.ok(groupState, "Group config found"); + Assert.ok(groupState.enabled, "Group is enabled"); + + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + BrowserTestUtils.startLoadingURIString(tab1.linkedBrowser, TEST_URL); + + let chiclet = document.getElementById("contextual-feature-recommendation"); + Assert.ok(chiclet, "CFR chiclet element found (tab1)"); + await BrowserTestUtils.waitForCondition( + () => !chiclet.hidden, + "Chiclet should be visible (tab1)" + ); + + await BrowserTestUtils.waitForCondition( + () => + ASRouter.state.messageImpressions[msg.id] && + ASRouter.state.messageImpressions[msg.id].length === 1, + "First impression recorded" + ); + + BrowserTestUtils.removeTab(tab1); + + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + BrowserTestUtils.startLoadingURIString(tab2.linkedBrowser, TEST_URL); + + Assert.ok(chiclet, "CFR chiclet element found (tab2)"); + await BrowserTestUtils.waitForCondition( + () => !chiclet.hidden, + "Chiclet should be visible (tab2)" + ); + + await BrowserTestUtils.waitForCondition( + () => + ASRouter.state.messageImpressions[msg.id] && + ASRouter.state.messageImpressions[msg.id].length === 2, + "Second impression recorded" + ); + + Assert.ok( + !ASRouter.isBelowFrequencyCaps(msg), + "Should have reached freq limit" + ); + + BrowserTestUtils.removeTab(tab2); + + let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + BrowserTestUtils.startLoadingURIString(tab3.linkedBrowser, TEST_URL); + + await BrowserTestUtils.waitForCondition( + () => chiclet.hidden, + "Heartbeat button should be hidden" + ); + Assert.equal( + ASRouter.state.messageImpressions[msg.id] && + ASRouter.state.messageImpressions[msg.id].length, + 2, + "Number of impressions did not increase" + ); + + BrowserTestUtils.removeTab(tab3); + + info("Cleanup"); + await client.db.clear(); + // Reset group impressions + await ASRouter.resetGroupsState(); + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + await SpecialPowers.popPrefEnv(); + CFRPageActions.clearRecommendations(); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js b/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js new file mode 100644 index 0000000000..3bfc05ba48 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js @@ -0,0 +1,158 @@ +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +/** + * Load and modify a message for the test. + */ +add_setup(async function () { + const initialMsgCount = ASRouter.state.messages.length; + const heartbeatMsg = (await CFRMessageProvider.getMessages()).find( + m => m.id === "HEARTBEAT_TACTIC_2" + ); + const testMessage = { + ...heartbeatMsg, + groups: ["messaging-experiments"], + targeting: "true", + // Ensure no overlap due to frequency capping with other tests + id: `HEARTBEAT_MESSAGE_${Date.now()}`, + }; + const client = RemoteSettings("cfr"); + await client.db.importChanges({}, Date.now(), [testMessage], { clear: true }); + + // Force the CFR provider cache to 0 by modifying updateCycleInMs + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.cfr", + `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`, + ], + ], + }); + + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + await BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.length > initialMsgCount, + "Should load the extra heartbeat message" + ); + + BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.find(m => m.id === testMessage.id), + "Wait to load the message" + ); + + const msg = ASRouter.state.messages.find(m => m.id === testMessage.id); + Assert.equal(msg.targeting, "true"); + Assert.equal(msg.groups[0], "messaging-experiments"); + + registerCleanupFunction(async () => { + await client.db.clear(); + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + await BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.length === initialMsgCount, + "Should reset messages" + ); + await SpecialPowers.popPrefEnv(); + }); +}); + +/** + * Test group user preferences. + * Group is enabled if both user preferences are enabled. + */ +add_task(async function test_heartbeat_tactic_2() { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const TEST_URL = "http://example.com"; + const msg = ASRouter.state.messages.find(m => + m.groups.includes("messaging-experiments") + ); + Assert.ok(msg, "Message found"); + const groupConfiguration = { + id: "messaging-experiments", + enabled: true, + userPreferences: ["browser.userPreference.messaging-experiments"], + }; + const client = RemoteSettings("message-groups"); + await client.db.importChanges({}, Date.now(), [groupConfiguration], { + clear: true, + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.message-groups", + `{"id":"message-groups","enabled":true,"type":"remote-settings","collection":"message-groups","updateCycleInMs":0}`, + ], + ["browser.userPreference.messaging-experiments", true], + ], + }); + + await BrowserTestUtils.waitForCondition(async () => { + const msgs = await client.get(); + return msgs.find(m => m.id === groupConfiguration.id); + }, "Wait for RS message"); + + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadAllMessageGroups(); + + let groupState = await BrowserTestUtils.waitForCondition( + () => ASRouter.state.groups.find(g => g.id === groupConfiguration.id), + "Wait for group config to load" + ); + Assert.ok(groupState, "Group config found"); + Assert.ok(groupState.enabled, "Group is enabled"); + Assert.ok(ASRouter.isUnblockedMessage(msg), "Message is unblocked"); + + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + BrowserTestUtils.startLoadingURIString(tab1.linkedBrowser, TEST_URL); + + let chiclet = document.getElementById("contextual-feature-recommendation"); + Assert.ok(chiclet, "CFR chiclet element found"); + await BrowserTestUtils.waitForCondition( + () => !chiclet.hidden, + "Chiclet should be visible (userprefs enabled)" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.userPreference.messaging-experiments", false]], + }); + + await BrowserTestUtils.waitForCondition( + () => + ASRouter.state.groups.find( + g => g.id === groupConfiguration.id && !g.enable + ), + "Wait for group config to load" + ); + + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + BrowserTestUtils.startLoadingURIString(tab2.linkedBrowser, TEST_URL); + + await BrowserTestUtils.waitForCondition( + () => chiclet.hidden, + "Heartbeat button should not be visible (userprefs disabled)" + ); + + info("Cleanup"); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + await client.db.clear(); + // Reset group impressions + await ASRouter.resetGroupsState(); + // Reload the providers + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + await SpecialPowers.popPrefEnv(); + CFRPageActions.clearRecommendations(); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js new file mode 100644 index 0000000000..b80b3ec7a4 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { InfoBar } = ChromeUtils.importESModule( + "resource:///modules/asrouter/InfoBar.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +add_task(async function show_and_send_telemetry() { + let message = (await CFRMessageProvider.getMessages()).find( + m => m.id === "INFOBAR_ACTION_86" + ); + + Assert.ok(message.id, "Found the message"); + + let dispatchStub = sinon.stub(); + let infobar = await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + { + ...message, + content: { + priority: window.gNotificationBox.PRIORITY_WARNING_HIGH, + ...message.content, + }, + }, + dispatchStub + ); + + Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION"); + // This is the call to increment impressions for frequency capping + Assert.equal(dispatchStub.firstCall.args[0].type, "IMPRESSION"); + Assert.equal(dispatchStub.firstCall.args[0].data.id, message.id); + // This is the telemetry ping + Assert.equal(dispatchStub.secondCall.args[0].data.event, "IMPRESSION"); + Assert.equal(dispatchStub.secondCall.args[0].data.message_id, message.id); + Assert.equal( + infobar.notification.priority, + window.gNotificationBox.PRIORITY_WARNING_HIGH, + "Has the priority level set in the message definition" + ); + + let primaryBtn = infobar.notification.buttonContainer.querySelector( + ".notification-button.primary" + ); + + Assert.ok(primaryBtn, "Has a primary button"); + primaryBtn.click(); + + Assert.equal(dispatchStub.callCount, 4, "Called again with CLICK + removed"); + Assert.equal(dispatchStub.thirdCall.args[0].type, "USER_ACTION"); + Assert.equal( + dispatchStub.lastCall.args[0].data.event, + "CLICK_PRIMARY_BUTTON" + ); + + await BrowserTestUtils.waitForCondition( + () => !InfoBar._activeInfobar, + "Wait for notification to be dismissed by primary btn click." + ); +}); + +add_task(async function react_to_trigger() { + let message = { + ...(await CFRMessageProvider.getMessages()).find( + m => m.id === "INFOBAR_ACTION_86" + ), + }; + message.targeting = "true"; + message.content.type = "tab"; + message.groups = []; + message.provider = ASRouter.state.providers[0].id; + message.content.message = "Infobar Mochitest"; + await ASRouter.setState({ messages: [message] }); + + let notificationStack = gBrowser.getNotificationBox(gBrowser.selectedBrowser); + Assert.ok( + !notificationStack.currentNotification, + "No notification to start with" + ); + + await ASRouter.sendTriggerMessage({ + browser: BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + id: "defaultBrowserCheck", + }); + + await BrowserTestUtils.waitForCondition( + () => notificationStack.currentNotification, + "Wait for notification to show" + ); + + Assert.equal( + notificationStack.currentNotification.getAttribute("value"), + message.id, + "Notification id should match" + ); + + let defaultPriority = notificationStack.PRIORITY_SYSTEM; + Assert.ok( + notificationStack.currentNotification.priority === defaultPriority, + "Notification has default priority" + ); + // Dismiss the notification + notificationStack.currentNotification.closeButtonEl.click(); +}); + +add_task(async function dismiss_telemetry() { + let message = { + ...(await CFRMessageProvider.getMessages()).find( + m => m.id === "INFOBAR_ACTION_86" + ), + }; + message.content.type = "tab"; + + let dispatchStub = sinon.stub(); + let infobar = await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + + // Remove any IMPRESSION pings + dispatchStub.reset(); + + infobar.notification.closeButtonEl.click(); + + await BrowserTestUtils.waitForCondition( + () => infobar.notification === null, + "Set to null by `removed` event" + ); + + Assert.equal(dispatchStub.callCount, 1, "Only called once"); + Assert.equal( + dispatchStub.firstCall.args[0].data.event, + "DISMISSED", + "Called with dismissed" + ); + + // Remove DISMISSED ping + dispatchStub.reset(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + infobar = await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + + await BrowserTestUtils.waitForCondition( + () => dispatchStub.callCount > 0, + "Wait for impression ping" + ); + + // Remove IMPRESSION ping + dispatchStub.reset(); + BrowserTestUtils.removeTab(tab); + + await BrowserTestUtils.waitForCondition( + () => infobar.notification === null, + "Set to null by `disconnect` event" + ); + + // Called by closing the tab and triggering "disconnect" + Assert.equal(dispatchStub.callCount, 1, "Only called once"); + Assert.equal( + dispatchStub.firstCall.args[0].data.event, + "DISMISSED", + "Called with dismissed" + ); +}); + +add_task(async function prevent_multiple_messages() { + let message = (await CFRMessageProvider.getMessages()).find( + m => m.id === "INFOBAR_ACTION_86" + ); + + Assert.ok(message.id, "Found the message"); + + let dispatchStub = sinon.stub(); + let infobar = await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + + Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION"); + + // Try to stack 2 notifications + await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + + Assert.equal(dispatchStub.callCount, 2, "Impression count did not increase"); + + // Dismiss the first notification + infobar.notification.closeButtonEl.click(); + Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification"); + + // Reset impressions count + dispatchStub.reset(); + // Try show the message again + infobar = await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + Assert.ok(InfoBar._activeInfobar, "activeInfobar is set"); + Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION"); + // Dismiss the notification again + infobar.notification.closeButtonEl.click(); + Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification"); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js new file mode 100644 index 0000000000..f752d01116 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js @@ -0,0 +1,116 @@ +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); +const { MomentsPageHub } = ChromeUtils.importESModule( + "resource:///modules/asrouter/MomentsPageHub.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; + +add_task(async function test_with_rs_messages() { + // Force the WNPanel provider cache to 0 by modifying updateCycleInMs + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel", + `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`, + ], + ], + }); + const [msg] = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + const initialMessageCount = ASRouter.state.messages.length; + const client = RemoteSettings("cfr"); + await client.db.importChanges( + {}, + Date.now(), + [ + { + // Modify targeting and randomize message name to work around the message + // getting blocked (for --verify) + ...msg, + id: `MOMENTS_MOCHITEST_${Date.now()}`, + targeting: "true", + }, + ], + { clear: true } + ); + // Reload the provider + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + // Wait to load the WNPanel messages + await BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.length > initialMessageCount, + "Messages did not load" + ); + + await MomentsPageHub.messageRequest({ + triggerId: "momentsUpdate", + template: "update_action", + }); + + await BrowserTestUtils.waitForCondition(() => { + return Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "").length; + }, "Pref value was not set"); + + let value = Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, ""); + is(JSON.parse(value).url, msg.content.action.data.url, "Correct value set"); + + // Insert a new message and test that priority override works as expected + msg.content.action.data.url = "https://www.mozilla.org/#mochitest"; + await client.db.create( + // Modify targeting to ensure the messages always show up + { + ...msg, + id: `MOMENTS_MOCHITEST_${Date.now()}`, + priority: 2, + targeting: "true", + } + ); + + // Reset so we can `await` for the pref value to be set again + Services.prefs.clearUserPref(HOMEPAGE_OVERRIDE_PREF); + + let prevLength = ASRouter.state.messages.length; + // Wait to load the messages + await ASRouter.loadMessagesFromAllProviders(); + await BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.length > prevLength, + "Messages did not load" + ); + + await MomentsPageHub.messageRequest({ + triggerId: "momentsUpdate", + template: "update_action", + }); + + await BrowserTestUtils.waitForCondition(() => { + return Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "").length; + }, "Pref value was not set"); + + value = Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, ""); + is( + JSON.parse(value).url, + msg.content.action.data.url, + "Correct value set for higher priority message" + ); + + await client.db.clear(); + // Wait to reset the WNPanel messages from state + const previousMessageCount = ASRouter.state.messages.length; + await ASRouter.loadMessagesFromAllProviders(); + await BrowserTestUtils.waitForCondition( + () => ASRouter.state.messages.length < previousMessageCount, + "ASRouter messages should have been removed" + ); + await SpecialPowers.popPrefEnv(); + // Reload the provider + await ASRouter._updateMessageProviders(); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js new file mode 100644 index 0000000000..432b4b75a7 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js @@ -0,0 +1,1706 @@ +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + AttributionCode: "resource:///modules/AttributionCode.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", + CFRMessageProvider: "resource:///modules/asrouter/CFRMessageProvider.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + QueryCache: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", +}); + +function sendFormAutofillMessage(name, data) { + let actor = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ); + return actor.receiveMessage({ name, data }); +} + +async function removeAutofillRecords() { + let addresses = ( + await sendFormAutofillMessage("FormAutofill:GetRecords", { + collectionName: "addresses", + }) + ).records; + if (addresses.length) { + let observePromise = TestUtils.topicObserved( + "formautofill-storage-changed" + ); + await sendFormAutofillMessage("FormAutofill:RemoveAddresses", { + guids: addresses.map(address => address.guid), + }); + await observePromise; + } + let creditCards = ( + await sendFormAutofillMessage("FormAutofill:GetRecords", { + collectionName: "creditCards", + }) + ).records; + if (creditCards.length) { + let observePromise = TestUtils.topicObserved( + "formautofill-storage-changed" + ); + await sendFormAutofillMessage("FormAutofill:RemoveCreditCards", { + guids: creditCards.map(cc => cc.guid), + }); + await observePromise; + } +} + +// ASRouterTargeting.findMatchingMessage +add_task(async function find_matching_message() { + const messages = [ + { id: "foo", targeting: "FOO" }, + { id: "bar", targeting: "!FOO" }, + ]; + const context = { FOO: true }; + + const match = await ASRouterTargeting.findMatchingMessage({ + messages, + context, + }); + + is(match, messages[0], "should match and return the correct message"); +}); + +add_task(async function return_nothing_for_no_matching_message() { + const messages = [{ id: "bar", targeting: "!FOO" }]; + const context = { FOO: true }; + + const match = await ASRouterTargeting.findMatchingMessage({ + messages, + context, + }); + + ok(!match, "should return nothing since no matching message exists"); +}); + +add_task(async function check_other_error_handling() { + let called = false; + function onError(...args) { + called = true; + } + + const messages = [{ id: "foo", targeting: "foo" }]; + const context = { + get foo() { + throw new Error("test error"); + }, + }; + const match = await ASRouterTargeting.findMatchingMessage({ + messages, + context, + onError, + }); + + ok(!match, "should return nothing since no valid matching message exists"); + + Assert.ok(called, "Attribute error caught"); +}); + +// ASRouterTargeting.Environment +add_task(async function check_locale() { + ok( + Services.locale.appLocaleAsBCP47, + "Services.locale.appLocaleAsBCP47 exists" + ); + const message = { + id: "foo", + targeting: `locale == "${Services.locale.appLocaleAsBCP47}"`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item when filtering by locale" + ); +}); +add_task(async function check_localeLanguageCode() { + const currentLanguageCode = Services.locale.appLocaleAsBCP47.substr(0, 2); + is( + Services.locale.negotiateLanguages( + [currentLanguageCode], + [Services.locale.appLocaleAsBCP47] + )[0], + Services.locale.appLocaleAsBCP47, + "currentLanguageCode should resolve to the current locale (e.g en => en-US)" + ); + const message = { + id: "foo", + targeting: `localeLanguageCode == "${currentLanguageCode}"`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item when filtering by localeLanguageCode" + ); +}); + +add_task(async function checkProfileAgeCreated() { + let profileAccessor = await ProfileAge(); + is( + await ASRouterTargeting.Environment.profileAgeCreated, + await profileAccessor.created, + "should return correct profile age creation date" + ); + + const message = { + id: "foo", + targeting: `profileAgeCreated > ${(await profileAccessor.created) - 100}`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by profile age created" + ); +}); + +add_task(async function checkProfileAgeReset() { + let profileAccessor = await ProfileAge(); + is( + await ASRouterTargeting.Environment.profileAgeReset, + await profileAccessor.reset, + "should return correct profile age reset" + ); + + const message = { + id: "foo", + targeting: `profileAgeReset == ${await profileAccessor.reset}`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by profile age reset" + ); +}); + +add_task(async function checkCurrentDate() { + let message = { + id: "foo", + targeting: `currentDate < '${new Date(Date.now() + 5000)}'|date`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select message based on currentDate < timestamp" + ); + + message = { + id: "foo", + targeting: `currentDate > '${new Date(Date.now() - 5000)}'|date`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select message based on currentDate > timestamp" + ); +}); + +add_task(async function check_usesFirefoxSync() { + await pushPrefs(["services.sync.username", "someone@foo.com"]); + is( + await ASRouterTargeting.Environment.usesFirefoxSync, + true, + "should return true if a fx account is set" + ); + + const message = { id: "foo", targeting: "usesFirefoxSync" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by usesFirefoxSync" + ); +}); + +add_task(async function check_isFxAEnabled() { + await pushPrefs(["identity.fxaccounts.enabled", false]); + is( + await ASRouterTargeting.Environment.isFxAEnabled, + false, + "should return false if fxa is disabled" + ); + + const message = { id: "foo", targeting: "isFxAEnabled" }; + ok( + !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })), + "should not select a message if fxa is disabled" + ); +}); + +add_task(async function check_isFxAEnabled() { + await pushPrefs(["identity.fxaccounts.enabled", true]); + is( + await ASRouterTargeting.Environment.isFxAEnabled, + true, + "should return true if fxa is enabled" + ); + + const message = { id: "foo", targeting: "isFxAEnabled" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select the correct message" + ); +}); + +add_task(async function check_isFxASignedIn_false() { + await pushPrefs( + ["identity.fxaccounts.enabled", true], + ["services.sync.username", ""] + ); + const sandbox = sinon.createSandbox(); + registerCleanupFunction(async () => sandbox.restore()); + sandbox.stub(FxAccounts.prototype, "getSignedInUser").resolves(null); + is( + await ASRouterTargeting.Environment.isFxASignedIn, + false, + "user should not appear signed in" + ); + + const message = { id: "foo", targeting: "isFxASignedIn" }; + isnot( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should not select the message since user is not signed in" + ); + + sandbox.restore(); +}); + +add_task(async function check_isFxASignedIn_true() { + await pushPrefs( + ["identity.fxaccounts.enabled", true], + ["services.sync.username", ""] + ); + const sandbox = sinon.createSandbox(); + registerCleanupFunction(async () => sandbox.restore()); + sandbox.stub(FxAccounts.prototype, "getSignedInUser").resolves({}); + is( + await ASRouterTargeting.Environment.isFxASignedIn, + true, + "user should appear signed in" + ); + + const message = { id: "foo", targeting: "isFxASignedIn" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select the correct message" + ); + + sandbox.restore(); +}); + +add_task(async function check_totalBookmarksCount() { + // Make sure we remove default bookmarks so they don't interfere + await clearHistoryAndBookmarks(); + const message = { id: "foo", targeting: "totalBookmarksCount > 0" }; + + const results = await ASRouterTargeting.findMatchingMessage({ + messages: [message], + }); + ok( + !(results ? JSON.stringify(results) : results), + "Should not select any message because bookmarks count is not 0" + ); + + const bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "foo", + url: "https://mozilla1.com/nowNew", + }); + + QueryCache.queries.TotalBookmarksCount.expire(); + + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "Should select correct item after bookmarks are added." + ); + + // Cleanup + await PlacesUtils.bookmarks.remove(bookmark.guid); +}); + +add_task(async function check_needsUpdate() { + QueryCache.queries.CheckBrowserNeedsUpdate.setUp(true); + + const message = { id: "foo", targeting: "needsUpdate" }; + + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "Should select message because update count > 0" + ); + + QueryCache.queries.CheckBrowserNeedsUpdate.setUp(false); + + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + null, + "Should not select message because update count == 0" + ); +}); + +add_task(async function checksearchEngines() { + const result = await ASRouterTargeting.Environment.searchEngines; + const expectedInstalled = (await Services.search.getAppProvidedEngines()) + .map(engine => engine.identifier) + .sort() + .join(","); + ok( + result.installed.length, + "searchEngines.installed should be a non-empty array" + ); + is( + result.installed.sort().join(","), + expectedInstalled, + "searchEngines.installed should be an array of visible search engines" + ); + ok( + result.current && typeof result.current === "string", + "searchEngines.current should be a truthy string" + ); + is( + result.current, + (await Services.search.getDefault()).identifier, + "searchEngines.current should be the current engine name" + ); + + const message = { + id: "foo", + targeting: `searchEngines[.current == ${ + (await Services.search.getDefault()).identifier + }]`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by searchEngines.current" + ); + + const message2 = { + id: "foo", + targeting: `searchEngines[${ + (await Services.search.getAppProvidedEngines())[0].identifier + } in .installed]`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message2] }), + message2, + "should select correct item by searchEngines.installed" + ); +}); + +add_task(async function checkisDefaultBrowser() { + const expected = ShellService.isDefaultBrowser(); + const result = await ASRouterTargeting.Environment.isDefaultBrowser; + is(typeof result, "boolean", "isDefaultBrowser should be a boolean value"); + is( + result, + expected, + "isDefaultBrowser should be equal to ShellService.isDefaultBrowser()" + ); + const message = { + id: "foo", + targeting: `isDefaultBrowser == ${expected.toString()}`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by isDefaultBrowser" + ); +}); + +add_task(async function checkdevToolsOpenedCount() { + await pushPrefs(["devtools.selfxss.count", 5]); + is( + ASRouterTargeting.Environment.devToolsOpenedCount, + 5, + "devToolsOpenedCount should be equal to devtools.selfxss.count pref value" + ); + const message = { id: "foo", targeting: "devToolsOpenedCount >= 5" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by devToolsOpenedCount" + ); +}); + +add_task(async function check_platformName() { + const message = { + id: "foo", + targeting: `platformName == "${AppConstants.platform}"`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by platformName" + ); +}); + +AddonTestUtils.initMochitest(this); + +add_task(async function checkAddonsInfo() { + const FAKE_ID = "testaddon@tests.mozilla.org"; + const FAKE_NAME = "Test Addon"; + const FAKE_VERSION = "0.5.7"; + + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + browser_specific_settings: { gecko: { id: FAKE_ID } }, + name: FAKE_NAME, + version: FAKE_VERSION, + }, + }); + + await Promise.all([ + AddonTestUtils.promiseWebExtensionStartup(FAKE_ID), + AddonManager.installTemporaryAddon(xpi), + ]); + + const { addons } = await AddonManager.getActiveAddons([ + "extension", + "service", + ]); + + const { addons: asRouterAddons, isFullData } = await ASRouterTargeting + .Environment.addonsInfo; + + ok( + addons.every(({ id }) => asRouterAddons[id]), + "should contain every addon" + ); + + ok( + Object.getOwnPropertyNames(asRouterAddons).every(id => + addons.some(addon => addon.id === id) + ), + "should contain no incorrect addons" + ); + + const testAddon = asRouterAddons[FAKE_ID]; + + ok( + Object.prototype.hasOwnProperty.call(testAddon, "version") && + testAddon.version === FAKE_VERSION, + "should correctly provide `version` property" + ); + + ok( + Object.prototype.hasOwnProperty.call(testAddon, "type") && + testAddon.type === "extension", + "should correctly provide `type` property" + ); + + ok( + Object.prototype.hasOwnProperty.call(testAddon, "isSystem") && + testAddon.isSystem === false, + "should correctly provide `isSystem` property" + ); + + ok( + Object.prototype.hasOwnProperty.call(testAddon, "isWebExtension") && + testAddon.isWebExtension === true, + "should correctly provide `isWebExtension` property" + ); + + // As we installed our test addon the addons database must be initialised, so + // (in this test environment) we expect to receive "full" data + + ok(isFullData, "should receive full data"); + + ok( + Object.prototype.hasOwnProperty.call(testAddon, "name") && + testAddon.name === FAKE_NAME, + "should correctly provide `name` property from full data" + ); + + ok( + Object.prototype.hasOwnProperty.call(testAddon, "userDisabled") && + testAddon.userDisabled === false, + "should correctly provide `userDisabled` property from full data" + ); + + ok( + Object.prototype.hasOwnProperty.call(testAddon, "installDate") && + Math.abs(Date.now() - new Date(testAddon.installDate)) < 60 * 1000, + "should correctly provide `installDate` property from full data" + ); +}); + +add_task(async function checkFrecentSites() { + const now = Date.now(); + const timeDaysAgo = numDays => now - numDays * 24 * 60 * 60 * 1000; + + const visits = []; + for (const [uri, count, visitDate] of [ + ["https://mozilla1.com/", 10, timeDaysAgo(0)], // frecency 1000 + ["https://mozilla2.com/", 5, timeDaysAgo(1)], // frecency 500 + ["https://mozilla3.com/", 1, timeDaysAgo(2)], // frecency 100 + ]) { + [...Array(count).keys()].forEach(() => + visits.push({ + uri, + visitDate: visitDate * 1000, // Places expects microseconds + }) + ); + } + + await PlacesTestUtils.addVisits(visits); + + let message = { + id: "foo", + targeting: "'mozilla3.com' in topFrecentSites|mapToProperty('host')", + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by host in topFrecentSites" + ); + + message = { + id: "foo", + targeting: "'non-existent.com' in topFrecentSites|mapToProperty('host')", + }; + ok( + !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })), + "should not select incorrect item by host in topFrecentSites" + ); + + message = { + id: "foo", + targeting: + "'mozilla2.com' in topFrecentSites[.frecency >= 400]|mapToProperty('host')", + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item when filtering by frecency" + ); + + message = { + id: "foo", + targeting: + "'mozilla2.com' in topFrecentSites[.frecency >= 600]|mapToProperty('host')", + }; + ok( + !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })), + "should not select incorrect item when filtering by frecency" + ); + + message = { + id: "foo", + targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${ + timeDaysAgo(1) - 1 + }]|mapToProperty('host')`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item when filtering by lastVisitDate" + ); + + message = { + id: "foo", + targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${ + timeDaysAgo(0) - 1 + }]|mapToProperty('host')`, + }; + ok( + !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })), + "should not select incorrect item when filtering by lastVisitDate" + ); + + message = { + id: "foo", + targeting: `(topFrecentSites[.frecency >= 900 && .lastVisitDate >= ${ + timeDaysAgo(1) - 1 + }]|mapToProperty('host') intersect ['mozilla3.com', 'mozilla2.com', 'mozilla1.com'])|length > 0`, + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item when filtering by frecency and lastVisitDate with multiple candidate domains" + ); + + // Cleanup + await clearHistoryAndBookmarks(); +}); + +add_task(async function check_pinned_sites() { + // Fresh profiles come with an empty set of pinned websites (pref doesn't + // exist). Search shortcut topsites make this test more complicated because + // the feature pins a new website on startup. Behaviour can vary when running + // with --verify so it's more predictable to clear pins entirely. + Services.prefs.clearUserPref("browser.newtabpage.pinned"); + NewTabUtils.pinnedLinks.resetCache(); + const originalPin = JSON.stringify(NewTabUtils.pinnedLinks.links); + const sitesToPin = [ + { url: "https://foo.com" }, + { url: "https://bloo.com" }, + { url: "https://floogle.com", searchTopSite: true }, + ]; + sitesToPin.forEach(site => + NewTabUtils.pinnedLinks.pin(site, NewTabUtils.pinnedLinks.links.length) + ); + + // Unpinning adds null to the list of pinned sites, which we should test that we handle gracefully for our targeting + NewTabUtils.pinnedLinks.unpin(sitesToPin[1]); + ok( + NewTabUtils.pinnedLinks.links.includes(null), + "should have set an item in pinned links to null via unpinning for testing" + ); + + let message; + + message = { + id: "foo", + targeting: "'https://foo.com' in pinnedSites|mapToProperty('url')", + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by url in pinnedSites" + ); + + message = { + id: "foo", + targeting: "'foo.com' in pinnedSites|mapToProperty('host')", + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by host in pinnedSites" + ); + + message = { + id: "foo", + targeting: + "'floogle.com' in pinnedSites[.searchTopSite == true]|mapToProperty('host')", + }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by host and searchTopSite in pinnedSites" + ); + + // Cleanup + sitesToPin.forEach(site => NewTabUtils.pinnedLinks.unpin(site)); + + await clearHistoryAndBookmarks(); + Services.prefs.clearUserPref("browser.newtabpage.pinned"); + NewTabUtils.pinnedLinks.resetCache(); + is( + JSON.stringify(NewTabUtils.pinnedLinks.links), + originalPin, + "should restore pinned sites to its original state" + ); +}); + +add_task(async function check_firefox_version() { + const message = { id: "foo", targeting: "firefoxVersion > 0" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item when filtering by firefox version" + ); +}); + +add_task(async function check_region() { + Region._setHomeRegion("DE", false); + const message = { id: "foo", targeting: "region in ['DE']" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item when filtering by firefox geo" + ); +}); + +add_task(async function check_browserSettings() { + is( + await JSON.stringify(ASRouterTargeting.Environment.browserSettings.update), + JSON.stringify(TelemetryEnvironment.currentEnvironment.settings.update), + "should return correct update info" + ); +}); + +add_task(async function check_sync() { + is( + await ASRouterTargeting.Environment.sync.desktopDevices, + Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0), + "should return correct desktopDevices info" + ); + is( + await ASRouterTargeting.Environment.sync.mobileDevices, + Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0), + "should return correct mobileDevices info" + ); + is( + await ASRouterTargeting.Environment.sync.totalDevices, + Services.prefs.getIntPref("services.sync.numClients", 0), + "should return correct mobileDevices info" + ); +}); + +add_task(async function check_provider_cohorts() { + await pushPrefs([ + "browser.newtabpage.activity-stream.asrouter.providers.onboarding", + JSON.stringify({ + id: "onboarding", + messages: [], + enabled: true, + cohort: "foo", + }), + ]); + await pushPrefs([ + "browser.newtabpage.activity-stream.asrouter.providers.cfr", + JSON.stringify({ id: "cfr", enabled: true, cohort: "bar" }), + ]); + is( + await ASRouterTargeting.Environment.providerCohorts.onboarding, + "foo", + "should have cohort foo for onboarding" + ); + is( + await ASRouterTargeting.Environment.providerCohorts.cfr, + "bar", + "should have cohort bar for cfr" + ); +}); + +add_task(async function check_xpinstall_enabled() { + // should default to true if pref doesn't exist + is(await ASRouterTargeting.Environment.xpinstallEnabled, true); + // flip to false, check targeting reflects that + await pushPrefs(["xpinstall.enabled", false]); + is(await ASRouterTargeting.Environment.xpinstallEnabled, false); + // flip to true, check targeting reflects that + await pushPrefs(["xpinstall.enabled", true]); + is(await ASRouterTargeting.Environment.xpinstallEnabled, true); +}); + +add_task(async function check_pinned_tabs() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async browser => { + is( + await ASRouterTargeting.Environment.hasPinnedTabs, + false, + "No pin tabs yet" + ); + + let tab = gBrowser.getTabForBrowser(browser); + gBrowser.pinTab(tab); + + is( + await ASRouterTargeting.Environment.hasPinnedTabs, + true, + "Should detect pinned tab" + ); + + gBrowser.unpinTab(tab); + } + ); +}); + +add_task(async function check_hasAccessedFxAPanel() { + is( + await ASRouterTargeting.Environment.hasAccessedFxAPanel, + false, + "Not accessed yet" + ); + + await pushPrefs(["identity.fxaccounts.toolbar.accessed", true]); + + is( + await ASRouterTargeting.Environment.hasAccessedFxAPanel, + true, + "Should detect panel access" + ); +}); + +add_task(async function checkCFRFeaturesUserPref() { + await pushPrefs([ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + false, + ]); + is( + ASRouterTargeting.Environment.userPrefs.cfrFeatures, + false, + "cfrFeature should be false according to pref" + ); + const message = { id: "foo", targeting: "userPrefs.cfrFeatures == false" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by cfrFeature" + ); +}); + +add_task(async function checkCFRAddonsUserPref() { + await pushPrefs([ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", + false, + ]); + is( + ASRouterTargeting.Environment.userPrefs.cfrAddons, + false, + "cfrFeature should be false according to pref" + ); + const message = { id: "foo", targeting: "userPrefs.cfrAddons == false" }; + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item by cfrAddons" + ); +}); + +add_task(async function check_blockedCountByType() { + const message = { + id: "foo", + targeting: + "blockedCountByType.cryptominerCount == 0 && blockedCountByType.socialCount == 0", + }; + + is( + await ASRouterTargeting.findMatchingMessage({ messages: [message] }), + message, + "should select correct item" + ); +}); + +add_task(async function checkPatternMatches() { + const now = Date.now(); + const timeMinutesAgo = numMinutes => now - numMinutes * 60 * 1000; + const messages = [ + { + id: "message_with_pattern", + targeting: "true", + trigger: { id: "frequentVisits", patterns: ["*://*.github.com/"] }, + }, + ]; + const trigger = { + id: "frequentVisits", + context: { + recentVisits: [ + { timestamp: timeMinutesAgo(33) }, + { timestamp: timeMinutesAgo(17) }, + { timestamp: timeMinutesAgo(1) }, + ], + }, + param: { host: "github.com", url: "https://gist.github.com" }, + }; + + is( + (await ASRouterTargeting.findMatchingMessage({ messages, trigger })).id, + "message_with_pattern", + "should select PIN_TAB mesage" + ); +}); + +add_task(async function checkPatternsValid() { + const messages = (await CFRMessageProvider.getMessages()).filter( + m => m.trigger?.patterns + ); + + for (const message of messages) { + Assert.ok(new MatchPatternSet(message.trigger.patterns)); + } +}); + +add_task(async function check_isChinaRepack() { + const prefDefaultBranch = Services.prefs.getDefaultBranch("distribution."); + const messages = [ + { id: "msg_for_china_repack", targeting: "isChinaRepack == true" }, + { id: "msg_for_everyone_else", targeting: "isChinaRepack == false" }, + ]; + + is( + await ASRouterTargeting.Environment.isChinaRepack, + false, + "Fx w/o partner repack info set is not China repack" + ); + is( + (await ASRouterTargeting.findMatchingMessage({ messages })).id, + "msg_for_everyone_else", + "should select the message for non China repack users" + ); + + prefDefaultBranch.setCharPref("id", "MozillaOnline"); + + is( + await ASRouterTargeting.Environment.isChinaRepack, + true, + "Fx with `distribution.id` set to `MozillaOnline` is China repack" + ); + is( + (await ASRouterTargeting.findMatchingMessage({ messages })).id, + "msg_for_china_repack", + "should select the message for China repack users" + ); + + prefDefaultBranch.setCharPref("id", "Example"); + + is( + await ASRouterTargeting.Environment.isChinaRepack, + false, + "Fx with `distribution.id` set to other string is not China repack" + ); + is( + (await ASRouterTargeting.findMatchingMessage({ messages })).id, + "msg_for_everyone_else", + "should select the message for non China repack users" + ); + + prefDefaultBranch.deleteBranch(""); +}); + +add_task(async function check_userId() { + await SpecialPowers.pushPrefEnv({ + set: [["app.normandy.user_id", "foo123"]], + }); + is( + await ASRouterTargeting.Environment.userId, + "foo123", + "should read userID from normandy user id pref" + ); +}); + +add_task(async function check_profileRestartCount() { + ok( + !isNaN(ASRouterTargeting.Environment.profileRestartCount), + "it should return a number" + ); +}); + +add_task(async function check_homePageSettings_default() { + let settings = ASRouterTargeting.Environment.homePageSettings; + + ok(settings.isDefault, "should set as default"); + ok(!settings.isLocked, "should not set as locked"); + ok(!settings.isWebExt, "should not be web extension"); + ok(!settings.isCustomUrl, "should not be custom URL"); + is(settings.urls.length, 1, "should be an 1-entry array"); + is(settings.urls[0].url, "about:home", "should be about:home"); + is(settings.urls[0].host, "", "should be an empty string"); +}); + +add_task(async function check_homePageSettings_locked() { + const PREF = "browser.startup.homepage"; + Services.prefs.lockPref(PREF); + let settings = ASRouterTargeting.Environment.homePageSettings; + + ok(settings.isDefault, "should set as default"); + ok(settings.isLocked, "should set as locked"); + ok(!settings.isWebExt, "should not be web extension"); + ok(!settings.isCustomUrl, "should not be custom URL"); + is(settings.urls.length, 1, "should be an 1-entry array"); + is(settings.urls[0].url, "about:home", "should be about:home"); + is(settings.urls[0].host, "", "should be an empty string"); + Services.prefs.unlockPref(PREF); +}); + +add_task(async function check_homePageSettings_customURL() { + await HomePage.set("https://www.google.com"); + let settings = ASRouterTargeting.Environment.homePageSettings; + + ok(!settings.isDefault, "should not be the default"); + ok(!settings.isLocked, "should set as locked"); + ok(!settings.isWebExt, "should not be web extension"); + ok(settings.isCustomUrl, "should be custom URL"); + is(settings.urls.length, 1, "should be an 1-entry array"); + is(settings.urls[0].url, "https://www.google.com", "should be a custom URL"); + is( + settings.urls[0].host, + "google.com", + "should be the host name without 'www.'" + ); + + HomePage.reset(); +}); + +add_task(async function check_homePageSettings_customURL_multiple() { + await HomePage.set("https://www.google.com|https://www.youtube.com"); + let settings = ASRouterTargeting.Environment.homePageSettings; + + ok(!settings.isDefault, "should not be the default"); + ok(!settings.isLocked, "should not set as locked"); + ok(!settings.isWebExt, "should not be web extension"); + ok(settings.isCustomUrl, "should be custom URL"); + is(settings.urls.length, 2, "should be a 2-entry array"); + is(settings.urls[0].url, "https://www.google.com", "should be a custom URL"); + is( + settings.urls[0].host, + "google.com", + "should be the host name without 'www.'" + ); + is(settings.urls[1].url, "https://www.youtube.com", "should be a custom URL"); + is( + settings.urls[1].host, + "youtube.com", + "should be the host name without 'www.'" + ); + + HomePage.reset(); +}); + +add_task(async function check_homePageSettings_webExtension() { + const extURI = + "moz-extension://0d735548-ba3c-aa43-a0e4-7089584fbb53/homepage.html"; + await HomePage.set(extURI); + let settings = ASRouterTargeting.Environment.homePageSettings; + + ok(!settings.isDefault, "should not be the default"); + ok(!settings.isLocked, "should not set as locked"); + ok(settings.isWebExt, "should be a web extension"); + ok(!settings.isCustomUrl, "should be custom URL"); + is(settings.urls.length, 1, "should be an 1-entry array"); + is(settings.urls[0].url, extURI, "should be a webExtension URI"); + is(settings.urls[0].host, "", "should be an empty string"); + + HomePage.reset(); +}); + +add_task(async function check_newtabSettings_default() { + let settings = ASRouterTargeting.Environment.newtabSettings; + + ok(settings.isDefault, "should set as default"); + ok(!settings.isWebExt, "should not be web extension"); + ok(!settings.isCustomUrl, "should not be custom URL"); + is(settings.url, "about:newtab", "should be about:home"); + is(settings.host, "", "should be an empty string"); +}); + +add_task(async function check_newTabSettings_customURL() { + AboutNewTab.newTabURL = "https://www.google.com"; + let settings = ASRouterTargeting.Environment.newtabSettings; + + ok(!settings.isDefault, "should not be the default"); + ok(!settings.isWebExt, "should not be web extension"); + ok(settings.isCustomUrl, "should be custom URL"); + is(settings.url, "https://www.google.com", "should be a custom URL"); + is(settings.host, "google.com", "should be the host name without 'www.'"); + + AboutNewTab.resetNewTabURL(); +}); + +add_task(async function check_newTabSettings_webExtension() { + const extURI = + "moz-extension://0d735548-ba3c-aa43-a0e4-7089584fbb53/homepage.html"; + AboutNewTab.newTabURL = extURI; + let settings = ASRouterTargeting.Environment.newtabSettings; + + ok(!settings.isDefault, "should not be the default"); + ok(settings.isWebExt, "should not be web extension"); + ok(!settings.isCustomUrl, "should be custom URL"); + is(settings.url, extURI, "should be the web extension URI"); + is(settings.host, "", "should be an empty string"); + + AboutNewTab.resetNewTabURL(); +}); + +add_task(async function check_openUrlTrigger_context() { + const message = { + ...(await CFRMessageProvider.getMessages()).find( + m => m.id === "YOUTUBE_ENHANCE_3" + ), + targeting: "visitsCount == 3", + }; + const trigger = { + id: "openURL", + context: { visitsCount: 3 }, + param: { host: "youtube.com", url: "https://www.youtube.com" }, + }; + + is( + ( + await ASRouterTargeting.findMatchingMessage({ + messages: [message], + trigger, + }) + ).id, + message.id, + `should select ${message.id} mesage` + ); +}); + +add_task(async function check_is_major_upgrade() { + let message = { + id: "check_is_major_upgrade", + targeting: `isMajorUpgrade != undefined && isMajorUpgrade == ${ + Cc["@mozilla.org/browser/clh;1"].getService(Ci.nsIBrowserHandler) + .majorUpgrade + }`, + }; + + is( + (await ASRouterTargeting.findMatchingMessage({ messages: [message] })).id, + message.id, + "Should select the message" + ); +}); + +add_task(async function check_userMonthlyActivity() { + ok( + Array.isArray(await ASRouterTargeting.Environment.userMonthlyActivity), + "value is an array" + ); +}); + +add_task(async function check_doesAppNeedPin() { + is( + typeof (await ASRouterTargeting.Environment.doesAppNeedPin), + "boolean", + "Should return a boolean" + ); +}); + +add_task(async function check_doesAppNeedPrivatePin() { + is( + typeof (await ASRouterTargeting.Environment.doesAppNeedPrivatePin), + "boolean", + "Should return a boolean" + ); +}); + +add_task(async function check_isBackgroundTaskMode() { + if (!AppConstants.MOZ_BACKGROUNDTASKS) { + // `mochitest-browser` suite `add_task` does not yet support + // `properties.skip_if`. + ok(true, "Skipping because !AppConstants.MOZ_BACKGROUNDTASKS"); + return; + } + + const bts = Cc["@mozilla.org/backgroundtasks;1"].getService( + Ci.nsIBackgroundTasks + ); + + // Pretend that this is a background task. + bts.overrideBackgroundTaskNameForTesting("taskName"); + is( + await ASRouterTargeting.Environment.isBackgroundTaskMode, + true, + "Is in background task mode" + ); + is( + await ASRouterTargeting.Environment.backgroundTaskName, + "taskName", + "Has expected background task name" + ); + + // Unset, so that subsequent test functions don't see background task mode. + bts.overrideBackgroundTaskNameForTesting(null); + is( + await ASRouterTargeting.Environment.isBackgroundTaskMode, + false, + "Is not in background task mode" + ); + is( + await ASRouterTargeting.Environment.backgroundTaskName, + null, + "Has no background task name" + ); +}); + +add_task(async function check_userPrefersReducedMotion() { + is( + typeof (await ASRouterTargeting.Environment.userPrefersReducedMotion), + "boolean", + "Should return a boolean" + ); +}); + +add_task(async function test_mr2022Holdback() { + await ExperimentAPI.ready(); + + ok( + !ASRouterTargeting.Environment.inMr2022Holdback, + "Should not be in holdback (no experiment)" + ); + + { + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "majorRelease2022", + value: { + onboarding: true, + }, + }); + + ok( + !ASRouterTargeting.Environment.inMr2022Holdback, + "Should not be in holdback (onboarding = true)" + ); + + await doExperimentCleanup(); + } + + { + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "majorRelease2022", + value: { + onboarding: false, + }, + }); + + ok( + ASRouterTargeting.Environment.inMr2022Holdback, + "Should be in holdback (onboarding = false)" + ); + + await doExperimentCleanup(); + } +}); + +add_task(async function test_distributionId() { + is( + ASRouterTargeting.Environment.distributionId, + "", + "Should return an empty distribution Id" + ); + + Services.prefs.getDefaultBranch(null).setCharPref("distribution.id", "test"); + + is( + ASRouterTargeting.Environment.distributionId, + "test", + "Should return the correct distribution Id" + ); +}); + +add_task(async function test_fxViewButtonAreaType_default() { + is( + typeof (await ASRouterTargeting.Environment.fxViewButtonAreaType), + "string", + "Should return a string" + ); + + is( + await ASRouterTargeting.Environment.fxViewButtonAreaType, + "toolbar", + "Should return name of container if button hasn't been removed" + ); +}); + +add_task(async function test_fxViewButtonAreaType_removed() { + CustomizableUI.removeWidgetFromArea("firefox-view-button"); + + is( + await ASRouterTargeting.Environment.fxViewButtonAreaType, + null, + "Should return null if button has been removed" + ); + CustomizableUI.reset(); +}); + +add_task(async function test_creditCardsSaved() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.formautofill.creditCards.supported", "on"], + ["extensions.formautofill.creditCards.enabled", true], + ], + }); + + is( + await ASRouterTargeting.Environment.creditCardsSaved, + 0, + "Should return 0 when no credit cards are saved" + ); + + let creditcard = { + "cc-name": "Test User", + "cc-number": "5038146897157463", + "cc-exp-month": "11", + "cc-exp-year": "20", + }; + + // Intermittently fails on macOS, likely related to Bug 1714221. So, mock the + // autofill actor. + if (AppConstants.platform === "macosx") { + const sandbox = sinon.createSandbox(); + registerCleanupFunction(async () => sandbox.restore()); + let stub = sandbox + .stub( + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ), + "receiveMessage" + ) + .withArgs( + sandbox.match({ + name: "FormAutofill:GetRecords", + data: { collectionName: "creditCards" }, + }) + ) + .resolves({ records: [creditcard] }) + .callThrough(); + + is( + await ASRouterTargeting.Environment.creditCardsSaved, + 1, + "Should return 1 when 1 credit card is saved" + ); + ok( + stub.calledWithMatch({ name: "FormAutofill:GetRecords" }), + "Targeting called FormAutofill:GetRecords" + ); + + sandbox.restore(); + } else { + let observePromise = TestUtils.topicObserved( + "formautofill-storage-changed" + ); + await sendFormAutofillMessage("FormAutofill:SaveCreditCard", { + creditcard, + }); + await observePromise; + + is( + await ASRouterTargeting.Environment.creditCardsSaved, + 1, + "Should return 1 when 1 credit card is saved" + ); + await removeAutofillRecords(); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_addressesSaved() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.formautofill.addresses.supported", "on"], + ["extensions.formautofill.addresses.enabled", true], + ], + }); + + is( + await ASRouterTargeting.Environment.addressesSaved, + 0, + "Should return 0 when no addresses are saved" + ); + + let observePromise = TestUtils.topicObserved("formautofill-storage-changed"); + await sendFormAutofillMessage("FormAutofill:SaveAddress", { + address: { + "given-name": "John", + "additional-name": "R.", + "family-name": "Smith", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", + }, + }); + await observePromise; + + is( + await ASRouterTargeting.Environment.addressesSaved, + 1, + "Should return 1 when 1 address is saved" + ); + + await removeAutofillRecords(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_migrationInteractions() { + const PREF_GETTER_MAPPING = new Map([ + ["browser.migrate.interactions.bookmarks", "hasMigratedBookmarks"], + ["browser.migrate.interactions.csvpasswords", "hasMigratedCSVPasswords"], + ["browser.migrate.interactions.history", "hasMigratedHistory"], + ["browser.migrate.interactions.passwords", "hasMigratedPasswords"], + ]); + + for (let [pref, getterName] of PREF_GETTER_MAPPING) { + await pushPrefs([pref, false]); + ok(!(await ASRouterTargeting.Environment[getterName])); + await pushPrefs([pref, true]); + ok(await ASRouterTargeting.Environment[getterName]); + } +}); + +add_task(async function check_useEmbeddedMigrationWizard() { + await pushPrefs([ + "browser.migrate.content-modal.about-welcome-behavior", + "default", + ]); + + ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard)); + + await pushPrefs([ + "browser.migrate.content-modal.about-welcome-behavior", + "autoclose", + ]); + + ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard)); + + await pushPrefs([ + "browser.migrate.content-modal.about-welcome-behavior", + "embedded", + ]); + + ok(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard); + + await pushPrefs([ + "browser.migrate.content-modal.about-welcome-behavior", + "standalone", + ]); + + ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard)); +}); + +add_task(async function check_isRTAMO() { + is( + typeof ASRouterTargeting.Environment.isRTAMO, + "boolean", + "Should return a boolean" + ); + + const TEST_CASES = [ + { + title: "no attribution data", + attributionData: {}, + expected: false, + }, + { + title: "null attribution data", + attributionData: null, + expected: false, + }, + { + title: "no content", + attributionData: { + source: "addons.mozilla.org", + }, + expected: false, + }, + { + title: "empty content", + attributionData: { + source: "addons.mozilla.org", + content: "", + }, + expected: false, + }, + { + title: "null content", + attributionData: { + source: "addons.mozilla.org", + content: null, + }, + expected: false, + }, + { + title: "empty source", + attributionData: { + source: "", + }, + expected: false, + }, + { + title: "null source", + attributionData: { + source: null, + }, + expected: false, + }, + { + title: "valid attribution data for RTAMO with content not encoded", + attributionData: { + source: "addons.mozilla.org", + content: "rta:<encoded-addon-id>", + }, + expected: true, + }, + { + title: "valid attribution data for RTAMO with content encoded once", + attributionData: { + source: "addons.mozilla.org", + content: "rta%3A<encoded-addon-id>", + }, + expected: true, + }, + { + title: "valid attribution data for RTAMO with content encoded twice", + attributionData: { + source: "addons.mozilla.org", + content: "rta%253A<encoded-addon-id>", + }, + expected: true, + }, + { + title: "invalid source", + attributionData: { + source: "www.mozilla.org", + content: "rta%3A<encoded-addon-id>", + }, + expected: false, + }, + ]; + + const sandbox = sinon.createSandbox(); + registerCleanupFunction(async () => { + sandbox.restore(); + }); + + const stub = sandbox.stub(AttributionCode, "getCachedAttributionData"); + + for (const { title, attributionData, expected } of TEST_CASES) { + stub.returns(attributionData); + + is( + ASRouterTargeting.Environment.isRTAMO, + expected, + `${title} - Expected isRTAMO to have the expected value` + ); + } + + sandbox.restore(); +}); + +add_task(async function check_isDeviceMigration() { + is( + typeof ASRouterTargeting.Environment.isDeviceMigration, + "boolean", + "Should return a boolean" + ); + + const TEST_CASES = [ + { + title: "no attribution data", + attributionData: {}, + expected: false, + }, + { + title: "null attribution data", + attributionData: null, + expected: false, + }, + { + title: "no campaign", + attributionData: { + source: "support.mozilla.org", + }, + expected: false, + }, + { + title: "empty campaign", + attributionData: { + source: "support.mozilla.org", + campaign: "", + }, + expected: false, + }, + { + title: "null campaign", + attributionData: { + source: "addons.mozilla.org", + campaign: null, + }, + expected: false, + }, + { + title: "empty source", + attributionData: { + source: "", + }, + expected: false, + }, + { + title: "null source", + attributionData: { + source: null, + }, + expected: false, + }, + { + title: "other source", + attributionData: { + source: "www.mozilla.org", + campaign: "migration", + }, + expected: true, + }, + { + title: "valid attribution data for isDeviceMigration", + attributionData: { + source: "support.mozilla.org", + campaign: "migration", + }, + expected: true, + }, + ]; + + const sandbox = sinon.createSandbox(); + registerCleanupFunction(async () => { + sandbox.restore(); + }); + + const stub = sandbox.stub(AttributionCode, "getCachedAttributionData"); + + for (const { title, attributionData, expected } of TEST_CASES) { + stub.returns(attributionData); + + is( + ASRouterTargeting.Environment.isDeviceMigration, + expected, + `${title} - Expected isDeviceMigration to have the expected value` + ); + } + + sandbox.restore(); +}); + +add_task(async function check_primaryResolution() { + is( + typeof ASRouterTargeting.Environment.primaryResolution, + "object", + "Should return an object" + ); + + is( + typeof ASRouterTargeting.Environment.primaryResolution.width, + "number", + "Width property should return a number" + ); + + is( + typeof ASRouterTargeting.Environment.primaryResolution.height, + "number", + "Height property should return a number" + ); +}); + +add_task(async function check_archBits() { + const bits = ASRouterTargeting.Environment.archBits; + is(typeof bits, "number", "archBits should be a number"); + ok(bits === 32 || bits === 64, "archBits is either 32 or 64"); +}); + +add_task(async function check_memoryMB() { + const memory = ASRouterTargeting.Environment.memoryMB; + is(typeof memory, "number", "Memory is a number"); + // To make sure we get a sensible number we verify that whatever system + // runs this unit test it has between 500MB and 1TB of RAM. + ok(memory > 500 && memory < 5_000_000); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js b/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js new file mode 100644 index 0000000000..2c1adb477b --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js @@ -0,0 +1,139 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// At the time of writing, toast notifications (including XUL notifications) +// don't support action buttons, so there's little to be tested here beyond +// display. + +"use strict"; + +const { ToastNotification } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ToastNotification.sys.mjs" +); +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); + +function getMessage(id) { + return PanelTestProvider.getMessages().then(msgs => + msgs.find(m => m.id === id) + ); +} + +// Ensure we don't fall back to a real implementation. +const showAlertStub = sinon.stub(); +const AlertsServiceStub = sinon.stub(ToastNotification, "AlertsService").value({ + showAlert: showAlertStub, +}); + +registerCleanupFunction(() => { + AlertsServiceStub.restore(); +}); + +// Test that toast notifications do, in fact, invoke the AlertsService. These +// tests don't *need* to be `browser` tests, but we may eventually be able to +// interact with the XUL notification elements, which would require `browser` +// tests, so we follow suit with the equivalent `Spotlight`, etc, tests and use +// the `browser` framework. +add_task(async function test_showAlert() { + const l10n = new Localization([ + "branding/brand.ftl", + "browser/newtab/asrouter.ftl", + ]); + let expectedTitle = await l10n.formatValue( + "cfr-doorhanger-bookmark-fxa-header" + ); + + showAlertStub.reset(); + + let dispatchStub = sinon.stub(); + + let message = await getMessage("TEST_TOAST_NOTIFICATION1"); + await ToastNotification.showToastNotification(message, dispatchStub); + + // Test display. + Assert.equal( + showAlertStub.callCount, + 1, + "AlertsService.showAlert is invoked" + ); + + let [alert] = showAlertStub.firstCall.args; + Assert.equal(alert.title, expectedTitle, "Should match"); + Assert.equal(alert.text, "Body", "Should match"); + Assert.equal(alert.name, "test_toast_notification", "Should match"); +}); + +// Test that the `title` of each `action` of a toast notification is localized. +add_task(async function test_actionLocalization() { + const l10n = new Localization([ + "branding/brand.ftl", + "browser/newtab/asrouter.ftl", + ]); + let expectedTitle = await l10n.formatValue( + "mr2022-background-update-toast-title" + ); + let expectedText = await l10n.formatValue( + "mr2022-background-update-toast-text" + ); + let expectedPrimary = await l10n.formatValue( + "mr2022-background-update-toast-primary-button-label" + ); + let expectedSecondary = await l10n.formatValue( + "mr2022-background-update-toast-secondary-button-label" + ); + + showAlertStub.reset(); + + let dispatchStub = sinon.stub(); + + let message = await getMessage("MR2022_BACKGROUND_UPDATE_TOAST_NOTIFICATION"); + await ToastNotification.showToastNotification(message, dispatchStub); + + // Test display. + Assert.equal( + showAlertStub.callCount, + 1, + "AlertsService.showAlert is invoked" + ); + + let [alert] = showAlertStub.firstCall.args; + Assert.equal(alert.title, expectedTitle, "Should match title"); + Assert.equal(alert.text, expectedText, "Should match text"); + Assert.equal(alert.name, "mr2022_background_update", "Should match"); + Assert.equal(alert.actions[0].title, expectedPrimary, "Should match primary"); + Assert.equal( + alert.actions[1].title, + expectedSecondary, + "Should match secondary" + ); +}); + +// Test that toast notifications report sensible telemetry. +add_task(async function test_telemetry() { + let dispatchStub = sinon.stub(); + + let message = await getMessage("TEST_TOAST_NOTIFICATION1"); + await ToastNotification.showToastNotification(message, dispatchStub); + + Assert.equal( + dispatchStub.callCount, + 2, + "1 IMPRESSION and 1 TOAST_NOTIFICATION_TELEMETRY" + ); + Assert.equal( + dispatchStub.firstCall.args[0].type, + "TOAST_NOTIFICATION_TELEMETRY", + "Should match" + ); + Assert.equal( + dispatchStub.firstCall.args[0].data.event, + "IMPRESSION", + "Should match" + ); + Assert.equal( + dispatchStub.secondCall.args[0].type, + "IMPRESSION", + "Should match" + ); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js b/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js new file mode 100644 index 0000000000..90e0d5ceeb --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js @@ -0,0 +1,149 @@ +const { OnboardingMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs" +); +const { ToolbarBadgeHub } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs" +); + +add_task(async function test_setup() { + // Cleanup pref value because we click the fxa accounts button. + // This is not required during tests because we "force show" the message + // by sending it directly to the Hub bypassing targeting. + registerCleanupFunction(() => { + // Clicking on the Firefox Accounts button while in the signed out + // state opens a new tab for signing in. + // We'll clean those up here for now. + gBrowser.removeAllTabsBut(gBrowser.tabs[0]); + // Stop the load in the last tab that remains. + gBrowser.stop(); + Services.prefs.clearUserPref("identity.fxaccounts.toolbar.accessed"); + }); +}); + +add_task(async function test_fxa_badge_shown_nodelay() { + const [msg] = (await OnboardingMessageProvider.getMessages()).filter( + ({ id }) => id === "FXA_ACCOUNTS_BADGE" + ); + + Assert.ok(msg, "FxA test message exists"); + + // Ensure we badge immediately + msg.content.delay = undefined; + + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + // Click the button and clear the badge that occurs normally at startup + let fxaButton = browserWindow.document.getElementById(msg.content.target); + fxaButton.click(); + + await BrowserTestUtils.waitForCondition( + () => + !browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Initially element is not badged" + ); + + ToolbarBadgeHub.registerBadgeNotificationListener(msg); + + await BrowserTestUtils.waitForCondition( + () => + browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Wait for element to be badged" + ); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + + await BrowserTestUtils.waitForCondition( + () => + browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Wait for element to be badged" + ); + + await BrowserTestUtils.closeWindow(newWin); + browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + + // Click the button and clear the badge + fxaButton = document.getElementById(msg.content.target); + fxaButton.click(); + + await BrowserTestUtils.waitForCondition( + () => + !browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Button should no longer be badged" + ); +}); + +add_task(async function test_fxa_badge_shown_withdelay() { + const [msg] = (await OnboardingMessageProvider.getMessages()).filter( + ({ id }) => id === "FXA_ACCOUNTS_BADGE" + ); + + Assert.ok(msg, "FxA test message exists"); + + // Enough to trigger the setTimeout badging + msg.content.delay = 1; + + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + // Click the button and clear the badge that occurs normally at startup + let fxaButton = browserWindow.document.getElementById(msg.content.target); + fxaButton.click(); + + await BrowserTestUtils.waitForCondition( + () => + !browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Initially element is not badged" + ); + + ToolbarBadgeHub.registerBadgeNotificationListener(msg); + + await BrowserTestUtils.waitForCondition( + () => + browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Wait for element to be badged" + ); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + + await BrowserTestUtils.waitForCondition( + () => + browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Wait for element to be badged" + ); + + await BrowserTestUtils.closeWindow(newWin); + browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + + // Click the button and clear the badge + fxaButton = document.getElementById(msg.content.target); + fxaButton.click(); + + await BrowserTestUtils.waitForCondition( + () => + !browserWindow.document + .getElementById(msg.content.target) + .querySelector(".toolbarbutton-badge") + .classList.contains("feature-callout"), + "Button should no longer be badged" + ); +}); diff --git a/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js new file mode 100644 index 0000000000..b7169d6be7 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js @@ -0,0 +1,1122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +const { DefaultBrowserCheck } = ChromeUtils.importESModule( + "resource:///modules/BrowserGlue.sys.mjs" +); + +const PDF_TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/file_pdf.PDF"; + +async function openURLInWindow(window, url) { + const { selectedBrowser } = window.gBrowser; + BrowserTestUtils.startLoadingURIString(selectedBrowser, url); + await BrowserTestUtils.browserLoaded(selectedBrowser, false, url); + return selectedBrowser; +} + +async function openURLInNewTab(window, ...args) { + return BrowserTestUtils.openNewForegroundTab(window.gBrowser, ...args); +} + +const pdfMatch = sinon.match(val => { + return ( + val?.id === "pdfJsFeatureCalloutCheck" && val?.context?.source === "open" + ); +}); + +const validateCalloutCustomPosition = (element, absolutePosition, doc) => { + const browserBox = doc.querySelector("hbox#browser"); + for (let position in absolutePosition) { + if (Object.prototype.hasOwnProperty.call(absolutePosition, position)) { + // remove the `px` at the end of our absolute position strings + const relativePos = parseFloat(absolutePosition[position]); + const elPos = element.getBoundingClientRect()[position]; + const browserPos = browserBox.getBoundingClientRect()[position]; + + if (position in ["top", "left"]) { + if (elPos !== browserPos + relativePos) { + return false; + } + } else if (position in ["right", "bottom"]) { + if (elPos !== browserPos - relativePos) { + return false; + } + } + } + } + return true; +}; + +const validateCalloutRTLPosition = (element, absolutePosition) => { + for (let position in absolutePosition) { + if (Object.prototype.hasOwnProperty.call(absolutePosition, position)) { + const pixels = parseFloat(absolutePosition[position]); + if (position === "left") { + if (element.getBoundingClientRect().right !== pixels) { + return false; + } + } else if (position === "right") { + if (element.getBoundingClientRect().left !== pixels) { + return false; + } + } + } + } + return true; +}; + +const testMessage = { + message: { + id: "TEST_MESSAGE", + template: "feature_callout", + content: { + id: "TEST_MESSAGE", + template: "multistage", + backdrop: "transparent", + transitions: false, + screens: [ + { + id: "TEST_MESSAGE_1", + anchors: [ + { selector: "#PanelUI-menu-button", arrow_position: "top-end" }, + ], + content: { + position: "callout", + title: { + raw: "Test title", + }, + subtitle: { + raw: "Test subtitle", + }, + primary_button: { + label: { + raw: "Done", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + priority: 1, + targeting: "true", + trigger: { id: "pdfJsFeatureCalloutCheck" }, + }, +}; + +const newtabTestMessage = { + id: "TEST_MESSAGE", + template: "feature_callout", + content: { + id: "TEST_MESSAGE", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: "browser.newtab.feature-tour", + tour_pref_default_value: JSON.stringify({ + screen: "TEST_MESSAGE_1", + complete: false, + }), + screens: [ + { + id: "TEST_MESSAGE_1", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "45px", right: "55px" }, + }, + ], + content: { + position: "callout", + title: "Test callout title", + subtitle: "Test callout subtitle", + primary_button: { + label: "Test callout button", + }, + }, + }, + ], + }, + priority: 1, + targeting: "true", + trigger: { id: "newtabFeatureCalloutCheck" }, +}; + +const testMessageScreenId = testMessage.message.content.screens[0].id; +const newtabTestMessageScreenId = newtabTestMessage.content.screens[0].id; + +const inChaosMode = !!parseInt(Services.env.get("MOZ_CHAOSMODE"), 16); + +add_setup(async function () { + let timeoutFactor = 3; + // Runtime increases in chaos mode on Mac. + if (inChaosMode && AppConstants.platform === "macosx") { + timeoutFactor = 5; + } + requestLongerTimeout(timeoutFactor); +}); + +// Test that a feature callout message can be loaded into ASRouter and displayed +// via a standard trigger. Also test that the callout can be a feature tour, +// even if its tour pref doesn't exist in Firefox. The tour pref will be created +// and cleaned up automatically. This allows a feature callout to be implemented +// entirely off-train in an experiment, without landing anything in tree. +add_task(async function triggered_feature_tour_with_custom_pref() { + let sandbox = sinon.createSandbox(); + const TEST_MESSAGES = [ + { + id: "TEST_FEATURE_TOUR", + template: "feature_callout", + content: { + id: "TEST_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: "messaging-system-action.browser.test.feature-tour", + tour_pref_default_value: JSON.stringify({ + screen: "FEATURE_CALLOUT_1", + complete: false, + }), + screens: [ + { + id: "FEATURE_CALLOUT_1", + anchors: [ + { + selector: "#PanelUI-menu-button", + arrow_position: "top-center-arrow-end", + }, + ], + content: { + position: "callout", + title: { string_id: "callout-pdfjs-edit-title" }, + subtitle: { string_id: "callout-pdfjs-edit-body-b" }, + primary_button: { + label: { string_id: "callout-pdfjs-edit-button" }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "BLOCK_MESSAGE", + data: { id: "TEST_FEATURE_TOUR" }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + }, + }, + }, + ], + }, + }, + }, + }, + }, + { + id: "FEATURE_CALLOUT_2", + anchors: [ + { + selector: "#back-button", + arrow_position: "top-center-arrow-start", + }, + ], + content: { + position: "callout", + title: { string_id: "callout-pdfjs-draw-title" }, + subtitle: { string_id: "callout-pdfjs-draw-body-b" }, + primary_button: { + label: { string_id: "callout-pdfjs-draw-button" }, + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "BLOCK_MESSAGE", + data: { id: "TEST_FEATURE_TOUR" }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + }, + }, + }, + ], + }, + }, + }, + dismiss_button: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "BLOCK_MESSAGE", + data: { id: "TEST_FEATURE_TOUR" }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + }, + }, + }, + ], + }, + }, + }, + }, + }, + ], + }, + priority: 2, + targeting: `(('messaging-system-action.browser.test.feature-tour' | preferenceValue) ? (('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')) ? ('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')[1] != "true") : true) : true)`, + trigger: { id: "nthTabClosed" }, + }, + { + id: "TEST_FEATURE_TOUR_2", + template: "feature_callout", + content: { + id: "TEST_FEATURE_TOUR_2", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FEATURE_CALLOUT_TEST", + anchors: [ + { + selector: "#PanelUI-menu-button", + arrow_position: "top-center-arrow-end", + }, + ], + content: { + position: "callout", + title: { string_id: "callout-pdfjs-edit-title" }, + subtitle: { string_id: "callout-pdfjs-edit-body-b" }, + primary_button: { + label: { string_id: "callout-pdfjs-edit-button" }, + action: { dismiss: true }, + }, + }, + }, + ], + }, + priority: 1, + targeting: "true", + trigger: { id: "nthTabClosed" }, + }, + ]; + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + // Test that callout is triggered and shown in browser chrome + const win1 = await BrowserTestUtils.openNewBrowserWindow(); + win1.focus(); + const tab1 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser); + await TestUtils.waitForTick(); + win1.gBrowser.removeTab(tab1); + await waitForCalloutScreen( + win1.document, + TEST_MESSAGES[0].content.screens[0].id + ); + ok( + win1.document.querySelector(calloutSelector), + "Feature Callout is rendered in the browser chrome when a message is available" + ); + + // Test that a callout does NOT appear if another is already shown in any window. + const showFeatureCalloutSpy = sandbox.spy( + FeatureCalloutBroker, + "showFeatureCallout" + ); + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + win2.focus(); + const tab2 = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser); + await TestUtils.waitForTick(); + win2.gBrowser.removeTab(tab2); + await BrowserTestUtils.waitForCondition(async () => { + const rvs = await Promise.all(showFeatureCalloutSpy.returnValues); + return ( + showFeatureCalloutSpy.calledWith( + win2.gBrowser.selectedBrowser, + sinon.match(TEST_MESSAGES[0]) + ) && rvs.every(rv => !rv) + ); + }, "Waiting for showFeatureCallout to be called"); + ok( + !win2.document.querySelector(calloutSelector), + "Feature Callout is not rendered when a callout is already shown in any window" + ); + await BrowserTestUtils.closeWindow(win2); + win1.focus(); + await BrowserTestUtils.waitForCondition( + async () => Services.focus.activeWindow === win1, + "Waiting for window 1 to be active" + ); + + // Test that the tour pref doesn't exist yet + ok( + !Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name), + "Tour pref does not exist yet" + ); + + // Test that the callout advances screen and sets the tour pref + win1.document.querySelector(calloutCTASelector).click(); + await BrowserTestUtils.waitForCondition( + () => + Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name), + "Waiting for tour pref to be set" + ); + SimpleTest.isDeeply( + JSON.parse( + Services.prefs.getStringPref( + TEST_MESSAGES[0].content.tour_pref_name, + "{}" + ) + ), + { screen: "FEATURE_CALLOUT_2", complete: false }, + "Tour pref is set correctly" + ); + await waitForCalloutScreen( + win1.document, + TEST_MESSAGES[0].content.screens[1].id + ); + ok( + win1.document.querySelector(calloutSelector), + "Feature Callout screen 2 is rendered" + ); + + // Test that the callout is dismissed and cleans up the tour pref + win1.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win1.document); + ok( + !win1.document.querySelector(calloutSelector), + "Feature Callout is not rendered after being dismissed" + ); + ok( + !Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name), + "Tour pref is cleaned up correctly" + ); + await BrowserTestUtils.waitForCondition( + () => !FeatureCalloutBroker.isCalloutShowing, + "Waiting for all callouts to empty from the callout broker" + ); + + // Test that the message was blocked so a different callout is shown + const tab3 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser); + await TestUtils.waitForTick(); + win1.gBrowser.removeTab(tab3); + await waitForCalloutScreen( + win1.document, + TEST_MESSAGES[1].content.screens[0].id + ); + ok( + win1.document.querySelector(calloutSelector), + "A different Feature Callout is rendered" + ); + win1.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win1.document); + ok(!FeatureCalloutBroker.isCalloutShowing, "No Feature Callout is shown"); + + BrowserTestUtils.closeWindow(win1); + + sandbox.restore(); + await ASRouter.unblockMessageById(TEST_MESSAGES[0].id); + await ASRouter.resetMessageState(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); +}); + +add_task(async function callout_not_shown_if_dialog_open() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + let dialogPromise = BrowserTestUtils.promiseAlertDialog(null, undefined, { + callback: async dialogWin => { + let rv = await FeatureCalloutBroker.showFeatureCallout( + win.gBrowser.selectedBrowser, + testMessage.message + ); + ok( + !rv, + "Feature callout not shown when a dialog is open in the same window" + ); + dialogWin.document.querySelector("dialog").getButton("cancel").click(); + }, + isSubDialog: true, + }); + DefaultBrowserCheck.prompt(win); + await dialogPromise; + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function callout_not_shown_if_panel_open() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const gCUITestUtils = new CustomizableUITestUtils(win); + await gCUITestUtils.openMainMenu(); + + let rv = await FeatureCalloutBroker.showFeatureCallout( + win.gBrowser.selectedBrowser, + testMessage.message + ); + ok(!rv, "Feature callout not shown when a panel is open in the same window"); + + await gCUITestUtils.hideMainMenu(); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function feature_callout_renders_in_browser_chrome_for_pdf() { + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(pdfMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win, PDF_TEST_URL); + const doc = win.document; + await waitForCalloutScreen(doc, testMessageScreenId); + const container = doc.querySelector(calloutSelector); + ok( + container, + "Feature Callout is rendered in the browser chrome with a new window when a message is available" + ); + + // click CTA to close + doc.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(doc); + ok( + true, + "Feature callout removed from browser chrome after clicking button configured to navigate" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); + +add_task( + async function feature_callout_renders_and_hides_in_chrome_when_switching_tabs() { + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(pdfMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + PDF_TEST_URL + ); + tab1.focus(); + await waitForCalloutScreen(doc, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout rendered when opening a new tab with PDF url" + ); + + const tab2 = await openURLInNewTab(win, "about:preferences"); + tab2.focus(); + await BrowserTestUtils.waitForCondition(() => { + return !doc.body.querySelector( + "#multi-stage-message-root.featureCallout" + ); + }); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout removed when tab without PDF URL is navigated to" + ); + + const tab3 = await openURLInNewTab(win, PDF_TEST_URL); + tab3.focus(); + await waitForCalloutScreen(doc, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout still renders when opening a new tab with PDF url after being initially rendered on another tab" + ); + + tab1.focus(); + await waitForCalloutScreen(doc, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout rendered on original tab after switching tabs multiple times" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_disappears_when_navigating_to_non_pdf_url_in_same_tab() { + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(pdfMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + PDF_TEST_URL + ); + tab1.focus(); + await waitForCalloutScreen(doc, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout rendered when opening a new tab with PDF url" + ); + + BrowserTestUtils.startLoadingURIString(win.gBrowser, "about:preferences"); + await BrowserTestUtils.waitForLocationChange( + win.gBrowser, + "about:preferences" + ); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout not rendered on original tab after navigating to non pdf URL" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_disappears_when_closing_foreground_pdf_tab() { + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(pdfMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + PDF_TEST_URL + ); + tab1.focus(); + await waitForCalloutScreen(doc, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout rendered when opening a new tab with PDF url" + ); + + BrowserTestUtils.removeTab(tab1); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout disappears after closing foreground tab" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + } +); + +add_task( + async function feature_callout_does_not_appear_when_opening_background_pdf_tab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + + const tab1 = await BrowserTestUtils.addTab(win.gBrowser, PDF_TEST_URL); + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout not rendered when opening a background tab with PDF url" + ); + + BrowserTestUtils.removeTab(tab1); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout still not rendered after closing background tab with PDF url" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function newtab_feature_callout_appears_in_browser_chrome_on_newtab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:newtab" + ); + tab1.focus(); + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab feature callout rendered when opening a focused newtab" + ); + + BrowserTestUtils.removeTab(tab1); + await waitForCalloutRemoved(doc); + ok( + !doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout disappears after closing new tab" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function newtab_feature_callout_does_not_appear_when_opening_background_newtab_tab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + + await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:preferences" + ); + const tab2 = await BrowserTestUtils.addTab(win.gBrowser, "about:newtab"); + ok( + !doc.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab feature callout not rendered when opening a background newtab" + ); + + BrowserTestUtils.removeTab(tab2); + await waitForCalloutRemoved(doc); + ok( + !doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout still not rendered after closing background tab" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function newtab_feature_callout_does_not_appear_in_browser_chrome_on_new_window() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win, "about:newtab"); + const doc = win.document; + + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab Feature Callout is in the browser chrome of first window when a message is available" + ); + + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win2, "about:newtab"); + const doc2 = win2.document; + ok( + !doc2.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab Feature Callout is not in the browser chrome new window when a message is available" + ); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function feature_callout_disappears_when_navigating_from_newtab_to_pdf_url_in_same_tab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:newtab" + ); + tab1.focus(); + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout rendered when opening a newtab" + ); + + BrowserTestUtils.startLoadingURIString(win.gBrowser, PDF_TEST_URL); + await BrowserTestUtils.waitForLocationChange(win.gBrowser, PDF_TEST_URL); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout not rendered on original tab after navigating to PDF" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function feature_callout_disappears_when_navigating_from_newtab_to_pdf_url_in_different_tab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:newtab" + ); + tab1.focus(); + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout rendered when opening a newtab" + ); + + const tab2 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + PDF_TEST_URL + ); + tab2.focus(); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Newtab feature callout not rendered after navigating to PDF" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function feature_callout_is_positioned_relative_to_browser_window() { + // Deep copying our test message so we can alter it without disrupting future tests + const pdfTestMessage = JSON.parse(JSON.stringify(testMessage)); + const pdfTestMessageCalloutSelector = + pdfTestMessage.message.content.screens[0].id; + + pdfTestMessage.message.content.screens[0].anchors[0] = { + selector: "hbox#browser", + absolute_position: { top: "45px", right: "25px" }, + }; + + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [pdfTestMessage.message]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win, PDF_TEST_URL); + const doc = win.document; + await waitForCalloutScreen(doc, pdfTestMessageCalloutSelector); + + // Verify that callout renders in appropriate position (without infobar element) + const callout = doc.querySelector(`.${pdfTestMessageCalloutSelector}`); + ok(callout, "Callout is rendered when navigating to PDF file"); + ok( + validateCalloutCustomPosition( + callout, + pdfTestMessage.message.content.screens[0].anchors[0].absolute_position, + doc + ), + "Callout custom position is as expected" + ); + + // Add height to the top of the browser to simulate an infobar or other element + const navigatorToolBox = doc.querySelector("#navigator-toolbox"); + navigatorToolBox.style.height = "150px"; + // We test in a new tab because the callout does not adjust itself + // when size of the navigator-toolbox-background box changes. + const tab = await openURLInNewTab(win, "https://example.com/some2.pdf"); + // Verify that callout renders in appropriate position (with infobar element displayed) + ok( + validateCalloutCustomPosition( + callout, + pdfTestMessage.message.content.screens[0].anchors[0].absolute_position, + doc + ), + "Callout custom position is as expected while navigator toolbox height is extended" + ); + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function custom_position_callout_is_horizontally_reversed_in_rtl_layouts() { + // Deep copying our test message so we can alter it without disrupting future tests + const pdfTestMessage = JSON.parse(JSON.stringify(testMessage)); + const pdfTestMessageCalloutSelector = + pdfTestMessage.message.content.screens[0].id; + + pdfTestMessage.message.content.screens[0].anchors[0] = { + selector: "hbox#browser", + absolute_position: { top: "45px", right: "25px" }, + }; + + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(pdfMatch).resolves(pdfTestMessage); + sendTriggerStub.callThrough(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.document.dir = "rtl"; + Assert.strictEqual( + win.document.documentElement.getAttribute("dir"), + "rtl", + "browser window is in RTL" + ); + + await openURLInWindow(win, PDF_TEST_URL); + const doc = win.document; + await waitForCalloutScreen(doc, pdfTestMessageCalloutSelector); + + const callout = doc.querySelector(`.${pdfTestMessageCalloutSelector}`); + ok(callout, "Callout is rendered when navigating to PDF file"); + ok( + validateCalloutRTLPosition( + callout, + pdfTestMessage.message.content.screens[0].anchors[0].absolute_position + ), + "Callout custom position is rendered appropriately in RTL mode" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + } +); + +add_task(async function feature_callout_dismissed_on_escape() { + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(pdfMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win, PDF_TEST_URL); + const doc = win.document; + await waitForCalloutScreen(doc, testMessageScreenId); + const container = doc.querySelector(calloutSelector); + ok( + container, + "Feature Callout is rendered in the browser chrome with a new window when a message is available" + ); + + // Ensure the browser is focused + win.gBrowser.selectedBrowser.focus(); + + // Press Escape to close + EventUtils.synthesizeKey("KEY_Escape", {}, win); + await waitForCalloutRemoved(doc); + ok(true, "Feature callout dismissed after pressing Escape"); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); + +add_task( + async function feature_callout_not_dismissed_on_escape_with_interactive_elm_focused() { + const sandbox = sinon.createSandbox(); + const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage"); + sendTriggerStub.withArgs(pdfMatch).resolves(testMessage); + sendTriggerStub.callThrough(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win, PDF_TEST_URL); + const doc = win.document; + await waitForCalloutScreen(doc, testMessageScreenId); + const container = doc.querySelector(calloutSelector); + ok( + container, + "Feature Callout is rendered in the browser chrome with a new window when a message is available" + ); + + // Ensure an interactive element is focused + win.gURLBar.focus(); + + // Press Escape to close + EventUtils.synthesizeKey("KEY_Escape", {}, win); + await TestUtils.waitForTick(); + // Wait 500ms for transition to complete + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + ok( + doc.querySelector(calloutSelector), + "Feature callout is not dismissed after pressing Escape because an interactive element is focused" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + } +); + +add_task(async function first_anchor_selected_is_valid() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const config = { + win, + location: "chrome", + context: "chrome", + browser: win.gBrowser.selectedBrowser, + theme: { preset: "chrome" }, + }; + + const message = JSON.parse(JSON.stringify(testMessage.message)); + const sandbox = sinon.createSandbox(); + + const doc = win.document; + const featureCallout = new FeatureCallout(config); + const getAnchorSpy = sandbox.spy(featureCallout, "_getAnchor"); + featureCallout.showFeatureCallout(message); + await waitForCalloutScreen(doc, message.content.screens[0].id); + ok( + getAnchorSpy.alwaysReturned( + sandbox.match(message.content.screens[0].anchors[0]) + ), + "The first anchor is selected" + ); + + win.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win.document); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); + +add_task(async function first_anchor_selected_is_invalid() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + const config = { + win, + location: "chrome", + context: "chrome", + browser: win.gBrowser.selectedBrowser, + theme: { preset: "chrome" }, + }; + + let stopReloadButton = doc.getElementById("stop-reload-button"); + + await gCustomizeMode.addToPanel(stopReloadButton); + + const message = JSON.parse(JSON.stringify(testMessage.message)); + message.content.screens[0].anchors = [ + // element that does not exist + { selector: "#some-fake-id.some-fake-class", arrow_position: "top" }, + // element that exists but has no height/width + { selector: "#a11y-announcement", arrow_position: "top" }, + // element that exists but is hidden by CSS + { selector: "#window-modal-dialog", arrow_position: "top" }, + // customizable widget that's in the overflow panel + { selector: "#stop-reload-button", arrow_position: "top" }, + // element that is fully visible + { selector: "#PanelUI-menu-button", arrow_position: "top" }, + ]; + const sandbox = sinon.createSandbox(); + + const featureCallout = new FeatureCallout(config); + const getAnchorSpy = sandbox.spy(featureCallout, "_getAnchor"); + featureCallout.showFeatureCallout(message); + await waitForCalloutScreen(doc, message.content.screens[0].id); + is( + getAnchorSpy.lastCall.returnValue.selector, + message.content.screens[0].anchors[4].selector, + "The first valid anchor (anchor 5) is selected" + ); + + win.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win.document); + CustomizableUI.reset(); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); diff --git a/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js new file mode 100644 index 0000000000..1f87c71ec7 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js @@ -0,0 +1,430 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getTestMessage() { + return { + id: "TEST_PANEL_FEATURE_CALLOUT", + template: "feature_callout", + groups: [], + content: { + id: "TEST_PANEL_FEATURE_CALLOUT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "TEST_PANEL_FEATURE_CALLOUT", + anchors: [ + { + selector: "#PanelUI-menu-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + }, + ], + content: { + position: "callout", + title: { raw: "Panel Feature Callout" }, + dismiss_button: { + action: { dismiss: true }, + }, + }, + }, + ], + }, + }; +} + +/** + * Set up a callout and show it. + * + * @param {MozBrowser} browser Probably the selected browser in the top window. + * @param {object} message The message to show. + * @returns {Promise<{featureCallout: FeatureCallout, showing: boolean, closed: Promise}>} + * A promise that resolves to an object containing the FeatureCallout + * instance, a boolean for whether the callout started showing correctly, and + * a promise that resolves when the callout is closed. + */ +async function showFeatureCallout(browser, message) { + let resolveClosed; + let closed = new Promise(resolve => { + resolveClosed = resolve; + }); + const config = { + win: browser.ownerGlobal, + location: "chrome", + context: "chrome", + browser, + theme: { preset: "chrome" }, + listener: (_, event) => { + if (event === "end") { + resolveClosed(); + } + }, + }; + const featureCallout = new FeatureCallout(config); + let showing = await featureCallout.showFeatureCallout(message); + return { featureCallout, showing, closed }; +} + +/** + * Make a new window, open a feature callout in it, run a function to hide the + * callout, and assert that the callout is hidden correctly. Optionally run a + * function after the callout is closed, for additional assertions. Finally, + * close the window. + * + * @param {function(Window, Element, FeatureCallout)} hideFn A function that + * hides the callout. Passed the following params: window, callout container, + * and FeatureCallout instance. + * @param {function(Window, Element, FeatureCallout)} afterCloseFn An optional + * function to run after the callout is closed. Same params as hideFn. + * @param {object} message The message to show. + */ +async function testCalloutHiddenIf( + hideFn, + afterCloseFn, + message = getTestMessage() +) { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.focus(); + const doc = win.document; + const browser = win.gBrowser.selectedBrowser; + const { featureCallout, showing, closed } = await showFeatureCallout( + browser, + message + ); + + await waitForCalloutScreen(doc, message.content.screens[0].id); + let calloutContainer = featureCallout._container; + ok(showing && calloutContainer, "Feature callout should be showing"); + + await hideFn(win, calloutContainer, featureCallout); + + await closed; + await waitForCalloutRemoved(doc); + ok(!doc.querySelector(calloutSelector), "Feature callout should be hidden"); + + await afterCloseFn?.(win, calloutContainer, featureCallout); + await BrowserTestUtils.closeWindow(win); +} + +// Test that the callout is correctly created as a panel and positioned. +add_task(async function panel_feature_callout() { + await testCalloutHiddenIf(async (win, calloutContainer) => { + is(calloutContainer.localName, "panel", "Callout container is a panel"); + await BrowserTestUtils.waitForMutationCondition( + calloutContainer, + { attributeFilter: ["arrow-position"] }, + () => calloutContainer.getAttribute("arrow-position") === "top-end" + ); + is( + calloutContainer.anchorNode.id, + "PanelUI-menu-button", + "Callout container is anchored to the app menu button" + ); + is( + calloutContainer.getAttribute("arrow-position"), + "top-end", + "Callout container arrow is positioned correctly" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }); +}); + +// Test that the callout is hidden if another popup is shown. +add_task(async function panel_feature_callout_hidden_on_popupshowing() { + await testCalloutHiddenIf(async win => { + // Click the app menu button to open the panel. + win.document.querySelector("#PanelUI-menu-button").click(); + }); +}); + +// Test that the callout is hidden if its anchor node is hidden. +add_task(async function panel_feature_callout_hidden_on_anchor_hidden() { + await testCalloutHiddenIf(async win => { + // Hide the app menu button. + win.document.querySelector("#PanelUI-menu-button").hidden = true; + }); +}); + +// Panels automatically track the movement of their anchor nodes, so test that +// the callout moves with its anchor node. +add_task(async function panel_feature_callout_follows_anchor() { + await testCalloutHiddenIf(async (win, calloutContainer) => { + let startingX = calloutContainer.getBoundingClientRect().x; + + // Move the app menu button away from the right edge of the window. + calloutContainer.anchorNode.style.marginInlineEnd = "100px"; + + // Wait for the callout to reposition itself. + await BrowserTestUtils.waitForCondition( + () => calloutContainer.getBoundingClientRect().x !== startingX, + "Callout should reposition itself" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }); +}); + +// Panels normally set the `[open]` attribute on their anchor node when they're +// open, so that the anchor node can be styled differently when the panel is +// open. Not every anchor node has styles for this, but e.g. chrome buttons do. +add_task(async function panel_feature_callout_anchor_open_attr() { + let anchor; + await testCalloutHiddenIf( + async (win, calloutContainer) => { + anchor = calloutContainer.anchorNode; + ok( + anchor.hasAttribute("open"), + "Callout container's anchor node should have its [open] attribute set" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }, + (win, calloutContainer) => { + ok( + !anchor.hasAttribute("open"), + "Callout container's anchor node should not have its [open] attribute set" + ); + } + ); +}); + +// However, some panels don't want to set the `[open]` attribute on their anchor +// node. Sometimes the panel is more of a hint than a menu, and the `[open]` +// style could give the impression that it's a menu. Or the anchor might already +// have its `[open]` attribute set by something else, and we may not want to +// interfere with that. So this feature is configurable by adding the +// no_open_on_anchor property to the anchor. +add_task(async function panel_feature_callout_no_anchor_open_attr() { + let message = getTestMessage(); + message.content.screens[0].anchors[0].no_open_on_anchor = true; + await testCalloutHiddenIf( + async (win, calloutContainer) => { + let anchor = calloutContainer.anchorNode; + ok( + !anchor.hasAttribute("open"), + "Callout container's anchor node should not have its [open] attribute set" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }, + null, + message + ); +}); + +add_task(async function feature_callout_split_dismiss_button() { + let message = getTestMessage(); + message.content.screens[0].content.secondary_button = { + label: { raw: "Advance" }, + action: { navigate: true }, + }; + message.content.screens[0].content.submenu_button = { + submenu: [ + { + type: "action", + label: { raw: "Item 1" }, + action: { navigate: true }, + id: "item1", + }, + { + type: "action", + label: { raw: "Item 2" }, + action: { navigate: true }, + id: "item2", + }, + { + type: "menu", + label: { raw: "Menu 1" }, + submenu: [ + { + type: "action", + label: { raw: "Item 3" }, + action: { navigate: true }, + id: "item3", + }, + { + type: "action", + label: { raw: "Item 4" }, + action: { navigate: true }, + id: "item4", + }, + ], + id: "menu1", + }, + ], + attached_to: "secondary_button", + }; + + await testCalloutHiddenIf( + async (win, calloutContainer) => { + let splitButtonContainer = calloutContainer.querySelector( + `#${calloutId} .split-button-container` + ); + let secondaryButton = calloutContainer.querySelector( + `#${calloutId} .secondary:not(.submenu-button)` + ); + let submenuButton = calloutContainer.querySelector( + `#${calloutId} .submenu-button` + ); + let submenu = calloutContainer.querySelector( + `#${calloutId} .fxms-multi-stage-submenu` + ); + ok(splitButtonContainer, "Callout should have a split button container"); + ok(secondaryButton, "Callout should have a split secondary button"); + ok(submenuButton, "Callout should have a split submenu button"); + ok(submenu, "Callout should have a submenu"); + + // Click the submenu button and wait for the submenu (menupopup) to open. + let opened = BrowserTestUtils.waitForEvent(submenu, "popupshown"); + submenuButton.click(); + await opened; + + // Assert that all the menu items are present and that the order and + // structure is correct. + async function recursiveTestMenuItems(items, popup) { + let children = [...popup.children]; + for (let element of children) { + let index = children.indexOf(element); + let itemAtIndex = items[index]; + switch (element.localName) { + case "menuitem": + is( + itemAtIndex.type, + "action", + `Menu item ${itemAtIndex.id} should be an action` + ); + is( + JSON.stringify(element.config), + JSON.stringify(itemAtIndex), + `Menu item ${itemAtIndex.id} should have correct config` + ); + is( + element.value, + itemAtIndex.id, + `Menu item ${itemAtIndex.id} should have correct value` + ); + break; + case "menu": + is( + itemAtIndex.type, + "menu", + `Menu item ${itemAtIndex.id} should be a menu` + ); + is( + element.value, + itemAtIndex.id, + `Menu item ${itemAtIndex.id} should have correct value` + ); + info(`Testing submenu ${itemAtIndex.id}`); + await recursiveTestMenuItems( + itemAtIndex.submenu, + element.querySelector("menupopup") + ); + break; + case "menuseparator": + is( + itemAtIndex.type, + "separator", + `Menu item ${index} should be a separator` + ); + break; + default: + ok(false, "Child of unknown type in submenu"); + } + } + } + + info("Testing main menu"); + await recursiveTestMenuItems( + message.content.screens[0].content.submenu_button.submenu, + submenu + ); + + submenu.querySelector(`menuitem[value="item1"]`).click(); + }, + null, + message + ); +}); + +add_task(async function feature_callout_tab_order() { + let message = getTestMessage(); + message.content.screens[0].content.secondary_button = { + label: { raw: "Dismiss" }, + action: { dismiss: true }, + }; + message.content.screens[0].content.primary_button = { + label: { raw: "Advance" }, + action: { navigate: true }, + }; + + await testCalloutHiddenIf( + async (win, calloutContainer) => { + // Test that feature callout initially focuses the primary button. + let primaryButton = calloutContainer.querySelector( + `#${calloutId} .primary` + ); + await BrowserTestUtils.waitForCondition( + () => win.document.activeElement === primaryButton, + "Primary button should be focused" + ); + + // Test that pressing Tab loops through the primary button, secondary + // button, and dismiss button. + let secondaryButton = calloutContainer.querySelector( + `#${calloutId} .secondary` + ); + let onFocused2 = BrowserTestUtils.waitForEvent(secondaryButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + await onFocused2; + is( + win.document.activeElement, + secondaryButton, + "Secondary button should be focused" + ); + + let dismissButton = calloutContainer.querySelector( + `#${calloutId} .dismiss-button` + ); + let onFocused3 = BrowserTestUtils.waitForEvent(dismissButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + await onFocused3; + is( + win.document.activeElement, + dismissButton, + "Dismiss button should be focused" + ); + + let onFocused4 = BrowserTestUtils.waitForEvent(primaryButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + await onFocused4; + is( + win.document.activeElement, + primaryButton, + "Primary button should be focused" + ); + + // Test that pressing Shift+Tab loops back to the dismiss button. + let onFocused5 = BrowserTestUtils.waitForEvent(dismissButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, win); + await onFocused5; + is( + win.document.activeElement, + dismissButton, + "Dismiss button should be focused" + ); + + EventUtils.synthesizeKey("VK_SPACE", {}, win); + }, + + null, + message + ); +}); diff --git a/browser/components/asrouter/tests/browser/browser_trigger_listeners.js b/browser/components/asrouter/tests/browser/browser_trigger_listeners.js new file mode 100644 index 0000000000..7c86645221 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_trigger_listeners.js @@ -0,0 +1,430 @@ +/* eslint-disable @microsoft/sdl/no-insecure-url */ +const { ASRouterTriggerListeners } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs" +); + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +const mockIdleService = { + _observers: new Set(), + _fireObservers(state) { + for (let observer of this._observers.values()) { + observer.observe(this, state, null); + } + }, + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 1200000, + addIdleObserver(observer, time) { + this._observers.add(observer); + }, + removeIdleObserver(observer, time) { + this._observers.delete(observer); + }, +}; + +const sleepMs = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); // eslint-disable-line mozilla/no-arbitrary-setTimeout + +const inChaosMode = !!parseInt(Services.env.get("MOZ_CHAOSMODE"), 16); + +async function waitForUrlLoad(url) { + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, url); + await BrowserTestUtils.browserLoaded(browser, false, url); +} + +add_setup(async function () { + // Runtime increases in chaos mode on Mac. + if (inChaosMode && AppConstants.platform === "macosx") { + requestLongerTimeout(2); + } + + registerCleanupFunction(() => { + const trigger = ASRouterTriggerListeners.get("openURL"); + trigger.uninit(); + }); +}); + +add_task(async function test_openURL_visit_counter() { + const trigger = ASRouterTriggerListeners.get("openURL"); + const stub = sinon.stub(); + trigger.uninit(); + + trigger.init(stub, ["example.com"]); + + await waitForUrlLoad("about:blank"); + await waitForUrlLoad("https://example.com/"); + await waitForUrlLoad("about:blank"); + await waitForUrlLoad("http://example.com/"); + await waitForUrlLoad("about:blank"); + await waitForUrlLoad("http://example.com/"); + + Assert.equal(stub.callCount, 3, "Stub called 3 times for example.com host"); + Assert.equal( + stub.firstCall.args[1].context.visitsCount, + 1, + "First call should have count 1" + ); + Assert.equal( + stub.thirdCall.args[1].context.visitsCount, + 2, + "Third call should have count 2 for http://example.com" + ); +}); + +add_task(async function test_openURL_visit_counter_withPattern() { + const trigger = ASRouterTriggerListeners.get("openURL"); + const stub = sinon.stub(); + trigger.uninit(); + + // Match any valid URL + trigger.init(stub, [], ["*://*/*"]); + + await waitForUrlLoad("about:blank"); + await waitForUrlLoad("https://example.com/"); + await waitForUrlLoad("about:blank"); + await waitForUrlLoad("http://example.com/"); + await waitForUrlLoad("about:blank"); + await waitForUrlLoad("http://example.com/"); + + Assert.equal(stub.callCount, 3, "Stub called 3 times for example.com host"); + Assert.equal( + stub.firstCall.args[1].context.visitsCount, + 1, + "First call should have count 1" + ); + Assert.equal( + stub.thirdCall.args[1].context.visitsCount, + 2, + "Third call should have count 2 for http://example.com" + ); +}); + +add_task(async function test_captivePortalLogin() { + const stub = sinon.stub(); + const captivePortalTrigger = + ASRouterTriggerListeners.get("captivePortalLogin"); + + captivePortalTrigger.init(stub); + + Services.obs.notifyObservers(this, "captive-portal-login-success", {}); + + Assert.ok(stub.called, "Called after login event"); + + captivePortalTrigger.uninit(); + + Services.obs.notifyObservers(this, "captive-portal-login-success", {}); + + Assert.equal(stub.callCount, 1, "Not called after uninit"); +}); + +add_task(async function test_preferenceObserver() { + const stub = sinon.stub(); + const poTrigger = ASRouterTriggerListeners.get("preferenceObserver"); + + poTrigger.uninit(); + + poTrigger.init(stub, ["foo.bar", "bar.foo"]); + + Services.prefs.setStringPref("foo.bar", "foo.bar"); + + Assert.ok(stub.calledOnce, "Called for pref foo.bar"); + Assert.deepEqual( + stub.firstCall.args[1], + { + id: "preferenceObserver", + param: { type: "foo.bar" }, + }, + "Called with expected arguments" + ); + + Services.prefs.setStringPref("bar.foo", "bar.foo"); + Assert.ok(stub.calledTwice, "Called again for second pref."); + Services.prefs.clearUserPref("foo.bar"); + Assert.ok(stub.calledThrice, "Called when clearing the pref as well."); + + stub.resetHistory(); + poTrigger.uninit(); + + Services.prefs.clearUserPref("bar.foo"); + Assert.ok(stub.notCalled, "Not called after uninit"); +}); + +add_task(async function test_nthTabClosed() { + const handlerStub = sinon.stub(); + const tabClosedTrigger = ASRouterTriggerListeners.get("nthTabClosed"); + tabClosedTrigger.uninit(); + tabClosedTrigger.init(handlerStub); + + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + BrowserTestUtils.removeTab(tab1); + Assert.ok(handlerStub.calledOnce, "Called once after first tab closed"); + + BrowserTestUtils.removeTab(tab2); + Assert.ok(handlerStub.calledTwice, "Called twice after second tab closed"); + + handlerStub.resetHistory(); + tabClosedTrigger.uninit(); + + Assert.ok(handlerStub.notCalled, "Not called after uninit"); +}); + +add_task(async function test_cookieBannerDetected() { + const handlerStub = sinon.stub(); + const bannerDetectedTrigger = ASRouterTriggerListeners.get( + "cookieBannerDetected" + ); + bannerDetectedTrigger.uninit(); + bannerDetectedTrigger.init(handlerStub); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + let eventWait = BrowserTestUtils.waitForEvent(win, "cookiebannerdetected"); + win.dispatchEvent(new Event("cookiebannerdetected")); + await eventWait; + let closeWindow = BrowserTestUtils.closeWindow(win); + + Assert.ok( + handlerStub.called, + "Called after `cookiebannerdetected` event fires" + ); + + handlerStub.resetHistory(); + bannerDetectedTrigger.uninit(); + + Assert.ok(handlerStub.notCalled, "Not called after uninit"); + await closeWindow; +}); + +add_task(async function test_cookieBannerHandled() { + const handlerStub = sinon.stub(); + const bannerHandledTrigger = ASRouterTriggerListeners.get( + "cookieBannerHandled" + ); + bannerHandledTrigger.uninit(); + bannerHandledTrigger.init(handlerStub); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.focus(); + let eventWait = BrowserTestUtils.waitForEvent(win, "cookiebannerhandled"); + win.windowUtils.dispatchEventToChromeOnly( + win, + new CustomEvent("cookiebannerhandled", { + bubbles: true, + cancelable: false, + detail: { + windowContext: { + rootFrameLoader: { ownerElement: win.gBrowser.selectedBrowser }, + }, + }, + }) + ); + await eventWait; + let closeWindow = BrowserTestUtils.closeWindow(win); + + Assert.ok( + handlerStub.called, + "Called after `cookiebannerhandled` event fires" + ); + + handlerStub.resetHistory(); + bannerHandledTrigger.uninit(); + + Assert.ok(handlerStub.notCalled, "Not called after uninit"); + await closeWindow; +}); + +function getIdleTriggerMock() { + const idleTrigger = ASRouterTriggerListeners.get("activityAfterIdle"); + idleTrigger.uninit(); + const sandbox = sinon.createSandbox(); + const handlerStub = sandbox.stub(); + sandbox.stub(idleTrigger, "_triggerDelay").value(0); + sandbox.stub(idleTrigger, "_wakeDelay").value(30); + sandbox.stub(idleTrigger, "_idleService").value(mockIdleService); + let restored = false; + const restore = () => { + if (restored) { + return; + } + restored = true; + idleTrigger.uninit(); + sandbox.restore(); + }; + registerCleanupFunction(restore); + idleTrigger.init(handlerStub); + return { idleTrigger, handlerStub, restore }; +} + +// Test that the trigger fires under normal conditions. +add_task(async function test_activityAfterIdle() { + const { handlerStub, restore } = getIdleTriggerMock(); + let firedOnActive = new Promise(resolve => + handlerStub.callsFake(() => resolve(true)) + ); + mockIdleService._fireObservers("idle"); + await TestUtils.waitForTick(); + ok(handlerStub.notCalled, "Not called when idle"); + mockIdleService._fireObservers("active"); + ok(await firedOnActive, "Called once when active after idle"); + restore(); +}); + +// Test that the trigger does not fire when the active window is private. +add_task(async function test_activityAfterIdlePrivateWindow() { + const { handlerStub, restore } = getIdleTriggerMock(); + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWin), "Window is private"); + await TestUtils.waitForTick(); + mockIdleService._fireObservers("idle"); + await TestUtils.waitForTick(); + mockIdleService._fireObservers("active"); + await TestUtils.waitForTick(); + ok(handlerStub.notCalled, "Not called when active window is private"); + await BrowserTestUtils.closeWindow(privateWin); + restore(); +}); + +// Test that the trigger does not fire when the window is minimized, but does +// fire after the window is restored. +add_task(async function test_activityAfterIdleHiddenWindow() { + const { handlerStub, restore } = getIdleTriggerMock(); + let firedOnRestore = new Promise(resolve => + handlerStub.callsFake(() => resolve(true)) + ); + window.minimize(); + await BrowserTestUtils.waitForCondition( + () => window.windowState === window.STATE_MINIMIZED, + "Window should be minimized" + ); + mockIdleService._fireObservers("idle"); + await TestUtils.waitForTick(); + mockIdleService._fireObservers("active"); + await TestUtils.waitForTick(); + ok(handlerStub.notCalled, "Not called when window is minimized"); + window.restore(); + ok(await firedOnRestore, "Called once after restoring minimized window"); + restore(); +}); + +// Test that the trigger does not fire immediately after waking from sleep. +add_task(async function test_activityAfterIdleWake() { + const { handlerStub, restore } = getIdleTriggerMock(); + let firedAfterWake = new Promise(resolve => + handlerStub.callsFake(() => resolve(true)) + ); + mockIdleService._fireObservers("wake_notification"); + mockIdleService._fireObservers("idle"); + await sleepMs(1); + mockIdleService._fireObservers("active"); + await sleepMs(inChaosMode ? 32 : 300); + ok(handlerStub.notCalled, "Not called immediately after waking from sleep"); + + mockIdleService._fireObservers("idle"); + await TestUtils.waitForTick(); + mockIdleService._fireObservers("active"); + ok( + await firedAfterWake, + "Called once after waiting for wake delay before firing idle" + ); + restore(); +}); + +add_task(async function test_formAutofillTrigger() { + const sandbox = sinon.createSandbox(); + const handlerStub = sandbox.stub(); + const formAutofillTrigger = ASRouterTriggerListeners.get("formAutofill"); + sandbox.stub(formAutofillTrigger, "_triggerDelay").value(0); + formAutofillTrigger.uninit(); + formAutofillTrigger.init(handlerStub); + + function notifyCreditCardSaved() { + Services.obs.notifyObservers( + { + wrappedJSObject: { sourceSync: false, collectionName: "creditCards" }, + }, + formAutofillTrigger._topic, + "add" + ); + } + + // Saving credit cards for autofill currently fails for some hardware + // configurations, so mock the event instead of really adding a card. + notifyCreditCardSaved(); + await sleepMs(1); + Assert.ok(handlerStub.called, "Called after event"); + + // Test that the trigger doesn't fire when the credit card manager is open. + handlerStub.resetHistory(); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:preferences#privacy" }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => + ( + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector("#creditCardAutofill button"), + "Waiting for credit card manager button" + ) + )?.click() + ); + await BrowserTestUtils.waitForCondition( + () => browser.contentWindow?.gSubDialog?.dialogs.length + ); + notifyCreditCardSaved(); + await sleepMs(1); + Assert.ok( + handlerStub.notCalled, + "Not called when credit card manager is open" + ); + } + ); + + formAutofillTrigger.uninit(); + handlerStub.resetHistory(); + notifyCreditCardSaved(); + await sleepMs(1); + Assert.ok(handlerStub.notCalled, "Not called after uninit"); + + sandbox.restore(); + formAutofillTrigger.uninit(); +}); + +add_task(async function test_pageActionInUrlbarTrigger() { + const sandbox = sinon.createSandbox(); + const receivedTrigger = new Promise(resolve => { + sandbox + .stub(ASRouter, "sendTriggerMessage") + .callsFake(({ id, context }) => { + if ( + id === "pageActionInUrlbar" && + context?.pageAction === "picture-in-picture-button" + ) { + resolve(true); + } + }); + }); + sandbox + .stub(PictureInPicture, "getEligiblePipVideoCount") + .returns({ totalPipCount: 1, totalPipDisabled: 0 }); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.enabled", true], + ["media.videocontrols.picture-in-picture.urlbar-button.enabled", true], + ], + }); + + PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser); + + let pageAction = await receivedTrigger; + ok(pageAction, "pageActionInUrlbar trigger sent with PiP button id"); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + + PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser); +}); diff --git a/browser/components/asrouter/tests/browser/head.js b/browser/components/asrouter/tests/browser/head.js new file mode 100644 index 0000000000..dd5e451540 --- /dev/null +++ b/browser/components/asrouter/tests/browser/head.js @@ -0,0 +1,66 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", + + FeatureCalloutBroker: + "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs", + + FeatureCalloutMessages: + "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs", + + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); +XPCOMUtils.defineLazyModuleGetters(this, { + QueryCache: "resource:///modules/asrouter/ASRouterTargeting.jsm", +}); +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +// We import sinon here to make it available across all mochitest test files +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// Feature callout constants +const calloutId = "feature-callout"; +const calloutSelector = `#${calloutId}.featureCallout`; +const calloutCTASelector = `#${calloutId} :is(.primary, .secondary)`; +const calloutDismissSelector = `#${calloutId} .dismiss-button`; + +function pushPrefs(...prefs) { + return SpecialPowers.pushPrefEnv({ set: prefs }); +} + +async function clearHistoryAndBookmarks() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + QueryCache.expireAll(); +} + +/** + * Helper function to navigate and wait for page to load + * https://searchfox.org/mozilla-central/rev/314b4297e899feaf260e7a7d1a9566a218216e7a/testing/mochitest/BrowserTestUtils/BrowserTestUtils.sys.mjs#404 + */ +async function waitForUrlLoad(url) { + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, url); + await BrowserTestUtils.browserLoaded(browser, false, url); +} + +async function waitForCalloutScreen(target, screenId) { + await BrowserTestUtils.waitForMutationCondition( + target, + { childList: true, subtree: true, attributeFilter: ["class"] }, + () => target.querySelector(`${calloutSelector}:not(.hidden) .${screenId}`) + ); +} + +async function waitForCalloutRemoved(target) { + await BrowserTestUtils.waitForMutationCondition( + target, + { childList: true, subtree: true }, + () => !target.querySelector(calloutSelector) + ); +} diff --git a/browser/components/asrouter/tests/unit/ASRouter.test.js b/browser/components/asrouter/tests/unit/ASRouter.test.js new file mode 100644 index 0000000000..7df1449a14 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouter.test.js @@ -0,0 +1,2870 @@ +import { _ASRouter, MessageLoaderUtils } from "modules/ASRouter.sys.mjs"; +import { QueryCache } from "modules/ASRouterTargeting.sys.mjs"; +import { + FAKE_LOCAL_MESSAGES, + FAKE_LOCAL_PROVIDER, + FAKE_LOCAL_PROVIDERS, + FAKE_REMOTE_MESSAGES, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +} from "./constants"; +import { + ASRouterPreferences, + TARGETING_PREFERENCES, +} from "modules/ASRouterPreferences.sys.mjs"; +import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs"; +import { CFRPageActions } from "modules/CFRPageActions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; +import ProviderResponseSchema from "content-src/schemas/provider-response.schema.json"; + +const MESSAGE_PROVIDER_PREF_NAME = + "browser.newtabpage.activity-stream.asrouter.providers.cfr"; +const FAKE_PROVIDERS = [ + FAKE_LOCAL_PROVIDER, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +]; +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +const FAKE_RESPONSE_HEADERS = { get() {} }; +const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]]; + +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +// eslint-disable-next-line max-statements +describe("ASRouter", () => { + let Router; + let globals; + let sandbox; + let initParams; + let messageBlockList; + let providerBlockList; + let messageImpressions; + let groupImpressions; + let previousSessionEnd; + let fetchStub; + let clock; + let fakeAttributionCode; + let fakeTargetingContext; + let FakeToolbarBadgeHub; + let FakeToolbarPanelHub; + let FakeMomentsPageHub; + let ASRouterTargeting; + let screenImpressions; + + function setMessageProviderPref(value) { + sandbox.stub(ASRouterPreferences, "providers").get(() => value); + } + + function initASRouter(router) { + const getStub = sandbox.stub(); + getStub.returns(Promise.resolve()); + getStub + .withArgs("messageBlockList") + .returns(Promise.resolve(messageBlockList)); + getStub + .withArgs("providerBlockList") + .returns(Promise.resolve(providerBlockList)); + getStub + .withArgs("messageImpressions") + .returns(Promise.resolve(messageImpressions)); + getStub.withArgs("groupImpressions").resolves(groupImpressions); + getStub + .withArgs("previousSessionEnd") + .returns(Promise.resolve(previousSessionEnd)); + getStub + .withArgs("screenImpressions") + .returns(Promise.resolve(screenImpressions)); + initParams = { + storage: { + get: getStub, + set: sandbox.stub().returns(Promise.resolve()), + }, + sendTelemetry: sandbox.stub().resolves(), + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + dispatchCFRAction: sandbox.stub().resolves(), + }; + sandbox.stub(router, "loadMessagesFromAllProviders").callThrough(); + return router.init(initParams); + } + + async function createRouterAndInit(providers = FAKE_PROVIDERS) { + setMessageProviderPref(providers); + // `.freeze` to catch any attempts at modifying the object + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + await initASRouter(Router); + } + + beforeEach(async () => { + globals = new GlobalOverrider(); + messageBlockList = []; + providerBlockList = []; + messageImpressions = {}; + groupImpressions = {}; + previousSessionEnd = 100; + screenImpressions = {}; + sandbox = sinon.createSandbox(); + ASRouterTargeting = { + isMatch: sandbox.stub(), + findMatchingMessage: sandbox.stub(), + Environment: { + locale: "en-US", + localeLanguageCode: "en", + browserSettings: { + update: { + channel: "default", + enabled: true, + autoDownload: true, + }, + }, + attributionData: {}, + currentDate: "2000-01-01T10:00:0.001Z", + profileAgeCreated: {}, + profileAgeReset: {}, + usesFirefoxSync: false, + isFxAEnabled: true, + isFxASignedIn: false, + sync: { + desktopDevices: 0, + mobileDevices: 0, + totalDevices: 0, + }, + xpinstallEnabled: true, + addonsInfo: {}, + searchEngines: {}, + isDefaultBrowser: false, + devToolsOpenedCount: 5, + topFrecentSites: {}, + recentBookmarks: {}, + pinnedSites: [ + { + url: "https://amazon.com", + host: "amazon.com", + searchTopSite: true, + }, + ], + providerCohorts: { + onboarding: "", + cfr: "", + "message-groups": "", + "messaging-experiments": "", + "whats-new-panel": "", + }, + totalBookmarksCount: {}, + firefoxVersion: 80, + region: "US", + needsUpdate: {}, + hasPinnedTabs: false, + hasAccessedFxAPanel: false, + isWhatsNewPanelEnabled: true, + userPrefs: { + cfrFeatures: true, + cfrAddons: true, + }, + totalBlockedCount: {}, + blockedCountByType: {}, + attachedFxAOAuthClients: [], + platformName: "macosx", + scores: {}, + scoreThreshold: 5000, + isChinaRepack: false, + userId: "adsf", + }, + }; + + ASRouterPreferences.specialConditions = { + someCondition: true, + }; + sandbox.spy(ASRouterPreferences, "init"); + sandbox.spy(ASRouterPreferences, "uninit"); + sandbox.spy(ASRouterPreferences, "addListener"); + sandbox.spy(ASRouterPreferences, "removeListener"); + + clock = sandbox.useFakeTimers(); + fetchStub = sandbox + .stub(global, "fetch") + .withArgs("http://fake.com/endpoint") + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + sandbox.stub(global.Services.prefs, "getStringPref"); + + fakeAttributionCode = { + allowedCodeKeys: ["foo", "bar", "baz"], + _clearCache: () => sinon.stub(), + getAttrDataAsync: () => Promise.resolve({ content: "addonID" }), + deleteFileAsync: () => Promise.resolve(), + writeAttributionFile: () => Promise.resolve(), + getCachedAttributionData: sinon.stub(), + }; + FakeToolbarPanelHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + forceShowMessage: sandbox.stub(), + enableToolbarButton: sandbox.stub(), + }; + FakeToolbarBadgeHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + registerBadgeNotificationListener: sandbox.stub(), + }; + FakeMomentsPageHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + executeAction: sandbox.stub(), + }; + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + }; + let fakeNimbusFeatures = [ + "cfr", + "infobar", + "spotlight", + "moments-page", + "pbNewtab", + ].reduce((features, featureId) => { + features[featureId] = { + getAllVariables: sandbox.stub().returns(null), + recordExposureEvent: sandbox.stub(), + }; + return features; + }, {}); + globals.set({ + // Testing framework doesn't know how to `defineLazyModuleGetters` so we're + // importing these modules into the global scope ourselves. + GroupsConfigurationProvider: { getMessages: () => Promise.resolve([]) }, + ASRouterPreferences, + TARGETING_PREFERENCES, + ASRouterTargeting, + ASRouterTriggerListeners, + QueryCache, + gBrowser: { selectedBrowser: {} }, + gURLBar: {}, + isSeparateAboutWelcome: true, + AttributionCode: fakeAttributionCode, + PanelTestProvider, + MacAttribution: { applicationPath: "" }, + ToolbarBadgeHub: FakeToolbarBadgeHub, + ToolbarPanelHub: FakeToolbarPanelHub, + MomentsPageHub: FakeMomentsPageHub, + KintoHttpClient: class { + bucket() { + return this; + } + collection() { + return this; + } + getRecord() { + return Promise.resolve({ data: {} }); + } + }, + Downloader: class { + download() { + return Promise.resolve("/path/to/download"); + } + }, + NimbusFeatures: fakeNimbusFeatures, + ExperimentAPI: { + getExperimentMetaData: sandbox.stub().returns({ + slug: "experiment-slug", + active: true, + branch: { slug: "experiment-branch-slug" }, + }), + getExperiment: sandbox.stub().returns({ + branch: { + slug: "unit-slug", + feature: { + featureId: "foo", + value: { id: "test-message" }, + }, + }, + }), + getAllBranches: sandbox.stub().resolves([]), + ready: sandbox.stub().resolves(), + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + TargetingContext: class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + }, + RemoteL10n: { + // This is just a subset of supported locales that happen to be used in + // the test. + isLocaleSupported: locale => ["en-US", "ja-JP-mac"].includes(locale), + }, + }); + await createRouterAndInit(); + }); + afterEach(() => { + Router.uninit(); + ASRouterPreferences.uninit(); + sandbox.restore(); + globals.restore(); + }); + + describe(".state", () => { + it("should throw if an attempt to set .state was made", () => { + assert.throws(() => { + Router.state = {}; + }); + }); + }); + + describe("#init", () => { + it("should only be called once", async () => { + Router = new _ASRouter(); + let state = await initASRouter(Router); + + assert.equal(state, Router.state); + + assert.isNull(await Router.init({})); + }); + it("should only be called once", async () => { + Router = new _ASRouter(); + initASRouter(Router); + let secondCall = await Router.init({}); + + assert.isNull( + secondCall, + "Should not init twice, it should exit early with null" + ); + }); + it("should set state.messageBlockList to the block list in persistent storage", async () => { + messageBlockList = ["foo"]; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageBlockList, ["foo"]); + }); + it("should initialize all the hub providers", async () => { + // ASRouter init called in `beforeEach` block above + + assert.calledOnce(FakeToolbarBadgeHub.init); + assert.calledOnce(FakeToolbarPanelHub.init); + assert.calledOnce(FakeMomentsPageHub.init); + + assert.calledWithExactly( + FakeToolbarBadgeHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + unblockMessageById: Router.unblockMessageById, + } + ); + + assert.calledWithExactly( + FakeToolbarPanelHub.init, + Router.waitForInitialized, + { + getMessages: Router.handleMessageRequest, + sendTelemetry: Router.sendTelemetry, + } + ); + + assert.calledWithExactly( + FakeMomentsPageHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + } + ); + }); + it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => { + // Note that messageImpressions are only kept if a message exists in router and has a .frequency property, + // otherwise they will be cleaned up by .cleanupImpressions() + const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } }; + messageImpressions = { foo: [0, 1, 2] }; + setMessageProviderPref([ + { id: "onboarding", type: "local", messages: [testMessage] }, + ]); + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + it("should set state.screenImpressions to the screenImpressions object in persistent storage", async () => { + screenImpressions = { test: 123 }; + + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.screenImpressions, screenImpressions); + }); + it("should clear impressions for groups that are not active", async () => { + groupImpressions = { foo: [0, 1, 2] }; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.notProperty(Router.state.groupImpressions, "foo"); + }); + it("should keep impressions for groups that are active", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + lifetime: Infinity, + }, + }, + ], + groupImpressions: { foo: [Date.now()] }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should remove old impressions for a group", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + }, + }, + ], + groupImpressions: { + foo: [Date.now() - ONE_DAY_IN_MS - 1, Date.now()], + }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => { + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + + await initASRouter(Router); + + assert.calledOnce(Router.loadMessagesFromAllProviders); + assert.isArray(Router.state.messages); + assert.lengthOf( + Router.state.messages, + FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length + ); + }); + it("should set state.previousSessionEnd from IndexedDB", async () => { + previousSessionEnd = 200; + await createRouterAndInit(); + + assert.equal(Router.state.previousSessionEnd, previousSessionEnd); + }); + it("should assign ASRouterPreferences.specialConditions to state", async () => { + assert.isTrue(ASRouterPreferences.specialConditions.someCondition); + assert.isTrue(Router.state.someCondition); + }); + it("should add observer for `intl:app-locales-changed`", async () => { + sandbox.spy(global.Services.obs, "addObserver"); + await createRouterAndInit(); + + assert.calledWithExactly( + global.Services.obs.addObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should add a pref observer", async () => { + sandbox.spy(global.Services.prefs, "addObserver"); + await createRouterAndInit(); + + assert.calledOnce(global.Services.prefs.addObserver); + assert.calledWithExactly( + global.Services.prefs.addObserver, + USE_REMOTE_L10N_PREF, + Router + ); + }); + describe("lazily loading local test providers", () => { + afterEach(() => { + Router.uninit(); + }); + it("should add the local test providers on init if devtools are enabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + + await createRouterAndInit(); + + assert.property(Router._localProviders, "PanelTestProvider"); + }); + it("should not add the local test providers on init if devtools are disabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + + await createRouterAndInit(); + + assert.notProperty(Router._localProviders, "PanelTestProvider"); + }); + }); + }); + + describe("preference changes", () => { + it("should call ASRouterPreferences.init and add a listener on init", () => { + assert.calledOnce(ASRouterPreferences.init); + assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange); + }); + it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => { + Router.uninit(); + assert.calledOnce(ASRouterPreferences.uninit); + assert.calledWith( + ASRouterPreferences.removeListener, + Router.onPrefChange + ); + }); + it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => { + const messageTargeted = { + id: "1", + campaign: "foocampaign", + targeting: "true", + groups: ["cfr"], + provider: "cfr", + }; + const messageNotTargeted = { + id: "2", + campaign: "foocampaign", + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ + messages: [messageTargeted, messageNotTargeted], + providers: [{ id: "cfr" }], + }); + fakeTargetingContext.evalWithDefault.resolves(false); + + await Router.onPrefChange("services.sync.username"); + + assert.calledOnce(initParams.clearChildMessages); + assert.calledWith(initParams.clearChildMessages, [messageTargeted.id]); + }); + it("should call loadMessagesFromAllProviders on pref change", () => { + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should update groups state if a user pref changes", async () => { + await Router.setState({ + groups: [{ id: "foo", userPreferences: ["bar"], enabled: true }], + }); + sandbox.stub(ASRouterPreferences, "getUserPreference"); + + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledWithExactly(ASRouterPreferences.getUserPreference, "bar"); + }); + it("should update the list of providers on pref change", async () => { + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + url: "baz.com", + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + + const { length } = Router.state.providers; + + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + await Router._updateMessageProviders(); + + const provider = Router.state.providers.find(p => p.url === "baz.com"); + assert.lengthOf(Router.state.providers, length); + assert.isDefined(provider); + }); + it("should clear disabled providers on pref change", async () => { + const TEST_PROVIDER_ID = "some_provider_id"; + await Router.setState({ + providers: [{ id: TEST_PROVIDER_ID }], + }); + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + id: TEST_PROVIDER_ID, + enabled: false, + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledOnce(initParams.clearChildProviders); + assert.calledWith(initParams.clearChildProviders, [TEST_PROVIDER_ID]); + }); + }); + + describe("setState", () => { + it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + sandbox.stub(Router, "getTargetingParameters").resolves({}); + const state = await Router.setState({ foo: 123 }); + + assert.calledOnce(initParams.updateAdminState); + assert.deepEqual(state.providerPrefs, ASRouterPreferences.providers); + assert.deepEqual( + state.userPrefs, + ASRouterPreferences.getAllUserPreferences() + ); + assert.deepEqual(state.targetingParameters, {}); + assert.deepEqual(state.errors, Router.errors); + }); + it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + await Router.setState({ foo: 123 }); + + assert.notCalled(initParams.updateAdminState); + }); + }); + + describe("getTargetingParameters", () => { + it("should return the targeting parameters", async () => { + const stub = sandbox.stub().resolves("foo"); + const obj = { foo: 1 }; + sandbox.stub(obj, "foo").get(stub); + const result = await Router.getTargetingParameters(obj, obj); + + assert.calledTwice(stub); + assert.propertyVal(result, "foo", "foo"); + }); + }); + + describe("evaluateExpression", () => { + it("should call ASRouterTargeting to evaluate", async () => { + fakeTargetingContext.evalWithDefault.resolves("foo"); + const response = await Router.evaluateExpression({}); + assert.equal(response.evaluationStatus.result, "foo"); + assert.isTrue(response.evaluationStatus.success); + }); + it("should catch evaluation errors", async () => { + fakeTargetingContext.evalWithDefault.returns( + Promise.reject(new Error("fake error")) + ); + const response = await Router.evaluateExpression({}); + assert.isFalse(response.evaluationStatus.success); + }); + }); + + describe("#routeCFRMessage", () => { + let browser; + beforeEach(() => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + sandbox.stub(CFRPageActions, "addRecommendation"); + browser = {}; + }); + it("should route whatsnew_panel_message message to the right hub", () => { + Router.routeCFRMessage( + { template: "whatsnew_panel_message" }, + browser, + "", + true + ); + + assert.calledOnce(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route moments messages to the right hub", () => { + Router.routeCFRMessage({ template: "update_action" }, browser, "", true); + + assert.calledOnce(FakeMomentsPageHub.executeAction); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + }); + it("should route toolbar_badge message to the right hub", () => { + Router.routeCFRMessage({ template: "toolbar_badge" }, browser); + + assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route milestone_message to the right hub", () => { + Router.routeCFRMessage( + { template: "milestone_message" }, + browser, + "", + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_doorhanger" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = true", () => { + Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + const { args } = CFRPageActions.addRecommendation.firstCall; + // Host should be null + assert.isNull(args[1]); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = true", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + {}, + true + ); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route default to sending to content", () => { + Router.routeCFRMessage( + { template: "some_other_template" }, + browser, + {}, + true + ); + + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + }); + + describe("#loadMessagesFromAllProviders", () => { + function assertRouterContainsMessages(messages) { + const messageIdsInRouter = Router.state.messages.map(m => m.id); + for (const message of messages) { + assert.include(messageIdsInRouter, message.id); + } + } + + it("should not trigger an update if not enough time has passed for a provider", async () => { + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + enabled: true, + url: "http://fake.com/endpoint", + updateCycleInMs: 300, + }, + ]); + + const previousState = Router.state; + + // Since we've previously gotten messages during init and we haven't advanced our fake timer, + // no updates should be triggered. + await Router.loadMessagesFromAllProviders(); + assert.deepEqual(Router.state, previousState); + }); + it("should not trigger an update if we only have local providers", async () => { + await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + + const previousState = Router.state; + const stub = sandbox.stub(MessageLoaderUtils, "loadMessagesForProvider"); + + clock.tick(300); + + await Router.loadMessagesFromAllProviders(); + + assert.deepEqual(Router.state, previousState); + assert.notCalled(stub); + }); + it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => { + const NEW_MESSAGES = [{ id: "new_123" }]; + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, + updateCycleInMs: 300, + }, + { + id: "alocalprovider", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + fetchStub.withArgs("http://fake.com/endpoint").resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: NEW_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + + clock.tick(301); + await Router.loadMessagesFromAllProviders(); + + // These are the new messages + assertRouterContainsMessages(NEW_MESSAGES); + // These are the local messages that should not have been deleted + assertRouterContainsMessages(FAKE_LOCAL_MESSAGES); + }); + it("should parse the triggers in the messages and register the trigger listeners", async () => { + sandbox.spy( + ASRouterTriggerListeners.get("openURL"), + "init" + ); /* eslint-disable object-property-newline */ + + /* eslint-disable object-curly-newline */ await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "foo", + template: "simple_template", + trigger: { id: "firstRun" }, + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "bar1", + template: "simple_template", + trigger: { + id: "openURL", + params: ["www.mozilla.org", "www.mozilla.com"], + }, + content: { title: "Bar1", body: "Bar123" }, + }, + { + id: "bar2", + template: "simple_template", + trigger: { id: "openURL", params: ["www.example.com"] }, + content: { title: "Bar2", body: "Bar123" }, + }, + ], + }, + ]); /* eslint-enable object-property-newline */ + /* eslint-enable object-curly-newline */ assert.calledTwice( + ASRouterTriggerListeners.get("openURL").init + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.mozilla.org", "www.mozilla.com"], + undefined + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.example.com"], + undefined + ); + }); + it("should parse the message's messagesLoaded trigger and immediately fire trigger", async () => { + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "bar3", + template: "simple_template", + trigger: { id: "messagesLoaded" }, + content: { title: "Bar3", body: "Bar123" }, + }, + ], + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle messages loading before a window or browser exists", async () => { + sandbox.stub(global, "gBrowser").value(undefined); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns(undefined); + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + "whatsnew_panel_message", + "cfr_doorhanger", + "toolbar_badge", + "update_action", + "infobar", + "spotlight", + "toast_notification", + ].map((template, i) => { + return { + id: `foo${i}`, + template, + trigger: { id: "messagesLoaded" }, + content: { title: `Foo${i}`, body: "Bar123" }, + }; + }), + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .rejects("fake error"); + await createRouterAndInit(); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_ERROR", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should dispatch undesired event if RemoteSettings returns no messages", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([]); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should download the attachment if RemoteSettings returns some messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + const spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledOnce(spy); + }); + it("should dispatch undesired event if the ms-language-packs returns no messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + sandbox + .stub(global.KintoHttpClient.prototype, "getRecord") + .resolves(null); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "ms-language-packs", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + }); + + describe("#_updateMessageProviders", () => { + it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", async () => { + // If this test fails, you need to update the constant STARTPAGE_VERSION in + // ASRouter.sys.mjs to match the `version` property of provider-response-schema.json + const expectedStartpageVersion = ProviderResponseSchema.version; + const provider = { + id: "foo", + enabled: true, + type: "remote", + url: "https://www.mozilla.org/%STARTPAGE_VERSION%/", + }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.equal( + Router.state.providers[0].url, + `https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/` + ); + }); + it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", async () => { + const url = "https://www.example.com/"; + const replacedUrl = "https://www.foo.bar/"; + const stub = sandbox + .stub(global.Services.urlFormatter, "formatURL") + .withArgs(url) + .returns(replacedUrl); + const provider = { id: "foo", enabled: true, type: "remote", url }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.calledOnce(stub); + assert.calledWithExactly(stub, url); + assert.equal(Router.state.providers[0].url, replacedUrl); + }); + it("should only add the providers that are enabled", async () => { + const providers = [ + { + id: "foo", + enabled: false, + type: "remote", + url: "https://www.foo.com/", + }, + { + id: "bar", + enabled: true, + type: "remote", + url: "https://www.bar.com/", + }, + ]; + setMessageProviderPref(providers); + await Router._updateMessageProviders(); + assert.equal(Router.state.providers.length, 1); + assert.equal(Router.state.providers[0].id, providers[1].id); + }); + }); + + describe("#handleMessageRequest", () => { + beforeEach(async () => { + await Router.setState(() => ({ + providers: [{ id: "cfr" }, { id: "badge" }], + })); + }); + it("should not return a blocked message", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "cfr", groups: ["cfr"] }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + messageBlockList: ["foo"], + })); + await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }], + }); + }); + it("should not return a message from a disabled group", async () => { + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "cfr", groups: ["cfr"] }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + groups: [{ id: "cfr", enabled: false }], + })); + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.isNull(result); + }); + it("should not return a message from a blocked campaign", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { + id: "foo", + provider: "cfr", + campaign: "foocampaign", + groups: ["cfr"], + }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + messageBlockList: ["foocampaign"], + })); + + await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }], + }); + }); + it("should not return a message excluded by the provider", async () => { + // There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving + // only FAKE_REMOTE_PROVIDER unblocked, which provides only one message + await Router.setState(() => ({ + providers: [{ id: "cfr", exclude: ["foo"] }], + })); + + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "cfr" }], + messageBlockList: ["foocampaign"], + })); + + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.isNull(result); + }); + it("should not return a message if the frequency cap has been hit", async () => { + sandbox.stub(Router, "isBelowFrequencyCaps").returns(false); + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "cfr" }], + })); + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.isNull(result); + }); + it("should get unblocked messages that match the trigger", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ triggerId: "foo" }); + + assert.deepEqual(result, message1); + }); + it("should get unblocked messages that match trigger and template", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + template: "badge", + trigger: { id: "foo" }, + groups: ["badge"], + provider: "badge", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + template: "test_template", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ + triggerId: "foo", + template: "badge", + }); + + assert.deepEqual(result, message1); + }); + it("should have messageImpressions in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "messageImpressions", + Router.state.messageImpressions + ); + }); + it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => { + const message1 = { + provider: "whats_new", + id: "1", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message2 = { + provider: "whats_new", + id: "2", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message3 = { + provider: "whats_new", + id: "3", + template: "badge", + groups: ["whats_new"], + }; + ASRouterTargeting.findMatchingMessage.callsFake(() => [ + message2, + message1, + ]); + await Router.setState({ + messages: [message3, message2, message1], + providers: [{ id: "whats_new" }], + }); + const result = await Router.handleMessageRequest({ + template: "whatsnew_panel_message", + triggerId: "whatsNewPanelOpened", + returnAll: true, + }); + + assert.deepEqual(result, [message2, message1]); + }); + it("should forward trigger param info", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options.trigger, "id", trigger.triggerId); + assert.propertyVal(options.trigger, "param", trigger.triggerParam); + assert.propertyVal(options.trigger, "context", trigger.triggerContext); + }); + it("should not cache badge messages", async () => { + const trigger = { + triggerId: "bar", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "cfr", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", false); + }); + it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => { + const trigger = { triggerId: "foo" }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["cfr"], + provider: "cfr", + }; + const message3 = { + id: "3", + campaign: "bazcampaign", + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ + messages: [message2, message1, message3], + groups: [{ id: "cfr", enabled: true }], + }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake(args => args.messages); + + const result = Router.handleMessageRequest(trigger); + + assert.lengthOf(result, 1); + assert.deepEqual(result[0], message1); + }); + }); + + describe("#uninit", () => { + it("should unregister the trigger listeners", () => { + for (const listener of ASRouterTriggerListeners.values()) { + sandbox.spy(listener, "uninit"); + } + + Router.uninit(); + + for (const listener of ASRouterTriggerListeners.values()) { + assert.calledOnce(listener.uninit); + } + }); + it("should set .dispatchCFRAction to null", () => { + Router.uninit(); + assert.isNull(Router.dispatchCFRAction); + assert.isNull(Router.clearChildMessages); + assert.isNull(Router.sendTelemetry); + }); + it("should save previousSessionEnd", () => { + Router.uninit(); + + assert.calledOnce(Router._storage.set); + assert.calledWithExactly( + Router._storage.set, + "previousSessionEnd", + sinon.match.number + ); + }); + it("should remove the observer for `intl:app-locales-changed`", () => { + sandbox.spy(global.Services.obs, "removeObserver"); + Router.uninit(); + + assert.calledWithExactly( + global.Services.obs.removeObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => { + sandbox.spy(global.Services.prefs, "removeObserver"); + Router.uninit(); + + // Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`. + const call = global.Services.prefs.removeObserver.lastCall; + assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router); + }); + }); + + describe("#setMessageById", async () => { + it("should send an empty message if provided id did not resolve to a message", async () => { + let response = await Router.setMessageById({ id: -1 }, true, {}); + assert.deepEqual(response.message, {}); + }); + }); + + describe("#isUnblockedMessage", () => { + it("should block a message if the group is blocked", async () => { + const msg = { id: "msg1", groups: ["foo"], provider: "unit-test" }; + await Router.setState({ + groups: [{ id: "foo", enabled: false }], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ groups: [{ id: "foo", enabled: true }] }); + + assert.isTrue(Router.isUnblockedMessage(msg)); + }); + it("should block a message if at least one group is blocked", async () => { + const msg = { + id: "msg1", + groups: ["foo", "bar"], + provider: "unit-test", + }; + await Router.setState({ + groups: [ + { id: "foo", enabled: false }, + { id: "bar", enabled: false }, + ], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ + groups: [ + { id: "foo", enabled: true }, + { id: "bar", enabled: false }, + ], + }); + + assert.isFalse(Router.isUnblockedMessage(msg)); + }); + }); + + describe("#blockMessageById", () => { + it("should add the id to the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + }); + it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => { + await Router.setState({ + messages: [ + { id: "1", campaign: "foocampaign" }, + { id: "2", campaign: "foocampaign" }, + ], + }); + await Router.blockMessageById("1"); + + assert.isTrue(Router.state.messageBlockList.includes("foocampaign")); + assert.isEmpty(Router.state.messages.filter(Router.isUnblockedMessage)); + }); + it("should be able to add multiple items to the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id)); + }); + it("should save the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.calledWithExactly(Router._storage.set, "messageBlockList", [ + FAKE_BUNDLE[0].id, + FAKE_BUNDLE[1].id, + ]); + }); + }); + + describe("#unblockMessageById", () => { + it("should remove the id from the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + await Router.unblockMessageById("foo"); + assert.isFalse(Router.state.messageBlockList.includes("foo")); + }); + it("should remove the campaign from the messageBlockList if it is defined", async () => { + await Router.setState({ messages: [{ id: "1", campaign: "foo" }] }); + await Router.blockMessageById("1"); + assert.isTrue( + Router.state.messageBlockList.includes("foo"), + "blocklist has campaign id" + ); + await Router.unblockMessageById("1"); + assert.isFalse( + Router.state.messageBlockList.includes("foo"), + "campaign id removed from blocklist" + ); + }); + it("should save the messageBlockList", async () => { + await Router.unblockMessageById("foo"); + assert.calledWithExactly(Router._storage.set, "messageBlockList", []); + }); + }); + + describe("#routeCFRMessage", () => { + it("should allow for echoing back message modifications", () => { + const message = { somekey: "some value" }; + const data = { content: message }; + const browser = {}; + let msg = Router.routeCFRMessage(data.content, browser, data, false); + assert.deepEqual(msg.message, message); + }); + it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, null, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + }); + it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => { + sandbox.stub(CFRPageActions, "addRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, {}, false); + assert.calledOnce(CFRPageActions.addRecommendation); + }); + }); + + describe("#updateTargetingParameters", () => { + it("should return an object containing the whole state", async () => { + sandbox.stub(Router, "getTargetingParameters").resolves({}); + let msg = await Router.updateTargetingParameters(); + let expected = Object.assign({}, Router.state, { + providerPrefs: ASRouterPreferences.providers, + userPrefs: ASRouterPreferences.getAllUserPreferences(), + targetingParameters: {}, + errors: Router.errors, + devtoolsEnabled: ASRouterPreferences.devtoolsEnabled, + }); + + assert.deepEqual(msg, expected); + }); + }); + + describe("#reachEvent", () => { + let experimentAPIStub; + let featureIds = ["cfr", "moments-page", "infobar", "spotlight"]; + beforeEach(() => { + let getExperimentMetaDataStub = sandbox.stub(); + let getAllBranchesStub = sandbox.stub(); + featureIds.forEach(feature => { + global.NimbusFeatures[feature].getAllVariables.returns({ + id: `message-${feature}`, + }); + getExperimentMetaDataStub.withArgs({ featureId: feature }).returns({ + slug: `slug-${feature}`, + branch: { + slug: `branch-${feature}`, + }, + }); + getAllBranchesStub.withArgs(`slug-${feature}`).resolves([ + { + slug: `other-branch-${feature}`, + [feature]: { value: { trigger: "unit-test" } }, + }, + ]); + }); + experimentAPIStub = { + getExperimentMetaData: getExperimentMetaDataStub, + getAllBranches: getAllBranchesStub, + }; + globals.set("ExperimentAPI", experimentAPIStub); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should tag `forReachEvent` for all the expected message types", async () => { + // This should match the `providers.messaging-experiments` + let response = await MessageLoaderUtils.loadMessagesForProvider({ + type: "remote-experiments", + featureIds, + }); + + // 1 message for reach 1 for expose + assert.property(response, "messages"); + assert.lengthOf(response.messages, featureIds.length * 2); + assert.lengthOf( + response.messages.filter(m => m.forReachEvent), + featureIds.length + ); + }); + }); + + describe("#sendTriggerMessage", () => { + it("should pass the trigger to ASRouterTargeting when sending trigger message", async () => { + await Router.setState({ + messages: [ + { + id: "foo1", + provider: "onboarding", + template: "onboarding", + trigger: { id: "firstRun" }, + content: { title: "Foo1", body: "Foo123-1" }, + groups: ["onboarding"], + }, + ], + providers: [{ id: "onboarding" }], + }); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "firstRun", + }); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + assert.deepEqual( + ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger, + { + id: "firstRun", + param: undefined, + context: undefined, + } + ); + }); + it("should record telemetry information", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + const tabId = 123; + + await Router.sendTriggerMessage({ + tabId, + browser: {}, + id: "firstRun", + }); + + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should have previousSessionEnd in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "previousSessionEnd", + 100 + ); + }); + it("should record the Reach event if found any", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "simple_template", + trigger: { id: "bar" }, + content: { title: "Foo2", body: "Foo123-2" }, + provider: "onboarding", + }, + { + id: "foo3", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp02", + branchSlug: "branch02", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledTwice(Services.telemetry.recordEvent); + }); + it("should not record the Reach event if it's already sent", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: true, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + assert.notCalled(Services.telemetry.recordEvent); + }); + it("should record the Exposure event for each valid feature", async () => { + ["cfr_doorhanger", "update_action", "infobar", "spotlight"].forEach( + async template => { + let featureMap = { + cfr_doorhanger: "cfr", + spotlight: "spotlight", + infobar: "infobar", + update_action: "moments-page", + }; + assert.notCalled( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + + let messages = [ + { + id: "foo1", + template, + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledOnce( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + } + ); + }); + }); + + describe("forceAttribution", () => { + let setAttributionString; + beforeEach(() => { + setAttributionString = sandbox.spy(Router, "setAttributionString"); + sandbox.stub(global.Services.env, "set"); + }); + afterEach(() => { + sandbox.reset(); + }); + it("should double encode on windows", async () => { + sandbox.stub(fakeAttributionCode, "writeAttributionFile"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.notCalled(setAttributionString); + assert.calledWithMatch( + fakeAttributionCode.writeAttributionFile, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + it("should set attribution string on mac", async () => { + sandbox.stub(global.AppConstants, "platform").value("macosx"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.calledOnce(setAttributionString); + assert.calledWithMatch( + setAttributionString, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + }); + + describe("#forceWNPanel", () => { + let browser = { + ownerGlobal: { + document: new Document(), + PanelUI: { + showSubView: sinon.stub(), + panel: { + setAttribute: sinon.stub(), + }, + }, + }, + }; + let fakePanel = { + setAttribute: sinon.stub(), + }; + sinon + .stub(browser.ownerGlobal.document, "getElementById") + .returns(fakePanel); + + it("should call enableToolbarButton", async () => { + await Router.forceWNPanel(browser); + assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton); + assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView); + assert.calledWith(fakePanel.setAttribute, "noautohide", true); + }); + }); + + describe("_triggerHandler", () => { + it("should call #sendTriggerMessage with the correct trigger", () => { + const getter = sandbox.stub(); + getter.returns(false); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ + id: "FAKE_TRIGGER", + param: "some fake param", + }) + ); + }); + }); + + describe("_triggerHandler_kiosk", () => { + it("should not call #sendTriggerMessage", () => { + const getter = sandbox.stub(); + getter.returns(true); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.notCalled(Router.sendTriggerMessage); + }); + }); + + describe("valid preview endpoint", () => { + it("should report an error if url protocol is not https", () => { + sandbox.stub(console, "error"); + + assert.equal(false, Router._validPreviewEndpoint("http://foo.com")); + assert.calledTwice(console.error); + }); + }); + + describe("impressions", () => { + describe("#addImpression for groups", () => { + it("should save an impression in each group-with-frequency in a message", async () => { + const fooMessageImpressions = [0]; + const aGroupImpressions = [0, 1, 2]; + const bGroupImpressions = [3, 4, 5]; + const cGroupImpressions = [6, 7, 8]; + + const message = { + id: "foo", + provider: "bar", + groups: ["a", "b", "c"], + }; + const groups = [ + { id: "a", frequency: { lifetime: 3 } }, + { id: "b", frequency: { lifetime: 4 } }, + { id: "c", frequency: { lifetime: 5 } }, + ]; + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.a = aGroupImpressions; + gImpressions.b = bGroupImpressions; + gImpressions.c = cGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.addImpression(message); + + assert.deepEqual( + Router.state.groupImpressions.a, + [0, 1, 2, 0], + "a impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.b, + [3, 4, 5, 0], + "b impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.c, + [6, 7, 8, 0], + "c impressions" + ); + }); + }); + + describe("#isBelowFrequencyCaps", () => { + it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => { + sinon.spy(Router, "_isBelowItemFrequencyCap"); + + const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter + const fooMessageImpressions = [0, 1]; + const barGroupImpressions = [0, 1, 2]; + + const message = { + id: "foo", + provider: "bar", + groups: ["bar"], + frequency: { lifetime: 3 }, + }; + const groups = [{ id: "bar", frequency: { lifetime: 5 } }]; + + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.bar = barGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.isBelowFrequencyCaps(message); + + assert.calledTwice(Router._isBelowItemFrequencyCap); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + message, + fooMessageImpressions, + MAX_MESSAGE_LIFETIME_CAP + ); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + groups[0], + barGroupImpressions + ); + }); + }); + + describe("#_isBelowItemFrequencyCap", () => { + it("should return false if the # of impressions exceeds the maxLifetimeCap", () => { + const item = { id: "foo", frequency: { lifetime: 5 } }; + const impressions = [0, 1]; + const maxLifetimeCap = 1; + const result = Router._isBelowItemFrequencyCap( + item, + impressions, + maxLifetimeCap + ); + assert.isFalse(result); + }); + + describe("lifetime frequency caps", () => { + it("should return true if .frequency is not defined on the item", () => { + const item = { id: "foo" }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if there are no impressions", () => { + const item = { + id: "foo", + frequency: { + lifetime: 10, + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + }, + }; + const impressions = []; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2, 3]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + + describe("custom frequency caps", () => { + it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "foo", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => { + clock.tick(200); + const item = { + id: "msg1", + frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 }, + }; + const impressions = [0, 160, 161]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 200); + const itemTrue = { + id: "msg2", + frequency: { custom: [{ period: 100, cap: 2 }] }, + }; + const itemFalse = { + id: "msg1", + frequency: { + custom: [ + { period: 100, cap: 2 }, + { period: ONE_DAY_IN_MS, cap: 3 }, + ], + }, + }; + const impressions = [ + 0, + ONE_DAY_IN_MS + 160, + ONE_DAY_IN_MS - 100, + ONE_DAY_IN_MS - 200, + ]; + assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions)); + assert.isFalse( + Router._isBelowItemFrequencyCap(itemFalse, impressions) + ); + }); + it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [ + 0, + 1, + 2, + 3, + ONE_DAY_IN_MS + 1, + ONE_DAY_IN_MS + 2, + ONE_DAY_IN_MS + 3, + ]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + }); + + describe("#getLongestPeriod", () => { + it("should return the period if there is only one definition", () => { + const message = { + id: "foo", + frequency: { custom: [{ period: 200, cap: 2 }] }, + }; + assert.equal(Router.getLongestPeriod(message), 200); + }); + it("should return the longest period if there are more than one definitions", () => { + const message = { + id: "foo", + frequency: { + custom: [ + { period: 1000, cap: 3 }, + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }; + assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS); + }); + it("should return null if there are is no .frequency", () => { + const message = { id: "foo" }; + assert.isNull(Router.getLongestPeriod(message)); + }); + it("should return null if there are is no .frequency.custom", () => { + const message = { id: "foo", frequency: { lifetime: 10 } }; + assert.isNull(Router.getLongestPeriod(message)); + }); + }); + + describe("cleanup on init", () => { + it("should clear messageImpressions for messages which do not exist in state.messages", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + messageImpressions = { foo: [0], bar: [0, 1] }; + // Impressions for "bar" should be removed since that id does not exist in messages + const result = { foo: [0] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { + custom: [ + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions if they are not properly formatted", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + // this is impromperly formatted since messageImpressions are supposed to be an array + messageImpressions = { foo: 0 }; + const result = {}; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should not clear messageImpressions for messages which do exist in state.messages", async () => { + const messages = [ + { id: "foo", frequency: { lifetime: 10 } }, + { id: "bar", frequency: { lifetime: 10 } }, + ]; + messageImpressions = { foo: [0], bar: [] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.notCalled(Router._storage.set); + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + }); + }); + + describe("#_onLocaleChanged", () => { + it("should call _maybeUpdateL10nAttachment in the handler", async () => { + sandbox.spy(Router, "_maybeUpdateL10nAttachment"); + await Router._onLocaleChanged(); + + assert.calledOnce(Router._maybeUpdateL10nAttachment); + }); + }); + + describe("#_maybeUpdateL10nAttachment", () => { + it("should update the l10n attachment if the locale was changed", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + sandbox.spy(Router, "setState"); + Router.loadMessagesFromAllProviders.resetHistory(); + + await Router._maybeUpdateL10nAttachment(); + + assert.calledWith(Router.setState, { + localeInUse: "fr", + providers: [ + { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + lastUpdated: undefined, + errors: [], + }, + ], + }); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "localProvider", + enabled: true, + type: "local", + }; + await createRouterAndInit([provider]); + Router.loadMessagesFromAllProviders.resetHistory(); + sandbox.spy(Router, "setState"); + + await Router._maybeUpdateL10nAttachment(); + + assert.notCalled(Router.setState); + assert.notCalled(Router.loadMessagesFromAllProviders); + }); + }); + describe("#observe", () => { + it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", USE_REMOTE_L10N_PREF); + + assert.calledOnce(CFRPageActions.reloadL10n); + }); + it("should not react to other pref changes", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", "foo"); + + assert.notCalled(CFRPageActions.reloadL10n); + }); + }); + describe("#loadAllMessageGroups", () => { + it("should disable the group if the pref is false", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should enable the group if at least one pref is true", async () => { + sandbox + .stub(ASRouterPreferences, "getUserPreference") + .withArgs("cfrAddons") + .returns(false) + .withArgs("cfrFeatures") + .returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons", "cfrFeatures"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + }); + it("should be keep the group disabled if disabled is true", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: false, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should keep local groups unchanged if provider doesn't require an update", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(MessageLoaderUtils, "_loadDataForProvider"); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + // Because it should not have updated + assert.notCalled(MessageLoaderUtils._loadDataForProvider); + }); + it("should update local groups on pref change (no RS update)", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + userPreferences: ["cfrAddons"], + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + // Pref changed, updated the group state + assert.propertyVal(group, "enabled", false); + }); + }); + describe("unblockAll", () => { + it("Clears the message block list and returns the state value", async () => { + await Router.setState({ messageBlockList: ["one", "two", "three"] }); + assert.equal(Router.state.messageBlockList.length, 3); + const state = await Router.unblockAll(); + assert.equal(Router.state.messageBlockList.length, 0); + assert.equal(state.messageBlockList.length, 0); + }); + }); + describe("#loadMessagesForProvider", () => { + it("should fetch messages from the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["spotlight"], + }; + + await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.calledOnce(global.NimbusFeatures.spotlight.getAllVariables); + assert.calledOnce(global.ExperimentAPI.getExperimentMetaData); + assert.calledWithExactly(global.ExperimentAPI.getExperimentMetaData, { + featureId: "spotlight", + }); + }); + it("should handle the case of no experiments in the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + + global.ExperimentAPI.getExperiment.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should normally load ExperimentAPI messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + const enrollment = { + branch: { + slug: "branch01", + infobar: { + featureId: "infobar", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.infobar.getAllVariables.returns( + enrollment.branch.infobar.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.returns([ + enrollment.branch, + { + slug: "control", + infobar: { + featureId: "infobar", + value: null, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 1); + }); + it("should skip disabled features and not load the messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + + global.NimbusFeatures.cfr.getAllVariables.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should fetch branches with trigger", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].id, "id01"); + assert.equal(result.messages[1].id, "id02"); + assert.equal(result.messages[1].experimentSlug, "exp01"); + assert.equal(result.messages[1].branchSlug, "branch02"); + assert.deepEqual(result.messages[1].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + it("should fetch branches with trigger even if enrolled branch is disabled", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: {}, + }, + }, + }; + + // Nedds to match the `featureIds` value to return an enrollment + // for that feature + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, "id02"); + assert.equal(result.messages[0].experimentSlug, "exp01"); + assert.equal(result.messages[0].branchSlug, "branch02"); + assert.deepEqual(result.messages[0].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + }); + describe("#_remoteSettingsLoader", () => { + let provider; + let spy; + beforeEach(() => { + provider = { + id: "cfr", + collection: "cfr", + }; + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + }); + it("should be called with the expected dir path", async () => { + const dlSpy = sandbox.spy(global, "Downloader"); + + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledWith( + dlSpy, + "main", + "ms-language-packs", + "browser", + "newtab" + ); + }); + it("should allow fetch for known locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledOnce(spy); + }); + it("should fallback to 'en-US' for locale 'und' ", async () => { + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "und"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("en-US")); + assert.calledOnce(spy); + }); + it("should fallback to 'ja-JP-mac' for locale 'ja-JP-macos'", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "ja-JP-macos"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("ja-JP-mac")); + assert.calledOnce(spy); + }); + it("should not allow fetch for unsupported locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "unkown"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.notCalled(spy); + }); + }); + describe("#resetMessageState", () => { + it("should reset all message impressions", async () => { + await Router.setState({ + messages: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test messages + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.resetMessageState(); + impressions = Object.values(Router.state.messageImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both messages now have zero impressions + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + }); + }); + }); + describe("#resetGroupsState", () => { + it("should reset all group impressions", async () => { + await Router.setState({ + groups: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + groupImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test groups + let impressions = Object.values(Router.state.groupImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both groups have impressions + + Router.resetGroupsState(); + impressions = Object.values(Router.state.groupImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both groups now have zero impressions + assert.calledWithExactly(Router._storage.set, "groupImpressions", { + 1: [], + 2: [], + }); + }); + }); + describe("#resetScreenImpressions", () => { + it("should reset all screen impressions", async () => { + await Router.setState({ screenImpressions: { 1: 1, 2: 2 } }); + let impressions = Object.values(Router.state.screenImpressions); + assert.equal(impressions.filter(i => i).length, 2); // Both screens have impressions + + Router.resetScreenImpressions(); + impressions = Object.values(Router.state.screenImpressions); + + assert.isEmpty(impressions.filter(i => i)); // Both screens now have zero impressions + assert.calledWithExactly(Router._storage.set, "screenImpressions", {}); + }); + }); + describe("#editState", () => { + it("should update message impressions", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + await Router.setState({ messages: [{ id: "1" }, { id: "2" }] }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.editState("messageImpressions", { + 1: [], + 2: [], + 3: [0, 1, 2], + }); + + // The original messages now have zero impressions + assert.isEmpty(Router.state.messageImpressions["1"]); + assert.isEmpty(Router.state.messageImpressions["2"]); + // A new impression array was added for the new message + assert.equal(Router.state.messageImpressions["3"].length, 3); + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + 3: [0, 1, 2], + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterChild.test.js b/browser/components/asrouter/tests/unit/ASRouterChild.test.js new file mode 100644 index 0000000000..41fdd79ea2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterChild.test.js @@ -0,0 +1,71 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterChild } from "actors/ASRouterChild.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterChild", () => { + let asRouterChild = null; + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + Cu: { + cloneInto: sandbox.stub().returns(Promise.resolve()), + }, + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + asRouterChild = new ASRouterChild(); + asRouterChild.telemetry = { + sendTelemetry: sandbox.stub(), + }; + sandbox.stub(asRouterChild, "sendAsyncMessage"); + sandbox.stub(asRouterChild, "sendQuery").returns(Promise.resolve()); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + asRouterChild = null; + }); + describe("asRouterMessage", () => { + describe("uses sendAsyncMessage for types that don't need an async response", () => { + [ + msg.DISABLE_PROVIDER, + msg.ENABLE_PROVIDER, + msg.EXPIRE_QUERY_CACHE, + msg.FORCE_WHATSNEW_PANEL, + msg.IMPRESSION, + msg.RESET_PROVIDER_PREF, + msg.SET_PROVIDER_USER_PREF, + msg.USER_ACTION, + ].forEach(type => { + it(`type ${type}`, () => { + asRouterChild.asRouterMessage({ + type, + data: { + something: 1, + }, + }); + sandbox.assert.calledOnce(asRouterChild.sendAsyncMessage); + sandbox.assert.calledWith(asRouterChild.sendAsyncMessage, type, { + something: 1, + }); + }); + }); + }); + // Some legacy privileged extensions still send this legacy NEWTAB_MESSAGE_REQUEST + // action type. We simply + it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => { + assert.doesNotThrow(async () => { + let result = await asRouterChild.asRouterMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: {}, + }); + sandbox.assert.deepEqual(result, {}); + sandbox.assert.notCalled(asRouterChild.sendAsyncMessage); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js new file mode 100644 index 0000000000..664b685881 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js @@ -0,0 +1,153 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterNewTabHook } from "modules/ASRouterNewTabHook.sys.mjs"; + +describe("ASRouterNewTabHook", () => { + let sandbox = null; + let initParams = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + initParams = { + router: { + init: sandbox.stub().callsFake(() => { + // Fake the initialization + initParams.router.initialized = true; + }), + uninit: sandbox.stub(), + }, + messageHandler: { + handleCFRAction: {}, + handleTelemetry: {}, + }, + createStorage: () => Promise.resolve({}), + }; + }); + afterEach(() => { + sandbox.restore(); + }); + describe("ASRouterNewTabHook", () => { + describe("getInstance", () => { + it("awaits createInstance and router init before returning instance", async () => { + const getInstanceCall = sandbox.spy(); + const waitForInstance = + ASRouterNewTabHook.getInstance().then(getInstanceCall); + await ASRouterNewTabHook.createInstance(initParams); + await waitForInstance; + assert.callOrder(initParams.router.init, getInstanceCall); + }); + }); + describe("createInstance", () => { + it("calls router init", async () => { + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + it("only calls router init once", async () => { + initParams.router.init.callsFake(() => { + initParams.router.initialized = true; + }); + await ASRouterNewTabHook.createInstance(initParams); + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + }); + describe("destroy", () => { + it("disconnects new tab, uninits ASRouter, and destroys instance", async () => { + await ASRouterNewTabHook.createInstance(initParams); + const instance = await ASRouterNewTabHook.getInstance(); + const destroy = instance.destroy.bind(instance); + sandbox.stub(instance, "destroy").callsFake(destroy); + ASRouterNewTabHook.destroy(); + assert.calledOnce(initParams.router.uninit); + assert.calledOnce(instance.destroy); + assert.isNotNull(instance); + assert.isNull(instance._newTabMessageHandler); + }); + }); + describe("instance", () => { + let routerParams = null; + let messageHandler = null; + let instance = null; + beforeEach(async () => { + messageHandler = { + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + }; + initParams.router.init.callsFake(params => { + routerParams = params; + }); + await ASRouterNewTabHook.createInstance(initParams); + instance = await ASRouterNewTabHook.getInstance(); + }); + describe("connect", () => { + it("before connection messageHandler methods are not called", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("after connect updateAdminState and clearChildMessages calls are forwarded to handler", async () => { + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.called(messageHandler.clearChildMessages); + assert.called(messageHandler.clearChildProviders); + assert.called(messageHandler.updateAdminState); + }); + it("calls from before connection are dropped", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + }); + }); + describe("disconnect", () => { + it("calls after disconnect are dropped", async () => { + instance.connect(messageHandler); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("only calls from when there is a connection are forwarded", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([200]); + routerParams.clearChildProviders(["bar"]); + routerParams.updateAdminState({ + messages: { + data: "accept", + }, + }); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + assert.calledWith(messageHandler.clearChildMessages, [200]); + assert.calledWith(messageHandler.clearChildProviders, ["bar"]); + assert.calledWith(messageHandler.updateAdminState, { + messages: { + data: "accept", + }, + }); + }); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterParent.test.js b/browser/components/asrouter/tests/unit/ASRouterParent.test.js new file mode 100644 index 0000000000..0358b1261c --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterParent.test.js @@ -0,0 +1,83 @@ +import { ASRouterParent } from "actors/ASRouterParent.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; + +describe("ASRouterParent", () => { + let asRouterParent = null; + let sandbox = null; + let handleMessage = null; + let tabs = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + handleMessage = sandbox.stub().resolves("handle-message-result"); + ASRouterParent.nextTabId = 1; + const methods = { + destroy: sandbox.stub(), + size: 1, + messageAll: sandbox.stub().resolves(), + registerActor: sandbox.stub(), + unregisterActor: sandbox.stub(), + loadingMessageHandler: Promise.resolve({ + handleMessage, + }), + }; + tabs = { + methods, + factory: sandbox.stub().returns(methods), + }; + asRouterParent = new ASRouterParent({ tabsFactory: tabs.factory }); + ASRouterParent.tabs = tabs.methods; + asRouterParent.browsingContext = { + embedderElement: { + getAttribute: () => true, + }, + }; + asRouterParent.tabId = ASRouterParent.nextTabId; + }); + afterEach(() => { + sandbox.restore(); + asRouterParent = null; + }); + describe("actorCreated", () => { + it("after ASRouterTabs is instanced", () => { + asRouterParent.actorCreated(); + assert.equal(asRouterParent.tabId, 2); + assert.notCalled(tabs.factory); + assert.calledOnce(tabs.methods.registerActor); + }); + it("before ASRouterTabs is instanced", () => { + ASRouterParent.tabs = null; + ASRouterParent.nextTabId = 0; + asRouterParent.actorCreated(); + assert.calledOnce(tabs.factory); + assert.isNotNull(ASRouterParent.tabs); + assert.equal(asRouterParent.tabId, 1); + }); + }); + describe("didDestroy", () => { + it("one still remains", () => { + ASRouterParent.tabs.size = 1; + asRouterParent.didDestroy(); + assert.isNotNull(ASRouterParent.tabs); + assert.calledOnce(ASRouterParent.tabs.unregisterActor); + assert.notCalled(ASRouterParent.tabs.destroy); + }); + it("none remain", () => { + ASRouterParent.tabs.size = 0; + const tabsCopy = ASRouterParent.tabs; + asRouterParent.didDestroy(); + assert.isNull(ASRouterParent.tabs); + assert.calledOnce(tabsCopy.unregisterActor); + assert.calledOnce(tabsCopy.destroy); + }); + }); + describe("receiveMessage", async () => { + it("passes call to parentProcessMessageHandler and returns the result from handler", async () => { + const result = await asRouterParent.receiveMessage({ + name: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.calledOnce(handleMessage); + assert.equal(result, "handle-message-result"); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js new file mode 100644 index 0000000000..7bfec3e099 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js @@ -0,0 +1,428 @@ +import { ASRouterParentProcessMessageHandler } from "modules/ASRouterParentProcessMessageHandler.sys.mjs"; +import { _ASRouter } from "modules/ASRouter.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; + +describe("ASRouterParentProcessMessageHandler", () => { + let handler = null; + let sandbox = null; + let config = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + const returnValue = { value: 1 }; + const router = new _ASRouter(); + [ + "addImpression", + "evaluateExpression", + "forceAttribution", + "forceWNPanel", + "closeWNPanel", + "forcePBWindow", + "resetGroupsState", + "resetMessageState", + "resetScreenImpressions", + "editState", + ].forEach(method => sandbox.stub(router, `${method}`).resolves()); + [ + "blockMessageById", + "loadMessagesFromAllProviders", + "sendTriggerMessage", + "routeCFRMessage", + "setMessageById", + "updateTargetingParameters", + "unblockMessageById", + "unblockAll", + ].forEach(method => + sandbox.stub(router, `${method}`).resolves(returnValue) + ); + router._storage = { + set: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + }; + sandbox.stub(router, "setState").callsFake(callback => { + if (typeof callback === "function") { + callback({ + messageBlockList: [ + { + id: 0, + }, + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + { + id: 4, + }, + ], + }); + } + return Promise.resolve(returnValue); + }); + const preferences = { + enableOrDisableProvider: sandbox.stub(), + resetProviderPref: sandbox.stub(), + setUserPreference: sandbox.stub(), + }; + const specialMessageActions = { + handleAction: sandbox.stub(), + }; + const queryCache = { + expireAll: sandbox.stub(), + }; + const sendTelemetry = sandbox.stub(); + config = { + router, + preferences, + specialMessageActions, + queryCache, + sendTelemetry, + }; + handler = new ASRouterParentProcessMessageHandler(config); + }); + afterEach(() => { + sandbox.restore(); + handler = null; + config = null; + }); + describe("constructor", () => { + it("does not throw", () => { + assert.isNotNull(handler); + assert.isNotNull(config); + }); + }); + describe("handleCFRAction", () => { + it("non-telemetry type isn't sent to telemetry", () => { + handler.handleCFRAction({ + type: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.router.blockMessageById); + }); + it("passes browser to handleMessage", async () => { + await handler.handleCFRAction( + { + type: msg.USER_ACTION, + data: { id: 1 }, + }, + { ownerGlobal: {} } + ); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { id: 1 }, + { ownerGlobal: {} } + ); + }); + [ + msg.AS_ROUTER_TELEMETRY_USER_EVENT, + msg.TOOLBAR_BADGE_TELEMETRY, + msg.TOOLBAR_PANEL_TELEMETRY, + msg.MOMENTS_PAGE_TELEMETRY, + msg.DOORHANGER_TELEMETRY, + ].forEach(type => { + it(`telemetry type "${type}" is sent to telemetry`, () => { + handler.handleCFRAction({ + type, + data: { id: 1 }, + }); + assert.calledOnce(config.sendTelemetry); + assert.notCalled(config.router.blockMessageById); + }); + }); + }); + describe("#handleMessage", () => { + it("#default: should throw for unknown msg types", () => { + handler.handleMessage("err").then( + () => assert.fail("It should not succeed"), + () => assert.ok(true) + ); + }); + describe("#AS_ROUTER_TELEMETRY_USER_EVENT", () => { + it("should route AS_ROUTER_TELEMETRY_USER_EVENT to handleTelemetry", async () => { + const data = { data: "foo" }; + await handler.handleMessage(msg.AS_ROUTER_TELEMETRY_USER_EVENT, data); + + assert.calledOnce(handler.handleTelemetry); + assert.calledWithExactly(handler.handleTelemetry, { + type: msg.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); + }); + }); + describe("BLOCK_MESSAGE_BY_ID action", () => { + it("with preventDismiss returns false", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + preventDismiss: true, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isFalse(result); + }); + it("by default returns true", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isTrue(result); + }); + }); + describe("USER_ACTION action", () => { + it("default calls SpecialMessageActions.handleAction", async () => { + await handler.handleMessage( + msg.USER_ACTION, + { + type: "SOMETHING", + }, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { type: "SOMETHING" }, + { ownerGlobal: {} } + ); + }); + }); + describe("IMPRESSION action", () => { + it("default calls addImpression", () => { + handler.handleMessage(msg.IMPRESSION, { + id: 1, + }); + assert.calledOnce(config.router.addImpression); + }); + }); + describe("TRIGGER action", () => { + it("default calls sendTriggerMessage and returns state", async () => { + const result = await handler.handleMessage( + msg.TRIGGER, + { + trigger: { stuff: {} }, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.sendTriggerMessage); + assert.calledWith(config.router.sendTriggerMessage, { + stuff: {}, + tabId: 100, + browser: { ownerGlobal: {} }, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("ADMIN_CONNECT_STATE action", () => { + it("with endpoint url calls loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE, { + endpoint: { + url: "test", + }, + }); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + it("default returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE); + assert.calledOnce(config.router.updateTargetingParameters); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_MESSAGE_BY_ID action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.unblockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_ALL action", () => { + it("default calls unblockAll", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_ALL); + assert.calledOnce(config.router.unblockAll); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("BLOCK_BUNDLE action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.BLOCK_BUNDLE, { + bundle: [ + { + id: 8, + }, + { + id: 13, + }, + ], + }); + assert.calledOnce(config.router.blockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_BUNDLE action", () => { + it("default calls setState", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_BUNDLE, { + bundle: [ + { + id: 1, + }, + { + id: 3, + }, + ], + }); + assert.calledOnce(config.router.setState); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("DISABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.DISABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("ENABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.ENABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("EVALUATE_JEXL_EXPRESSION action", () => { + it("default calls evaluateExpression", () => { + handler.handleMessage(msg.EVALUATE_JEXL_EXPRESSION, {}); + assert.calledOnce(config.router.evaluateExpression); + }); + }); + describe("EXPIRE_QUERY_CACHE action", () => { + it("default calls QueryCache.expireAll", () => { + handler.handleMessage(msg.EXPIRE_QUERY_CACHE); + assert.calledOnce(config.queryCache.expireAll); + }); + }); + describe("FORCE_ATTRIBUTION action", () => { + it("default calls forceAttribution", () => { + handler.handleMessage(msg.FORCE_ATTRIBUTION, {}); + assert.calledOnce(config.router.forceAttribution); + }); + }); + describe("FORCE_WHATSNEW_PANEL action", () => { + it("default calls forceWNPanel", () => { + handler.handleMessage( + msg.FORCE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forceWNPanel); + assert.calledWith(config.router.forceWNPanel, { ownerGlobal: {} }); + }); + }); + describe("CLOSE_WHATSNEW_PANEL action", () => { + it("default calls closeWNPanel", () => { + handler.handleMessage( + msg.CLOSE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.closeWNPanel); + assert.calledWith(config.router.closeWNPanel, { ownerGlobal: {} }); + }); + }); + describe("FORCE_PRIVATE_BROWSING_WINDOW action", () => { + it("default calls forcePBWindow", () => { + handler.handleMessage( + msg.FORCE_PRIVATE_BROWSING_WINDOW, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forcePBWindow); + assert.calledWith(config.router.forcePBWindow, { ownerGlobal: {} }); + }); + }); + describe("MODIFY_MESSAGE_JSON action", () => { + it("default calls routeCFRMessage", async () => { + const result = await handler.handleMessage( + msg.MODIFY_MESSAGE_JSON, + { + content: { + text: "something", + }, + }, + { browser: { ownerGlobal: {} }, id: 100 } + ); + assert.calledOnce(config.router.routeCFRMessage); + assert.calledWith( + config.router.routeCFRMessage, + { text: "something" }, + { ownerGlobal: {} }, + { content: { text: "something" } }, + true + ); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("OVERRIDE_MESSAGE action", () => { + it("default calls setMessageById", async () => { + const result = await handler.handleMessage( + msg.OVERRIDE_MESSAGE, + { + id: 1, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.setMessageById); + assert.calledWith(config.router.setMessageById, { id: 1 }, true, { + ownerGlobal: {}, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("RESET_PROVIDER_PREF action", () => { + it("default calls ASRouterPreferences.resetProviderPref", () => { + handler.handleMessage(msg.RESET_PROVIDER_PREF); + assert.calledOnce(config.preferences.resetProviderPref); + }); + }); + describe("SET_PROVIDER_USER_PREF action", () => { + it("default calls ASRouterPreferences.setUserPreference", () => { + handler.handleMessage(msg.SET_PROVIDER_USER_PREF, { + id: 1, + value: true, + }); + assert.calledOnce(config.preferences.setUserPreference); + assert.calledWith(config.preferences.setUserPreference, 1, true); + }); + }); + describe("RESET_GROUPS_STATE action", () => { + it("default calls resetGroupsState, loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.RESET_GROUPS_STATE, { + property: "value", + }); + assert.calledOnce(config.router.resetGroupsState); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("RESET_MESSAGE_STATE action", () => { + it("default calls resetMessageState", () => { + handler.handleMessage(msg.RESET_MESSAGE_STATE); + assert.calledOnce(config.router.resetMessageState); + }); + }); + describe("RESET_SCREEN_IMPRESSIONS action", () => { + it("default calls resetScreenImpressions", () => { + handler.handleMessage(msg.RESET_SCREEN_IMPRESSIONS); + assert.calledOnce(config.router.resetScreenImpressions); + }); + }); + describe("EDIT_STATE action", () => { + it("default calls editState with correct args", () => { + handler.handleMessage(msg.EDIT_STATE, { property: "value" }); + assert.calledWith(config.router.editState, "property", "value"); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js new file mode 100644 index 0000000000..a3fe1fc5c9 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js @@ -0,0 +1,480 @@ +import { + _ASRouterPreferences, + ASRouterPreferences as ASRouterPreferencesSingleton, + TEST_PROVIDERS, +} from "modules/ASRouterPreferences.sys.mjs"; +const FAKE_PROVIDERS = [{ id: "foo" }, { id: "bar" }]; + +const PROVIDER_PREF_BRANCH = + "browser.newtabpage.activity-stream.asrouter.providers."; +const DEVTOOLS_PREF = + "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled"; +const CFR_USER_PREF_ADDONS = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons"; +const CFR_USER_PREF_FEATURES = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features"; + +/** NUMBER_OF_PREFS_TO_OBSERVE includes: + * 1. asrouter.providers. pref branch + * 2. asrouter.devtoolsEnabled + * 3. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons (user preference - cfr) + * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features (user preference - cfr) + * 5. services.sync.username + */ +const NUMBER_OF_PREFS_TO_OBSERVE = 5; + +describe("ASRouterPreferences", () => { + let ASRouterPreferences; + let sandbox; + let addObserverStub; + let stringPrefStub; + let boolPrefStub; + let resetStub; + let hasUserValueStub; + let childListStub; + let setStringPrefStub; + + beforeEach(() => { + ASRouterPreferences = new _ASRouterPreferences(); + + sandbox = sinon.createSandbox(); + addObserverStub = sandbox.stub(global.Services.prefs, "addObserver"); + stringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + resetStub = sandbox.stub(global.Services.prefs, "clearUserPref"); + setStringPrefStub = sandbox.stub(global.Services.prefs, "setStringPref"); + FAKE_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(JSON.stringify(provider)); + }); + + boolPrefStub = sandbox + .stub(global.Services.prefs, "getBoolPref") + .returns(false); + + hasUserValueStub = sandbox + .stub(global.Services.prefs, "prefHasUserValue") + .returns(false); + + childListStub = sandbox.stub(global.Services.prefs, "getChildList"); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + FAKE_PROVIDERS.map(provider => `${PROVIDER_PREF_BRANCH}${provider.id}`) + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function getPrefNameForProvider(providerId) { + return `${PROVIDER_PREF_BRANCH}${providerId}`; + } + + function setPrefForProvider(providerId, value) { + stringPrefStub + .withArgs(getPrefNameForProvider(providerId)) + .returns(JSON.stringify(value)); + } + + it("ASRouterPreferences should be an instance of _ASRouterPreferences", () => { + assert.instanceOf(ASRouterPreferencesSingleton, _ASRouterPreferences); + }); + describe("#init", () => { + it("should set ._initialized to true", () => { + ASRouterPreferences.init(); + assert.isTrue(ASRouterPreferences._initialized); + }); + it("should migrate the provider prefs", () => { + ASRouterPreferences.uninit(); + // Should be migrated because they contain bucket and not collection + const MIGRATE_PROVIDERS = [ + { id: "baz", bucket: "buk" }, + { id: "qux", bucket: "buk" }, + ]; + // Should be cleared to defaults because it throws on setStringPref + const ERROR_PROVIDER = { id: "err", bucket: "buk" }; + // Should not be migrated because, although modified, it lacks bucket + const MODIFIED_SAFE_PROVIDER = { id: "safe" }; + const ALL_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + ...FAKE_PROVIDERS, // Should not be migrated because they're unmodified + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + // The migrator should attempt to read prefs for all of these providers + const TRY_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + + // Update the full list of provider prefs + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + ALL_PROVIDERS.map(provider => getPrefNameForProvider(provider.id)) + ); + // Stub the pref values so the migrator can read them + ALL_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(getPrefNameForProvider(provider.id)) + .returns(JSON.stringify(provider)); + }); + + // Consider these providers' prefs "modified" + TRY_PROVIDERS.forEach(provider => { + hasUserValueStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(true); + }); + // Spoof an error when trying to set the pref for this provider so we can + // test that the pref is gracefully reset on error + setStringPrefStub + .withArgs(getPrefNameForProvider(ERROR_PROVIDER.id)) + .throws(); + + ASRouterPreferences.init(); + + // The migrator should have tried to check each pref for user modification + ALL_PROVIDERS.forEach(provider => + assert.calledWith(hasUserValueStub, getPrefNameForProvider(provider.id)) + ); + // Test that we don't call getStringPref for providers that don't have a + // user-defined value + FAKE_PROVIDERS.forEach(provider => + assert.neverCalledWith( + stringPrefStub, + getPrefNameForProvider(provider.id) + ) + ); + // But we do call it for providers that do have a user-defined value + TRY_PROVIDERS.forEach(provider => + assert.calledWith(stringPrefStub, getPrefNameForProvider(provider.id)) + ); + + // Test that we don't call setStringPref to migrate providers that don't + // have a bucket property + assert.neverCalledWith( + setStringPrefStub, + getPrefNameForProvider(MODIFIED_SAFE_PROVIDER.id) + ); + + /** + * For a given provider, return a sinon matcher that matches if the value + * looks like a migrated version of the original provider. Requires that: + * its id matches the original provider's id; it has no bucket; and its + * collection is set to the value of the original provider's bucket. + * @param {object} provider the provider object to compare to + * @returns {object} custom matcher object for sinon + */ + function providerJsonMatches(provider) { + return sandbox.match(migrated => { + const parsed = JSON.parse(migrated); + return ( + parsed.id === provider.id && + !("bucket" in parsed) && + parsed.collection === provider.bucket + ); + }); + } + + // Test that we call setStringPref to migrate providers that have a bucket + // property and don't have a collection property + MIGRATE_PROVIDERS.forEach(provider => + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider(provider.id), + providerJsonMatches(provider) // Verify the migrated pref value + ) + ); + + // Test that we clear the pref for providers that throw when we try to + // read or write them + assert.calledWith(resetStub, getPrefNameForProvider(ERROR_PROVIDER.id)); + }); + it(`should set ${NUMBER_OF_PREFS_TO_OBSERVE} observers and not re-initialize if already initialized`, () => { + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + ASRouterPreferences.init(); + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + }); + }); + describe("#uninit", () => { + it("should set ._initialized to false", () => { + ASRouterPreferences.init(); + ASRouterPreferences.uninit(); + assert.isFalse(ASRouterPreferences._initialized); + }); + it("should clear cached values for ._initialized, .devtoolsEnabled", () => { + ASRouterPreferences.init(); + // trigger caching + // eslint-disable-next-line no-unused-vars + const result = [ + ASRouterPreferences.providers, + ASRouterPreferences.devtoolsEnabled, + ]; + assert.isNotNull( + ASRouterPreferences._providers, + "providers should not be null" + ); + assert.isNotNull( + ASRouterPreferences._devtoolsEnabled, + "devtolosEnabled should not be null" + ); + + ASRouterPreferences.uninit(); + assert.isNull(ASRouterPreferences._providers); + assert.isNull(ASRouterPreferences._devtoolsEnabled); + }); + it("should clear all listeners and remove observers (only once)", () => { + const removeStub = sandbox.stub(global.Services.prefs, "removeObserver"); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(() => {}); + ASRouterPreferences.addListener(() => {}); + assert.equal(ASRouterPreferences._callbacks.size, 2); + ASRouterPreferences.uninit(); + // Tests to make sure we don't remove observers that weren't set + ASRouterPreferences.uninit(); + + assert.callCount(removeStub, NUMBER_OF_PREFS_TO_OBSERVE); + assert.calledWith(removeStub, PROVIDER_PREF_BRANCH); + assert.calledWith(removeStub, DEVTOOLS_PREF); + assert.isEmpty(ASRouterPreferences._callbacks); + }); + }); + describe(".providers", () => { + it("should return the value the first time .providers is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.providers; + assert.deepEqual(result, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should return the cached value the second time .providers is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(secondCall, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(firstCall, FAKE_PROVIDERS); + assert.deepEqual(secondCall, FAKE_PROVIDERS); + assert.callCount(stringPrefStub, 4); + }); + it("should skip the pref without throwing if a pref is not parsable", () => { + stringPrefStub.withArgs(`${PROVIDER_PREF_BRANCH}foo`).returns("not json"); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [{ id: "bar" }]); + }); + it("should include TEST_PROVIDERS if devtools is turned on", () => { + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [ + ...TEST_PROVIDERS, + ...FAKE_PROVIDERS, + ]); + }); + }); + describe(".devtoolsEnabled", () => { + it("should read the pref the first time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.devtoolsEnabled; + assert.deepEqual(result, false); + assert.calledOnce(boolPrefStub); + }); + it("should return the cached value the second time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(secondCall, false); + assert.calledOnce(boolPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(firstCall, false); + assert.deepEqual(secondCall, false); + assert.calledTwice(boolPrefStub); + }); + }); + describe("#getAllUserPreferences", () => { + it("should return all user preferences", () => { + boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false); + boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true); + const result = ASRouterPreferences.getAllUserPreferences(); + assert.deepEqual(result, { + cfrAddons: false, + cfrFeatures: true, + }); + }); + }); + describe("#enableOrDisableProvider", () => { + it("should enable an existing provider if second param is true", () => { + setPrefForProvider("foo", { id: "foo", enabled: false }); + assert.isFalse(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", true); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: true }) + ); + }); + it("should disable an existing provider if second param is false", () => { + setPrefForProvider("foo", { id: "foo", enabled: true }); + assert.isTrue(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", false); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: false }) + ); + }); + it("should not throw if the id does not exist", () => { + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("does_not_exist", true); + }); + }); + it("should not throw if pref is not parseable", () => { + stringPrefStub + .withArgs(getPrefNameForProvider("foo")) + .returns("not valid"); + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("foo", true); + }); + }); + }); + describe("#setUserPreference", () => { + it("should do nothing if the pref doesn't exist", () => { + ASRouterPreferences.setUserPreference("foo", true); + assert.notCalled(boolPrefStub); + }); + it("should set the given pref", () => { + const setStub = sandbox.stub(global.Services.prefs, "setBoolPref"); + ASRouterPreferences.setUserPreference("cfrAddons", true); + assert.calledWith(setStub, CFR_USER_PREF_ADDONS, true); + }); + }); + describe("#resetProviderPref", () => { + it("should reset the pref and user prefs", () => { + ASRouterPreferences.resetProviderPref(); + FAKE_PROVIDERS.forEach(provider => { + assert.calledWith(resetStub, getPrefNameForProvider(provider.id)); + }); + assert.calledWith(resetStub, CFR_USER_PREF_ADDONS); + assert.calledWith(resetStub, CFR_USER_PREF_FEATURES); + }); + }); + describe("observer, listeners", () => { + it("should invalidate .providers when the pref is changed", () => { + const testProvider = { id: "newstuff" }; + const newProviders = [...FAKE_PROVIDERS, testProvider]; + + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, FAKE_PROVIDERS); + stringPrefStub + .withArgs(getPrefNameForProvider(testProvider.id)) + .returns(JSON.stringify(testProvider)); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + newProviders.map(provider => getPrefNameForProvider(provider.id)) + ); + ASRouterPreferences.observe( + null, + null, + getPrefNameForProvider(testProvider.id) + ); + + // Cache should be invalidated so we access the new value of the pref now + assert.deepEqual(ASRouterPreferences.providers, newProviders); + }); + it("should invalidate .devtoolsEnabled and .providers when the pref is changed", () => { + ASRouterPreferences.init(); + + assert.isFalse(ASRouterPreferences.devtoolsEnabled); + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + childListStub.withArgs(PROVIDER_PREF_BRANCH).returns([]); + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + + // Cache should be invalidated so we access the new value of the pref now + // Note that providers needs to be invalidated because devtools adds test content to it. + assert.isTrue(ASRouterPreferences.devtoolsEnabled); + assert.deepEqual(ASRouterPreferences.providers, TEST_PROVIDERS); + }); + it("should call listeners added with .addListener", () => { + const callback1 = sinon.stub(); + const callback2 = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback1); + ASRouterPreferences.addListener(callback2); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback1, getPrefNameForProvider("foo")); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.calledWith(callback2, DEVTOOLS_PREF); + }); + it("should not call listeners after they are removed with .removeListeners", () => { + const callback = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback, getPrefNameForProvider("foo")); + + callback.reset(); + ASRouterPreferences.removeListener(callback); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.notCalled(callback); + }); + }); + describe("#_transformPersonalizedCfrScores", () => { + it("should report JSON.parse errors", () => { + sandbox.stub(global.console, "error"); + + ASRouterPreferences._transformPersonalizedCfrScores(""); + + assert.calledOnce(global.console.error); + }); + it("should return an object parsed from a string", () => { + const scores = { FOO: 3000, BAR: 4000 }; + assert.deepEqual( + ASRouterPreferences._transformPersonalizedCfrScores( + JSON.stringify(scores) + ), + scores + ); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js new file mode 100644 index 0000000000..610b488b47 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js @@ -0,0 +1,574 @@ +import { + ASRouterTargeting, + CachedTargetingGetter, + getSortedMessages, + QueryCache, +} from "modules/ASRouterTargeting.sys.mjs"; +import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; +import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +// Note that tests for the ASRouterTargeting environment can be found in +// test/functional/mochitest/browser_asrouter_targeting.js + +describe("#CachedTargetingGetter", () => { + const sixHours = 6 * 60 * 60 * 1000; + let sandbox; + let clock; + let frecentStub; + let topsitesCache; + let globals; + let doesAppNeedPinStub; + let getAddonsByTypesStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + frecentStub = sandbox.stub( + global.NewTabUtils.activityStreamProvider, + "getTopFrecentSites" + ); + topsitesCache = new CachedTargetingGetter("getTopFrecentSites"); + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return sinon.stub(); + } + + evalWithDefault(expr) { + return sinon.stub(); + } + } + ); + doesAppNeedPinStub = sandbox.stub().resolves(); + getAddonsByTypesStub = sandbox.stub().resolves(); + }); + + afterEach(() => { + sandbox.restore(); + clock.restore(); + globals.restore(); + }); + + it("should cache allow for optional getter argument", async () => { + let pinCachedGetter = new CachedTargetingGetter( + "doesAppNeedPin", + true, + undefined, + { doesAppNeedPin: doesAppNeedPinStub } + ); + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await pinCachedGetter.get(); + await pinCachedGetter.get(); + await pinCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(doesAppNeedPinStub); + + // Called with option argument + assert.calledWith(doesAppNeedPinStub, true); + + // Expire and call again + clock.tick(sixHours); + await pinCachedGetter.get(); + + // Call goes through + assert.calledTwice(doesAppNeedPinStub); + + let themesCachedGetter = new CachedTargetingGetter( + "getAddonsByTypes", + ["foo"], + undefined, + { getAddonsByTypes: getAddonsByTypesStub } + ); + + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await themesCachedGetter.get(); + await themesCachedGetter.get(); + await themesCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(getAddonsByTypesStub); + + // Called with option argument + assert.calledWith(getAddonsByTypesStub, ["foo"]); + + // Expire and call again + clock.tick(sixHours); + await themesCachedGetter.get(); + + // Call goes through + assert.calledTwice(getAddonsByTypesStub); + }); + + it("should only make a request every 6 hours", async () => { + frecentStub.resolves(); + clock.tick(sixHours); + + await topsitesCache.get(); + await topsitesCache.get(); + + assert.calledOnce( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + + clock.tick(sixHours); + + await topsitesCache.get(); + + assert.calledTwice( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + }); + it("throws when failing getter", async () => { + frecentStub.rejects(new Error("fake error")); + clock.tick(sixHours); + + // assert.throws expect a function as the first parameter, try/catch is a + // workaround + let rejected = false; + try { + await topsitesCache.get(); + } catch (e) { + rejected = true; + } + + assert(rejected); + }); + describe("sortMessagesByPriority", () => { + it("should sort messages in descending priority order", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: 1 }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m2.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m1.id); + }); + it("should sort messages with no priority last", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: undefined }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m1.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + it("should keep the order of messages with same priority unchanged", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 2, targeting: undefined, rank: 1 }, + { ...m2, priority: undefined, targeting: undefined, rank: 1 }, + { ...m3, priority: 2, targeting: undefined, rank: 1 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m1.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m3.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + }); +}); +describe("#isTriggerMatch", () => { + let trigger; + let message; + beforeEach(() => { + trigger = { id: "openURL" }; + message = { id: "openURL" }; + }); + it("should return false if trigger and candidate ids are different", () => { + trigger.id = "trigger"; + message.id = "message"; + + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + assert.isTrue( + ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" }) + ); + }); + it("should return true if the message we check doesn't have trigger params or patterns", () => { + // No params or patterns defined + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return false if the trigger does not have params defined", () => { + message.params = {}; + + // trigger.param is undefined + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger host", () => { + message.params = ["mozilla.org"]; + trigger.param = { host: "mozilla.org" }; + + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger param.type", () => { + message.params = ["ContentBlockingMilestone"]; + trigger.param = { type: "ContentBlockingMilestone" }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); + it("should return true if message params match trigger mask", () => { + // STATE_BLOCKED_FINGERPRINTING_CONTENT + message.params = [0x00000040]; + trigger.param = { type: 538091584 }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); +}); +describe("#CacheListAttachedOAuthClients", () => { + const fourHours = 4 * 60 * 60 * 1000; + let sandbox; + let clock; + let fakeFxAccount; + let authClientsCache; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fakeFxAccount = { + listAttachedOAuthClients: () => {}, + }; + globals.set("fxAccounts", fakeFxAccount); + authClientsCache = QueryCache.queries.ListAttachedOAuthClients; + sandbox + .stub(global.fxAccounts, "listAttachedOAuthClients") + .returns(Promise.resolve({})); + }); + + afterEach(() => { + authClientsCache.expire(); + sandbox.restore(); + clock.restore(); + }); + + it("should only make additional request every 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + clock.tick(fourHours); + await authClientsCache.get(); + assert.calledTwice(global.fxAccounts.listAttachedOAuthClients); + }); + + it("should not make additional request before 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + }); +}); +describe("ASRouterTargeting", () => { + let evalStub; + let sandbox; + let clock; + let globals; + let fakeTargetingContext; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.replace(ASRouterTargeting, "Environment", {}); + clock = sinon.useFakeTimers(); + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + setTelemetrySource: sandbox.stub(), + }; + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + setTelemetrySource(id) { + fakeTargetingContext.setTelemetrySource(id); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + } + ); + evalStub = fakeTargetingContext.evalWithDefault; + }); + afterEach(() => { + clock.restore(); + sandbox.restore(); + globals.restore(); + }); + it("should provide message.id as source", async () => { + await ASRouterTargeting.checkMessageTargeting( + { + id: "message", + targeting: "true", + }, + fakeTargetingContext, + sandbox.stub(), + false + ); + assert.calledOnce(fakeTargetingContext.evalWithDefault); + assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true"); + assert.calledWithExactly( + fakeTargetingContext.setTelemetrySource, + "message" + ); + }); + it("should cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl2" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + it("should not cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + + assert.calledThrice(evalStub); + }); + it("should expire cache entries", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + clock.tick(5 * 60 * 1000 + 1); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + + describe("#findMatchingMessage", () => { + let matchStub; + let messages = [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + { id: "BAZ" }, + ]; + beforeEach(() => { + matchStub = sandbox + .stub(ASRouterTargeting, "_isMessageMatch") + .callsFake(message => message.targeting === "match"); + }); + it("should return an array of matches if returnAll is true", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + ] + ); + }); + it("should return an empty array if no matches were found and returnAll is true", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [] + ); + }); + it("should return the first match if returnAll is false", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + messages[0] + ); + }); + it("should return null if if no matches were found and returnAll is false", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + null + ); + }); + }); +}); + +/** + * Messages should be sorted in the following order: + * 1. Rank + * 2. Priority + * 3. If the message has targeting + * 4. Order or randomization, depending on input + */ +describe("getSortedMessages", () => { + let globals = new GlobalOverrider(); + let sandbox; + beforeEach(() => { + globals.set({ ASRouterPreferences }); + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + /** + * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages, + * returns the items in the expected order. + * + * @param {Message[]} expectedOrderArray - The array of messages in its expected order + * @param {{}} options - The options param for getSortedMessages + * @returns + */ + function assertSortsCorrectly(expectedOrderArray, options) { + const input = [...expectedOrderArray].reverse(); + const result = getSortedMessages(input, options); + const indexes = result.map(message => expectedOrderArray.indexOf(message)); + return assert.equal( + indexes.join(","), + [...expectedOrderArray.keys()].join(","), + "Messsages are out of order" + ); + } + + it("should sort messages by priority, then by targeting", () => { + assertSortsCorrectly([ + { priority: 100, targeting: "isFoo" }, + { priority: 100 }, + { priority: 99 }, + { priority: 1, targeting: "isFoo" }, + { priority: 1 }, + {}, + ]); + }); + it("should sort messages by priority, then targeting, then order if ordered param is true", () => { + assertSortsCorrectly( + [ + { priority: 100, order: 4 }, + { priority: 100, order: 5 }, + { priority: 1, order: 3, targeting: "isFoo" }, + { priority: 1, order: 0 }, + { priority: 1, order: 1 }, + { priority: 1, order: 2 }, + { order: 0 }, + ], + { ordered: true } + ); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js new file mode 100644 index 0000000000..aa455e23a2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js @@ -0,0 +1,833 @@ +import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs"; +import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterTriggerListeners", () => { + let sandbox; + let globals; + let existingWindow; + let isWindowPrivateStub; + const triggerHandler = () => {}; + const openURLListener = ASRouterTriggerListeners.get("openURL"); + const frequentVisitsListener = ASRouterTriggerListeners.get("frequentVisits"); + const captivePortalLoginListener = + ASRouterTriggerListeners.get("captivePortalLogin"); + const bookmarkedURLListener = + ASRouterTriggerListeners.get("openBookmarkedURL"); + const openArticleURLListener = ASRouterTriggerListeners.get("openArticleURL"); + const nthTabClosedListener = ASRouterTriggerListeners.get("nthTabClosed"); + const idleListener = ASRouterTriggerListeners.get("activityAfterIdle"); + const formAutofillListener = ASRouterTriggerListeners.get("formAutofill"); + const cookieBannerDetectedListener = ASRouterTriggerListeners.get( + "cookieBannerDetected" + ); + const cookieBannerHandledListener = ASRouterTriggerListeners.get( + "cookieBannerHandled" + ); + const hosts = ["www.mozilla.com", "www.mozilla.org"]; + + const regionFake = { + _home: "cn", + _current: "cn", + get home() { + return this._home; + }, + get current() { + return this._current; + }, + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + existingWindow = { + gBrowser: { + addTabsProgressListener: sandbox.stub(), + removeTabsProgressListener: sandbox.stub(), + currentURI: { host: "" }, + }, + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }; + sandbox.spy(openURLListener, "init"); + sandbox.spy(openURLListener, "uninit"); + isWindowPrivateStub = sandbox.stub(); + // Assume no window is private so that we execute the action + isWindowPrivateStub.returns(false); + globals.set("PrivateBrowsingUtils", { + isWindowPrivate: isWindowPrivateStub, + }); + const ewUninit = new Map(); + globals.set("EveryWindow", { + registerCallback: (id, init, uninit) => { + init(existingWindow); + ewUninit.set(id, uninit); + }, + unregisterCallback: id => { + ewUninit.get(id)(existingWindow); + }, + }); + globals.set("Region", regionFake); + globals.set("ASRouterPreferences", ASRouterPreferences); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("openBookmarkedURL", () => { + let observerStub; + describe("#init", () => { + beforeEach(() => { + observerStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns({ gBrowser: { selectedBrowser: {} } }); + }); + afterEach(() => { + bookmarkedURLListener.uninit(); + }); + it("should set hosts to the recentBookmarks", async () => { + await bookmarkedURLListener.init(sandbox.stub()); + + assert.calledOnce(observerStub); + assert.calledWithExactly( + observerStub, + bookmarkedURLListener, + "bookmark-icon-updated" + ); + }); + it("should provide id to triggerHandler", async () => { + const newTriggerHandler = sinon.stub(); + const subject = {}; + await bookmarkedURLListener.init(newTriggerHandler); + + bookmarkedURLListener.observe( + subject, + "bookmark-icon-updated", + "starred" + ); + + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, subject, { + id: bookmarkedURLListener.id, + }); + }); + }); + }); + + describe("captivePortal", () => { + describe("observe", () => { + it("should not call the trigger handler if _shouldShowCaptivePortalVPNPromo returns false", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(false); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.notCalled(captivePortalLoginListener._triggerHandler); + }); + + it("should call the trigger handler if _shouldShowCaptivePortalVPNPromo returns true", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(true); + sandbox.stub(Services.wm, "getMostRecentBrowserWindow").returns({ + gBrowser: { + selectedBrowser: true, + }, + }); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.calledOnce(captivePortalLoginListener._triggerHandler); + }); + }); + }); + + describe("openArticleURL", () => { + describe("#init", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ + patterns, + matches: url => patterns.has(url), + })) + ); + sandbox.stub(global.AboutReaderParent, "addMessageListener"); + sandbox.stub(global.AboutReaderParent, "removeMessageListener"); + }); + afterEach(() => { + openArticleURLListener.uninit(); + }); + it("setup an event listener on init", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + + assert.calledOnce(global.AboutReaderParent.addMessageListener); + assert.calledWithExactly( + global.AboutReaderParent.addMessageListener, + openArticleURLListener.readerModeEvent, + sinon.match.object + ); + }); + it("should call triggerHandler correctly for matches [host match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: hosts[0], spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: hosts[0], url: hosts[1] }, + }); + }); + it("should call triggerHandler correctly for matches [pattern match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: null, spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: null, url: hosts[1] }, + }); + }); + it("should remove the message listener", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + openArticleURLListener.uninit(); + + assert.calledOnce(global.AboutReaderParent.removeMessageListener); + }); + }); + }); + + describe("frequentVisits", () => { + let _triggerHandler; + beforeEach(() => { + _triggerHandler = sandbox.stub(); + sandbox.useFakeTimers(); + frequentVisitsListener.init(_triggerHandler, hosts); + }); + afterEach(() => { + sandbox.clock.restore(); + frequentVisitsListener.uninit(); + }); + it("should be initialized", () => { + assert.isTrue(frequentVisitsListener._initialized); + }); + it("should listen for TabSelect events", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith( + existingWindow.addEventListener, + "TabSelect", + frequentVisitsListener.onTabSwitch + ); + }); + it("should call _triggerHandler if the visit is valid (is recoreded)", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler only once", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler again after 15 minutes", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + sandbox.clock.tick(15 * 60 * 1000 + 1); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledTwice(_triggerHandler); + }); + it("should call triggerHandler on valid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = hosts[0]; // eslint-disable-line prefer-destructuring + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.calledOnce(stub); + }); + it("should not call triggerHandler on invalid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = "foo.com"; + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.notCalled(stub); + }); + describe("MatchPattern", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ patterns: patterns || [] })) + ); + }); + afterEach(() => { + frequentVisitsListener.uninit(); + }); + it("should create a matchPatternSet", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + + assert.calledOnce(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern"]), + undefined + ); + }); + it("should allow to add multiple patterns and dedupe", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.calledTwice(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern", "foo"]), + undefined + ); + }); + it("should handle bad arguments to MatchPatternSet", () => { + const badArgs = ["www.example.com"]; + window.MatchPatternSet.withArgs(new Set(badArgs)).throws(); + frequentVisitsListener.init(_triggerHandler, hosts, badArgs); + + // Fails with an empty MatchPatternSet + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + + // Second try is succesful + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + assert.isTrue( + frequentVisitsListener._matchPatternSet.patterns.has("foo") + ); + }); + }); + }); + + describe("nthTabClosed", () => { + describe("#init", () => { + beforeEach(() => { + nthTabClosedListener.init(triggerHandler); + }); + afterEach(() => { + nthTabClosedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + nthTabClosedListener.init(newTriggerHandler); + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, newTriggerHandler); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith(existingWindow.addEventListener, "TabClose"); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + nthTabClosedListener.init(triggerHandler); + nthTabClosedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler, closed tabs count", () => { + assert.notOk(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, null); + assert.equal(nthTabClosedListener._closedTabs, 0); + }); + + it("should do nothing if already uninitialised", () => { + nthTabClosedListener.uninit(); + assert.notOk(nthTabClosedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.removeEventListener); + }); + }); + }); + + describe("activityAfterIdle", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getEnumerator") + .returns([{ closed: false, document: { hidden: false } }]); + idleListener.init(triggerHandler); + }); + afterEach(() => { + idleListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + idleListener.init(newTriggerHandler); + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, newTriggerHandler); + }); + + it("should add observers for idle and activity", () => { + assert.called(addObsStub); + }); + + it("should add event listeners to all existing browser windows", () => { + assert.called(existingWindow.addEventListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + sandbox.stub(global.Services.wm, "getEnumerator").returns([]); + idleListener.init(triggerHandler); + idleListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(idleListener._initialized); + assert.equal(idleListener._triggerHandler, null); + assert.equal(idleListener._quietSince, null); + }); + + it("should do nothing if already uninitialised", () => { + idleListener.uninit(); + assert.notOk(idleListener._initialized); + }); + + it("should remove observers for idle and activity", () => { + assert.called(removeObsStub); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); + + describe("formAutofill", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + formAutofillListener.init(triggerHandler); + }); + afterEach(() => { + formAutofillListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + formAutofillListener.init(newTriggerHandler); + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, newTriggerHandler); + }); + + it(`should add observer for ${formAutofillListener._topic}`, () => { + assert.called(addObsStub); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + formAutofillListener.init(triggerHandler); + formAutofillListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler", () => { + assert.notOk(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + formAutofillListener.uninit(); + assert.notOk(formAutofillListener._initialized); + }); + + it(`should remove observers for ${formAutofillListener._topic}`, () => { + assert.called(removeObsStub); + }); + }); + }); + + describe("openURL listener", () => { + it("should exist and initially be uninitialised", () => { + assert.ok(openURLListener); + assert.notOk(openURLListener._initialized); + }); + + describe("#init", () => { + beforeEach(() => { + openURLListener.init(triggerHandler, hosts); + }); + afterEach(() => { + openURLListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler and hosts", () => { + assert.ok(openURLListener._initialized); + assert.deepEqual(openURLListener._hosts, new Set(hosts)); + assert.equal(openURLListener._triggerHandler, triggerHandler); + }); + + it("should add tab progress listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.addTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.addTabsProgressListener, + openURLListener + ); + }); + + it("if already initialised, should only update the trigger handler and add the new hosts", () => { + const newHosts = ["www.example.com"]; + const newTriggerHandler = () => {}; + existingWindow.gBrowser.addTabsProgressListener.reset(); + + openURLListener.init(newTriggerHandler, newHosts); + assert.ok(openURLListener._initialized); + assert.deepEqual( + openURLListener._hosts, + new Set([...hosts, ...newHosts]) + ); + assert.equal(openURLListener._triggerHandler, newTriggerHandler); + assert.notCalled(existingWindow.gBrowser.addTabsProgressListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + openURLListener.init(triggerHandler, hosts); + openURLListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler and hosts", () => { + assert.notOk(openURLListener._initialized); + assert.equal(openURLListener._hosts, null); + assert.equal(openURLListener._triggerHandler, null); + }); + + it("should remove tab progress listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.removeTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.removeTabsProgressListener, + openURLListener + ); + }); + + it("should do nothing if already uninitialised", () => { + existingWindow.gBrowser.removeTabsProgressListener.reset(); + + openURLListener.uninit(); + assert.notOk(openURLListener._initialized); + assert.notCalled(existingWindow.gBrowser.removeTabsProgressListener); + }); + }); + + describe("#onLocationChange", () => { + afterEach(() => { + openURLListener.uninit(); + frequentVisitsListener.uninit(); + }); + + it("should call the ._triggerHandler with the right arguments", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const location = "www.mozilla.org"; + openURLListener.onLocationChange(browser, webProgress, undefined, { + host: location, + spec: location, + }); + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should fail for subdomains (not redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { + spec: "subdomain.mozilla.org", + host: "subdomain.mozilla.org", + }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.notCalled(newTriggerHandler); + }); + }); + }); + + describe("cookieBannerDetected", () => { + describe("#init", () => { + beforeEach(() => { + cookieBannerDetectedListener.init(triggerHandler); + }); + afterEach(() => { + cookieBannerDetectedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + triggerHandler + ); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + cookieBannerDetectedListener.init(newTriggerHandler); + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + newTriggerHandler + ); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + cookieBannerDetectedListener.init(triggerHandler); + cookieBannerDetectedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(cookieBannerDetectedListener._initialized); + assert.equal(cookieBannerDetectedListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + cookieBannerDetectedListener.uninit(); + assert.notOk(cookieBannerDetectedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); + + describe("cookieBannerHandled", () => { + describe("#init", () => { + beforeEach(() => { + cookieBannerHandledListener.init(triggerHandler); + }); + afterEach(() => { + cookieBannerHandledListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(cookieBannerHandledListener._initialized); + assert.equal( + cookieBannerHandledListener._triggerHandler, + triggerHandler + ); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + cookieBannerHandledListener.init(newTriggerHandler); + assert.ok(cookieBannerHandledListener._initialized); + assert.equal( + cookieBannerHandledListener._triggerHandler, + newTriggerHandler + ); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + cookieBannerHandledListener.init(triggerHandler); + cookieBannerHandledListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(cookieBannerHandledListener._initialized); + assert.equal(cookieBannerHandledListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + cookieBannerHandledListener.uninit(); + assert.notOk(cookieBannerHandledListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js new file mode 100644 index 0000000000..fe6959852c --- /dev/null +++ b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js @@ -0,0 +1,32 @@ +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; + +const REGULAR_IDS = [ + "FACEBOOK_CONTAINER", + "GOOGLE_TRANSLATE", + "YOUTUBE_ENHANCE", + // These are excluded for now. + // "WIKIPEDIA_CONTEXT_MENU_SEARCH", + // "REDDIT_ENHANCEMENT", +]; + +describe("CFRMessageProvider", () => { + let messages; + beforeEach(async () => { + messages = await CFRMessageProvider.getMessages(); + }); + it("should have a total of 11 messages", () => { + assert.lengthOf(messages, 11); + }); + it("should have one message each for the three regular addons", () => { + for (const id of REGULAR_IDS) { + const cohort3 = messages.find(msg => msg.id === `${id}_3`); + assert.ok(cohort3, `contains three day cohort for ${id}`); + assert.deepEqual( + cohort3.frequency, + { lifetime: 3 }, + "three day cohort has the right frequency cap" + ); + assert.notInclude(cohort3.targeting, `providerCohorts.cfr`); + } + }); +}); diff --git a/browser/components/asrouter/tests/unit/CFRPageActions.test.js b/browser/components/asrouter/tests/unit/CFRPageActions.test.js new file mode 100644 index 0000000000..31970eb43a --- /dev/null +++ b/browser/components/asrouter/tests/unit/CFRPageActions.test.js @@ -0,0 +1,1414 @@ +/* eslint max-nested-callbacks: ["error", 100] */ + +import { CFRPageActions, PageAction } from "modules/CFRPageActions.sys.mjs"; +import { FAKE_RECOMMENDATION } from "./constants"; +import { GlobalOverrider } from "test/unit/utils"; +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; + +describe("CFRPageActions", () => { + let sandbox; + let clock; + let fakeRecommendation; + let fakeHost; + let fakeBrowser; + let dispatchStub; + let globals; + let containerElem; + let elements; + let announceStub; + let fakeRemoteL10n; + let isElmVisibleStub; + let getWidgetStub; + + const elementIDs = [ + "urlbar", + "urlbar-input", + "contextual-feature-recommendation", + "cfr-button", + "cfr-label", + "contextual-feature-recommendation-notification", + "cfr-notification-header-label", + "cfr-notification-header-link", + "cfr-notification-header-image", + "cfr-notification-author", + "cfr-notification-footer", + "cfr-notification-footer-text", + "cfr-notification-footer-filled-stars", + "cfr-notification-footer-empty-stars", + "cfr-notification-footer-users", + "cfr-notification-footer-spacer", + "cfr-notification-footer-learn-more-link", + ]; + const elementClassNames = ["popup-notification-body-container"]; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sandbox.useFakeTimers(); + isElmVisibleStub = sandbox.stub().returns(true); + getWidgetStub = sandbox.stub(); + + announceStub = sandbox.stub(); + const A11yUtils = { announce: announceStub }; + fakeRecommendation = { ...FAKE_RECOMMENDATION }; + fakeHost = "mozilla.org"; + fakeBrowser = { + documentURI: { + scheme: "https", + host: fakeHost, + }, + ownerGlobal: window, + }; + dispatchStub = sandbox.stub(); + + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox.stub().returns(document.createElement("div")), + }; + + const gURLBar = document.createElement("div"); + gURLBar.textbox = document.createElement("div"); + + globals = new GlobalOverrider(); + globals.set({ + RemoteL10n: fakeRemoteL10n, + promiseDocumentFlushed: sandbox + .stub() + .callsFake(fn => Promise.resolve(fn())), + PopupNotifications: { + show: sandbox.stub(), + remove: sandbox.stub(), + }, + PrivateBrowsingUtils: { isWindowPrivate: sandbox.stub().returns(false) }, + gBrowser: { selectedBrowser: fakeBrowser }, + A11yUtils, + gURLBar, + isElementVisible: isElmVisibleStub, + CustomizableUI: { getWidget: getWidgetStub }, + }); + document.createXULElement = document.createElement; + + elements = {}; + const [body] = document.getElementsByTagName("body"); + containerElem = document.createElement("div"); + body.appendChild(containerElem); + for (const id of elementIDs) { + const elem = document.createElement("div"); + elem.setAttribute("id", id); + containerElem.appendChild(elem); + elements[id] = elem; + } + for (const className of elementClassNames) { + const elem = document.createElement("div"); + elem.setAttribute("class", className); + containerElem.appendChild(elem); + elements[className] = elem; + } + }); + + afterEach(() => { + CFRPageActions.clearRecommendations(); + containerElem.remove(); + sandbox.restore(); + globals.restore(); + }); + + describe("PageAction", () => { + let pageAction; + + beforeEach(() => { + pageAction = new PageAction(window, dispatchStub); + }); + + describe("#addImpression", () => { + it("should call _sendTelemetry with the impression payload", () => { + const recommendation = { + id: "foo", + content: { bucket_id: "bar" }, + }; + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction.addImpression(recommendation); + + assert.calledWith(pageAction._sendTelemetry, { + message_id: "foo", + bucket_id: "bar", + event: "IMPRESSION", + }); + }); + }); + + describe("#showAddressBarNotifier", () => { + it("should un-hideAddressBarNotifier the element and set the right label value", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.isFalse(pageAction.container.hidden); + assert.equal( + pageAction.label.value, + fakeRecommendation.content.notification_text + ); + }); + it("should wait for the document layout to flush", async () => { + sandbox.spy(pageAction.label, "getClientRects"); + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.calledOnce(global.promiseDocumentFlushed); + assert.callOrder( + global.promiseDocumentFlushed, + pageAction.label.getClientRects + ); + }); + it("should set the CSS variable --cfr-label-width correctly", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + const expectedWidth = pageAction.label.getClientRects()[0].width; + assert.equal( + pageAction.urlbarinput.style.getPropertyValue("--cfr-label-width"), + `${expectedWidth}px` + ); + }); + it("should cause an expansion, and dispatch an impression if `expand` is true", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + sandbox.spy(pageAction, "_expand"); + sandbox.spy(pageAction, "_dispatchImpression"); + + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.notCalled(pageAction._dispatchImpression); + clock.tick(1001); + assert.notEqual( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledOnce(pageAction._clearScheduledStateChanges); + clock.tick(1001); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + assert.calledOnce(pageAction._dispatchImpression); + assert.calledWith(pageAction._dispatchImpression, fakeRecommendation); + }); + it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + }); + + describe("#hideAddressBarNotifier", () => { + it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction.hideAddressBarNotifier(); + assert.isTrue(pageAction.container.hidden); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + }); + it("should remove the `currentNotification`", () => { + const notification = {}; + pageAction.currentNotification = notification; + pageAction.hideAddressBarNotifier(); + assert.calledWith(global.PopupNotifications.remove, notification); + }); + }); + + describe("#_expand", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to 'expanded'", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._expand(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + it("with a delay, should set the expanded state after the correct amount of time", () => { + const delay = 1234; + pageAction._expand(delay); + // We expect that an expansion has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + }); + + describe("#_collapse", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._collapse(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state") + ); + pageAction.urlbarinput.setAttribute( + "cfr-recommendation-state", + "expanded" + ); + pageAction._collapse(); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + it("with a delay, should set the collapsed state after the correct amount of time", () => { + const delay = 1234; + pageAction._collapse(delay); + clock.tick(delay + 1); + // The state was _not_ "expanded" and so should not have been set to "collapsed" + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + + pageAction._expand(); + pageAction._collapse(delay); + // We expect that a collapse has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + // This time it was "expanded" so should now (after the delay) be "collapsed" + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + }); + + describe("#_clearScheduledStateChanges", () => { + it("should call .clearTimeout on all stored timeoutIDs", () => { + pageAction.stateTransitionTimeoutIDs = [42, 73, 1997]; + sandbox.spy(pageAction.window, "clearTimeout"); + pageAction._clearScheduledStateChanges(); + assert.calledThrice(pageAction.window.clearTimeout); + assert.calledWith(pageAction.window.clearTimeout, 42); + assert.calledWith(pageAction.window.clearTimeout, 73); + assert.calledWith(pageAction.window.clearTimeout, 1997); + }); + }); + + describe("#_popupStateChange", () => { + it("should collapse the notification and send dismiss telemetry on 'dismissed'", () => { + pageAction._expand(); + + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction._popupStateChange("dismissed"); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + + assert.equal( + pageAction._sendTelemetry.lastCall.args[0].event, + "DISMISS" + ); + }); + it("should remove the notification on 'removed'", () => { + pageAction._expand(); + const fakeNotification = {}; + + pageAction.currentNotification = fakeNotification; + pageAction._popupStateChange("removed"); + assert.calledOnce(global.PopupNotifications.remove); + assert.calledWith(global.PopupNotifications.remove, fakeNotification); + }); + it("should do nothing for other states", () => { + pageAction._popupStateChange("opened"); + assert.notCalled(global.PopupNotifications.remove); + }); + }); + + describe("#dispatchUserAction", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakeAction = {}; + pageAction.dispatchUserAction(fakeAction); + assert.calledOnce(dispatchStub); + assert.calledWith( + dispatchStub, + { type: "USER_ACTION", data: fakeAction }, + fakeBrowser + ); + }); + }); + + describe("#_dispatchImpression", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._dispatchImpression("fake impression"); + assert.calledWith(dispatchStub, { + type: "IMPRESSION", + data: "fake impression", + }); + }); + }); + + describe("#_sendTelemetry", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakePing = { message_id: 42 }; + pageAction._sendTelemetry(fakePing); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: 42, + }, + }); + }); + }); + + describe("#_blockMessage", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._blockMessage("fake id"); + assert.calledOnce(dispatchStub); + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: "fake id" }, + }); + }); + }); + + describe("#getStrings", () => { + let formatMessagesStub; + const localeStrings = [ + { + value: "你好世界", + attributes: [ + { name: "first_attr", value: 42 }, + { name: "second_attr", value: "some string" }, + { name: "third_attr", value: [1, 2, 3] }, + ], + }, + ]; + + beforeEach(() => { + formatMessagesStub = sandbox + .stub() + .withArgs({ id: "hello_world" }) + .resolves(localeStrings); + global.RemoteL10n.l10n.formatMessages = formatMessagesStub; + }); + + it("should return the argument if a string_id is not defined", async () => { + assert.deepEqual(await pageAction.getStrings({}), {}); + assert.equal(await pageAction.getStrings("some string"), "some string"); + }); + it("should get the right locale string", async () => { + assert.equal( + await pageAction.getStrings({ string_id: "hello_world" }), + localeStrings[0].value + ); + }); + it("should return the right sub-attribute if specified", async () => { + assert.equal( + await pageAction.getStrings( + { string_id: "hello_world" }, + "second_attr" + ), + "some string" + ); + }); + it("should attach attributes to string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson); + + assert.equal(result, fromJson.value); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes when doing string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson, "accesskey"); + + assert.equal(result, "A"); + }); + it("should resolve ftl strings and attach subAttributes", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl); + + assert.equal(result, "Add Now"); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes from ftl ids", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl, "accesskey"); + + assert.equal(result, "A"); + }); + it("should report an error when no attributes are present but subAttribute is requested", async () => { + const fromJson = { value: "Foo" }; + const stub = sandbox.stub(global.console, "error"); + + await pageAction.getStrings(fromJson, "accesskey"); + + assert.calledOnce(stub); + stub.restore(); + }); + }); + + describe("#_cfrUrlbarButtonClick", () => { + let translateElementsStub; + let setAttributesStub; + let getStringsStub; + beforeEach(async () => { + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + getStringsStub = sandbox.stub(pageAction, "getStrings").resolves(""); + getStringsStub + .callsFake(async a => a) // eslint-disable-line max-nested-callbacks + .withArgs({ string_id: "primary_button_id" }) + .resolves({ value: "Primary Button", attributes: { accesskey: "p" } }) + .withArgs({ string_id: "secondary_button_id" }) + .resolves({ + value: "Secondary Button", + attributes: { accesskey: "s" }, + }) + .withArgs({ string_id: "secondary_button_id_2" }) + .resolves({ + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }) + .withArgs({ string_id: "secondary_button_id_3" }) + .resolves({ + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }) + .withArgs( + sinon.match({ + string_id: "cfr-doorhanger-extension-learn-more-link", + }) + ) + .resolves("Learn more") + .withArgs( + sinon.match({ string_id: "cfr-doorhanger-extension-total-users" }) + ) + .callsFake(async ({ args }) => `${args.total} users`); // eslint-disable-line max-nested-callbacks + + translateElementsStub = sandbox.stub().resolves(); + setAttributesStub = sandbox.stub(); + global.RemoteL10n.l10n.setAttributes = setAttributesStub; + global.RemoteL10n.l10n.translateElements = translateElementsStub; + }); + + it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.delete(fakeBrowser); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.notCalled(global.PopupNotifications.show); + }); + it("should cancel any planned state changes", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + assert.notCalled(pageAction._clearScheduledStateChanges); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction._clearScheduledStateChanges); + }); + it("should set the right text values", async () => { + await pageAction._cfrUrlbarButtonClick({}); + const headerLabel = elements["cfr-notification-header-label"]; + const headerLink = elements["cfr-notification-header-link"]; + const headerImage = elements["cfr-notification-header-image"]; + const footerLink = elements["cfr-notification-footer-learn-more-link"]; + assert.equal( + headerLabel.value, + fakeRecommendation.content.heading_text + ); + assert.isTrue( + headerLink + .getAttribute("href") + .endsWith(fakeRecommendation.content.info_icon.sumo_path) + ); + assert.equal( + headerImage.getAttribute("tooltiptext"), + fakeRecommendation.content.info_icon.label + ); + const htmlFooterEl = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => + args && args.content === fakeRecommendation.content.text + ); + assert.ok(htmlFooterEl); + assert.equal(footerLink.value, "Learn more"); + assert.equal( + footerLink.getAttribute("href"), + fakeRecommendation.content.addon.amo_url + ); + }); + it("should add the rating correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerFilledStars = + elements["cfr-notification-footer-filled-stars"]; + const footerEmptyStars = + elements["cfr-notification-footer-empty-stars"]; + // .toFixed to sort out some floating precision errors + assert.equal( + footerFilledStars.style.width, + `${(4.2 * 16).toFixed(1)}px` + ); + assert.equal( + footerEmptyStars.style.width, + `${(0.8 * 16).toFixed(1)}px` + ); + }); + it("should add the number of users correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerUsers = elements["cfr-notification-footer-users"]; + assert.isNull(footerUsers.getAttribute("hidden")); + assert.equal( + footerUsers.getAttribute("value"), + `${fakeRecommendation.content.addon.users}` + ); + }); + it("should send the right telemetry", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should set the main action correctly", async () => { + sinon + .stub(CFRPageActions, "_fetchLatestAddonVersion") + .resolves("latest-addon.xpi"); + await pageAction._cfrUrlbarButtonClick(); + const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring + assert.deepEqual(mainAction.label, { + value: "Primary Button", + attributes: { accesskey: "p" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + await mainAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + // Should block the message + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: fakeRecommendation.id }, + }); + // Should trigger the action + assert.calledWith( + dispatchStub, + { + type: "USER_ACTION", + data: { id: "primary_action", data: { url: "latest-addon.xpi" } }, + }, + fakeBrowser + ); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "INSTALL", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should set the secondary action correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const [secondaryAction] = + global.PopupNotifications.show.firstCall.args[5]; + + assert.deepEqual(secondaryAction.label, { + value: "Secondary Button", + attributes: { accesskey: "s" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + secondaryAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "DISMISS", + }, + }); + // Don't remove the recommendation on `DISMISS` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should send right telemetry for BLOCK secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const blockAction = global.PopupNotifications.show.firstCall.args[5][1]; + + assert.deepEqual(blockAction.label, { + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + sandbox.spy(pageAction, "_blockMessage"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + blockAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.calledOnce(pageAction._blockMessage); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "BLOCK", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should send right telemetry for MANAGE secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const manageAction = + global.PopupNotifications.show.firstCall.args[5][2]; + + assert.deepEqual(manageAction.label, { + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + manageAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "MANAGE", + }, + }); + // Don't remove the recommendation on `MANAGE` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should call PopupNotifications.show with the right arguments", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith( + global.PopupNotifications.show, + fakeBrowser, + "contextual-feature-recommendation", + fakeRecommendation.content.addon.title, + "cfr", + sinon.match.any, // Corresponds to the main action, tested above + sinon.match.any, // Corresponds to the secondary action, tested above + { + popupIconURL: fakeRecommendation.content.addon.icon, + hideClose: true, + eventCallback: pageAction._popupStateChange, + persistent: false, + persistWhileVisible: false, + popupIconClass: fakeRecommendation.content.icon_class, + recordTelemetryInPrivateBrowsing: + fakeRecommendation.content.show_in_private_browsing, + name: { + string_id: "cfr-doorhanger-extension-author", + args: { name: fakeRecommendation.content.addon.author }, + }, + } + ); + }); + }); + describe("#_cfrUrlbarButtonClick/cfr_urlbar_chiclet", () => { + let heartbeatRecommendation; + beforeEach(async () => { + heartbeatRecommendation = (await CFRMessageProvider.getMessages()).find( + m => m.template === "cfr_urlbar_chiclet" + ); + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + heartbeatRecommendation, + dispatchStub + ); + }); + it("should dispatch a click event", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: heartbeatRecommendation.id, + bucket_id: heartbeatRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should dispatch a USER_ACTION for chiclet_open_url layout", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "USER_ACTION", + data: { + data: { + args: heartbeatRecommendation.content.action.url, + where: heartbeatRecommendation.content.action.where, + }, + type: "OPEN_URL", + }, + }); + }); + it("should block the message after the click", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: heartbeatRecommendation.id }, + }); + }); + it("should remove the button and browser entry", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + + describe("#showMilestonePopup", () => { + let milestoneRecommendation; + let fakeTrackingDBService; + beforeEach(async () => { + fakeTrackingDBService = { + sumAllEvents: sandbox.stub(), + }; + globals.set({ TrackingDBService: fakeTrackingDBService }); + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + sandbox + .stub(pageAction, "getStrings") + .callsFake(async a => a) // eslint-disable-line max-nested-callbacks + .resolves({ value: "element", attributes: { accesskey: "e" } }); + + milestoneRecommendation = (await CFRMessageProvider.getMessages()).find( + m => m.template === "milestone_message" + ); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("Set current date in header when earliest date undefined", async () => { + fakeTrackingDBService.getEarliestRecordedDate = sandbox.stub(); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + milestoneRecommendation, + dispatchStub + ); + const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => args && args.content && args.attributes + ); + assert.equal( + headerElementArgs.content.string_id, + milestoneRecommendation.content.heading_text.string_id + ); + assert.equal(headerElementArgs.attributes.date, new Date().getTime()); + assert.calledOnce(global.PopupNotifications.show); + }); + + it("Set date in header to earliest date timestamp by default", async () => { + let earliestDateTimeStamp = 1705601996435; + fakeTrackingDBService.getEarliestRecordedDate = sandbox + .stub() + .returns(earliestDateTimeStamp); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + milestoneRecommendation, + dispatchStub + ); + const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => args && args.content && args.attributes + ); + assert.equal( + headerElementArgs.content.string_id, + milestoneRecommendation.content.heading_text.string_id + ); + assert.equal(headerElementArgs.attributes.date, earliestDateTimeStamp); + assert.calledOnce(global.PopupNotifications.show); + }); + }); + }); + + describe("CFRPageActions", () => { + beforeEach(() => { + // Spy on the prototype methods to inspect calls for any PageAction instance + sandbox.spy(PageAction.prototype, "showAddressBarNotifier"); + sandbox.spy(PageAction.prototype, "hideAddressBarNotifier"); + }); + + describe("updatePageActions", () => { + let savedRec; + + beforeEach(() => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.set( + win, + new PageAction(win, dispatchStub) + ); + const { id, content } = fakeRecommendation; + savedRec = { + id, + host: fakeHost, + content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + }); + + it("should do nothing if a pageAction doesn't exist for the window", () => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.delete(win); + CFRPageActions.updatePageActions(fakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should do nothing if the browser is not the `selectedBrowser`", () => { + const someOtherFakeBrowser = {}; + CFRPageActions.updatePageActions(someOtherFakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => { + CFRPageActions.RecommendationMap.delete(fakeBrowser); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + }); + it("should show the pageAction if a recommendation exists and the host matches", () => { + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + savedRec + ); + }); + it("should show the pageAction if a recommendation exists and it doesn't have a host defined", () => { + const recNoHost = { ...savedRec, host: undefined }; + CFRPageActions.RecommendationMap.set(fakeBrowser, recNoHost); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + recNoHost + ); + }); + it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + fakeBrowser.documentURI.host = someOtherFakeHost; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should not call `delete` if retain is true", () => { + savedRec.retain = true; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should call `delete` if retain is false", () => { + savedRec.retain = false; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + + describe("forceRecommendation", () => { + it("should succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + }); + + describe("showPopup", () => { + let savedRec; + let pageAction; + let fakeAnchorId = "fake_anchor_id"; + let fakeAltAnchorId = "fake_alt_anchor_id"; + let TEST_MESSAGE; + let getElmStub; + let getStyleStub; + let isCustomizingStub; + beforeEach(() => { + TEST_MESSAGE = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + heading_text: "Fake Heading Text", + anchor_id: fakeAnchorId, + }, + }; + getElmStub = sandbox + .stub(window.document, "getElementById") + .callsFake(id => ({ id })); + getStyleStub = sandbox + .stub(window, "getComputedStyle") + .returns({ display: "block", visibility: "visible" }); + + isCustomizingStub = sandbox.stub().returns(false); + globals.set({ + CustomizationHandler: { isCustomizing: isCustomizingStub }, + }); + + savedRec = { + id: TEST_MESSAGE.id, + host: fakeHost, + content: TEST_MESSAGE.content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + pageAction = new PageAction(window, dispatchStub); + sandbox.stub(pageAction, "_renderPopup"); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should use anchor_id if element exists and is not a customizable widget", async () => { + await pageAction.showPopup(); + assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId); + }); + + it("should use anchor_id if element exists and is in the toolbar", async () => { + getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: "toolbar" }); + await pageAction.showPopup(); + assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId); + }); + + it("should use the cfr button if element exists but is in the widget overflow panel", async () => { + getWidgetStub + .withArgs(fakeAnchorId) + .returns({ areaType: "menu-panel" }); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use the cfr button if element exists but is in the customization palette", async () => { + getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element cannot be found", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getElmStub.withArgs(fakeAnchorId).returns(null); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element is hidden by CSS", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element has no height/width", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + isElmVisibleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns(false); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use the button if the anchor_id and alt_anchor_id are both not visible", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use the default container if the anchor_id, alt_anchor_id, and cfr button are not visible", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + getStyleStub + .withArgs(sandbox.match({ id: "cfr-button" })) + .returns({ display: "none", visibility: "visible" }); + getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.container.id + ); + }); + }); + + describe("addRecommendation", () => { + it("should fail and not add a recommendation if the browser is part of a private window", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should successfully add a private browsing recommendation and send correct telemetry", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + fakeRecommendation.content.show_in_private_browsing = true; + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + const pageAction = CFRPageActions.PageActionMap.get( + fakeBrowser.ownerGlobal + ); + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + is_private: true, + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + it("should fail and not add a recommendation if the browser is not the selected browser", async () => { + global.gBrowser.selectedBrowser = {}; // Some other browser + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should fail and not add a recommendation if the browser does not exist", async () => { + assert.isFalse( + await CFRPageActions.addRecommendation( + undefined, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should fail and not add a recommendation if the host doesn't match", async () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + someOtherFakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should otherwise succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + it("should add the right url if we fetched and addon install URL", async () => { + fakeRecommendation.template = "cfr_doorhanger"; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const recommendation = + CFRPageActions.RecommendationMap.get(fakeBrowser); + + // sanity check - just go through some of the rest of the attributes to make sure they were untouched + assert.equal(recommendation.id, fakeRecommendation.id); + assert.equal( + recommendation.content.heading_text, + fakeRecommendation.content.heading_text + ); + assert.equal( + recommendation.content.addon, + fakeRecommendation.content.addon + ); + assert.equal( + recommendation.content.text, + fakeRecommendation.content.text + ); + assert.equal( + recommendation.content.buttons.secondary, + fakeRecommendation.content.buttons.secondary + ); + assert.equal( + recommendation.content.buttons.primary.action.id, + fakeRecommendation.content.buttons.primary.action.id + ); + + delete fakeRecommendation.template; + }); + it("should prevent a second message if one is currently displayed", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + let messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + + assert.isTrue(messageAdded); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + + messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + // Adding failed + assert.isFalse(messageAdded); + // First message is still there + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should send impressions just for the first message", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + + // Doorhanger telemetry + Impression for just 1 message + assert.calledTwice(dispatchStub); + const [firstArgs] = dispatchStub.firstCall.args; + const [secondArgs] = dispatchStub.secondCall.args; + assert.equal(firstArgs.data.id, secondArgs.data.message_id); + }); + }); + + describe("clearRecommendations", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + const browsers = [{}, {}, {}, {}]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + for (const browser of browsers) { + CFRPageActions.RecommendationMap.set(browser, {}); + } + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.clearRecommendations(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].hideAddressBarNotifier); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].hideAddressBarNotifier); + }); + it("should clear the PageActionMap and the RecommendationMap", () => { + CFRPageActions.clearRecommendations(); + + // Both are WeakMaps and so are not iterable, cannot be cleared, and + // cannot have their length queried directly, so we have to check + // whether previous elements still exist + assert.lengthOf(windows, 3); + for (const win of windows) { + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + } + assert.lengthOf(browsers, 4); + for (const browser of browsers) { + assert.isFalse(CFRPageActions.RecommendationMap.has(browser)); + } + }); + }); + + describe("reloadL10n", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier() {}, + reloadL10n: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should call reloadL10n for all the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.reloadL10n(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].reloadL10n); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].reloadL10n); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js new file mode 100644 index 0000000000..463e388651 --- /dev/null +++ b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js @@ -0,0 +1,459 @@ +import { MessageLoaderUtils } from "modules/ASRouter.sys.mjs"; +const { STARTPAGE_VERSION } = MessageLoaderUtils; + +const FAKE_OPTIONS = { + storage: { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve(); + }, + }, + dispatchToAS: () => {}, +}; +const FAKE_RESPONSE_HEADERS = { get() {} }; + +describe("MessageLoaderUtils", () => { + let fetchStub; + let clock; + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fetchStub = sinon.stub(global, "fetch"); + }); + afterEach(() => { + sandbox.restore(); + clock.restore(); + fetchStub.restore(); + }); + + describe("#loadMessagesForProvider", () => { + it("should return messages for a local provider with hardcoded messages", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + }); + it("should filter out local messages listed in the `exclude` field", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + exclude: ["foo"], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.lengthOf(result.messages, 0); + }); + it("should return messages for remote provider", async () => { + const sourceMessage = { id: "foo" }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: [sourceMessage] }), + headers: FAKE_RESPONSE_HEADERS, + }); + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + assert.propertyVal(message, "provider_url", "https://foo.com"); + }); + describe("remote provider HTTP codes", () => { + const testMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + const respJson = { messages: [testMessage] }; + + function assertReturnsCorrectMessages(actual) { + assert.isArray(actual.messages); + // Does the message have the right properties? + const [message] = actual.messages; + assert.propertyVal(message, "id", testMessage.id); + assert.propertyVal(message, "provider", provider.id); + assert.propertyVal(message, "provider_url", provider.url); + } + + it("should return messages for 200 response", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return messages for a 302 response with json", async () => { + fetchStub.resolves({ + ok: true, + status: 302, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return an empty array for a 204 response", async () => { + fetchStub.resolves({ + ok: true, + status: 204, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return an empty array for a 500 response", async () => { + fetchStub.resolves({ + ok: false, + status: 500, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return cached messages for a 304 response", async () => { + clock.tick(302); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 304, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return an empty array if json doesn't parse properly", async () => { + fetchStub.resolves({ + ok: false, + status: 200, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should report response parsing errors with MessageLoaderUtils.reportError", async () => { + const err = {}; + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().rejects(err), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report that json parsing failed + assert.calledWith(MessageLoaderUtils.reportError, err); + }); + + it("should report missing `messages` with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "No messages returned from https://foo.com." + ); + }); + + it("should report bad status responses with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: false, + status: 500, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "Invalid response status 500 from https://foo.com." + ); + }); + + it("should return an empty array if the request rejects", async () => { + fetchStub.rejects(new Error("something went wrong")); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + }); + describe("remote provider caching", () => { + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + + it("should return cached results if they aren't expired", async () => { + clock.tick(1); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: Date.now(), + }, + }); + }, + }; + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return fetch results if the cache messages are expired", async () => { + clock.tick(302); + const testMessage = { id: "foo" }; + const respJson = { messages: [testMessage] }; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages: [{ id: "message-1" }, { id: "message-2" }], + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, testMessage.id); + }); + }); + it("should return an empty array for a remote provider with a blank URL without attempting a request", async () => { + const provider = { id: "provider123", type: "remote", url: "" }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.notCalled(fetchStub); + assert.deepEqual(result.messages, []); + }); + it("should return .lastUpdated with the time at which the messages were fetched", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "foo.com", + }; + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + new Promise(resolve => { + clock.tick(42); + resolve({ messages: [sourceMessage] }); + }), + headers: FAKE_RESPONSE_HEADERS, + }); + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.propertyVal(result, "lastUpdated", 42); + }); + }); + + describe("#shouldProviderUpdate", () => { + it("should return true if the provider does not had a .lastUpdated property", () => { + assert.isTrue(MessageLoaderUtils.shouldProviderUpdate({ id: "foo" })); + }); + it("should return false if the provider does not had a .updateCycleInMs property and has a .lastUpdated", () => { + clock.tick(1); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ id: "foo", lastUpdated: 0 }) + ); + }); + it("should return true if the time since .lastUpdated is greater than .updateCycleInMs", () => { + clock.tick(301); + assert.isTrue( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + it("should return false if the time since .lastUpdated is less than .updateCycleInMs", () => { + clock.tick(299); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + }); + + describe("#cleanupCache", () => { + it("should remove data for providers no longer active", async () => { + const fakeStorage = { + get: sinon.stub().returns( + Promise.resolve({ + "id-1": {}, + "id-2": {}, + "id-3": {}, + }) + ), + set: sinon.stub().returns(Promise.resolve()), + }; + const fakeProviders = [ + { id: "id-1", type: "remote" }, + { id: "id-3", type: "remote" }, + ]; + + await MessageLoaderUtils.cleanupCache(fakeProviders, fakeStorage); + + assert.calledOnce(fakeStorage.set); + assert.calledWith( + fakeStorage.set, + MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, + { "id-1": {}, "id-3": {} } + ); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx new file mode 100644 index 0000000000..2320e16fc3 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx @@ -0,0 +1,69 @@ +import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay"; +import { mount } from "enzyme"; +import React from "react"; + +describe("ModalOverlayWrapper", () => { + let fakeDoc; + let sandbox; + let header; + beforeEach(() => { + sandbox = sinon.createSandbox(); + header = document.createElement("div"); + + fakeDoc = { + addEventListener: sandbox.stub(), + removeEventListener: sandbox.stub(), + body: { classList: { add: sandbox.stub(), remove: sandbox.stub() } }, + getElementById() { + return header; + }, + }; + }); + afterEach(() => { + sandbox.restore(); + }); + it("should add eventListener and a class on mount", async () => { + mount(<ModalOverlayWrapper document={fakeDoc} />); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledWith(fakeDoc.body.classList.add, "modal-open"); + }); + + it("should remove eventListener on unmount", async () => { + const wrapper = mount(<ModalOverlayWrapper document={fakeDoc} />); + wrapper.unmount(); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledOnce(fakeDoc.removeEventListener); + assert.calledWith(fakeDoc.body.classList.remove, "modal-open"); + }); + + it("should call props.onClose on an Escape key", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Escape" }); + + assert.calledOnce(onClose); + }); + + it("should not call props.onClose on other keys than Escape", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Ctrl" }); + + assert.notCalled(onClose); + }); + + it("should not call props.onClose when clicked outside dialog", async () => { + const onClose = sandbox.stub(); + const wrapper = mount( + <ModalOverlayWrapper document={fakeDoc} onClose={onClose} /> + ); + wrapper.find("div.modalOverlayOuter.active").simulate("click"); + assert.notCalled(onClose); + }); +}); diff --git a/browser/components/asrouter/tests/unit/MomentsPageHub.test.js b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js new file mode 100644 index 0000000000..63683a6849 --- /dev/null +++ b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js @@ -0,0 +1,336 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; +import { _MomentsPageHub } from "modules/MomentsPageHub.sys.mjs"; +const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; + +describe("MomentsPageHub", () => { + let globals; + let sandbox; + let instance; + let handleMessageRequestStub; + let addImpressionStub; + let blockMessageByIdStub; + let sendTelemetryStub; + let getStringPrefStub; + let setStringPrefStub; + let setIntervalStub; + let clearIntervalStub; + + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _MomentsPageHub(); + const messages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + handleMessageRequestStub = sandbox.stub().resolves(messages); + addImpressionStub = sandbox.stub(); + blockMessageByIdStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + setIntervalStub = sandbox.stub(); + clearIntervalStub = sandbox.stub(); + sendTelemetryStub = sandbox.stub(); + globals.set({ + setInterval: setIntervalStub, + clearInterval: clearIntervalStub, + Services: { + prefs: { + getStringPref: getStringPrefStub, + setStringPref: setStringPrefStub, + }, + telemetry: { + recordEvent: () => {}, + }, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should create an instance", async () => { + setIntervalStub.returns(42); + assert.ok(instance); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + assert.equal(instance.state._intervalId, 42); + }); + + it("should init only once", async () => { + assert.notCalled(handleMessageRequestStub); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledOnce(handleMessageRequestStub); + + instance.uninit(); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledTwice(handleMessageRequestStub); + }); + + it("should uninit the instance", () => { + instance.uninit(); + assert.calledOnce(clearIntervalStub); + }); + + it("should setInterval for `checkHomepageOverridePref`", async () => { + await instance.init(sandbox.stub().resolves(), {}); + sandbox.stub(instance, "checkHomepageOverridePref"); + + assert.calledOnce(setIntervalStub); + assert.calledWithExactly(setIntervalStub, sinon.match.func, 5 * 60 * 1000); + + assert.notCalled(instance.checkHomepageOverridePref); + const [cb] = setIntervalStub.firstCall.args; + + cb(); + + assert.calledOnce(instance.checkHomepageOverridePref); + }); + + describe("#messageRequest", () => { + beforeEach(async () => { + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + afterEach(() => { + instance.uninit(); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledTwice(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + returnAll: true, + }); + }); + it("shouldn't do anything if no message is provided", async () => { + // Reset the call from `instance.init` + setStringPrefStub.reset(); + handleMessageRequestStub.resolves([]); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(setStringPrefStub); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + it("should record Reach event for the Moments page experiment", async () => { + const momentsMessages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ...momentsMessages, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.calledOnce(instance.executeAction); + }); + it("should not record the Reach event if it's already sent", async () => { + const messages = [ + { + forReachEvent: { sent: true }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(global.Services.telemetry.recordEvent); + }); + it("should not trigger the action if it's only for the Reach event", async () => { + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.notCalled(instance.executeAction); + }); + }); + describe("executeAction", () => { + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", async () => { + const [msg] = await handleMessageRequestStub(); + sandbox.useFakeTimers(); + instance.executeAction(msg); + + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: instance.getExpirationDate( + msg.content.action.data.expireDelta + ), + }) + ); + }); + it("should block after taking the action", async () => { + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, msg.id); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(instance.getExpirationDate); + assert.calledWithExactly( + instance.getExpirationDate, + msg.content.action.data.expireDelta + ); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + const msgWithExpire = { + ...msg, + content: { + ...msg.content, + action: { + ...msg.content.action, + data: { ...msg.content.action.data, expire: 41 }, + }, + }, + }; + instance.executeAction(msgWithExpire); + + assert.notCalled(instance.getExpirationDate); + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: 41, + }) + ); + }); + it("should send user telemetry", async () => { + const [msg] = await handleMessageRequestStub(); + const sendUserEventTelemetrySpy = sandbox.spy( + instance, + "sendUserEventTelemetry" + ); + instance.executeAction(msg); + + assert.calledOnce(sendTelemetryStub); + assert.calledWithExactly(sendUserEventTelemetrySpy, msg); + assert.calledWithExactly(sendTelemetryStub, { + type: "MOMENTS_PAGE_TELEMETRY", + data: { + action: "moments_user_event", + bucket_id: "WNP_THANK_YOU", + event: "MOMENTS_PAGE_SET", + message_id: "WNP_THANK_YOU", + }, + }); + }); + }); + describe("#checkHomepageOverridePref", () => { + let messageRequestStub; + beforeEach(() => { + messageRequestStub = sandbox.stub(instance, "messageRequest"); + }); + it("should catch parse errors", () => { + getStringPrefStub.returns({}); + + instance.checkHomepageOverridePref(); + + assert.calledOnce(messageRequestStub); + assert.calledWithExactly(messageRequestStub, { + template: "update_action", + triggerId: "momentsUpdate", + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/RemoteL10n.test.js b/browser/components/asrouter/tests/unit/RemoteL10n.test.js new file mode 100644 index 0000000000..dd0f858750 --- /dev/null +++ b/browser/components/asrouter/tests/unit/RemoteL10n.test.js @@ -0,0 +1,217 @@ +import { RemoteL10n, _RemoteL10n } from "modules/RemoteL10n.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("RemoteL10n", () => { + let sandbox; + let globals; + let domL10nStub; + let l10nRegStub; + let l10nRegInstance; + let fileSourceStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + domL10nStub = sandbox.stub(); + l10nRegInstance = { + hasSource: sandbox.stub(), + registerSources: sandbox.stub(), + removeSources: sandbox.stub(), + }; + + fileSourceStub = sandbox.stub(); + l10nRegStub = { + getInstance: () => { + return l10nRegInstance; + }, + }; + globals.set("DOMLocalization", domL10nStub); + globals.set("L10nRegistry", l10nRegStub); + globals.set("L10nFileSource", fileSourceStub); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + describe("#RemoteL10n", () => { + it("should create a new instance", () => { + assert.ok(new _RemoteL10n()); + }); + it("should create a DOMLocalization instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.propertyVal(instance._createDOML10n(), "instance", true); + assert.calledOnce(domL10nStub); + }); + it("should create a new instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + + instance.reloadL10n(); + + assert.ok(instance.l10n); + + assert.calledTwice(domL10nStub); + }); + it("should reuse the instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + assert.ok(instance.l10n); + + assert.calledOnce(domL10nStub); + }); + }); + describe("#_createDOML10n", () => { + it("should load the remote Fluent file if USE_REMOTE_L10N_PREF is true", async () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + l10nRegInstance.hasSource.returns(false); + RemoteL10n._createDOML10n(); + + assert.calledOnce(domL10nStub); + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is the bundle generator. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.calledOnce(l10nRegInstance.registerSources); + assert.notCalled(l10nRegInstance.removeSources); + }); + it("should load the local Fluent file if USE_REMOTE_L10N_PREF is false", () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(false); + l10nRegInstance.hasSource.returns(true); + RemoteL10n._createDOML10n(); + + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is null. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.notCalled(l10nRegInstance.registerSources); + assert.calledOnce(l10nRegInstance.removeSources); + }); + }); + describe("#createElement", () => { + let doc; + let instance; + let setStringStub; + let elem; + beforeEach(() => { + elem = document.createElement("div"); + doc = { + createElement: sandbox.stub().returns(elem), + createElementNS: sandbox.stub().returns(elem), + }; + instance = new _RemoteL10n(); + setStringStub = sandbox.stub(instance, "setString"); + }); + it("should call createElement if string_id is defined", () => { + instance.createElement(doc, "span", { content: { string_id: "foo" } }); + + assert.calledOnce(doc.createElement); + }); + it("should call createElementNS if string_id is not present", () => { + instance.createElement(doc, "span", { content: "foo" }); + + assert.calledOnce(doc.createElementNS); + }); + it("should set classList", () => { + instance.createElement(doc, "span", { classList: "foo" }); + + assert.isTrue(elem.classList.contains("foo")); + }); + it("should call setString", () => { + const options = { classList: "foo" }; + instance.createElement(doc, "span", options); + + assert.calledOnce(setStringStub); + assert.calledWithExactly(setStringStub, elem, options); + }); + }); + describe("#setString", () => { + let instance; + beforeEach(() => { + instance = new _RemoteL10n(); + }); + it("should set fluent variables and id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { + content: { string_id: "foo" }, + attributes: { bar: "bar", baz: "baz" }, + }); + + assert.calledThrice(el.setAttribute); + assert.calledWithExactly(el.setAttribute, "fluent-variable-bar", "bar"); + assert.calledWithExactly(el.setAttribute, "fluent-variable-baz", "baz"); + assert.calledWithExactly(el.setAttribute, "fluent-remote-id", "foo"); + }); + it("should set content if no string_id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { content: "foo" }); + + assert.notCalled(el.setAttribute); + assert.equal(el.textContent, "foo"); + }); + }); + describe("#isLocaleSupported", () => { + it("should return true if the locale is en-US", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-US")); + }); + it("should return true if the locale is in all-locales", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-CA")); + }); + it("should return false if the locale is not in all-locales", () => { + assert.ok(!RemoteL10n.isLocaleSupported("und")); + }); + }); + describe("#formatLocalizableText", () => { + let instance; + let formatValueStub; + beforeEach(() => { + instance = new _RemoteL10n(); + formatValueStub = sandbox.stub(); + sandbox + .stub(instance, "l10n") + .get(() => ({ formatValue: formatValueStub })); + }); + it("should localize a string_id", async () => { + formatValueStub.resolves("VALUE"); + + assert.equal( + await instance.formatLocalizableText({ string_id: "ID" }), + "VALUE" + ); + assert.calledOnce(formatValueStub); + }); + it("should pass through a string", async () => { + formatValueStub.reset(); + + assert.equal( + await instance.formatLocalizableText("unchanged"), + "unchanged" + ); + assert.isFalse(formatValueStub.called); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/TargetingDocs.test.js b/browser/components/asrouter/tests/unit/TargetingDocs.test.js new file mode 100644 index 0000000000..d00f971453 --- /dev/null +++ b/browser/components/asrouter/tests/unit/TargetingDocs.test.js @@ -0,0 +1,88 @@ +import { ASRouterTargeting } from "modules/ASRouterTargeting.sys.mjs"; +import docs from "docs/targeting-attributes.md"; + +// The following targeting parameters are either deprecated or should not be included in the docs for some reason. +const SKIP_DOCS = []; +// These are extra message context attributes via ASRouter.sys.mjs +const MESSAGE_CONTEXT_ATTRIBUTES = ["previousSessionEnd"]; + +function getHeadingsFromDocs() { + const re = /### `(\w+)`/g; + const found = []; + let match = 1; + while (match) { + match = re.exec(docs); + if (match) { + found.push(match[1]); + } + } + return found; +} + +function getTOCFromDocs() { + const re = /## Available attributes\n+([^]+)\n+## Detailed usage/; + const sectionMatch = docs.match(re); + if (!sectionMatch) { + return []; + } + const [, listText] = sectionMatch; + const re2 = /\[(\w+)\]/g; + const found = []; + let match = 1; + while (match) { + match = re2.exec(listText); + if (match) { + found.push(match[1]); + } + } + return found; +} + +describe("ASRTargeting docs", () => { + const DOCS_TARGETING_HEADINGS = getHeadingsFromDocs(); + const DOCS_TOC = getTOCFromDocs(); + const ASRTargetingAttributes = [ + ...Object.keys(ASRouterTargeting.Environment).filter( + attribute => !SKIP_DOCS.includes(attribute) + ), + ...MESSAGE_CONTEXT_ATTRIBUTES, + ]; + + describe("All targeting params documented in targeting-attributes.md", () => { + for (const targetingParam of ASRTargetingAttributes) { + // If this test is failing, you probably forgot to add docs to content-src/asrouter/targeting-attributes.md + // for a new targeting attribute, or you forgot to put it in the table of contents up top. + it(`should have docs and table of contents entry for ${targetingParam}`, () => { + assert.include( + DOCS_TARGETING_HEADINGS, + targetingParam, + `Didn't find the heading: ### \`${targetingParam}\`` + ); + assert.include( + DOCS_TOC, + targetingParam, + `Didn't find a table of contents entry for ${targetingParam}` + ); + }); + } + }); + describe("No extra attributes in targeting-attributes.md", () => { + // "allow" includes targeting attributes that are not implemented by + // ASRTargetingAttributes. For example trigger context passed to the evaluation + // context in when a trigger runs or ASRouter state used in the evaluation. + const allow = ["messageImpressions", "screenImpressions"]; + for (const targetingParam of DOCS_TARGETING_HEADINGS.filter( + doc => !allow.includes(doc) + )) { + // If this test is failing, you might have spelled something wrong or removed a targeting param without + // removing its docs. + it(`should have an implementation for ${targetingParam} in ASRouterTargeting.Environment`, () => { + assert.include( + ASRTargetingAttributes, + targetingParam, + `Didn't find an implementation for ${targetingParam}` + ); + }); + } + }); +}); diff --git a/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js new file mode 100644 index 0000000000..3e91b657bc --- /dev/null +++ b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js @@ -0,0 +1,652 @@ +import { _ToolbarBadgeHub } from "modules/ToolbarBadgeHub.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; +import { + _ToolbarPanelHub, + ToolbarPanelHub, +} from "modules/ToolbarPanelHub.sys.mjs"; + +describe("ToolbarBadgeHub", () => { + let sandbox; + let instance; + let fakeAddImpression; + let fakeSendTelemetry; + let isBrowserPrivateStub; + let fxaMessage; + let whatsnewMessage; + let fakeElement; + let globals; + let everyWindowStub; + let clearTimeoutStub; + let setTimeoutStub; + let addObserverStub; + let removeObserverStub; + let getStringPrefStub; + let clearUserPrefStub; + let setStringPrefStub; + let requestIdleCallbackStub; + let fakeWindow; + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _ToolbarBadgeHub(); + fakeAddImpression = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE"); + whatsnewMessage = { + id: `WHATS_NEW_BADGE_71`, + template: "toolbar_badge", + content: { + delay: 1000, + target: "whats-new-menu-button", + action: { id: "show-whatsnew-button" }, + badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" }, + }, + priority: 1, + trigger: { id: "toolbarBadgeUpdate" }, + frequency: { + // Makes it so that we track impressions for this message while at the + // same time it can have unlimited impressions + lifetime: Infinity, + }, + // Never saw this message or saw it in the past 4 days or more recent + targeting: `isWhatsNewPanelEnabled && + (!messageImpressions['WHATS_NEW_BADGE_71'] || + (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 && + currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`, + }; + fakeElement = { + classList: { + add: sandbox.stub(), + remove: sandbox.stub(), + }, + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub(), + addEventListener: sandbox.stub(), + remove: sandbox.stub(), + appendChild: sandbox.stub(), + }; + // Share the same element when selecting child nodes + fakeElement.querySelector.returns(fakeElement); + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + clearTimeoutStub = sandbox.stub(); + setTimeoutStub = sandbox.stub(); + fakeWindow = { + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + gBrowser: { + selectedBrowser: "browser", + }, + }, + }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + clearUserPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn()); + globals.set({ + ToolbarPanelHub, + requestIdleCallback: requestIdleCallbackStub, + EveryWindow: everyWindowStub, + PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub }, + setTimeout: setTimeoutStub, + clearTimeout: clearTimeoutStub, + Services: { + wm: { + getMostRecentWindow: () => fakeWindow, + }, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getStringPref: getStringPrefStub, + clearUserPref: clearUserPrefStub, + setStringPref: setStringPrefStub, + }, + }, + }); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + it("should create an instance", () => { + assert.ok(instance); + }); + describe("#init", () => { + it("should make a single messageRequest on init", async () => { + sandbox.stub(instance, "messageRequest"); + const waitForInitialized = sandbox.stub().resolves(); + + await instance.init(waitForInitialized, {}); + await instance.init(waitForInitialized, {}); + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + + instance.uninit(); + + await instance.init(waitForInitialized, {}); + + assert.calledTwice(instance.messageRequest); + }); + it("should add a pref observer", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + assert.calledOnce(addObserverStub); + assert.calledWithExactly( + addObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), {}); + }); + it("should clear any setTimeout cbs", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + instance.state.showBadgeTimeoutId = 2; + + instance.uninit(); + + assert.calledOnce(clearTimeoutStub); + assert.calledWithExactly(clearTimeoutStub, 2); + }); + it("should remove the pref observer", () => { + instance.uninit(); + + assert.calledOnce(removeObserverStub); + assert.calledWithExactly( + removeObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("messageRequest", () => { + let handleMessageRequestStub; + beforeEach(() => { + handleMessageRequestStub = sandbox.stub().returns(fxaMessage); + sandbox + .stub(instance, "_handleMessageRequest") + .value(handleMessageRequestStub); + sandbox.stub(instance, "registerBadgeNotificationListener"); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledOnce(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + }); + }); + it("should call addToolbarNotification with browser window and message", async () => { + await instance.messageRequest("trigger"); + + assert.calledOnce(instance.registerBadgeNotificationListener); + assert.calledWithExactly( + instance.registerBadgeNotificationListener, + fxaMessage + ); + }); + it("shouldn't do anything if no message is provided", async () => { + handleMessageRequestStub.resolves(null); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(instance.registerBadgeNotificationListener); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + handleMessageRequestStub.returns(null); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + }); + describe("addToolbarNotification", () => { + let target; + let fakeDocument; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElement), + createElement: sandbox.stub().returns(fakeElement), + l10n: { setAttributes: sandbox.stub() }, + }; + target = { ...fakeWindow, browser: { ownerDocument: fakeDocument } }; + }); + afterEach(() => { + instance.uninit(); + }); + it("shouldn't do anything if target element is not found", () => { + fakeDocument.getElementById.returns(null); + instance.addToolbarNotification(target, fxaMessage); + + assert.notCalled(fakeElement.setAttribute); + }); + it("should target the element specified in the message", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeDocument.getElementById); + assert.calledWithExactly( + fakeDocument.getElementById, + fxaMessage.content.target + ); + }); + it("should show a notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeElement.setAttribute); + assert.calledWithExactly(fakeElement.setAttribute, "badged", true); + assert.calledWithExactly(fakeElement.classList.add, "feature-callout"); + }); + it("should attach a cb on the notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledTwice(fakeElement.addEventListener); + assert.calledWithExactly( + fakeElement.addEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeElement.addEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should execute actions if they exist", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.executeAction); + assert.calledWithExactly(instance.executeAction, { + ...whatsnewMessage.content.action, + message_id: whatsnewMessage.id, + }); + }); + it("should create a description element", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.createElement); + assert.calledWithExactly(fakeDocument.createElement, "span"); + }); + it("should set description id to element and to button", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledWithExactly( + fakeElement.setAttribute, + "id", + "toolbarbutton-notification-description" + ); + assert.calledWithExactly( + fakeElement.setAttribute, + "aria-labelledby", + `toolbarbutton-notification-description ${whatsnewMessage.content.target}` + ); + }); + it("should attach fluent id to description", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.l10n.setAttributes); + assert.calledWithExactly( + fakeDocument.l10n.setAttributes, + fakeElement, + whatsnewMessage.content.badgeDescription.string_id + ); + }); + it("should add an impression for the message", () => { + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance._addImpression); + assert.calledWithExactly(instance._addImpression, whatsnewMessage); + }); + it("should send an impression ping", async () => { + sandbox.stub(instance, "sendUserEventTelemetry"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly( + instance.sendUserEventTelemetry, + "IMPRESSION", + whatsnewMessage + ); + }); + }); + describe("registerBadgeNotificationListener", () => { + let msg_no_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + sandbox.stub(instance, "addToolbarNotification").returns(fakeElement); + sandbox.stub(instance, "removeToolbarNotification"); + msg_no_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 0, + }, + }; + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a callback that adds/removes the notification", () => { + instance.registerBadgeNotificationListener(msg_no_delay); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + instance.id, + sinon.match.func, + sinon.match.func + ); + + const [, initFn, uninitFn] = + everyWindowStub.registerCallback.firstCall.args; + + initFn(window); + // Test that it doesn't try to add a second notification + initFn(window); + + assert.calledOnce(instance.addToolbarNotification); + assert.calledWithExactly( + instance.addToolbarNotification, + window, + msg_no_delay + ); + + uninitFn(window); + + assert.calledOnce(instance.removeToolbarNotification); + assert.calledWithExactly(instance.removeToolbarNotification, fakeElement); + }); + it("should unregister notifications when forcing a badge via devtools", () => { + instance.registerBadgeNotificationListener(msg_no_delay, { force: true }); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should only call executeAction for 'update_action' messages", () => { + const stub = sandbox.stub(instance, "executeAction"); + const updateActionMsg = { ...msg_no_delay, template: "update_action" }; + + instance.registerBadgeNotificationListener(updateActionMsg); + + assert.notCalled(everyWindowStub.registerCallback); + assert.calledOnce(stub); + }); + }); + describe("executeAction", () => { + let blockMessageByIdStub; + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + blockMessageById: blockMessageByIdStub, + }); + }); + it("should call ToolbarPanelHub.enableToolbarButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableToolbarButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + it("should call ToolbarPanelHub.enableAppmenuButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableAppmenuButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + }); + describe("removeToolbarNotification", () => { + it("should remove the notification", () => { + instance.removeToolbarNotification(fakeElement); + + assert.calledThrice(fakeElement.removeAttribute); + assert.calledWithExactly(fakeElement.removeAttribute, "badged"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-labelledby"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-describedby"); + assert.calledOnce(fakeElement.classList.remove); + assert.calledWithExactly(fakeElement.classList.remove, "feature-callout"); + assert.calledOnce(fakeElement.remove); + }); + }); + describe("removeAllNotifications", () => { + let blockMessageByIdStub; + let fakeEvent; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + blockMessageByIdStub = sandbox.stub(); + sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub); + instance.state = { notification: { id: fxaMessage.id } }; + fakeEvent = { target: { removeEventListener: sandbox.stub() } }; + }); + it("should call to block the message", () => { + instance.removeAllNotifications(); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id); + }); + it("should remove the window listener", () => { + instance.removeAllNotifications(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should ignore right mouse button (mousedown event)", () => { + fakeEvent.type = "mousedown"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore right mouse button (click event)", () => { + fakeEvent.type = "click"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore keypresses that are not meant to focus the target", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "\t"; // not enter + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should send telemetry", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + sandbox.stub(instance, "sendUserEventTelemetry"); + + instance.removeAllNotifications(fakeEvent); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", { + id: "FXA_ACCOUNTS_BADGE", + }); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "Enter"; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + }); + describe("message with delay", () => { + let msg_with_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + }); + msg_with_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 500, + }, + }; + sandbox.stub(instance, "registerBadgeToAllWindows"); + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a cb to fire after msg.content.delay ms", () => { + instance.registerBadgeNotificationListener(msg_with_delay); + + assert.calledOnce(setTimeoutStub); + assert.calledWithExactly( + setTimeoutStub, + sinon.match.func, + msg_with_delay.content.delay + ); + + const [cb] = setTimeoutStub.firstCall.args; + + assert.notCalled(instance.registerBadgeToAllWindows); + + cb(); + + assert.calledOnce(instance.registerBadgeToAllWindows); + assert.calledWithExactly( + instance.registerBadgeToAllWindows, + msg_with_delay + ); + // Delayed actions should be executed inside requestIdleCallback + assert.calledOnce(requestIdleCallbackStub); + }); + }); + describe("#sendUserEventTelemetry", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should check for private window and not send", () => { + isBrowserPrivateStub.returns(true); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.notCalled(instance._sendTelemetry); + }); + it("should check for private window and send", () => { + isBrowserPrivateStub.returns(false); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.calledOnce(fakeSendTelemetry); + const [ping] = instance._sendTelemetry.firstCall.args; + assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY"); + assert.propertyVal(ping.data, "event", "CLICK"); + }); + }); + describe("#observe", () => { + it("should make a message request when the whats new pref is changed", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL); + + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + }); + it("should not react to other pref changes", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", "foo"); + + assert.notCalled(instance.messageRequest); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js new file mode 100644 index 0000000000..64cb8243b7 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js @@ -0,0 +1,762 @@ +import { _ToolbarPanelHub } from "modules/ToolbarPanelHub.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; + +describe("ToolbarPanelHub", () => { + let globals; + let sandbox; + let instance; + let everyWindowStub; + let fakeDocument; + let fakeWindow; + let fakeElementById; + let fakeElementByTagName; + let createdCustomElements = []; + let eventListeners = {}; + let addObserverStub; + let removeObserverStub; + let getBoolPrefStub; + let setBoolPrefStub; + let waitForInitializedStub; + let isBrowserPrivateStub; + let fakeSendTelemetry; + let getEarliestRecordedDateStub; + let getEventsByDateRangeStub; + let defaultSearchStub; + let scriptloaderStub; + let fakeRemoteL10n; + let getViewNodeStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + instance = new _ToolbarPanelHub(); + waitForInitializedStub = sandbox.stub().resolves(); + fakeElementById = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeElementByTagName = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElementById), + getElementsByTagName: sandbox.stub().returns(fakeElementByTagName), + querySelector: sandbox.stub().returns({}), + createElement: tagName => { + const element = { + tagName, + classList: {}, + addEventListener: (ev, fn) => { + eventListeners[ev] = fn; + }, + appendChild: sandbox.stub(), + setAttribute: sandbox.stub(), + textContent: "", + }; + element.classList.add = sandbox.stub(); + element.classList.includes = className => + element.classList.add.firstCall.args[0] === className; + createdCustomElements.push(element); + return element; + }, + l10n: { + translateElements: sandbox.stub(), + translateFragment: sandbox.stub(), + formatMessages: sandbox.stub().resolves([{}]), + setAttributes: sandbox.stub(), + }, + }; + fakeWindow = { + // eslint-disable-next-line object-shorthand + DocumentFragment: function () { + return fakeElementById; + }, + document: fakeDocument, + browser: { + ownerDocument: fakeDocument, + }, + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + openLinkIn: sandbox.stub(), + gBrowser: "gBrowser", + }, + PanelUI: { + panel: fakeElementById, + whatsNewPanel: fakeElementById, + }, + customElements: { get: sandbox.stub() }, + }; + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + scriptloaderStub = { loadSubScript: sandbox.stub() }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getBoolPrefStub = sandbox.stub(); + setBoolPrefStub = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + getEarliestRecordedDateStub = sandbox.stub().returns( + // A random date that's not the current timestamp + new Date() - 500 + ); + getEventsByDateRangeStub = sandbox.stub().returns([]); + getViewNodeStub = sandbox.stub().returns(fakeElementById); + defaultSearchStub = { defaultEngine: { name: "DDG" } }; + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox + .stub() + .callsFake((doc, el) => fakeDocument.createElement(el)), + }; + globals.set({ + EveryWindow: everyWindowStub, + Services: { + ...Services, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getBoolPref: getBoolPrefStub, + setBoolPref: setBoolPrefStub, + }, + search: defaultSearchStub, + scriptloader: scriptloaderStub, + }, + PrivateBrowsingUtils: { + isBrowserPrivate: isBrowserPrivateStub, + }, + TrackingDBService: { + getEarliestRecordedDate: getEarliestRecordedDateStub, + getEventsByDateRange: getEventsByDateRangeStub, + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + RemoteL10n: fakeRemoteL10n, + PanelMultiView: { + getViewNode: getViewNodeStub, + }, + }); + }); + afterEach(() => { + instance.uninit(); + sandbox.restore(); + globals.restore(); + eventListeners = {}; + createdCustomElements = []; + }); + it("should create an instance", () => { + assert.ok(instance); + }); + it("should enableAppmenuButton() on init() just once", async () => { + instance.enableAppmenuButton = sandbox.stub(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledOnce(instance.enableAppmenuButton); + + instance.uninit(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledTwice(instance.enableAppmenuButton); + }); + it("should unregisterCallback on uninit()", () => { + instance.uninit(); + assert.calledTwice(everyWindowStub.unregisterCallback); + }); + describe("#maybeLoadCustomElement", () => { + it("should not load customElements a second time", () => { + instance.maybeLoadCustomElement({ customElements: new Map() }); + instance.maybeLoadCustomElement({ + customElements: new Map([["remote-text", true]]), + }); + + assert.calledOnce(scriptloaderStub.loadSubScript); + }); + }); + describe("#toggleWhatsNewPref", () => { + it("should call Services.prefs.setBoolPref() with the opposite value", () => { + let checkbox = {}; + let event = { target: checkbox }; + // checkbox starts false + checkbox.checked = false; + + // toggling the checkbox to set the value to true; + // Preferences.set() gets called before the checkbox changes, + // so we have to call it with the opposite value. + instance.toggleWhatsNewPref(event); + + assert.calledOnce(setBoolPrefStub); + assert.calledWith( + setBoolPrefStub, + "browser.messaging-system.whatsNewPanel.enabled", + !checkbox.checked + ); + }); + it("should report telemetry with the opposite value", () => { + let sendUserEventTelemetryStub = sandbox.stub( + instance, + "sendUserEventTelemetry" + ); + let event = { + target: { checked: true, ownerGlobal: fakeWindow }, + }; + + instance.toggleWhatsNewPref(event); + + assert.calledOnce(sendUserEventTelemetryStub); + const { args } = sendUserEventTelemetryStub.firstCall; + assert.equal(args[1], "WNP_PREF_TOGGLE"); + assert.propertyVal(args[3].value, "prefValue", false); + }); + }); + describe("#enableAppmenuButton", () => { + it("should registerCallback on enableAppmenuButton() if there are messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "appMenu-whatsnew-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => { + instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("#disableAppmenuButton", () => { + it("should call the unregisterCallback", () => { + assert.notCalled(everyWindowStub.unregisterCallback); + + instance.disableAppmenuButton(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "appMenu-whatsnew-button" + ); + }); + }); + describe("#enableToolbarButton", () => { + it("should registerCallback on enableToolbarButton if messages.length", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableToolbarButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "whats-new-menu-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableToolbarButton if no messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + + await instance.enableToolbarButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("Show/Hide functions", () => { + it("should unhide appmenu button on _showAppmenuButton()", async () => { + await instance._showAppmenuButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide appmenu button on _hideAppmenuButton()", () => { + instance._hideAppmenuButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + it("should not do anything if the window is closed", () => { + instance._hideAppmenuButton(fakeWindow, true); + assert.notCalled(global.PanelMultiView.getViewNode); + }); + it("should not throw if the element does not exist", () => { + let fn = instance._hideAppmenuButton.bind(null, { + browser: { ownerDocument: {} }, + }); + getViewNodeStub.returns(undefined); + assert.doesNotThrow(fn); + }); + it("should unhide toolbar button on _showToolbarButton()", async () => { + await instance._showToolbarButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide toolbar button on _hideToolbarButton()", () => { + instance._hideToolbarButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + }); + describe("#renderMessages", () => { + let getMessagesStub; + beforeEach(() => { + getMessagesStub = sandbox.stub(); + instance.init(waitForInitializedStub, { + getMessages: getMessagesStub, + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should have correct state", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.propertyVal(instance.state.contentArguments, "trackerCount", 4); + assert.propertyVal( + instance.state.contentArguments, + "fingerprinterCount", + 3 + ); + }); + it("should render messages to the panel on renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + messages[0].content.link_text = { string_id: "link_text_id" }; + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + for (let message of messages) { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + ); + if (message.content.layout === "tracking-protections") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-subtitle" + ) + ); + } + if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => el === "h2" && args.content === 3 + ) + ); + } + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-content" + ) + ); + } + // Call the click handler to make coverage happy. + eventListeners.mouseup(); + assert.calledOnce(global.SpecialMessageActions.handleAction); + }); + it("should clear previous messages on 2nd renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const removeStub = sandbox.stub(); + fakeElementById.querySelectorAll.onCall(0).returns([]); + fakeElementById.querySelectorAll + .onCall(1) + .returns([{ remove: removeStub }, { remove: removeStub }]); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledTwice(removeStub); + }); + it("should sort based on order field value", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => + m.template === "whatsnew_panel_message" && + m.content.published_date === 1560969794394 + ); + + messages.forEach(m => (m.content.title = m.order)); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + // Select the title elements that are supposed to be set to the same + // value as the `order` field of the message + const titleEls = fakeRemoteL10n.createElement.args + .filter( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + .map(([doc, el, args]) => args.content); + assert.deepEqual(titleEls, [1, 2, 3]); + }); + it("should accept string for image attributes", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.id === "WHATS_NEW_70_1" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const imageEl = createdCustomElements.find(el => el.tagName === "img"); + assert.calledOnce(imageEl.setAttribute); + assert.calledWithExactly( + imageEl.setAttribute, + "alt", + "Firefox Send Logo" + ); + }); + it("should set state values as data-attribute", async () => { + const message = (await PanelTestProvider.getMessages()).find( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([message]); + instance.state.contentArguments = { foo: "foo", bar: "bar" }; + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const [, , args] = fakeRemoteL10n.createElement.args.find( + ([doc, el, elArgs]) => elArgs && elArgs.attributes + ); + assert.ok(args); + // Currently this.state.contentArguments has 8 different entries + assert.lengthOf(Object.keys(args.attributes), 8); + assert.equal( + args.attributes.searchEngineName, + defaultSearchStub.defaultEngine.name + ); + }); + it("should only render unique dates (no duplicates)", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const uniqueDates = [ + ...new Set(messages.map(m => m.content.published_date)), + ]; + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const dateElements = fakeRemoteL10n.createElement.args.filter( + ([doc, el, args]) => + el === "p" && args.classList === "whatsNew-message-date" + ); + assert.lengthOf(dateElements, uniqueDates.length); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + fakeDocument.getElementById + .withArgs("customizationui-widget-panel") + .returns(null); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.notCalled(fakeElementById.addEventListener); + }); + it("should attach doCommand cbs that handle user actions", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const messageEl = createdCustomElements.find( + el => + el.tagName === "div" && el.classList.includes("whatsNew-message-body") + ); + const anchorEl = createdCustomElements.find(el => el.tagName === "a"); + + assert.notCalled(global.SpecialMessageActions.handleAction); + + messageEl.doCommand(); + anchorEl.doCommand(); + + assert.calledTwice(global.SpecialMessageActions.handleAction); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(fakeElementById.addEventListener); + assert.calledWithExactly( + fakeElementById.addEventListener, + "popuphidden", + sinon.match.func, + { + once: true, + } + ); + const [, cb] = fakeElementById.addEventListener.firstCall.args; + + assert.notCalled(everyWindowStub.unregisterCallback); + + cb(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "whats-new-menu-button" + ); + }); + describe("#IMPRESSION", () => { + it("should dispatch a IMPRESSION for messages", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + assert.propertyVal( + spy.firstCall.args[2], + "id", + messages + .map(({ id }) => id) + .sort() + .join(",") + ); + }); + it("should dispatch a CLICK for clicking a message", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + // Force to render the message + fakeElementById.querySelector.returns(null); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([messages[0]]); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + + spy.resetHistory(); + + // Message click event listener cb + eventListeners.mouseup(); + + assert.calledOnce(spy); + assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]); + }); + it("should dispatch a IMPRESSION with toolbar_dropdown", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "toolbar_dropdown", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "toolbar_dropdown", + }); + }); + it("should dispatch a IMPRESSION with application_menu", async () => { + // means panel is triggered as a subview in the application menu + fakeElementById.hasAttribute.returns(false); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "application_menu", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "application_menu", + }); + }); + }); + describe("#forceShowMessage", () => { + const panelSelector = "PanelUI-whatsNew-message-container"; + let removeMessagesSpy; + let renderMessagesStub; + let addEventListenerStub; + let messages; + let browser; + beforeEach(async () => { + messages = (await PanelTestProvider.getMessages()).find( + m => m.id === "WHATS_NEW_70_1" + ); + removeMessagesSpy = sandbox.spy(instance, "removeMessages"); + renderMessagesStub = sandbox.spy(instance, "renderMessages"); + addEventListenerStub = fakeElementById.addEventListener; + browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument }; + fakeElementById.querySelectorAll.returns([fakeElementById]); + }); + it("should call removeMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should call renderMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(renderMessagesStub); + assert.calledWithExactly( + renderMessagesStub, + fakeWindow, + fakeDocument, + panelSelector, + { + force: true, + messages: Array.isArray(messages) ? messages : [messages], + } + ); + }); + it("should cleanup after the panel is hidden when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(addEventListenerStub); + assert.calledWithExactly( + addEventListenerStub, + "popuphidden", + sinon.match.func + ); + + const [, cb] = addEventListenerStub.firstCall.args; + // Reset the call count from the first `forceShowMessage` call + removeMessagesSpy.resetHistory(); + cb({ target: { ownerGlobal: fakeWindow } }); + + assert.calledOnce(removeMessagesSpy); + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should exit gracefully if called before a browser exists", () => { + instance.forceShowMessage(null, messages); + assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/asrouter-utils.test.js b/browser/components/asrouter/tests/unit/asrouter-utils.test.js new file mode 100644 index 0000000000..553c9608ed --- /dev/null +++ b/browser/components/asrouter/tests/unit/asrouter-utils.test.js @@ -0,0 +1,118 @@ +import { ASRouterUtils } from "content-src/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterUtils", () => { + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + ASRouterMessage: sandbox.stub().resolves({}), + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + }); + describe("sendMessage", () => { + it("default", () => { + ASRouterUtils.sendMessage({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { foo: "bar" }); + }); + it("throws if ASRouterMessage is not defined", () => { + overrider.set("ASRouterMessage", undefined); + assert.throws(() => ASRouterUtils.sendMessage({ foo: "bar" })); + }); + it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => { + assert.doesNotThrow(async () => { + let result = await ASRouterUtils.sendMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: {}, + }); + sandbox.assert.deepEqual(result, {}); + }); + }); + }); + describe("blockById", () => { + it("default", () => { + ASRouterUtils.blockById(1, { foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar", id: 1 } }) + ); + }); + }); + describe("modifyMessageJson", () => { + it("default", () => { + ASRouterUtils.modifyMessageJson({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { content: { foo: "bar" } } }) + ); + }); + }); + describe("executeAction", () => { + it("default", () => { + ASRouterUtils.executeAction({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar" } }) + ); + }); + }); + describe("unblockById", () => { + it("default", () => { + ASRouterUtils.unblockById(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 2 } }) + ); + }); + }); + describe("blockBundle", () => { + it("default", () => { + ASRouterUtils.blockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("unblockBundle", () => { + it("default", () => { + ASRouterUtils.unblockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("overrideMessage", () => { + it("default", () => { + ASRouterUtils.overrideMessage(12); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 12 } }) + ); + }); + }); + describe("editState", () => { + it("default", () => { + ASRouterUtils.editState("foo", "bar"); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar" } }) + ); + }); + }); + describe("sendTelemetry", () => { + it("default", () => { + ASRouterUtils.sendTelemetry({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/constants.js b/browser/components/asrouter/tests/unit/constants.js new file mode 100644 index 0000000000..82b88c47a2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/constants.js @@ -0,0 +1,131 @@ +export const CHILD_TO_PARENT_MESSAGE_NAME = "ASRouter:child-to-parent"; +export const PARENT_TO_CHILD_MESSAGE_NAME = "ASRouter:parent-to-child"; + +export const FAKE_LOCAL_MESSAGES = [ + { + id: "foo", + template: "milestone_message", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "foo1", + template: "fancy_template", + bundled: 2, + order: 1, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "fancy_template", + bundled: 2, + order: 2, + content: { title: "Foo2", body: "Foo123-2" }, + }, + { + id: "bar", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { id: "baz", content: { title: "Foo", body: "Foo123" } }, + { + id: "newsletter", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "fxa", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "belowsearch", + template: "fancy_template", + content: { text: "Foo" }, + }, +]; +export const FAKE_LOCAL_PROVIDER = { + id: "onboarding", + type: "local", + localProvider: "FAKE_LOCAL_PROVIDER", + enabled: true, + cohort: 0, +}; +export const FAKE_LOCAL_PROVIDERS = { + FAKE_LOCAL_PROVIDER: { + getMessages: () => Promise.resolve(FAKE_LOCAL_MESSAGES), + }, +}; + +export const FAKE_REMOTE_MESSAGES = [ + { + id: "qux", + template: "fancy_template", + content: { title: "Qux", body: "hello world" }, + }, +]; +export const FAKE_REMOTE_PROVIDER = { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, +}; + +export const FAKE_REMOTE_SETTINGS_PROVIDER = { + id: "remotey-settingsy", + type: "remote-settings", + collection: "collectionname", + enabled: true, +}; + +const notificationText = new String("Fake notification text"); // eslint-disable-line +notificationText.attributes = { tooltiptext: "Fake tooltip text" }; + +export const FAKE_RECOMMENDATION = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + category: "cfrDummy", + bucket_id: "fake_bucket_id", + notification_text: notificationText, + info_icon: { + label: "Fake Info Icon Label", + sumo_path: "a_help_path_fragment", + }, + heading_text: "Fake Heading Text", + icon_class: "Fake Icon class", + addon: { + title: "Fake Addon Title", + author: "Fake Addon Author", + icon: "a_path_to_some_icon", + rating: "4.2", + users: "1234", + amo_url: "a_path_to_amo", + }, + descriptionDetails: { + steps: [{ string_id: "cfr-features-step1" }], + }, + text: "Here is the recommendation text body", + buttons: { + primary: { + label: { string_id: "primary_button_id" }, + action: { + id: "primary_action", + data: {}, + }, + }, + secondary: [ + { + label: { string_id: "secondary_button_id" }, + action: { id: "secondary_action" }, + }, + { + label: { string_id: "secondary_button_id_2" }, + }, + { + label: { string_id: "secondary_button_id_3" }, + action: { id: "secondary_action" }, + }, + ], + }, + }, +}; diff --git a/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx new file mode 100644 index 0000000000..46d5704107 --- /dev/null +++ b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx @@ -0,0 +1,262 @@ +import { ASRouterAdminInner } from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { ASRouterUtils } from "content-src/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("ASRouterAdmin", () => { + let globalOverrider; + let sandbox; + let wrapper; + let globals; + let FAKE_PROVIDER_PREF = [ + { + enabled: true, + id: "local_testing", + localProvider: "TestProvider", + type: "local", + }, + ]; + let FAKE_PROVIDER = [ + { + enabled: true, + id: "local_testing", + localProvider: "TestProvider", + messages: [], + type: "local", + }, + ]; + beforeEach(() => { + globalOverrider = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns("foo"); + globals = { + ASRouterMessage: sandbox.stub().resolves(), + ASRouterAddParentListener: sandbox.stub(), + ASRouterRemoveParentListener: sandbox.stub(), + }; + globalOverrider.set(globals); + wrapper = shallow(<ASRouterAdminInner location={{ routes: [""] }} />); + wrapper.setState({ devtoolsEnabled: true }); + }); + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + it("should render ASRouterAdmin component", () => { + assert.ok(wrapper.exists()); + }); + it("should send ADMIN_CONNECT_STATE on mount", () => { + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { + type: "ADMIN_CONNECT_STATE", + data: { endpoint: "foo" }, + }); + }); + describe("#getSection", () => { + it("should render a message provider section by default", () => { + assert.equal(wrapper.find("h2").at(1).text(), "Messages"); + }); + it("should render a targeting section for targeting route", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["targeting"] }} /> + ); + wrapper.setState({ devtoolsEnabled: true }); + assert.equal(wrapper.find("h2").at(0).text(), "Targeting Utilities"); + }); + it("should render two error messages", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["errors"] }} Sections={[]} /> + ); + wrapper.setState({ devtoolsEnabled: true }); + const firstError = { + timestamp: Date.now() + 100, + error: { message: "first" }, + }; + const secondError = { + timestamp: Date.now(), + error: { message: "second" }, + }; + wrapper.setState({ + providers: [{ id: "foo", errors: [firstError, secondError] }], + }); + + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(0).text(), + "foo" + ); + assert.lengthOf(wrapper.find("tbody tr"), 2); + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(1).text(), + secondError.error.message + ); + }); + }); + describe("#render", () => { + beforeEach(() => { + wrapper.setState({ + providerPrefs: [], + providers: [], + userPrefs: {}, + }); + }); + describe("#renderProviders", () => { + it("should render the provider", () => { + wrapper.setState({ + providerPrefs: FAKE_PROVIDER_PREF, + providers: FAKE_PROVIDER, + }); + + // Header + 1 item + assert.lengthOf(wrapper.find(".message-item"), 2); + }); + }); + describe("#renderMessages", () => { + beforeEach(() => { + sandbox.stub(ASRouterUtils, "blockById").resolves(); + sandbox.stub(ASRouterUtils, "unblockById").resolves(); + sandbox.stub(ASRouterUtils, "overrideMessage").resolves({ foo: "bar" }); + sandbox.stub(ASRouterUtils, "sendMessage").resolves(); + wrapper.setState({ + messageFilter: "all", + messageBlockList: [], + messageImpressions: { foo: 2 }, + groups: [{ id: "messageProvider", enabled: true }], + providers: [{ id: "messageProvider", enabled: true }], + }); + }); + it("should render a message when no filtering is applied", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.primary").simulate("click"); + assert.calledOnce(ASRouterUtils.blockById); + assert.calledWith(ASRouterUtils.blockById, "foo"); + }); + it("should render a blocked message", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + groups: ["messageProvider"], + provider: "messageProvider", + }, + ], + messageBlockList: ["foo"], + }); + assert.lengthOf(wrapper.find(".message-item.blocked"), 1); + wrapper.find(".message-item.blocked button").simulate("click"); + assert.calledOnce(ASRouterUtils.unblockById); + assert.calledWith(ASRouterUtils.unblockById, "foo"); + }); + it("should render a message if provider matches filter", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + }); + it("should override with the selected message", async () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.show").simulate("click"); + assert.calledOnce(ASRouterUtils.overrideMessage); + assert.calledWith(ASRouterUtils.overrideMessage, "foo"); + await ASRouterUtils.overrideMessage(); + assert.equal(wrapper.state().foo, "bar"); + }); + it("should hide message if provider filter changes", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + + wrapper.find("select").simulate("change", { target: { value: "bar" } }); + + assert.lengthOf(wrapper.find(".message-id"), 0); + }); + it("should not display Reset All button if provider filter value is set to all or test providers", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".messages-reset"), 1); + wrapper.find("select").simulate("change", { target: { value: "all" } }); + + assert.lengthOf(wrapper.find(".messages-reset"), 0); + + wrapper + .find("select") + .simulate("change", { target: { value: "test_local_testing" } }); + assert.lengthOf(wrapper.find(".messages-reset"), 0); + }); + it("should trigger disable and enable provider on Reset All button click", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + providerPrefs: [ + { + id: "messageProvider", + }, + ], + }); + wrapper.find(".messages-reset").simulate("click"); + assert.calledTwice(ASRouterUtils.sendMessage); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "DISABLE_PROVIDER", + data: "messageProvider", + }); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "ENABLE_PROVIDER", + data: "messageProvider", + }); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx new file mode 100644 index 0000000000..9d9984ee85 --- /dev/null +++ b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx @@ -0,0 +1,112 @@ +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; +import CFRDoorhangerSchema from "content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json"; +import CFRChicletSchema from "content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json"; +import InfoBarSchema from "content-src/templates/CFR/templates/InfoBar.schema.json"; + +const SCHEMAS = { + cfr_urlbar_chiclet: CFRChicletSchema, + cfr_doorhanger: CFRDoorhangerSchema, + milestone_message: CFRDoorhangerSchema, + infobar: InfoBarSchema, +}; + +const DEFAULT_CONTENT = { + layout: "addon_recommendation", + category: "dummyCategory", + bucket_id: "some_bucket_id", + notification_text: "Recommendation", + heading_text: "Recommended Extension", + info_icon: { + label: { attributes: { tooltiptext: "Why am I seeing this" } }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: "Description of addon", + buttons: { + primary: { + label: { + value: "Add Now", + attributes: { accesskey: "A" }, + }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { + value: "Not Now", + attributes: { accesskey: "N" }, + }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +const L10N_CONTENT = { + layout: "addon_recommendation", + category: "dummyL10NCategory", + bucket_id: "some_bucket_id", + notification_text: { string_id: "notification_text_id" }, + heading_text: { string_id: "heading_text_id" }, + info_icon: { + label: { string_id: "why_seeing_this" }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: { string_id: "text_id" }, + buttons: { + primary: { + label: { string_id: "btn_ok_id" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { string_id: "btn_cancel_id" }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +describe("ExtensionDoorhanger", () => { + it("should validate DEFAULT_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: DEFAULT_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate L10N_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: L10N_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate all messages from CFRMessageProvider", async () => { + const messages = await CFRMessageProvider.getMessages(); + messages.forEach(msg => assert.jsonSchema(msg, SCHEMAS[msg.template])); + }); +}); diff --git a/browser/components/asrouter/tests/unit/unit-entry.js b/browser/components/asrouter/tests/unit/unit-entry.js new file mode 100644 index 0000000000..b8b799e051 --- /dev/null +++ b/browser/components/asrouter/tests/unit/unit-entry.js @@ -0,0 +1,727 @@ +import { + EventEmitter, + FakePrefs, + FakensIPrefService, + GlobalOverrider, + FakeConsoleAPI, + FakeLogger, +} from "newtab/test/unit/utils"; +import Adapter from "enzyme-adapter-react-16"; +import { chaiAssertions } from "newtab/test/schemas/pings"; +import chaiJsonSchema from "chai-json-schema"; +import enzyme from "enzyme"; +import FxMSCommonSchema from "../../content-src/schemas/FxMSCommon.schema.json"; +import { + MESSAGE_TYPE_LIST, + MESSAGE_TYPE_HASH, +} from "modules/ActorConstants.sys.mjs"; + +enzyme.configure({ adapter: new Adapter() }); + +// Cause React warnings to make tests that trigger them fail +const origConsoleError = console.error; +console.error = function (msg, ...args) { + origConsoleError.apply(console, [msg, ...args]); + + if ( + /(Invalid prop|Failed prop type|Check the render method|React Intl)/.test( + msg + ) + ) { + throw new Error(msg); + } +}; + +const req = require.context(".", true, /\.test\.jsx?$/); +const files = req.keys(); + +// This exposes sinon assertions to chai.assert +sinon.assert.expose(assert, { prefix: "" }); + +chai.use(chaiAssertions); +chai.use(chaiJsonSchema); +chai.tv4.addSchema("file:///FxMSCommon.schema.json", FxMSCommonSchema); + +const overrider = new GlobalOverrider(); + +const RemoteSettings = name => ({ + get: () => { + if (name === "attachment") { + return Promise.resolve([{ attachment: {} }]); + } + return Promise.resolve([]); + }, + on: () => {}, + off: () => {}, +}); +RemoteSettings.pollChanges = () => {}; + +class JSWindowActorParent { + sendAsyncMessage(name, data) { + return { name, data }; + } +} + +class JSWindowActorChild { + sendAsyncMessage(name, data) { + return { name, data }; + } + + sendQuery(name, data) { + return Promise.resolve({ name, data }); + } + + get contentWindow() { + return { + Promise, + }; + } +} + +// Detect plain object passed to lazy getter APIs, and set its prototype to +// global object, and return the global object for further modification. +// Returns the object if it's not plain object. +// +// This is a workaround to make the existing testharness and testcase keep +// working even after lazy getters are moved to plain `lazy` object. +const cachedPlainObject = new Set(); +function updateGlobalOrObject(object) { + // Given this function modifies the prototype, and the following + // condition doesn't meet on the second call, cache the result. + if (cachedPlainObject.has(object)) { + return global; + } + + if (Object.getPrototypeOf(object).constructor.name !== "Object") { + return object; + } + + cachedPlainObject.add(object); + Object.setPrototypeOf(object, global); + return global; +} + +const TEST_GLOBAL = { + JSWindowActorParent, + JSWindowActorChild, + AboutReaderParent: { + addMessageListener: (messageName, listener) => {}, + removeMessageListener: (messageName, listener) => {}, + }, + AboutWelcomeTelemetry: class { + submitGleanPingForPing() {} + }, + AddonManager: { + getActiveAddons() { + return Promise.resolve({ addons: [], fullData: false }); + }, + }, + AppConstants: { + MOZILLA_OFFICIAL: true, + MOZ_APP_VERSION: "69.0a1", + isChinaRepack() { + return false; + }, + isPlatformAndVersionAtMost() { + return false; + }, + platform: "win", + }, + ASRouterPreferences: { + console: new FakeConsoleAPI({ + maxLogLevel: "off", // set this to "debug" or "all" to get more ASRouter logging in tests + prefix: "ASRouter", + }), + }, + AWScreenUtils: { + evaluateTargetingAndRemoveScreens() { + return true; + }, + async removeScreens() { + return true; + }, + evaluateScreenTargeting() { + return true; + }, + }, + BrowserUtils: { + sendToDeviceEmailsSupported() { + return true; + }, + }, + UpdateUtils: { getUpdateChannel() {} }, + BasePromiseWorker: class { + constructor() { + this.ExceptionHandlers = []; + } + post() {} + }, + browserSearchRegion: "US", + BrowserWindowTracker: { getTopWindow() {} }, + ChromeUtils: { + defineLazyGetter(object, name, f) { + updateGlobalOrObject(object)[name] = f(); + }, + defineModuleGetter: updateGlobalOrObject, + defineESModuleGetters: updateGlobalOrObject, + generateQI() { + return {}; + }, + import() { + return global; + }, + importESModule() { + return global; + }, + }, + ClientEnvironment: { + get userId() { + return "foo123"; + }, + }, + Components: { + Constructor(classId) { + switch (classId) { + case "@mozilla.org/referrer-info;1": + return function (referrerPolicy, sendReferrer, originalReferrer) { + this.referrerPolicy = referrerPolicy; + this.sendReferrer = sendReferrer; + this.originalReferrer = originalReferrer; + }; + } + return function () {}; + }, + isSuccessCode: () => true, + }, + ConsoleAPI: FakeConsoleAPI, + // NB: These are functions/constructors + // eslint-disable-next-line object-shorthand + ContentSearchUIController: function () {}, + // eslint-disable-next-line object-shorthand + ContentSearchHandoffUIController: function () {}, + Cc: { + "@mozilla.org/browser/nav-bookmarks-service;1": { + addObserver() {}, + getService() { + return this; + }, + removeObserver() {}, + SOURCES: {}, + TYPE_BOOKMARK: {}, + }, + "@mozilla.org/browser/nav-history-service;1": { + addObserver() {}, + executeQuery() {}, + getNewQuery() {}, + getNewQueryOptions() {}, + getService() { + return this; + }, + insert() {}, + markPageAsTyped() {}, + removeObserver() {}, + }, + "@mozilla.org/io/string-input-stream;1": { + createInstance() { + return {}; + }, + }, + "@mozilla.org/security/hash;1": { + createInstance() { + return { + init() {}, + updateFromStream() {}, + finish() { + return "0"; + }, + }; + }, + }, + "@mozilla.org/updates/update-checker;1": { createInstance() {} }, + "@mozilla.org/widget/useridleservice;1": { + getService() { + return { + idleTime: 0, + addIdleObserver() {}, + removeIdleObserver() {}, + }; + }, + }, + "@mozilla.org/streamConverters;1": { + getService() { + return this; + }, + }, + "@mozilla.org/network/stream-loader;1": { + createInstance() { + return {}; + }, + }, + }, + Ci: { + nsICryptoHash: {}, + nsIReferrerInfo: { UNSAFE_URL: 5 }, + nsITimer: { TYPE_ONE_SHOT: 1 }, + nsIWebProgressListener: { LOCATION_CHANGE_SAME_DOCUMENT: 1 }, + nsIDOMWindow: Object, + nsITrackingDBService: { + TRACKERS_ID: 1, + TRACKING_COOKIES_ID: 2, + CRYPTOMINERS_ID: 3, + FINGERPRINTERS_ID: 4, + SOCIAL_ID: 5, + }, + nsICookieBannerService: { + MODE_DISABLED: 0, + MODE_REJECT: 1, + MODE_REJECT_OR_ACCEPT: 2, + MODE_UNSET: 3, + }, + }, + Cu: { + importGlobalProperties() {}, + now: () => window.performance.now(), + cloneInto: o => JSON.parse(JSON.stringify(o)), + }, + console: { + ...console, + error() {}, + }, + dump() {}, + EveryWindow: { + registerCallback: (id, init, uninit) => {}, + unregisterCallback: id => {}, + }, + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + fetch() {}, + // eslint-disable-next-line object-shorthand + Image: function () {}, // NB: This is a function/constructor + IOUtils: { + writeJSON() { + return Promise.resolve(0); + }, + readJSON() { + return Promise.resolve({}); + }, + read() { + return Promise.resolve(new Uint8Array()); + }, + makeDirectory() { + return Promise.resolve(0); + }, + write() { + return Promise.resolve(0); + }, + exists() { + return Promise.resolve(0); + }, + remove() { + return Promise.resolve(0); + }, + stat() { + return Promise.resolve(0); + }, + }, + NewTabUtils: { + activityStreamProvider: { + getTopFrecentSites: () => [], + executePlacesQuery: async (sql, options) => ({ sql, options }), + }, + }, + OS: { + File: { + writeAtomic() {}, + makeDir() {}, + stat() {}, + Error: {}, + read() {}, + exists() {}, + remove() {}, + removeEmptyDir() {}, + }, + Path: { + join() { + return "/"; + }, + }, + Constants: { + Path: { + localProfileDir: "/", + }, + }, + }, + PathUtils: { + join(...parts) { + return parts[parts.length - 1]; + }, + joinRelative(...parts) { + return parts[parts.length - 1]; + }, + getProfileDir() { + return Promise.resolve("/"); + }, + getLocalProfileDir() { + return Promise.resolve("/"); + }, + }, + PlacesUtils: { + get bookmarks() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-bookmarks-service;1"]; + }, + get history() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-history-service;1"]; + }, + observers: { + addListener() {}, + removeListener() {}, + }, + }, + Preferences: FakePrefs, + PrivateBrowsingUtils: { + isBrowserPrivate: () => false, + isWindowPrivate: () => false, + permanentPrivateBrowsing: false, + }, + DownloadsViewUI: { + getDisplayName: () => "filename.ext", + getSizeWithUnits: () => "1.5 MB", + }, + FileUtils: { + // eslint-disable-next-line object-shorthand + File: function () {}, // NB: This is a function/constructor + }, + Region: { + home: "US", + REGION_TOPIC: "browser-region-updated", + }, + Services: { + dirsvc: { + get: () => ({ parent: { parent: { path: "appPath" } } }), + }, + env: { + set: () => undefined, + }, + locale: { + get appLocaleAsBCP47() { + return "en-US"; + }, + negotiateLanguages() {}, + }, + urlFormatter: { formatURL: str => str, formatURLPref: str => str }, + mm: { + addMessageListener: (msg, cb) => this.receiveMessage(), + removeMessageListener() {}, + }, + obs: { + addObserver() {}, + removeObserver() {}, + notifyObservers() {}, + }, + telemetry: { + setEventRecordingEnabled: () => {}, + recordEvent: eventDetails => {}, + scalarSet: () => {}, + keyedScalarAdd: () => {}, + }, + uuid: { + generateUUID() { + return "{foo-123-foo}"; + }, + }, + console: { logStringMessage: () => {} }, + prefs: new FakensIPrefService(), + tm: { + dispatchToMainThread: cb => cb(), + idleDispatchToMainThread: cb => cb(), + }, + eTLD: { + getBaseDomain({ spec }) { + return spec.match(/\/([^/]+)/)[1]; + }, + getBaseDomainFromHost(host) { + return host.match(/.*?(\w+\.\w+)$/)[1]; + }, + getPublicSuffix() {}, + }, + io: { + newURI: spec => ({ + mutate: () => ({ + setRef: ref => ({ + finalize: () => ({ + ref, + spec, + }), + }), + }), + spec, + }), + }, + search: { + init() { + return Promise.resolve(); + }, + getVisibleEngines: () => + Promise.resolve([{ identifier: "google" }, { identifier: "bing" }]), + defaultEngine: { + identifier: "google", + searchForm: + "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b", + aliases: ["@google"], + }, + defaultPrivateEngine: { + identifier: "bing", + searchForm: "https://www.bing.com", + aliases: ["@bing"], + }, + getEngineByAlias: async () => null, + }, + scriptSecurityManager: { + createNullPrincipal() {}, + getSystemPrincipal() {}, + }, + wm: { + getMostRecentWindow: () => window, + getMostRecentBrowserWindow: () => window, + getEnumerator: () => [], + }, + ww: { registerNotification() {}, unregisterNotification() {} }, + appinfo: { appBuildID: "20180710100040", version: "69.0a1" }, + scriptloader: { loadSubScript: () => {} }, + startup: { + getStartupInfo() { + return { + process: { + getTime() { + return 1588010448000; + }, + }, + }; + }, + }, + }, + XPCOMUtils: { + defineLazyGlobalGetters: updateGlobalOrObject, + defineLazyModuleGetters: updateGlobalOrObject, + defineLazyServiceGetter: updateGlobalOrObject, + defineLazyServiceGetters: updateGlobalOrObject, + defineLazyPreferenceGetter(object, name) { + updateGlobalOrObject(object)[name] = ""; + }, + generateQI() { + return {}; + }, + }, + EventEmitter, + ShellService: { + doesAppNeedPin: () => false, + isDefaultBrowser: () => true, + }, + FilterExpressions: { + eval() { + return Promise.resolve(false); + }, + }, + RemoteSettings, + Localization: class { + async formatMessages(stringsIds) { + return Promise.resolve( + stringsIds.map(({ id, args }) => ({ value: { string_id: id, args } })) + ); + } + async formatValue(stringId) { + return Promise.resolve(stringId); + } + }, + FxAccountsConfig: { + promiseConnectAccountURI(id) { + return Promise.resolve(id); + }, + }, + FX_MONITOR_OAUTH_CLIENT_ID: "fake_client_id", + ExperimentAPI: { + getExperiment() {}, + getExperimentMetaData() {}, + getRolloutMetaData() {}, + }, + NimbusFeatures: { + glean: { + getVariable() {}, + }, + newtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + pocketNewtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + cookieBannerHandling: { + getVariable() {}, + }, + }, + TelemetryEnvironment: { + setExperimentActive() {}, + currentEnvironment: { + profile: { + creationDate: 16587, + }, + settings: {}, + }, + }, + TelemetryStopwatch: { + start: () => {}, + finish: () => {}, + }, + Sampling: { + ratioSample(seed, ratios) { + return Promise.resolve(0); + }, + }, + BrowserHandler: { + get kiosk() { + return false; + }, + }, + TelemetrySession: { + getMetadata(reason) { + return { + reason, + sessionId: "fake_session_id", + }; + }, + }, + PageThumbs: { + addExpirationFilter() {}, + removeExpirationFilter() {}, + }, + Logger: FakeLogger, + getFxAccountsSingleton() {}, + AboutNewTab: {}, + Glean: { + newtab: { + opened: { + record() {}, + }, + closed: { + record() {}, + }, + locale: { + set() {}, + }, + newtabCategory: { + set() {}, + }, + homepageCategory: { + set() {}, + }, + blockedSponsors: { + set() {}, + }, + sovAllocation: { + set() {}, + }, + }, + newtabSearch: { + enabled: { + set() {}, + }, + }, + pocket: { + enabled: { + set() {}, + }, + impression: { + record() {}, + }, + isSignedIn: { + set() {}, + }, + sponsoredStoriesEnabled: { + set() {}, + }, + click: { + record() {}, + }, + save: { + record() {}, + }, + topicClick: { + record() {}, + }, + }, + topsites: { + enabled: { + set() {}, + }, + sponsoredEnabled: { + set() {}, + }, + impression: { + record() {}, + }, + click: { + record() {}, + }, + rows: { + set() {}, + }, + showPrivacyClick: { + record() {}, + }, + dismiss: { + record() {}, + }, + prefChanged: { + record() {}, + }, + }, + topSites: { + pingType: { + set() {}, + }, + position: { + set() {}, + }, + source: { + set() {}, + }, + tileId: { + set() {}, + }, + reportingUrl: { + set() {}, + }, + advertiser: { + set() {}, + }, + contextId: { + set() {}, + }, + }, + }, + GleanPings: { + newtab: { + submit() {}, + }, + topSites: { + submit() {}, + }, + }, + Utils: { + SERVER_URL: "bogus://foo", + }, + + MESSAGE_TYPE_LIST, + MESSAGE_TYPE_HASH, +}; +overrider.set(TEST_GLOBAL); + +describe("asrouter", () => { + after(() => overrider.restore()); + files.forEach(file => req(file)); +}); diff --git a/browser/components/asrouter/tests/xpcshell/head.js b/browser/components/asrouter/tests/xpcshell/head.js new file mode 100644 index 0000000000..0c6cec1ac8 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/head.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", +}); + +function assertValidates(validator, obj, msg) { + const result = validator.validate(obj); + Assert.ok( + result.valid && result.errors.length === 0, + `${msg} - errors = ${JSON.stringify(result.errors, undefined, 2)}` + ); +} + +async function fetchSchema(uri) { + try { + dump(`URI: ${uri}\n`); + return fetch(uri, { credentials: "omit" }).then(rsp => rsp.json()); + } catch (e) { + throw new Error(`Could not fetch ${uri}`); + } +} + +async function schemaValidatorFor(uri, { common = false } = {}) { + const schema = await fetchSchema(uri); + const validator = new lazy.JsonSchema.Validator(schema); + + if (common) { + const commonSchema = await fetchSchema( + "resource://testing-common/FxMSCommon.schema.json" + ); + validator.addSchema(commonSchema); + } + + return validator; +} + +async function makeValidators() { + const experimentValidator = await schemaValidatorFor( + "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json" + ); + + const messageValidators = { + cfr_doorhanger: await schemaValidatorFor( + "resource://testing-common/ExtensionDoorhanger.schema.json", + { common: true } + ), + cfr_urlbar_chiclet: await schemaValidatorFor( + "resource://testing-common/CFRUrlbarChiclet.schema.json", + { common: true } + ), + infobar: await schemaValidatorFor( + "resource://testing-common/InfoBar.schema.json", + { common: true } + ), + pb_newtab: await schemaValidatorFor( + "resource://testing-common/NewtabPromoMessage.schema.json", + { common: true } + ), + spotlight: await schemaValidatorFor( + "resource://testing-common/Spotlight.schema.json", + { common: true } + ), + toast_notification: await schemaValidatorFor( + "resource://testing-common/ToastNotification.schema.json", + { common: true } + ), + toolbar_badge: await schemaValidatorFor( + "resource://testing-common/ToolbarBadgeMessage.schema.json", + { common: true } + ), + update_action: await schemaValidatorFor( + "resource://testing-common/UpdateAction.schema.json", + { common: true } + ), + whatsnew_panel_message: await schemaValidatorFor( + "resource://testing-common/WhatsNewMessage.schema.json", + { common: true } + ), + feature_callout: await schemaValidatorFor( + // For now, Feature Callout and Spotlight share a common schema + "resource://testing-common/Spotlight.schema.json", + { common: true } + ), + }; + + messageValidators.milestone_message = messageValidators.cfr_doorhanger; + + return { experimentValidator, messageValidators }; +} diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js new file mode 100644 index 0000000000..a37cb6c793 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { AttributionCode } = ChromeUtils.importESModule( + "resource:///modules/AttributionCode.sys.mjs" +); +const { ASRouterTargeting } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs" +); +const { MacAttribution } = ChromeUtils.importESModule( + "resource:///modules/MacAttribution.sys.mjs" +); +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); + +add_task(async function check_attribution_data() { + // Some setup to fake the correct attribution data + const campaign = "non-fx-button"; + const source = "addons.mozilla.org"; + const attrStr = `campaign%3D${campaign}%26source%3D${source}`; + await MacAttribution.setAttributionString(attrStr); + AttributionCode._clearCache(); + await AttributionCode.getAttrDataAsync(); + + const { campaign: attributionCampain, source: attributionSource } = + ASRouterTargeting.Environment.attributionData; + equal( + attributionCampain, + campaign, + "should get the correct campaign out of attributionData" + ); + equal( + attributionSource, + source, + "should get the correct source out of attributionData" + ); + + const messages = [ + { + id: "foo1", + targeting: + "attributionData.campaign == 'back_to_school' && attributionData.source == 'addons.mozilla.org'", + }, + { + id: "foo2", + targeting: + "attributionData.campaign == 'non-fx-button' && attributionData.source == 'addons.mozilla.org'", + }, + ]; + + equal( + await ASRouterTargeting.findMatchingMessage({ messages }), + messages[1], + "should select the message with the correct campaign and source" + ); + AttributionCode._clearCache(); +}); + +add_task(async function check_enterprise_targeting() { + const messages = [ + { + id: "foo1", + targeting: "hasActiveEnterprisePolicies", + }, + { + id: "foo2", + targeting: "!hasActiveEnterprisePolicies", + }, + ]; + + equal( + await ASRouterTargeting.findMatchingMessage({ messages }), + messages[1], + "should select the message for policies turned off" + ); + + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + DisableFirefoxStudies: { + Value: true, + }, + }, + }); + + equal( + await ASRouterTargeting.findMatchingMessage({ messages }), + messages[0], + "should select the message for policies turned on" + ); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js new file mode 100644 index 0000000000..74171ba1b9 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { ASRouterTargeting } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs" +); + +add_task(async function should_ignore_rejections() { + let target = { + get foo() { + return new Promise(resolve => resolve(1)); + }, + + get bar() { + return new Promise((resolve, reject) => reject(new Error("unspecified"))); + }, + }; + + let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + Assert.deepEqual(snapshot, { environment: { foo: 1 }, version: 1 }); +}); + +add_task(async function nested_objects() { + const target = { + get foo() { + return Promise.resolve("foo"); + }, + get bar() { + return Promise.reject(new Error("bar")); + }, + baz: { + get qux() { + return Promise.resolve("qux"); + }, + get quux() { + return Promise.reject(new Error("quux")); + }, + get corge() { + return { + get grault() { + return Promise.resolve("grault"); + }, + get garply() { + return Promise.reject(new Error("garply")); + }, + }; + }, + }, + }; + + const snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + Assert.deepEqual( + snapshot, + { + environment: { + foo: "foo", + baz: { + qux: "qux", + corge: { + grault: "grault", + }, + }, + }, + version: 1, + }, + "getEnvironmentSnapshot should resolve nested promises" + ); +}); + +add_task(async function arrays() { + const target = { + foo: [1, 2, 3], + bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)], + baz: Promise.resolve([1, 2, 3]), + qux: Promise.resolve([ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3), + ]), + quux: Promise.resolve({ + corge: [Promise.resolve(1), 2, 3], + }), + }; + + const snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + Assert.deepEqual( + snapshot, + { + environment: { + foo: [1, 2, 3], + bar: [1, 2, 3], + baz: [1, 2, 3], + qux: [1, 2, 3], + quux: { corge: [1, 2, 3] }, + }, + version: 1, + }, + "getEnvironmentSnapshot should resolve arrays correctly" + ); +}); + +add_task(async function target_order() { + let target1 = { + foo: 1, + bar: 1, + baz: 1, + }; + + let target2 = { + foo: 2, + bar: 2, + }; + + let target3 = { + foo: 3, + }; + + // target3 supercedes target2; both supercede target1. + let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target3, target2, target1], + }); + Assert.deepEqual(snapshot, { + environment: { foo: 3, bar: 2, baz: 1 }, + version: 1, + }); +}); + +/* + * NB: This test is last because it manipulates shutdown phases. + * + * Adding tests after this one will result in failures. + */ +add_task(async function should_ignore_rejections() { + // The order that `ASRouterTargeting.getEnvironmentSnapshot` + // enumerates the target object matters here, but it's guaranteed to + // be consistent by the `for ... in` ordering: see + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#description. + let target = { + get foo() { + return new Promise(resolve => resolve(1)); + }, + + get bar() { + return new Promise(resolve => { + // Pretend that we're about to shut down. + Services.startup.advanceShutdownPhase( + Services.startup.SHUTDOWN_PHASE_APPSHUTDOWN + ); + resolve(2); + }); + }, + + get baz() { + return new Promise(resolve => resolve(3)); + }, + }; + + let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + // `baz` is dropped since we're shutting down by the time it's processed. + Assert.deepEqual(snapshot, { environment: { foo: 1, bar: 2 }, version: 1 }); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js b/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js new file mode 100644 index 0000000000..bda6d0cd41 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +add_task(async function nested_objects() { + const target = { + get foo() { + return Promise.resolve("foo"); + }, + baz: { + get qux() { + return Promise.resolve("qux"); + }, + get corge() { + return { + get grault() { + return Promise.resolve("grault"); + }, + }; + }, + }, + }; + + const params = await ASRouter.getTargetingParameters(target); + Assert.deepEqual( + params, + { + foo: "foo", + baz: { + qux: "qux", + corge: { + grault: "grault", + }, + }, + }, + "getTargetingParameters should resolve nested promises" + ); +}); + +add_task(async function arrays() { + const target = { + foo: [1, 2, 3], + bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)], + baz: Promise.resolve([1, 2, 3]), + qux: Promise.resolve([ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3), + ]), + quux: Promise.resolve({ + corge: [Promise.resolve(1), 2, 3], + }), + }; + + const params = await ASRouter.getTargetingParameters(target); + Assert.deepEqual( + params, + { + foo: [1, 2, 3], + bar: [1, 2, 3], + baz: [1, 2, 3], + qux: [1, 2, 3], + quux: { corge: [1, 2, 3] }, + }, + "getEnvironmentSnapshot should resolve arrays correctly" + ); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js new file mode 100644 index 0000000000..3354013067 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +add_task(async function test_cfrMessages() { + const { experimentValidator, messageValidators } = await makeValidators(); + + const messages = await CFRMessageProvider.getMessages(); + for (const message of messages) { + const validator = messageValidators[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}.` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + + assertValidates( + validator, + message, + `Message ${message.id} validates as template ${message.template}` + ); + assertValidates( + experimentValidator, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js new file mode 100644 index 0000000000..fce99362c7 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { InflightAssetsMessageProvider } = ChromeUtils.importESModule( + "resource://testing-common/InflightAssetsMessageProvider.sys.mjs" +); + +const MESSAGE_VALIDATORS = {}; +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + const validators = await makeValidators(); + + EXPERIMENT_VALIDATOR = validators.experimentValidator; + Object.assign(MESSAGE_VALIDATORS, validators.messageValidators); +}); + +add_task(function test_InflightAssetsMessageProvider() { + const messages = InflightAssetsMessageProvider.getMessages(); + + for (const message of messages) { + const validator = MESSAGE_VALIDATORS[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + + assertValidates( + validator, + message, + `Message ${message.id} validates as ${message.template} template` + ); + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${message.id} validates as a MessagingExperiment` + ); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js new file mode 100644 index 0000000000..2fe01e2fed --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { NimbusRolloutMessageProvider } = ChromeUtils.importESModule( + "resource://testing-common/NimbusRolloutMessageProvider.sys.mjs" +); + +const MESSAGE_VALIDATORS = {}; +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + const validators = await makeValidators(); + + EXPERIMENT_VALIDATOR = validators.experimentValidator; + Object.assign(MESSAGE_VALIDATORS, validators.messageValidators); +}); + +add_task(function test_NimbusRolloutMessageProvider() { + const messages = NimbusRolloutMessageProvider.getMessages(); + + for (const message of messages) { + const validator = MESSAGE_VALIDATORS[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + + assertValidates( + validator, + message, + `Message ${message.id} validates as ${message.template} template` + ); + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${message.id} validates as a MessagingExperiment` + ); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js new file mode 100644 index 0000000000..7ea7c97a03 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js @@ -0,0 +1,229 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { OnboardingMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +function getOnboardingScreenById(screens, screenId) { + return screens.find(screen => { + return screen?.id === screenId; + }); +} + +add_task( + async function test_OnboardingMessageProvider_getUpgradeMessage_no_pin() { + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // If Firefox is not pinned, the screen should have "pin" content + equal( + message.content.screens[0].id, + "UPGRADE_PIN_FIREFOX", + "Screen has pin screen id" + ); + equal( + message.content.screens[0].content.primary_button.action.type, + "PIN_FIREFOX_TO_TASKBAR", + "Primary button has pin action type" + ); + sandbox.restore(); + } +); + +add_task( + async function test_OnboardingMessageProvider_getUpgradeMessage_pin_no_default() { + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false); + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // If Firefox is pinned, but not the default, the screen should have "make default" content + equal( + message.content.screens[0].id, + "UPGRADE_ONLY_DEFAULT", + "Screen has make default screen id" + ); + equal( + message.content.screens[0].content.primary_button.action.type, + "SET_DEFAULT_BROWSER", + "Primary button has make default action" + ); + sandbox.restore(); + } +); + +add_task( + async function test_OnboardingMessageProvider_getUpgradeMessage_pin_and_default() { + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false); + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(false); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // If Firefox is pinned and the default, the screen should have "get started" content + equal( + message.content.screens[0].id, + "UPGRADE_GET_STARTED", + "Screen has get started screen id" + ); + ok( + !message.content.screens[0].content.primary_button.action.type, + "Primary button has no action type" + ); + sandbox.restore(); + } +); + +add_task(async function test_OnboardingMessageProvider_getNoImport_default() { + let sandbox = sinon.createSandbox(); + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(false); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // No import screen is shown when user has Firefox both pinned and default + Assert.notEqual( + message.content.screens[1]?.id, + "UPGRADE_IMPORT_SETTINGS_EMBEDDED", + "Screen has no import screen id" + ); + sandbox.restore(); +}); + +add_task(async function test_OnboardingMessageProvider_getImport_nodefault() { + Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser"); + }); + + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedDefault").resolves(true); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // Import screen is shown when user doesn't have Firefox pinned and default + Assert.equal( + message.content.screens[1]?.id, + "UPGRADE_IMPORT_SETTINGS_EMBEDDED", + "Screen has import screen id" + ); + sandbox.restore(); +}); + +add_task( + async function test_OnboardingMessageProvider_getPinPrivateWindow_noPrivatePin() { + Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser"); + }); + let sandbox = sinon.createSandbox(); + // User needs default to ensure Pin Private window shows as third screen after import + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + + let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin"); + pinStub.resolves(false); + pinStub.withArgs(true).resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // Pin Private screen is shown when user doesn't have Firefox private pinned but has Firefox pinned + Assert.ok( + getOnboardingScreenById( + message.content.screens, + "UPGRADE_PIN_PRIVATE_WINDOW" + ) + ); + sandbox.restore(); + } +); + +add_task( + async function test_OnboardingMessageProvider_getNoPinPrivateWindow_noPin() { + Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser"); + }); + let sandbox = sinon.createSandbox(); + // User needs default to ensure Pin Private window shows as third screen after import + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + + let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin"); + pinStub.resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // Pin Private screen is not shown when user doesn't have Firefox pinned + Assert.ok( + !getOnboardingScreenById( + message.content.screens, + "UPGRADE_PIN_PRIVATE_WINDOW" + ) + ); + sandbox.restore(); + } +); + +add_task(async function test_schemaValidation() { + const { experimentValidator, messageValidators } = await makeValidators(); + + const messages = await OnboardingMessageProvider.getMessages(); + for (const message of messages) { + const validator = messageValidators[message.template]; + + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}.` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + assertValidates( + validator, + message, + `Message ${message.id} validates as template ${message.template}` + ); + assertValidates( + experimentValidator, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + } +}); + +add_task( + async function test_OnboardingMessageProvider_getPinPrivateWindow_pinPBMPrefDisabled() { + Services.prefs.setBoolPref( + "browser.startup.upgradeDialog.pinPBM.disabled", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref( + "browser.startup.upgradeDialog.pinPBM.disabled" + ); + }); + let sandbox = sinon.createSandbox(); + // User needs default to ensure Pin Private window shows as third screen after import + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + + let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin"); + pinStub.resolves(true); + + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // Pin Private screen is not shown when pref is turned on + Assert.ok( + !getOnboardingScreenById( + message.content.screens, + "UPGRADE_PIN_PRIVATE_WINDOW" + ) + ); + sandbox.restore(); + } +); diff --git a/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js new file mode 100644 index 0000000000..3523355659 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); + +const MESSAGE_VALIDATORS = {}; +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + const validators = await makeValidators(); + + EXPERIMENT_VALIDATOR = validators.experimentValidator; + Object.assign(MESSAGE_VALIDATORS, validators.messageValidators); +}); + +add_task(async function test_PanelTestProvider() { + const messages = await PanelTestProvider.getMessages(); + + const EXPECTED_MESSAGE_COUNTS = { + cfr_doorhanger: 1, + milestone_message: 0, + update_action: 1, + whatsnew_panel_message: 7, + spotlight: 3, + feature_callout: 1, + pb_newtab: 2, + toast_notification: 3, + }; + + const EXPECTED_TOTAL_MESSAGE_COUNT = Object.values( + EXPECTED_MESSAGE_COUNTS + ).reduce((a, b) => a + b, 0); + + Assert.strictEqual( + messages.length, + EXPECTED_TOTAL_MESSAGE_COUNT, + "PanelTestProvider should have the correct number of messages" + ); + + const messageCounts = Object.assign( + {}, + ...Object.keys(EXPECTED_MESSAGE_COUNTS).map(key => ({ [key]: 0 })) + ); + + for (const message of messages) { + const validator = MESSAGE_VALIDATORS[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + assertValidates( + validator, + message, + `Message ${message.id} validates as ${message.template} template` + ); + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + + messageCounts[message.template]++; + } + + for (const [template, count] of Object.entries(messageCounts)) { + Assert.equal( + count, + EXPECTED_MESSAGE_COUNTS[template], + `Expected ${EXPECTED_MESSAGE_COUNTS[template]} ${template} messages` + ); + } +}); + +add_task(async function test_emptyMessage() { + info( + "Testing blank FxMS messages validate with the Messaging Experiment schema" + ); + + assertValidates(EXPERIMENT_VALIDATOR, {}, "Empty message should validate"); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js b/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js new file mode 100644 index 0000000000..e69ce98677 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ObjectUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ObjectUtils.sys.mjs" +); + +const MESSAGES = [ + { + trigger: { id: "defaultBrowserCheck" }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5", + }, + { + groups: ["eco"], + trigger: { + id: "defaultBrowserCheck", + }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5", + }, +]; + +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + EXPERIMENT_VALIDATOR = await schemaValidatorFor( + "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json" + ); +}); + +add_task(function test_reach_experiments_validation() { + for (const [index, message] of MESSAGES.entries()) { + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${index} validates as a MessagingExperiment` + ); + } +}); + +function depError(has, missing) { + return { + instanceLocation: "#", + keyword: "dependentRequired", + keywordLocation: "#/oneOf/1/allOf/0/$ref/dependantRequired", + error: `Instance has "${has}" but does not have "${missing}".`, + }; +} + +function assertContains(haystack, needle) { + Assert.ok( + haystack.find(item => ObjectUtils.deepEqual(item, needle)) !== null + ); +} + +add_task(function test_reach_experiment_dependentRequired() { + info( + "Testing that if id is present then content and template are not required" + ); + + { + const message = { + ...MESSAGES[0], + id: "message-id", + }; + + const result = EXPERIMENT_VALIDATOR.validate(message); + Assert.ok(result.valid, "message should validate"); + } + + info("Testing that if content is present then id and template are required"); + { + const message = { + ...MESSAGES[0], + content: {}, + }; + + const result = EXPERIMENT_VALIDATOR.validate(message); + Assert.ok(!result.valid, "message should not validate"); + assertContains(result.errors, depError("content", "id")); + assertContains(result.errors, depError("content", "template")); + } + + info("Testing that if template is present then id and content are required"); + { + const message = { + ...MESSAGES[0], + template: "cfr", + }; + + const result = EXPERIMENT_VALIDATOR.validate(message); + Assert.ok(!result.valid, "message should not validate"); + assertContains(result.errors, depError("template", "content")); + assertContains(result.errors, depError("template", "id")); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js b/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js new file mode 100644 index 0000000000..40c0993b4f --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +add_task(async function test_multiMessageTreatment() { + const { experimentValidator } = await makeValidators(); + // Use the entire list of messages as if it was a single treatment branch's + // feature value. + let messages = await CFRMessageProvider.getMessages(); + let featureValue = { template: "multi", messages }; + assertValidates( + experimentValidator, + featureValue, + `Multi-message treatment validates as MessagingExperiment` + ); + for (const message of messages) { + assertValidates( + experimentValidator, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + } + + // Add an invalid message to the list and make sure it fails validation. + messages.push({ + id: "INVALID_MESSAGE", + template: "cfr_doorhanger", + }); + const result = experimentValidator.validate(featureValue); + Assert.ok( + !(result.valid && result.errors.length === 0), + "Multi-message treatment with invalid message fails validation" + ); +}); diff --git a/browser/components/asrouter/tests/xpcshell/xpcshell.toml b/browser/components/asrouter/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..db28042ad2 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/xpcshell.toml @@ -0,0 +1,24 @@ +[DEFAULT] +head = "head.js" +firefox-appdir = "browser" + +["test_ASRouterTargeting_attribution.js"] +run-if = ["os == 'mac'"] # osx specific tests + +["test_ASRouterTargeting_snapshot.js"] + +["test_ASRouter_getTargetingParameters.js"] + +["test_CFRMessageProvider.js"] + +["test_InflightAssetsMessageProvider.js"] + +["test_NimbusRolloutMessageProvider.js"] + +["test_OnboardingMessageProvider.js"] + +["test_PanelTestProvider.js"] + +["test_reach_experiments.js"] + +["test_remoteExperiments.js"] diff --git a/browser/components/asrouter/webpack.asrouter-admin.config.js b/browser/components/asrouter/webpack.asrouter-admin.config.js new file mode 100644 index 0000000000..a8eb54d43d --- /dev/null +++ b/browser/components/asrouter/webpack.asrouter-admin.config.js @@ -0,0 +1,35 @@ +/* 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/. */ + +const path = require("path"); +const config = require("../newtab/webpack.system-addon.config.js"); +const webpack = require("webpack"); +const absolute = relPath => path.join(__dirname, relPath); +const banner = ` + NOTE: This file is generated by webpack from ASRouterAdmin.jsx + using the npm bundle task. + `; +module.exports = Object.assign({}, config(), { + entry: absolute("content-src/components/ASRouterAdmin/ASRouterAdmin.jsx"), + output: { + path: absolute("content"), + filename: "asrouter-admin.bundle.js", + library: "ASRouterAdminRenderUtils", + }, + externals: { + "prop-types": "PropTypes", + react: "React", + "react-dom": "ReactDOM", + }, + plugins: [new webpack.BannerPlugin(banner)], + // This resolve config allows us to import with paths relative to the root directory + resolve: { + extensions: [".js", ".jsx"], + alias: { + newtab: absolute("../newtab"), + common: absolute("../newtab/common"), + modules: absolute("modules"), + }, + }, +}); diff --git a/browser/components/asrouter/yamscripts.yml b/browser/components/asrouter/yamscripts.yml new file mode 100644 index 0000000000..de16c269a4 --- /dev/null +++ b/browser/components/asrouter/yamscripts.yml @@ -0,0 +1,47 @@ +# This file compiles to package.json scripts. +# When you add or modify anything, you *MUST* run: +# npm run yamscripts +# to compile your changes. + +scripts: + # bundle: Build all assets for ASRouter + bundle: + admin: webpack-cli --config webpack.asrouter-admin.config.js + css: sass content-src:content --no-source-map + + # watchmc: Automatically rebuild when files are changed. NOTE: Includes sourcemaps, do not use for profiling/perf testing. + watchmc: + _parallel: true + bundle: =>bundle:admin -- --env development -w + css: =>bundle:css -- --source-map --embed-sources --embed-source-map -w + + testmc: + lint: =>lint + build: =>bundle:admin + unit: karma start karma.mc.config.js + + tddmc: karma start karma.mc.config.js --tdd + + debugcoverage: open logs/coverage/lcov-report/index.html + + # lint: Run various linters with mach or local dev dependencies + lint: + codespell: (cd $npm_package_config_mc_root && ./mach lint -l codespell $npm_package_config_asrouter_path) + eslint: (cd $npm_package_config_mc_root && ./mach lint -l eslint $npm_package_config_asrouter_path) + license: (cd $npm_package_config_mc_root && ./mach lint -l license $npm_package_config_asrouter_path) + stylelint: (cd $npm_package_config_mc_root && ./mach lint -l stylelint $npm_package_config_asrouter_path) + + # test: Run all tests once + test: =>testmc + + # tdd: Run content tests continuously + tdd: =>tddmc + + fix: + # Note that since we're currently running eslint-plugin-prettier, + # running fix:eslint will also reformat changed JS files using prettier. + eslint: =>lint:eslint -- --fix + stylelint: =>lint:stylelint -- --fix + + # script to import Nimbus rollouts into NimbusRolloutMessageProvider.sys.mjs + import-rollouts: node ./bin/import-rollouts.js |