diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json index b32c53d37777..b248d7e64508 100644 --- a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json @@ -28,11 +28,6 @@ "type": "string", "description": "Background css behind modal content" }, - "logoImageURL": { - "type": "string", - "format": "uri", - "description": "(Deprecated by logo.imageURL)" - }, "logo": { "type": "object", "properties": { @@ -49,6 +44,14 @@ "description": "The logo size." } }, + "oneOf": [ + { + "required": ["imageURL"] + }, + { + "required": ["imageId"] + } + ], "additionalProperties": false }, "body": { @@ -207,25 +210,6 @@ } }, "additionalProperties": false, - "if": { - "properties": { - "logoImageURL": { "type": "null" } - } - }, - "then": { - "properties": { - "logo": { - "oneOf": [ - { - "required": ["imageURL"] - }, - { - "required": ["imageId"] - } - ] - } - } - }, "required": ["template"] }, "frequency": { diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json index c6a66aa5e3c4..998f2cfc8d7e 100644 --- a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json @@ -67,7 +67,7 @@ "cta_url": { "description": "Target URL for the What's New message.", "type": "string", - "format": "moz-url-format" + "format": "uri" }, "cta_type": { "description": "Type of url open action", diff --git a/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json index 001411c356fd..fbf746525de9 100644 --- a/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json +++ b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json @@ -30,8 +30,7 @@ }, "infoLinkUrl": { "type": "string", - "description": "URL for the info section link.", - "format": "moz-url-format" + "description": "URL for the info section link." }, "promoEnabled": { "type": "boolean", @@ -60,8 +59,7 @@ }, "promoLinkUrl": { "type": "string", - "description": "URL for link in the promo box.", - "format": "moz-url-format" + "description": "URL for link in the promo box." }, "promoLinkType": { "type": "string", @@ -70,13 +68,11 @@ }, "promoImageLarge": { "type": "string", - "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.", - "format": "uri" + "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off." }, "promoImageSmall": { "type": "string", - "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.", - "format": "uri" + "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off." } } } diff --git a/browser/components/newtab/jar.mn b/browser/components/newtab/jar.mn index 3e3446e008a3..59ccd0ca577f 100644 --- a/browser/components/newtab/jar.mn +++ b/browser/components/newtab/jar.mn @@ -11,12 +11,6 @@ browser.jar: res/activity-stream/aboutwelcome/aboutwelcome.bundle.js (./aboutwelcome/content/aboutwelcome.bundle.js) res/activity-stream/aboutwelcome/aboutwelcome.html (./aboutwelcome/content/aboutwelcome.html) res/activity-stream/aboutwelcome/lib/ (./aboutwelcome/lib/*) - res/activity-stream/schemas/CFR/ExtensionDoorhanger.schema.json (./content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json) - res/activity-stream/schemas/CFR/InfoBar.schema.json (./content-src/asrouter/templates/CFR/templates/InfoBar.schema.json) - res/activity-stream/schemas/OnboardingMessage/UpdateAction.schema.json (./content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json) - res/activity-stream/schemas/OnboardingMessage/Spotlight.schema.json (./content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json) - res/activity-stream/schemas/OnboardingMessage/WhatsNewMessage.schema.json (./content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json) - res/activity-stream/schemas/PBNewtab/NewtabPromoMessage.schema.json (./content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json) res/activity-stream/vendor/Redux.jsm (./vendor/Redux.jsm) res/activity-stream/vendor/react.js (./vendor/react.js) res/activity-stream/vendor/react-dom.js (./vendor/react-dom.js) diff --git a/browser/components/newtab/lib/PanelTestProvider.jsm b/browser/components/newtab/lib/PanelTestProvider.jsm index e03ed62e3c07..6dd234148a65 100644 --- a/browser/components/newtab/lib/PanelTestProvider.jsm +++ b/browser/components/newtab/lib/PanelTestProvider.jsm @@ -34,7 +34,7 @@ const MESSAGES = () => [ icon_alt: { string_id: "cfr-badge-reader-label-newfeature" }, body: "Message body", link_text: "Click here", - cta_url: "about:blank", + cta_url: "", cta_type: "OPEN_PROTECTION_REPORT", }, targeting: `firefoxVersion >= 72`, @@ -105,9 +105,11 @@ const MESSAGES = () => [ bucket_id: "WHATS_NEW_PIONEER_82", published_date: 1603152000000, title: "Put your data to work for a better internet", + icon_url: "", + icon_alt: "", 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_url: "pioneer", cta_where: "tab", cta_type: "OPEN_ABOUT_PAGE", link_text: "Join Pioneer", diff --git a/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js b/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js index 19b303a316bb..f4d05a9ce268 100644 --- a/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js +++ b/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js @@ -22,7 +22,6 @@ const { TelemetryTestUtils } = ChromeUtils.import( const MESSAGE_CONTENT = { id: "xman_test_message", - groups: [], content: { text: "This is a test CFR", addon: { @@ -42,7 +41,7 @@ const MESSAGE_CONTENT = { }, action: { data: { - url: "about:blank", + url: null, }, type: "INSTALL_ADDON_FROM_URL", }, @@ -76,7 +75,6 @@ const MESSAGE_CONTENT = { ], }, category: "cfrAddons", - layout: "short_message", bucket_id: "CFR_M1", info_icon: { label: { @@ -335,3 +333,20 @@ add_task(async function test_exposure_ping_legacy() { exposureSpy.restore(); await cleanup(); }); + +add_task(async function test_featureless_experiment() { + let experiment = await getCFRExperiment(); + // Remove the feature property from the branch + experiment.branches.forEach(branch => delete branch.features); + Assert.ok(ExperimentAPI._store.getAllActive().length === 0, "Empty store"); + await setup(experiment); + // Fetch the new recipe from RS + await RemoteSettingsExperimentLoader.updateRecipes(); + // Enrollment was successful; featureless experiments shouldn't break anything + await BrowserTestUtils.waitForCondition( + () => ExperimentAPI._store.getAllActive().length === 1, + "ExperimentAPI should return an experiment" + ); + + await cleanup(); +}); diff --git a/browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js b/browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js new file mode 100644 index 000000000000..f8a65db5cd38 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js @@ -0,0 +1,50 @@ +import { PanelTestProvider } from "lib/PanelTestProvider.jsm"; +import update_schema from "content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json"; +import whats_new_schema from "content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json"; +import spotlight_schema from "content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json"; +import PBNewtabSchema from "content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json"; + +describe("PanelTestProvider", () => { + let messages; + beforeEach(async () => { + messages = await PanelTestProvider.getMessages(); + }); + it("should have correct number of messages", () => { + // Careful: when changing this number make sure that new messages also go + // through schema verifications. + assert.lengthOf(messages, 15); + }); + it("should be a valid message", () => { + const updateMessages = messages.filter( + ({ template }) => template === "update_action" + ); + for (let message of updateMessages) { + assert.jsonSchema(message, update_schema); + } + }); + it("should be a valid message", () => { + const whatsNewMessages = messages.filter( + ({ template }) => template === "whatsnew_panel_message" + ); + for (let message of whatsNewMessages) { + assert.jsonSchema(message.content, whats_new_schema); + // Not part of `message.content` so it can't be enforced through schema + assert.property(message, "order"); + } + }); + it("should be a valid spotlight message", () => { + const spotlightMessages = messages.filter( + ({ template }) => template === "spotlight" + ); + for (let message of spotlightMessages) { + assert.jsonSchema(message, spotlight_schema); + } + }); + it("should be a valid pb newtab message", () => { + const pbNewtabMessages = messages.filter( + ({ template }) => template === "pb_newtab" + ); + assert.lengthOf(pbNewtabMessages, 1); + pbNewtabMessages.forEach(m => assert.jsonSchema(m, PBNewtabSchema)); + }); +}); diff --git a/browser/components/newtab/test/xpcshell/test_PanelTestProvider.js b/browser/components/newtab/test/xpcshell/test_PanelTestProvider.js deleted file mode 100644 index 22b667e7ad95..000000000000 --- a/browser/components/newtab/test/xpcshell/test_PanelTestProvider.js +++ /dev/null @@ -1,101 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - http://creativecommons.org/publicdomain/zero/1.0/ */ - -const { PanelTestProvider } = ChromeUtils.import( - "resource://activity-stream/lib/PanelTestProvider.jsm" -); -const { validate } = ChromeUtils.import( - "resource://gre/modules/JsonSchema.jsm" -); - -Cu.importGlobalProperties(["fetch"]); - -let UPDATE_ACTION_SCHEMA; -let WHATS_NEW_SCHEMA; -let SPOTLIGHT_SCHEMA; -let PB_NEWTAB_SCHEMA; - -add_setup(async function setup() { - function fetchSchema(uri) { - return fetch(uri, { credentials: "omit" }).then(rsp => rsp.json()); - } - - UPDATE_ACTION_SCHEMA = await fetchSchema( - "resource://activity-stream/schemas/OnboardingMessage/UpdateAction.schema.json" - ); - WHATS_NEW_SCHEMA = await fetchSchema( - "resource://activity-stream/schemas/OnboardingMessage/WhatsNewMessage.schema.json" - ); - SPOTLIGHT_SCHEMA = await fetchSchema( - "resource://activity-stream/schemas/OnboardingMessage/Spotlight.schema.json" - ); - PB_NEWTAB_SCHEMA = await fetchSchema( - "resource://activity-stream/schemas/PBNewtab/NewtabPromoMessage.schema.json" - ); -}); - -function assertSchema(obj, schema, log) { - Assert.deepEqual(validate(obj, schema), { valid: true, errors: [] }, log); -} - -add_task(async function test_PanelTestProvider() { - const messages = await PanelTestProvider.getMessages(); - - // Careful: when changing this number make sure the new messages also go - // through schema validation. - Assert.strictEqual( - messages.length, - 15, - "PanelTestProvider should have the correct number of messages" - ); - - for (const [i, msg] of messages - .filter(m => m.template === "update_action") - .entries()) { - assertSchema( - msg, - UPDATE_ACTION_SCHEMA, - `update_action message ${msg.id ?? i} is valid` - ); - } - - for (const [i, msg] of messages - .filter(m => m.template === "whatsnew_panel_message") - .entries()) { - assertSchema( - msg.content, - WHATS_NEW_SCHEMA, - `whatsnew_panel_message message ${msg.id ?? i} is valid` - ); - Assert.ok( - Object.keys(msg).includes("order"), - `whatsnew_panel_message message ${msg.id ?? i} has "order" property` - ); - } - - for (const [i, msg] of messages - .filter(m => m.template === "spotlight") - .entries()) { - assertSchema( - msg, - SPOTLIGHT_SCHEMA, - `spotlight message ${msg.id ?? i} is valid` - ); - } - - for (const [i, msg] of messages - .filter(m => m.template === "pb_newtab") - .entries()) { - assertSchema( - msg, - PB_NEWTAB_SCHEMA, - `pb_newtab message ${msg.id ?? i} is valid` - ); - } - - Assert.strictEqual( - messages.filter(m => m.template === "pb_newtab").length, - 1, - "There is one pb_newtab message" - ); -}); diff --git a/browser/components/newtab/test/xpcshell/xpcshell.ini b/browser/components/newtab/test/xpcshell/xpcshell.ini index 5d2ed8315427..65e3ef0e3c63 100644 --- a/browser/components/newtab/test/xpcshell/xpcshell.ini +++ b/browser/components/newtab/test/xpcshell/xpcshell.ini @@ -17,4 +17,3 @@ support-files = [test_ASRouterTargeting_attribution.js] skip-if = toolkit != "cocoa" # osx specific tests [test_AboutWelcomeTelemetry.js] -[test_PanelTestProvider.js] diff --git a/third_party/js/cfworker/LICENSE.md b/third_party/js/cfworker/LICENSE.md deleted file mode 100644 index 85bcd787b59c..000000000000 --- a/third_party/js/cfworker/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 Jeremy Danyow - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/third_party/js/cfworker/build.sh b/third_party/js/cfworker/build.sh deleted file mode 100644 index a11d4356de2c..000000000000 --- a/third_party/js/cfworker/build.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -set -euo pipefail - -# Path to mach relative to /third_party/js/cfworker -MACH=$(realpath "../../../mach") - -if [[ $(uname -a) == *MSYS* ]]; then - MACH="python ${MACH}" -fi - -NODE="${MACH} node" -NPM="${MACH} npm" - -# Delete empty vestigial directories. -rm -rf .changeset/ .github/ .vscode/ - -# Patch typescript config to ensure we have LF endings. -patch tsconfig-base.json tsconfig-base.json.patch - -cd packages/json-schema - -# Install dependencies. -${NPM} install --also=dev - -# Compile TypeScript into JavaScript. -${NPM} run build - -# Install rollup and bundle into a single module. -${NPM} install rollup@~2.67.x -${NODE} node_modules/rollup/dist/bin/rollup \ - dist/index.js \ - --file json-schema.js \ - --format cjs - -cd ../.. - -# Patch the CommonJS module into a regular JS file and include a copyright notice. -patch packages/json-schema/json-schema.js json-schema.js.patch -awk -f exports.awk packages/json-schema/json-schema.js >json-schema.js - -# Remove source files we no longer need. -rm -rf packages/ tsconfig-base.json diff --git a/third_party/js/cfworker/exports.awk b/third_party/js/cfworker/exports.awk deleted file mode 100644 index 17f63d7bbfbf..000000000000 --- a/third_party/js/cfworker/exports.awk +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/awk -f - -BEGIN { - n_exports = 0; -} - -{ - if ($0 ~ /^exports.*=/) { - exports[n_exports] = substr($3, 0, length($3) - 1); - n_exports++; - } else { - print; - } -} - -END { - for (i = 0; i < n_exports; i++) { - print "this." exports[i] " = " exports[i] ";"; - } -} diff --git a/third_party/js/cfworker/json-schema.js b/third_party/js/cfworker/json-schema.js deleted file mode 100644 index c9d6b68a34c6..000000000000 --- a/third_party/js/cfworker/json-schema.js +++ /dev/null @@ -1,1180 +0,0 @@ -/* - * Copyright (c) 2020 Jeremy Danyow - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -'use strict'; - -function deepCompareStrict(a, b) { - const typeofa = typeof a; - if (typeofa !== typeof b) { - return false; - } - if (Array.isArray(a)) { - if (!Array.isArray(b)) { - return false; - } - const length = a.length; - if (length !== b.length) { - return false; - } - for (let i = 0; i < length; i++) { - if (!deepCompareStrict(a[i], b[i])) { - return false; - } - } - return true; - } - if (typeofa === 'object') { - if (!a || !b) { - return a === b; - } - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - const length = aKeys.length; - if (length !== bKeys.length) { - return false; - } - for (const k of aKeys) { - if (!deepCompareStrict(a[k], b[k])) { - return false; - } - } - return true; - } - return a === b; -} - -function encodePointer(p) { - return encodeURI(escapePointer(p)); -} -function escapePointer(p) { - return p.replace(/~/g, '~0').replace(/\//g, '~1'); -} - -const schemaKeyword = { - additionalItems: true, - unevaluatedItems: true, - items: true, - contains: true, - additionalProperties: true, - unevaluatedProperties: true, - propertyNames: true, - not: true, - if: true, - then: true, - else: true -}; -const schemaArrayKeyword = { - prefixItems: true, - items: true, - allOf: true, - anyOf: true, - oneOf: true -}; -const schemaMapKeyword = { - $defs: true, - definitions: true, - properties: true, - patternProperties: true, - dependentSchemas: true -}; -const ignoredKeyword = { - id: true, - $id: true, - $ref: true, - $schema: true, - $anchor: true, - $vocabulary: true, - $comment: true, - default: true, - enum: true, - const: true, - required: true, - type: true, - maximum: true, - minimum: true, - exclusiveMaximum: true, - exclusiveMinimum: true, - multipleOf: true, - maxLength: true, - minLength: true, - pattern: true, - format: true, - maxItems: true, - minItems: true, - uniqueItems: true, - maxProperties: true, - minProperties: true -}; -let initialBaseURI = typeof self !== 'undefined' && self.location - ? - new URL(self.location.origin + self.location.pathname + location.search) - : new URL('https://github.com/cfworker'); -function dereference(schema, lookup = Object.create(null), baseURI = initialBaseURI, basePointer = '') { - if (schema && typeof schema === 'object' && !Array.isArray(schema)) { - const id = schema.$id || schema.id; - if (id) { - const url = new URL(id, baseURI.href); - if (url.hash.length > 1) { - lookup[url.href] = schema; - } - else { - url.hash = ''; - if (basePointer === '') { - baseURI = url; - } - else { - dereference(schema, lookup, baseURI); - } - } - } - } - else if (schema !== true && schema !== false) { - return lookup; - } - const schemaURI = baseURI.href + (basePointer ? '#' + basePointer : ''); - if (lookup[schemaURI] !== undefined) { - throw new Error(`Duplicate schema URI "${schemaURI}".`); - } - lookup[schemaURI] = schema; - if (schema === true || schema === false) { - return lookup; - } - if (schema.__absolute_uri__ === undefined) { - Object.defineProperty(schema, '__absolute_uri__', { - enumerable: false, - value: schemaURI - }); - } - if (schema.$ref && schema.__absolute_ref__ === undefined) { - const url = new URL(schema.$ref, baseURI.href); - url.hash = url.hash; - Object.defineProperty(schema, '__absolute_ref__', { - enumerable: false, - value: url.href - }); - } - if (schema.$recursiveRef && schema.__absolute_recursive_ref__ === undefined) { - const url = new URL(schema.$recursiveRef, baseURI.href); - url.hash = url.hash; - Object.defineProperty(schema, '__absolute_recursive_ref__', { - enumerable: false, - value: url.href - }); - } - if (schema.$anchor) { - const url = new URL('#' + schema.$anchor, baseURI.href); - lookup[url.href] = schema; - } - for (let key in schema) { - if (ignoredKeyword[key]) { - continue; - } - const keyBase = `${basePointer}/${encodePointer(key)}`; - const subSchema = schema[key]; - if (Array.isArray(subSchema)) { - if (schemaArrayKeyword[key]) { - const length = subSchema.length; - for (let i = 0; i < length; i++) { - dereference(subSchema[i], lookup, baseURI, `${keyBase}/${i}`); - } - } - } - else if (schemaMapKeyword[key]) { - for (let subKey in subSchema) { - dereference(subSchema[subKey], lookup, baseURI, `${keyBase}/${encodePointer(subKey)}`); - } - } - else { - dereference(subSchema, lookup, baseURI, keyBase); - } - } - return lookup; -} - -const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; -const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; -const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d(?::?\d\d)?)?$/i; -const HOSTNAME = /^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$/i; -const URIREF = /^(?:[a-z][a-z0-9+\-.]*:)?(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'"()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i; -const URITEMPLATE = /^(?:(?:[^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i; -const URL_ = /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u{00a1}-\u{ffff}0-9]+-?)*[a-z\u{00a1}-\u{ffff}0-9]+)(?:\.(?:[a-z\u{00a1}-\u{ffff}0-9]+-?)*[a-z\u{00a1}-\u{ffff}0-9]+)*(?:\.(?:[a-z\u{00a1}-\u{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu; -const UUID = /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i; -const JSON_POINTER = /^(?:\/(?:[^~/]|~0|~1)*)*$/; -const JSON_POINTER_URI_FRAGMENT = /^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i; -const RELATIVE_JSON_POINTER = /^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/; -const FASTDATE = /^\d\d\d\d-[0-1]\d-[0-3]\d$/; -const FASTTIME = /^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i; -const FASTDATETIME = /^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i; -const FASTURIREFERENCE = /^(?:(?:[a-z][a-z0-9+-.]*:)?\/?\/)?(?:[^\\\s#][^\s#]*)?(?:#[^\\\s]*)?$/i; -const EMAIL = (input) => { - if (input[0] === '"') - return false; - const [name, host, ...rest] = input.split('@'); - if (!name || - !host || - rest.length !== 0 || - name.length > 64 || - host.length > 253) - return false; - if (name[0] === '.' || name.endsWith('.') || name.includes('..')) - return false; - if (!/^[a-z0-9.-]+$/i.test(host) || - !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(name)) - return false; - return host - .split('.') - .every(part => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(part)); -}; -const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/; -const IPV6 = /^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i; -const DURATION = (input) => input.length > 1 && - input.length < 80 && - (/^P\d+([.,]\d+)?W$/.test(input) || - (/^P[\dYMDTHS]*(\d[.,]\d+)?[YMDHS]$/.test(input) && - /^P([.,\d]+Y)?([.,\d]+M)?([.,\d]+D)?(T([.,\d]+H)?([.,\d]+M)?([.,\d]+S)?)?$/.test(input))); -function bind(r) { - return r.test.bind(r); -} -const fullFormat = { - date, - time: time.bind(undefined, false), - 'date-time': date_time, - duration: DURATION, - uri, - 'uri-reference': bind(URIREF), - 'uri-template': bind(URITEMPLATE), - url: bind(URL_), - email: EMAIL, - hostname: bind(HOSTNAME), - ipv4: bind(IPV4), - ipv6: bind(IPV6), - regex: regex, - uuid: bind(UUID), - 'json-pointer': bind(JSON_POINTER), - 'json-pointer-uri-fragment': bind(JSON_POINTER_URI_FRAGMENT), - 'relative-json-pointer': bind(RELATIVE_JSON_POINTER) -}; -const fastFormat = { - ...fullFormat, - date: bind(FASTDATE), - time: bind(FASTTIME), - 'date-time': bind(FASTDATETIME), - 'uri-reference': bind(FASTURIREFERENCE) -}; -function isLeapYear(year) { - return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); -} -function date(str) { - const matches = str.match(DATE); - if (!matches) - return false; - const year = +matches[1]; - const month = +matches[2]; - const day = +matches[3]; - return (month >= 1 && - month <= 12 && - day >= 1 && - day <= (month == 2 && isLeapYear(year) ? 29 : DAYS[month])); -} -function time(full, str) { - const matches = str.match(TIME); - if (!matches) - return false; - const hour = +matches[1]; - const minute = +matches[2]; - const second = +matches[3]; - const timeZone = !!matches[5]; - return (((hour <= 23 && minute <= 59 && second <= 59) || - (hour == 23 && minute == 59 && second == 60)) && - (!full || timeZone)); -} -const DATE_TIME_SEPARATOR = /t|\s/i; -function date_time(str) { - const dateTime = str.split(DATE_TIME_SEPARATOR); - return dateTime.length == 2 && date(dateTime[0]) && time(true, dateTime[1]); -} -const NOT_URI_FRAGMENT = /\/|:/; -const URI_PATTERN = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i; -function uri(str) { - return NOT_URI_FRAGMENT.test(str) && URI_PATTERN.test(str); -} -const Z_ANCHOR = /[^\\]\\Z/; -function regex(str) { - if (Z_ANCHOR.test(str)) - return false; - try { - new RegExp(str); - return true; - } - catch (e) { - return false; - } -} - -function ucs2length(s) { - let result = 0; - let length = s.length; - let index = 0; - let charCode; - while (index < length) { - result++; - charCode = s.charCodeAt(index++); - if (charCode >= 0xd800 && charCode <= 0xdbff && index < length) { - charCode = s.charCodeAt(index); - if ((charCode & 0xfc00) == 0xdc00) { - index++; - } - } - } - return result; -} - -function validate(instance, schema, draft = '2019-09', lookup = dereference(schema), shortCircuit = true, recursiveAnchor = null, instanceLocation = '#', schemaLocation = '#', evaluated = Object.create(null)) { - if (schema === true) { - return { valid: true, errors: [] }; - } - if (schema === false) { - return { - valid: false, - errors: [ - { - instanceLocation, - keyword: 'false', - keywordLocation: instanceLocation, - error: 'False boolean schema.' - } - ] - }; - } - const rawInstanceType = typeof instance; - let instanceType; - switch (rawInstanceType) { - case 'boolean': - case 'number': - case 'string': - instanceType = rawInstanceType; - break; - case 'object': - if (instance === null) { - instanceType = 'null'; - } - else if (Array.isArray(instance)) { - instanceType = 'array'; - } - else { - instanceType = 'object'; - } - break; - default: - throw new Error(`Instances of "${rawInstanceType}" type are not supported.`); - } - const { $ref, $recursiveRef, $recursiveAnchor, type: $type, const: $const, enum: $enum, required: $required, not: $not, anyOf: $anyOf, allOf: $allOf, oneOf: $oneOf, if: $if, then: $then, else: $else, format: $format, properties: $properties, patternProperties: $patternProperties, additionalProperties: $additionalProperties, unevaluatedProperties: $unevaluatedProperties, minProperties: $minProperties, maxProperties: $maxProperties, propertyNames: $propertyNames, dependentRequired: $dependentRequired, dependentSchemas: $dependentSchemas, dependencies: $dependencies, prefixItems: $prefixItems, items: $items, additionalItems: $additionalItems, unevaluatedItems: $unevaluatedItems, contains: $contains, minContains: $minContains, maxContains: $maxContains, minItems: $minItems, maxItems: $maxItems, uniqueItems: $uniqueItems, minimum: $minimum, maximum: $maximum, exclusiveMinimum: $exclusiveMinimum, exclusiveMaximum: $exclusiveMaximum, multipleOf: $multipleOf, minLength: $minLength, maxLength: $maxLength, pattern: $pattern, __absolute_ref__, __absolute_recursive_ref__ } = schema; - const errors = []; - if ($recursiveAnchor === true && recursiveAnchor === null) { - recursiveAnchor = schema; - } - if ($recursiveRef === '#') { - const refSchema = recursiveAnchor === null - ? lookup[__absolute_recursive_ref__] - : recursiveAnchor; - const keywordLocation = `${schemaLocation}/$recursiveRef`; - const result = validate(instance, recursiveAnchor === null ? schema : recursiveAnchor, draft, lookup, shortCircuit, refSchema, instanceLocation, keywordLocation, evaluated); - if (!result.valid) { - errors.push({ - instanceLocation, - keyword: '$recursiveRef', - keywordLocation, - error: 'A subschema had errors.' - }, ...result.errors); - } - } - if ($ref !== undefined) { - const uri = __absolute_ref__ || $ref; - const refSchema = lookup[uri]; - if (refSchema === undefined) { - let message = `Unresolved $ref "${$ref}".`; - if (__absolute_ref__ && __absolute_ref__ !== $ref) { - message += ` Absolute URI "${__absolute_ref__}".`; - } - message += `\nKnown schemas:\n- ${Object.keys(lookup).join('\n- ')}`; - throw new Error(message); - } - const keywordLocation = `${schemaLocation}/$ref`; - const result = validate(instance, refSchema, draft, lookup, shortCircuit, recursiveAnchor, instanceLocation, keywordLocation, evaluated); - if (!result.valid) { - errors.push({ - instanceLocation, - keyword: '$ref', - keywordLocation, - error: 'A subschema had errors.' - }, ...result.errors); - } - if (draft === '4' || draft === '7') { - return { valid: errors.length === 0, errors }; - } - } - if (Array.isArray($type)) { - let length = $type.length; - let valid = false; - for (let i = 0; i < length; i++) { - if (instanceType === $type[i] || - ($type[i] === 'integer' && - instanceType === 'number' && - instance % 1 === 0 && - instance === instance)) { - valid = true; - break; - } - } - if (!valid) { - errors.push({ - instanceLocation, - keyword: 'type', - keywordLocation: `${schemaLocation}/type`, - error: `Instance type "${instanceType}" is invalid. Expected "${$type.join('", "')}".` - }); - } - } - else if ($type === 'integer') { - if (instanceType !== 'number' || instance % 1 || instance !== instance) { - errors.push({ - instanceLocation, - keyword: 'type', - keywordLocation: `${schemaLocation}/type`, - error: `Instance type "${instanceType}" is invalid. Expected "${$type}".` - }); - } - } - else if ($type !== undefined && instanceType !== $type) { - errors.push({ - instanceLocation, - keyword: 'type', - keywordLocation: `${schemaLocation}/type`, - error: `Instance type "${instanceType}" is invalid. Expected "${$type}".` - }); - } - if ($const !== undefined) { - if (instanceType === 'object' || instanceType === 'array') { - if (!deepCompareStrict(instance, $const)) { - errors.push({ - instanceLocation, - keyword: 'const', - keywordLocation: `${schemaLocation}/const`, - error: `Instance does not match ${JSON.stringify($const)}.` - }); - } - } - else if (instance !== $const) { - errors.push({ - instanceLocation, - keyword: 'const', - keywordLocation: `${schemaLocation}/const`, - error: `Instance does not match ${JSON.stringify($const)}.` - }); - } - } - if ($enum !== undefined) { - if (instanceType === 'object' || instanceType === 'array') { - if (!$enum.some(value => deepCompareStrict(instance, value))) { - errors.push({ - instanceLocation, - keyword: 'enum', - keywordLocation: `${schemaLocation}/enum`, - error: `Instance does not match any of ${JSON.stringify($enum)}.` - }); - } - } - else if (!$enum.some(value => instance === value)) { - errors.push({ - instanceLocation, - keyword: 'enum', - keywordLocation: `${schemaLocation}/enum`, - error: `Instance does not match any of ${JSON.stringify($enum)}.` - }); - } - } - if ($not !== undefined) { - const keywordLocation = `${schemaLocation}/not`; - const result = validate(instance, $not, draft, lookup, shortCircuit, recursiveAnchor, instanceLocation, keywordLocation); - if (result.valid) { - errors.push({ - instanceLocation, - keyword: 'not', - keywordLocation, - error: 'Instance matched "not" schema.' - }); - } - } - let subEvaluateds = []; - if ($anyOf !== undefined) { - const keywordLocation = `${schemaLocation}/anyOf`; - const errorsLength = errors.length; - let anyValid = false; - for (let i = 0; i < $anyOf.length; i++) { - const subSchema = $anyOf[i]; - const subEvaluated = Object.create(evaluated); - const result = validate(instance, subSchema, draft, lookup, shortCircuit, $recursiveAnchor === true ? recursiveAnchor : null, instanceLocation, `${keywordLocation}/${i}`, subEvaluated); - errors.push(...result.errors); - anyValid = anyValid || result.valid; - if (result.valid) { - subEvaluateds.push(subEvaluated); - } - } - if (anyValid) { - errors.length = errorsLength; - } - else { - errors.splice(errorsLength, 0, { - instanceLocation, - keyword: 'anyOf', - keywordLocation, - error: 'Instance does not match any subschemas.' - }); - } - } - if ($allOf !== undefined) { - const keywordLocation = `${schemaLocation}/allOf`; - const errorsLength = errors.length; - let allValid = true; - for (let i = 0; i < $allOf.length; i++) { - const subSchema = $allOf[i]; - const subEvaluated = Object.create(evaluated); - const result = validate(instance, subSchema, draft, lookup, shortCircuit, $recursiveAnchor === true ? recursiveAnchor : null, instanceLocation, `${keywordLocation}/${i}`, subEvaluated); - errors.push(...result.errors); - allValid = allValid && result.valid; - if (result.valid) { - subEvaluateds.push(subEvaluated); - } - } - if (allValid) { - errors.length = errorsLength; - } - else { - errors.splice(errorsLength, 0, { - instanceLocation, - keyword: 'allOf', - keywordLocation, - error: `Instance does not match every subschema.` - }); - } - } - if ($oneOf !== undefined) { - const keywordLocation = `${schemaLocation}/oneOf`; - const errorsLength = errors.length; - const matches = $oneOf.filter((subSchema, i) => { - const subEvaluated = Object.create(evaluated); - const result = validate(instance, subSchema, draft, lookup, shortCircuit, $recursiveAnchor === true ? recursiveAnchor : null, instanceLocation, `${keywordLocation}/${i}`, subEvaluated); - errors.push(...result.errors); - if (result.valid) { - subEvaluateds.push(subEvaluated); - } - return result.valid; - }).length; - if (matches === 1) { - errors.length = errorsLength; - } - else { - errors.splice(errorsLength, 0, { - instanceLocation, - keyword: 'oneOf', - keywordLocation, - error: `Instance does not match exactly one subschema (${matches} matches).` - }); - } - } - if (instanceType === 'object' || instanceType === 'array') { - Object.assign(evaluated, ...subEvaluateds); - } - if ($if !== undefined) { - const keywordLocation = `${schemaLocation}/if`; - const conditionResult = validate(instance, $if, draft, lookup, shortCircuit, recursiveAnchor, instanceLocation, keywordLocation, evaluated).valid; - if (conditionResult) { - if ($then !== undefined) { - const thenResult = validate(instance, $then, draft, lookup, shortCircuit, recursiveAnchor, instanceLocation, `${schemaLocation}/then`, evaluated); - if (!thenResult.valid) { - errors.push({ - instanceLocation, - keyword: 'if', - keywordLocation, - error: `Instance does not match "then" schema.` - }, ...thenResult.errors); - } - } - } - else if ($else !== undefined) { - const elseResult = validate(instance, $else, draft, lookup, shortCircuit, recursiveAnchor, instanceLocation, `${schemaLocation}/else`, evaluated); - if (!elseResult.valid) { - errors.push({ - instanceLocation, - keyword: 'if', - keywordLocation, - error: `Instance does not match "else" schema.` - }, ...elseResult.errors); - } - } - } - if (instanceType === 'object') { - if ($required !== undefined) { - for (const key of $required) { - if (!(key in instance)) { - errors.push({ - instanceLocation, - keyword: 'required', - keywordLocation: `${schemaLocation}/required`, - error: `Instance does not have required property "${key}".` - }); - } - } - } - const keys = Object.keys(instance); - if ($minProperties !== undefined && keys.length < $minProperties) { - errors.push({ - instanceLocation, - keyword: 'minProperties', - keywordLocation: `${schemaLocation}/minProperties`, - error: `Instance does not have at least ${$minProperties} properties.` - }); - } - if ($maxProperties !== undefined && keys.length > $maxProperties) { - errors.push({ - instanceLocation, - keyword: 'maxProperties', - keywordLocation: `${schemaLocation}/maxProperties`, - error: `Instance does not have at least ${$maxProperties} properties.` - }); - } - if ($propertyNames !== undefined) { - const keywordLocation = `${schemaLocation}/propertyNames`; - for (const key in instance) { - const subInstancePointer = `${instanceLocation}/${encodePointer(key)}`; - const result = validate(key, $propertyNames, draft, lookup, shortCircuit, recursiveAnchor, subInstancePointer, keywordLocation); - if (!result.valid) { - errors.push({ - instanceLocation, - keyword: 'propertyNames', - keywordLocation, - error: `Property name "${key}" does not match schema.` - }, ...result.errors); - } - } - } - if ($dependentRequired !== undefined) { - const keywordLocation = `${schemaLocation}/dependantRequired`; - for (const key in $dependentRequired) { - if (key in instance) { - const required = $dependentRequired[key]; - for (const dependantKey of required) { - if (!(dependantKey in instance)) { - errors.push({ - instanceLocation, - keyword: 'dependentRequired', - keywordLocation, - error: `Instance has "${key}" but does not have "${dependantKey}".` - }); - } - } - } - } - } - if ($dependentSchemas !== undefined) { - for (const key in $dependentSchemas) { - const keywordLocation = `${schemaLocation}/dependentSchemas`; - if (key in instance) { - const result = validate(instance, $dependentSchemas[key], draft, lookup, shortCircuit, recursiveAnchor, instanceLocation, `${keywordLocation}/${encodePointer(key)}`, evaluated); - if (!result.valid) { - errors.push({ - instanceLocation, - keyword: 'dependentSchemas', - keywordLocation, - error: `Instance has "${key}" but does not match dependant schema.` - }, ...result.errors); - } - } - } - } - if ($dependencies !== undefined) { - const keywordLocation = `${schemaLocation}/dependencies`; - for (const key in $dependencies) { - if (key in instance) { - const propsOrSchema = $dependencies[key]; - if (Array.isArray(propsOrSchema)) { - for (const dependantKey of propsOrSchema) { - if (!(dependantKey in instance)) { - errors.push({ - instanceLocation, - keyword: 'dependencies', - keywordLocation, - error: `Instance has "${key}" but does not have "${dependantKey}".` - }); - } - } - } - else { - const result = validate(instance, propsOrSchema, draft, lookup, shortCircuit, recursiveAnchor, instanceLocation, `${keywordLocation}/${encodePointer(key)}`); - if (!result.valid) { - errors.push({ - instanceLocation, - keyword: 'dependencies', - keywordLocation, - error: `Instance has "${key}" but does not match dependant schema.` - }, ...result.errors); - } - } - } - } - } - const thisEvaluated = Object.create(null); - let stop = false; - if ($properties !== undefined) { - const keywordLocation = `${schemaLocation}/properties`; - for (const key in $properties) { - if (!(key in instance)) { - continue; - } - const subInstancePointer = `${instanceLocation}/${encodePointer(key)}`; - const result = validate(instance[key], $properties[key], draft, lookup, shortCircuit, recursiveAnchor, subInstancePointer, `${keywordLocation}/${encodePointer(key)}`); - if (result.valid) { - evaluated[key] = thisEvaluated[key] = true; - } - else { - stop = shortCircuit; - errors.push({ - instanceLocation, - keyword: 'properties', - keywordLocation, - error: `Property "${key}" does not match schema.` - }, ...result.errors); - if (stop) - break; - } - } - } - if (!stop && $patternProperties !== undefined) { - const keywordLocation = `${schemaLocation}/patternProperties`; - for (const pattern in $patternProperties) { - const regex = new RegExp(pattern); - const subSchema = $patternProperties[pattern]; - for (const key in instance) { - if (!regex.test(key)) { - continue; - } - const subInstancePointer = `${instanceLocation}/${encodePointer(key)}`; - const result = validate(instance[key], subSchema, draft, lookup, shortCircuit, recursiveAnchor, subInstancePointer, `${keywordLocation}/${encodePointer(pattern)}`); - if (result.valid) { - evaluated[key] = thisEvaluated[key] = true; - } - else { - stop = shortCircuit; - errors.push({ - instanceLocation, - keyword: 'patternProperties', - keywordLocation, - error: `Property "${key}" matches pattern "${pattern}" but does not match associated schema.` - }, ...result.errors); - } - } - } - } - if (!stop && $additionalProperties !== undefined) { - const keywordLocation = `${schemaLocation}/additionalProperties`; - for (const key in instance) { - if (thisEvaluated[key]) { - continue; - } - const subInstancePointer = `${instanceLocation}/${encodePointer(key)}`; - const result = validate(instance[key], $additionalProperties, draft, lookup, shortCircuit, recursiveAnchor, subInstancePointer, keywordLocation); - if (result.valid) { - evaluated[key] = true; - } - else { - stop = shortCircuit; - errors.push({ - instanceLocation, - keyword: 'additionalProperties', - keywordLocation, - error: `Property "${key}" does not match additional properties schema.` - }, ...result.errors); - } - } - } - else if (!stop && $unevaluatedProperties !== undefined) { - const keywordLocation = `${schemaLocation}/unevaluatedProperties`; - for (const key in instance) { - if (!evaluated[key]) { - const subInstancePointer = `${instanceLocation}/${encodePointer(key)}`; - const result = validate(instance[key], $unevaluatedProperties, draft, lookup, shortCircuit, recursiveAnchor, subInstancePointer, keywordLocation); - if (result.valid) { - evaluated[key] = true; - } - else { - errors.push({ - instanceLocation, - keyword: 'unevaluatedProperties', - keywordLocation, - error: `Property "${key}" does not match unevaluated properties schema.` - }, ...result.errors); - } - } - } - } - } - else if (instanceType === 'array') { - if ($maxItems !== undefined && instance.length > $maxItems) { - errors.push({ - instanceLocation, - keyword: 'maxItems', - keywordLocation: `${schemaLocation}/maxItems`, - error: `Array has too many items (${instance.length} > ${$maxItems}).` - }); - } - if ($minItems !== undefined && instance.length < $minItems) { - errors.push({ - instanceLocation, - keyword: 'minItems', - keywordLocation: `${schemaLocation}/minItems`, - error: `Array has too few items (${instance.length} < ${$minItems}).` - }); - } - const length = instance.length; - let i = 0; - let stop = false; - if ($prefixItems !== undefined) { - const keywordLocation = `${schemaLocation}/prefixItems`; - const length2 = Math.min($prefixItems.length, length); - for (; i < length2; i++) { - const result = validate(instance[i], $prefixItems[i], draft, lookup, shortCircuit, recursiveAnchor, `${instanceLocation}/${i}`, `${keywordLocation}/${i}`); - evaluated[i] = true; - if (!result.valid) { - stop = shortCircuit; - errors.push({ - instanceLocation, - keyword: 'prefixItems', - keywordLocation, - error: `Items did not match schema.` - }, ...result.errors); - if (stop) - break; - } - } - } - if ($items !== undefined) { - const keywordLocation = `${schemaLocation}/items`; - if (Array.isArray($items)) { - const length2 = Math.min($items.length, length); - for (; i < length2; i++) { - const result = validate(instance[i], $items[i], draft, lookup, shortCircuit, recursiveAnchor, `${instanceLocation}/${i}`, `${keywordLocation}/${i}`); - evaluated[i] = true; - if (!result.valid) { - stop = shortCircuit; - errors.push({ - instanceLocation, - keyword: 'items', - keywordLocation, - error: `Items did not match schema.` - }, ...result.errors); - if (stop) - break; - } - } - } - else { - for (; i < length; i++) { - const result = validate(instance[i], $items, draft, lookup, shortCircuit, recursiveAnchor, `${instanceLocation}/${i}`, keywordLocation); - evaluated[i] = true; - if (!result.valid) { - stop = shortCircuit; - errors.push({ - instanceLocation, - keyword: 'items', - keywordLocation, - error: `Items did not match schema.` - }, ...result.errors); - if (stop) - break; - } - } - } - if (!stop && $additionalItems !== undefined) { - const keywordLocation = `${schemaLocation}/additionalItems`; - for (; i < length; i++) { - const result = validate(instance[i], $additionalItems, draft, lookup, shortCircuit, recursiveAnchor, `${instanceLocation}/${i}`, keywordLocation); - evaluated[i] = true; - if (!result.valid) { - stop = shortCircuit; - errors.push({ - instanceLocation, - keyword: 'additionalItems', - keywordLocation, - error: `Items did not match additional items schema.` - }, ...result.errors); - } - } - } - } - if ($contains !== undefined) { - if (length === 0 && $minContains === undefined) { - errors.push({ - instanceLocation, - keyword: 'contains', - keywordLocation: `${schemaLocation}/contains`, - error: `Array is empty. It must contain at least one item matching the schema.` - }); - } - else if ($minContains !== undefined && length < $minContains) { - errors.push({ - instanceLocation, - keyword: 'minContains', - keywordLocation: `${schemaLocation}/minContains`, - error: `Array has less items (${length}) than minContains (${$minContains}).` - }); - } - else { - const keywordLocation = `${schemaLocation}/contains`; - const errorsLength = errors.length; - let contained = 0; - for (let j = 0; j < length; j++) { - const result = validate(instance[j], $contains, draft, lookup, shortCircuit, recursiveAnchor, `${instanceLocation}/${j}`, keywordLocation); - if (result.valid) { - evaluated[j] = true; - contained++; - } - else { - errors.push(...result.errors); - } - } - if (contained >= ($minContains || 0)) { - errors.length = errorsLength; - } - if ($minContains === undefined && - $maxContains === undefined && - contained === 0) { - errors.splice(errorsLength, 0, { - instanceLocation, - keyword: 'contains', - keywordLocation, - error: `Array does not contain item matching schema.` - }); - } - else if ($minContains !== undefined && contained < $minContains) { - errors.push({ - instanceLocation, - keyword: 'minContains', - keywordLocation: `${schemaLocation}/minContains`, - error: `Array must contain at least ${$minContains} items matching schema. Only ${contained} items were found.` - }); - } - else if ($maxContains !== undefined && contained > $maxContains) { - errors.push({ - instanceLocation, - keyword: 'maxContains', - keywordLocation: `${schemaLocation}/maxContains`, - error: `Array may contain at most ${$maxContains} items matching schema. ${contained} items were found.` - }); - } - } - } - if (!stop && $unevaluatedItems !== undefined) { - const keywordLocation = `${schemaLocation}/unevaluatedItems`; - for (i; i < length; i++) { - if (evaluated[i]) { - continue; - } - const result = validate(instance[i], $unevaluatedItems, draft, lookup, shortCircuit, recursiveAnchor, `${instanceLocation}/${i}`, keywordLocation); - evaluated[i] = true; - if (!result.valid) { - errors.push({ - instanceLocation, - keyword: 'unevaluatedItems', - keywordLocation, - error: `Items did not match unevaluated items schema.` - }, ...result.errors); - } - } - } - if ($uniqueItems) { - for (let j = 0; j < length; j++) { - const a = instance[j]; - const ao = typeof a === 'object' && a !== null; - for (let k = 0; k < length; k++) { - if (j === k) { - continue; - } - const b = instance[k]; - const bo = typeof b === 'object' && b !== null; - if (a === b || (ao && bo && deepCompareStrict(a, b))) { - errors.push({ - instanceLocation, - keyword: 'uniqueItems', - keywordLocation: `${schemaLocation}/uniqueItems`, - error: `Duplicate items at indexes ${j} and ${k}.` - }); - j = Number.MAX_SAFE_INTEGER; - k = Number.MAX_SAFE_INTEGER; - } - } - } - } - } - else if (instanceType === 'number') { - if (draft === '4') { - if ($minimum !== undefined && - (($exclusiveMinimum === true && instance <= $minimum) || - instance < $minimum)) { - errors.push({ - instanceLocation, - keyword: 'minimum', - keywordLocation: `${schemaLocation}/minimum`, - error: `${instance} is less than ${$exclusiveMinimum ? 'or equal to ' : ''} ${$minimum}.` - }); - } - if ($maximum !== undefined && - (($exclusiveMaximum === true && instance >= $maximum) || - instance > $maximum)) { - errors.push({ - instanceLocation, - keyword: 'maximum', - keywordLocation: `${schemaLocation}/maximum`, - error: `${instance} is greater than ${$exclusiveMaximum ? 'or equal to ' : ''} ${$maximum}.` - }); - } - } - else { - if ($minimum !== undefined && instance < $minimum) { - errors.push({ - instanceLocation, - keyword: 'minimum', - keywordLocation: `${schemaLocation}/minimum`, - error: `${instance} is less than ${$minimum}.` - }); - } - if ($maximum !== undefined && instance > $maximum) { - errors.push({ - instanceLocation, - keyword: 'maximum', - keywordLocation: `${schemaLocation}/maximum`, - error: `${instance} is greater than ${$maximum}.` - }); - } - if ($exclusiveMinimum !== undefined && instance <= $exclusiveMinimum) { - errors.push({ - instanceLocation, - keyword: 'exclusiveMinimum', - keywordLocation: `${schemaLocation}/exclusiveMinimum`, - error: `${instance} is less than ${$exclusiveMinimum}.` - }); - } - if ($exclusiveMaximum !== undefined && instance >= $exclusiveMaximum) { - errors.push({ - instanceLocation, - keyword: 'exclusiveMaximum', - keywordLocation: `${schemaLocation}/exclusiveMaximum`, - error: `${instance} is greater than or equal to ${$exclusiveMaximum}.` - }); - } - } - if ($multipleOf !== undefined) { - const remainder = instance % $multipleOf; - if (Math.abs(0 - remainder) >= 1.1920929e-7 && - Math.abs($multipleOf - remainder) >= 1.1920929e-7) { - errors.push({ - instanceLocation, - keyword: 'multipleOf', - keywordLocation: `${schemaLocation}/multipleOf`, - error: `${instance} is not a multiple of ${$multipleOf}.` - }); - } - } - } - else if (instanceType === 'string') { - const length = $minLength === undefined && $maxLength === undefined - ? 0 - : ucs2length(instance); - if ($minLength !== undefined && length < $minLength) { - errors.push({ - instanceLocation, - keyword: 'minLength', - keywordLocation: `${schemaLocation}/minLength`, - error: `String is too short (${length} < ${$minLength}).` - }); - } - if ($maxLength !== undefined && length > $maxLength) { - errors.push({ - instanceLocation, - keyword: 'maxLength', - keywordLocation: `${schemaLocation}/maxLength`, - error: `String is too long (${length} > ${$maxLength}).` - }); - } - if ($pattern !== undefined && !new RegExp($pattern).test(instance)) { - errors.push({ - instanceLocation, - keyword: 'pattern', - keywordLocation: `${schemaLocation}/pattern`, - error: `String does not match pattern.` - }); - } - if ($format !== undefined && - fastFormat[$format] && - !fastFormat[$format](instance)) { - errors.push({ - instanceLocation, - keyword: 'format', - keywordLocation: `${schemaLocation}/format`, - error: `String does not match format "${$format}".` - }); - } - } - return { valid: errors.length === 0, errors }; -} - -class Validator { - constructor(schema, draft = '2019-09', shortCircuit = true) { - this.schema = schema; - this.draft = draft; - this.shortCircuit = shortCircuit; - this.lookup = dereference(schema); - } - validate(instance) { - return validate(instance, this.schema, this.draft, this.lookup, this.shortCircuit); - } - addSchema(schema, id) { - if (id) { - schema = { ...schema, $id: id }; - } - dereference(schema, this.lookup); - } -} - -this.Validator = Validator; -this.deepCompareStrict = deepCompareStrict; -this.dereference = dereference; -this.encodePointer = encodePointer; -this.escapePointer = escapePointer; -this.fastFormat = fastFormat; -this.fullFormat = fullFormat; -this.ignoredKeyword = ignoredKeyword; -this.initialBaseURI = initialBaseURI; -this.schemaArrayKeyword = schemaArrayKeyword; -this.schemaKeyword = schemaKeyword; -this.schemaMapKeyword = schemaMapKeyword; -this.ucs2length = ucs2length; -this.validate = validate; diff --git a/third_party/js/cfworker/json-schema.js.patch b/third_party/js/cfworker/json-schema.js.patch deleted file mode 100644 index 82a0614394be..000000000000 --- a/third_party/js/cfworker/json-schema.js.patch +++ /dev/null @@ -1,31 +0,0 @@ ---- json-schema.js -+++ json-schema.js -@@ -1,6 +1,26 @@ --'use strict'; -+/* -+ * Copyright (c) 2020 Jeremy Danyow -+ * -+ * Permission is hereby granted, free of charge, to any person obtaining a copy -+ * of this software and associated documentation files (the "Software"), to deal -+ * in the Software without restriction, including without limitation the rights -+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -+ * copies of the Software, and to permit persons to whom the Software is -+ * furnished to do so, subject to the following conditions: -+ * -+ * The above copyright notice and this permission notice shall be included in -+ * all copies or substantial portions of the Software. -+ * -+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -+ * SOFTWARE. -+ */ - --Object.defineProperty(exports, '__esModule', { value: true }); -+'use strict'; - - function deepCompareStrict(a, b) { - const typeofa = typeof a; diff --git a/third_party/js/cfworker/moz.yaml b/third_party/js/cfworker/moz.yaml deleted file mode 100644 index 21907c1dd4cf..000000000000 --- a/third_party/js/cfworker/moz.yaml +++ /dev/null @@ -1,44 +0,0 @@ -schema: 1 - -bugzilla: - product: Toolkit - component: General - -origin: - name: "cfworker" - description: A JSON schema validator - url: https://github.com/cfworker/cfworker/tree/main/packages/json-schema - license: MIT - release: commit @cfworker/dev@1.13.2 (2022-01-23T22:05:24+00:00). - revision: "v1.10.1" - -vendoring: - url: https://github.com/cfworker/cfworker - source-hosting: github - tracking: tag - skip-vendoring-steps: ["update-moz-build"] - - keep: - - build.sh - - exports.awk - - json-schema.jsm.patch - - tsconfig-base.json.patch - - exclude: - - "**" - - ".*" - - ".changeset" - - ".github" - - ".vscode" - - include: - - LICENSE.md - - packages/json-schema/src - - packages/json-schema/package.json - - packages/json-schema/tsconfig.json - - tsconfig-base.json - - update-actions: - - action: run-script - script: 'build.sh' - cwd: '{yaml_dir}' diff --git a/third_party/js/cfworker/tsconfig-base.json.patch b/third_party/js/cfworker/tsconfig-base.json.patch deleted file mode 100644 index 64b921d49f57..000000000000 --- a/third_party/js/cfworker/tsconfig-base.json.patch +++ /dev/null @@ -1,9 +0,0 @@ ---- tsconfig-base.json -+++ tsconfig-base.json -@@ -1,5 +1,6 @@ - { - "compilerOptions": { -+ "newLine": "lf", - "moduleResolution": "node", - "esModuleInterop": true, - "strict": true, diff --git a/toolkit/components/nimbus/FeatureManifest.yaml b/toolkit/components/nimbus/FeatureManifest.yaml index d13225dfebc7..757ae0e9a225 100644 --- a/toolkit/components/nimbus/FeatureManifest.yaml +++ b/toolkit/components/nimbus/FeatureManifest.yaml @@ -203,7 +203,7 @@ pocketNewtab: hybridLayout: type: boolean fallbackPref: browser.newtabpage.activity-stream.discoverystream.hybridLayout.enabled - description: Enable compact cards on newtab grid only for specific breakpoints + description: Enable compact cards on newtab grid only for specific breakpoints hideCardBackground: type: boolean fallbackPref: browser.newtabpage.activity-stream.discoverystream.hideCardBackground.enabled @@ -455,7 +455,8 @@ cfr: hasExposure: true exposureDescription: "Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched." isEarlyStartup: false - schema: "resource://activity-stream/schemas/CFR/ExtensionDoorHanger.schema.json" + schema: >- + "browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json" variables: {} "moments-page": description: "Message with URL data for Messaging System" @@ -463,7 +464,8 @@ cfr: exposureDescription: >- "Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched." isEarlyStartup: false - schema: "resource://activity-stream/schemas/OnboardingMessage/Spotlight.schema.json" + schema: >- + "browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json" variables: {} infobar: description: "Message template for Messaging System" @@ -471,7 +473,8 @@ infobar: exposureDescription: >- "Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched." isEarlyStartup: false - schema: "resource://activity-stream/schemas/CFR/InfoBar.schema.json" + schema: >- + "browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json" variables: {} spotlight: description: "Modal message template for Messaging System" @@ -479,7 +482,8 @@ spotlight: exposureDescription: >- "Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched." isEarlyStartup: false - schema: "resource://activity-stream/schemas/OnboardingMessage/Spotlight.schema.json" + schema: >- + "browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json" variables: {} pbNewtab: description: Message shown on the PB newtab for Messaging System @@ -487,7 +491,8 @@ pbNewtab: exposureDescription: >- Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched. isEarlyStartup: false - schema: "resource://activity-stream/schemas/PBNewtab/NewtabPromoMessage.schema.json" + schema: >- + browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json variables: {} syncAfterTabChange: description: "Schedule a sync after any tab change" diff --git a/toolkit/components/nimbus/jar.mn b/toolkit/components/nimbus/jar.mn index 21445389193a..a7dd79ad89ba 100644 --- a/toolkit/components/nimbus/jar.mn +++ b/toolkit/components/nimbus/jar.mn @@ -7,4 +7,3 @@ toolkit.jar: res/nimbus/lib/ (./lib/*.jsm) res/nimbus/ExperimentAPI.jsm (./ExperimentAPI.jsm) res/nimbus/FeatureManifest.js (FeatureManifest.js) - res/nimbus/schemas/ (./schemas/*.schema.json) diff --git a/toolkit/components/nimbus/lib/ExperimentManager.jsm b/toolkit/components/nimbus/lib/ExperimentManager.jsm index de429da7af06..6fe0b9f03934 100644 --- a/toolkit/components/nimbus/lib/ExperimentManager.jsm +++ b/toolkit/components/nimbus/lib/ExperimentManager.jsm @@ -135,13 +135,7 @@ class _ExperimentManager { } } - _checkUnseenEnrollments( - enrollments, - sourceToCheck, - recipeMismatches, - invalidRecipes, - invalidBranches - ) { + _checkUnseenEnrollments(enrollments, sourceToCheck, recipeMismatches) { for (const enrollment of enrollments) { const { slug, source } = enrollment; if (sourceToCheck !== source) { @@ -150,16 +144,9 @@ class _ExperimentManager { if (!this.sessions.get(source)?.has(slug)) { log.debug(`Stopping study for recipe ${slug}`); try { - let reason; - if (recipeMismatches.includes(slug)) { - reason = "targeting-mismatch"; - } else if (invalidRecipes.includes(slug)) { - reason = "invalid-recipe"; - } else if (invalidBranches.includes(slug)) { - reason = "invalid-branch"; - } else { - reason = "recipe-not-seen"; - } + let reason = recipeMismatches.includes(slug) + ? "targeting-mismatch" + : "recipe-not-seen"; this.unenroll(slug, reason); } catch (err) { Cu.reportError(err); @@ -174,10 +161,7 @@ class _ExperimentManager { * @param {string} sourceToCheck * @param {object} options Extra context used in telemetry reporting */ - onFinalize( - sourceToCheck, - { recipeMismatches = [], invalidRecipes = [], invalidBranches = [] } = {} - ) { + onFinalize(sourceToCheck, { recipeMismatches } = { recipeMismatches: [] }) { if (!sourceToCheck) { throw new Error("When calling onFinalize, you must specify a source."); } @@ -186,16 +170,12 @@ class _ExperimentManager { this._checkUnseenEnrollments( activeExperiments, sourceToCheck, - recipeMismatches, - invalidRecipes, - invalidBranches + recipeMismatches ); this._checkUnseenEnrollments( activeRollouts, sourceToCheck, - recipeMismatches, - invalidRecipes, - invalidBranches + recipeMismatches ); this.sessions.delete(sourceToCheck); @@ -264,7 +244,6 @@ class _ExperimentManager { } } - dump(`*** *** call this._enroll recipe.slug = ${recipe.slug}\n`); return this._enroll(recipe, branch, source); } @@ -303,12 +282,10 @@ class _ExperimentManager { } if (isRollout) { - dump(`*** *** store.addEnrollment rollout\n`); experiment.experimentType = "rollout"; this.store.addEnrollment(experiment); this.setExperimentActive(experiment); } else { - dump(`*** *** store.addEnrollment non rollout\n`); this.store.addEnrollment(experiment); this.setExperimentActive(experiment); } diff --git a/toolkit/components/nimbus/lib/ExperimentStore.jsm b/toolkit/components/nimbus/lib/ExperimentStore.jsm index 6aadf5eaede2..4150e3e0bbe0 100644 --- a/toolkit/components/nimbus/lib/ExperimentStore.jsm +++ b/toolkit/components/nimbus/lib/ExperimentStore.jsm @@ -426,10 +426,6 @@ class ExperimentStore extends SharedDataMap { `Tried to add an experiment but it didn't have a .slug property.` ); } - dump( - `*** *** addEnrollment ${enrollment.slug} ${enrollment.branch.slug}\n` - ); - this.set(enrollment.slug, enrollment); this._updateSyncStore(enrollment); this._emitUpdates(enrollment); diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm index 03794eaca543..d13d9ef22b24 100644 --- a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm +++ b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm @@ -14,15 +14,12 @@ const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); -XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]); XPCOMUtils.defineLazyModuleGetters(this, { ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm", TargetingContext: "resource://messaging-system/targeting/Targeting.jsm", ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm", RemoteSettings: "resource://services-settings/remote-settings.js", CleanupManager: "resource://normandy/lib/CleanupManager.jsm", - NimbusFeatures: "resource://nimbus/ExperimentAPI.jsm", - Validator: "resource://gre/modules/JsonSchema.jsm", }); XPCOMUtils.defineLazyGetter(this, "log", () => { @@ -63,16 +60,6 @@ XPCOMUtils.defineLazyPreferenceGetter( false ); -const SCHEMAS = { - get NimbusExperiment() { - return fetch("resource://nimbus/schemas/NimbusExperiment.schema.json", { - credentials: "omit", - }) - .then(rsp => rsp.json()) - .then(json => json.definitions.NimbusExperiment); - }, -}; - class _RemoteSettingsExperimentLoader { constructor() { // Has the timer been set? @@ -211,37 +198,11 @@ class _RemoteSettingsExperimentLoader { Cu.reportError(e); } - const recipeValidator = new Validator(await SCHEMAS.NimbusExperiment); - let matches = 0; let recipeMismatches = []; - let invalidRecipes = []; - let invalidBranches = []; - let validatorCache = {}; - if (recipes && !loadingError) { for (const r of recipes) { - let validation = recipeValidator.validate(r); - if (!validation.valid) { - Cu.reportError( - `Could not validate experiment recipe ${r.id}: ${JSON.stringify( - validation.errors, - undefined, - 2 - )}` - ); - invalidRecipes.push(r.slug); - continue; - } - let type = r.isRollout ? "rollout" : "experiment"; - - if (!(await this._validateBranches(r, validatorCache))) { - invalidBranches.push(r.slug); - log.debug(`${r.id} did not validate`); - continue; - } - if (await this.checkTargeting(r)) { matches++; log.debug(`[${type}] ${r.id} matched`); @@ -253,11 +214,7 @@ class _RemoteSettingsExperimentLoader { } log.debug(`${matches} recipes matched. Finalizing ExperimentManager.`); - this.manager.onFinalize("rs-loader", { - recipeMismatches, - invalidRecipes, - invalidBranches, - }); + this.manager.onFinalize("rs-loader", { recipeMismatches }); } if (trigger !== "timer") { @@ -336,67 +293,6 @@ class _RemoteSettingsExperimentLoader { ); log.debug("Registered update timer"); } - - /** - * Validate the branches of an experiment using schemas - * - * @param recipe The recipe object. - * @param validatorCache A cache of JSON Schema validators keyed by feature - * ID. - * - * @returns Whether or not the branches pass validation. - */ - async _validateBranches({ id, branches }, validatorCache = {}) { - for (const [branchIdx, branch] of branches.entries()) { - const features = branch.features ?? [branch.feature]; - for (const feature of features) { - const { featureId, value } = feature; - if (!NimbusFeatures[featureId]) { - Cu.reportError( - `Experiment ${id} has unknown featureId: ${featureId}` - ); - return false; - } - - let validator; - if (validatorCache[featureId]) { - validator = validatorCache[featureId]; - } else if (NimbusFeatures[featureId].manifest.schema) { - const schema = await fetch( - NimbusFeatures[featureId].manifest.schema, - { - credentials: "omit", - } - ).then(rsp => rsp.json()); - validator = validatorCache[featureId] = new Validator(schema); - } else { - // TODO: Convert NimbusFeatures[featureId].manifest.variables into a - // schema OR add schemas for all. - continue; - } - - if (feature.enabled ?? true) { - const result = validator.validate(value); - if (!result.valid) { - Cu.reportError( - `Experiment ${id} branch ${branchIdx} feature ${featureId} does not validate: ${JSON.stringify( - result.errors, - undefined, - 2 - )}` - ); - return false; - } - } else { - log.debug( - `Experiment ${id} branch ${branchIdx} feature ${featureId} disabled; skipping validation` - ); - } - } - } - - return true; - } } const RemoteSettingsExperimentLoader = new _RemoteSettingsExperimentLoader(); diff --git a/toolkit/components/nimbus/moz.build b/toolkit/components/nimbus/moz.build index 8f1ada80cf9e..0eee7a00bbf9 100644 --- a/toolkit/components/nimbus/moz.build +++ b/toolkit/components/nimbus/moz.build @@ -29,6 +29,10 @@ XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] SPHINX_TREES["docs"] = "docs" TESTING_JS_MODULES += [ + "schemas/ExperimentFeatureManifest.schema.json", + "schemas/ExperimentFeatureRemote.schema.json", + "schemas/NimbusEnrollment.schema.json", + "schemas/NimbusExperiment.schema.json", "test/NimbusTestUtils.jsm", ] diff --git a/toolkit/components/nimbus/schemas/NimbusEnrollment.schema.json b/toolkit/components/nimbus/schemas/NimbusEnrollment.schema.json index 8edd26120c65..ffd6e9a63c38 100644 --- a/toolkit/components/nimbus/schemas/NimbusEnrollment.schema.json +++ b/toolkit/components/nimbus/schemas/NimbusEnrollment.schema.json @@ -1,208 +1,108 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/NimbusEnrollment", + "$ref": "#/definitions/NimbusExperiment", "definitions": { - "NimbusEnrollment": { + "NimbusExperiment": { "type": "object", "properties": { "slug": { - "type": "string", - "description": "Unique identifier for the experiment" - }, - "userFacingName": { - "type": "string", - "description": "Public name of the experiment displayed on \"about:studies\"" - }, - "userFacingDescription": { - "type": "string", - "description": "Short public description of the experiment displayed on on \"about:studies\"" - }, - "featureIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "A list of featureIds the experiment contains configurations for." + "type": "string" }, "branch": { - "anyOf": [ - { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "Identifier for the branch" - }, - "feature": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "description": "The identifier for the feature flag" - }, - "enabled": { - "type": "boolean", - "description": "This can be used to turn the whole feature on/off" - }, - "value": { - "type": "object", - "additionalProperties": {}, - "description": "Optional extra params for the feature (this should be validated against a schema)" - } + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "object", + "properties": { + "featureId": { + "type": "string", + "description": "The identifier for the feature flag" }, - "required": [ - "featureId", - "value" - ], - "description": "A single feature configuration" - } - }, - "required": [ - "slug", - "feature" - ] - }, - { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "Identifier for the branch" - }, - "feature": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "const": "unused-feature-id-for-legacy-support" - }, - "enabled": { - "type": "boolean", - "const": false - }, - "value": { - "type": "object", - "additionalProperties": {} - } + "isEarlyStartup": { + "type": "boolean", + "description": "Early startup features are stored to prefs." }, - "required": [ - "featureId", - "enabled", - "value" - ], - "description": "The feature key must be provided with valid values to prevent crashes if the DTO is encountered by Desktop clients earlier than version 95." - }, - "features": { - "type": "array", - "items": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "description": "The identifier for the feature flag" - }, - "enabled": { - "type": "boolean", - "description": "This can be used to turn the whole feature on/off" - }, - "value": { + "value": { + "anyOf": [ + { "type": "object", - "additionalProperties": {}, - "description": "Optional extra params for the feature (this should be validated against a schema)" + "additionalProperties": {} + }, + { + "type": "null" } - }, - "required": [ - "featureId", - "value" - ] + ], + "description": "Optional extra params for the feature (this should be validated against a schema)" }, - "description": "An array of feature configurations" - } - }, - "required": [ - "slug", - "feature", - "features" - ] - }, - { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "Identifier for the branch" + "enabled": { + "type": "boolean", + "description": "(deprecated)" + } }, - "features": { - "type": "array", - "items": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "description": "The identifier for the feature flag" - }, - "enabled": { - "type": "boolean", - "description": "This can be used to turn the whole feature on/off" - }, - "value": { - "type": "object", - "additionalProperties": {}, - "description": "Optional extra params for the feature (this should be validated against a schema)" - } - }, - "required": [ - "featureId", - "value" - ] - }, - "description": "An array of feature configurations" - } - }, - "required": [ - "slug", - "features" - ] + "required": ["featureId", "value"], + "additionalProperties": false + } } - ], - "description": "Branch configuration for the experiment" - }, - "isRollout": { - "type": "boolean", - "description": "Whether or not this experiment is considered a rollout." - }, - "experimentType": { - "type": "string", - "description": "What kind of experiment this enrollment corresponds to." - }, - "enrollmentId": { - "type": "string", - "description": "A unique identifier for the enrollment." + }, + "required": ["features"] }, "active": { "type": "boolean", - "description": "Whether or not the enrollment is active." + "description": "Experiment status" + }, + "isRollout": { + "type": "boolean", + "description": "If this is true, the enrollment is a rollout. If it is missing or false, it is an experiment." + }, + "enrollmentId": { + "type": "string", + "description": "Unique identifier used in telemetry" + }, + "experimentType": { + "type": "string" + }, + "isEnrollmentPaused": { + "type": "boolean" + }, + "source": { + "type": "string", + "description": "What triggered the enrollment" + }, + "userFacingName": { + "type": "string" + }, + "userFacingDescription": { + "type": "string" }, "lastSeen": { "type": "string", - "format": "date-time", - "description": "The last time the experiment was seen." + "description": "When was the enrollment made" }, "force": { "type": "boolean", - "description": "Whether or not this was a force enrollment." + "description": "(debug) If the enrollment happened naturally or through devtools" + }, + "featureIds": { + "type": "array", + "items": [{ "type": "string" }], + "description": "Array of strings corresponding to the branch features in the enrollment." } }, "required": [ "slug", + "branch", + "active", + "enrollmentId", + "experimentType", + "source", "userFacingName", "userFacingDescription", - "branch", - "enrollmentId", - "active", - "lastSeen" + "featureIds" ], - "description": "An enrollment in a Nimbus Experiment saved to disk" + "additionalProperties": false, + "description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API" } } } diff --git a/toolkit/components/nimbus/schemas/NimbusExperiment.schema.json b/toolkit/components/nimbus/schemas/NimbusExperiment.schema.json index 5fc676301090..f6c6e904e3ba 100644 --- a/toolkit/components/nimbus/schemas/NimbusExperiment.schema.json +++ b/toolkit/components/nimbus/schemas/NimbusExperiment.schema.json @@ -5,29 +5,17 @@ "NimbusExperiment": { "type": "object", "properties": { - "schemaVersion": { - "type": "string", - "description": "Version of the NimbusExperiment schema this experiment refers to" - }, "slug": { "type": "string", "description": "Unique identifier for the experiment" }, "id": { "type": "string", - "description": "Unique identifier for the experiment. This is a duplicate of slug, but is a required field for all Remote Settings records." + "description": "Unique identifier for the experiment. This is a duplicate of slug, but is a required field\nfor all Remote Settings records." }, - "appName": { + "application": { "type": "string", - "description": "A slug identifying the targeted product for this experiment. It should be a lowercase_with_underscores name that is short and unambiguous and it should match the app_name found in https://probeinfo.telemetry.mozilla.org/glean/repositories. Examples are \"fenix\" or \"firefox_desktop\"." - }, - "appId": { - "type": "string", - "description": "The platform identifier for the targeted app. The app's identifier exactly as it appears in the relevant app store listing (for relevant platforms) or in the app's Glean initialization call (for other platforms). Examples are \"org.mozilla.firefox_beta\" or \"firefox-desktop\"." - }, - "channel": { - "type": "string", - "description": "A specific channel of an application such as \"nightly\", \"beta\", or \"release\"" + "description": "A specific product such as Firefox Desktop or Fenix that supports Nimbus experiments" }, "userFacingName": { "type": "string", @@ -39,7 +27,7 @@ }, "isEnrollmentPaused": { "type": "boolean", - "description": "When this property is set to true, the the SDK should not enroll new users into the experiment that have not already been enrolled." + "description": "Should we enroll new users into the experiment?" }, "bucketConfig": { "type": "object", @@ -53,16 +41,16 @@ "description": "Additional inputs to the hashing function" }, "start": { - "type": "integer", + "type": "number", "description": "Index of start of the range of buckets" }, "count": { - "type": "integer", + "type": "number", "description": "Number of buckets to check" }, "total": { - "type": "integer", - "description": "Total number of buckets. You can assume this will always be 10000.", + "type": "number", + "description": "Total number of buckets", "default": 10000 } }, @@ -73,267 +61,116 @@ "count", "total" ], + "additionalProperties": false, "description": "Bucketing configuration" }, - "outcomes": { + "probeSets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of probe set slugs relevant to the experiment analysis" + }, + "branches": { "type": "array", "items": { "type": "object", "properties": { "slug": { "type": "string", - "description": "Identifier for the outcome" + "description": "Identifier for the branch" }, - "priority": { - "type": "string", - "description": "e.g. \"primary\" or \"secondary\"" - } - }, - "required": [ - "slug", - "priority" - ] - }, - "description": "A list of outcomes relevant to the experiment analysis." - }, - "featureIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "A list of featureIds the experiment contains configurations for." - }, - "branches": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "Identifier for the branch" - }, - "ratio": { - "type": "integer", - "description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3, branch A would get 25% of the population)", - "default": 1 - }, - "feature": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "description": "The identifier for the feature flag" - }, - "enabled": { - "type": "boolean", - "description": "This can be used to turn the whole feature on/off" - }, - "value": { - "type": "object", - "additionalProperties": {}, - "description": "Optional extra params for the feature (this should be validated against a schema)" - } + "ratio": { + "type": "number", + "description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3,\nbranch A would get 25% of the population)", + "default": 1 + }, + "features": { + "type": "array", + "items": { + "type": "object", + "properties": { + "featureId": { + "type": "string", + "description": "The identifier for the feature flag" }, - "required": [ - "featureId", - "value" - ], - "description": "A single feature configuration" - } - }, - "required": [ - "slug", - "ratio", - "feature" - ] - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "Identifier for the branch" - }, - "ratio": { - "type": "integer", - "description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3, branch A would get 25% of the population)", - "default": 1 - }, - "feature": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "const": "unused-feature-id-for-legacy-support" - }, - "enabled": { - "type": "boolean", - "const": false - }, - "value": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "featureId", - "enabled", - "value" - ], - "description": "The feature key must be provided with valid values to prevent crashes if the DTO is encountered by Desktop clients earlier than version 95." - }, - "features": { - "type": "array", - "items": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "description": "The identifier for the feature flag" - }, - "enabled": { - "type": "boolean", - "description": "This can be used to turn the whole feature on/off" - }, - "value": { + "value": { + "anyOf": [ + { "type": "object", - "additionalProperties": {}, - "description": "Optional extra params for the feature (this should be validated against a schema)" + "additionalProperties": {} + }, + { + "type": "null" } - }, - "required": [ - "featureId", - "value" - ] - }, - "description": "An array of feature configurations" - } - }, - "required": [ - "slug", - "ratio", - "feature", - "features" - ] + ], + "description": "Optional extra params for the feature (this should be validated against a schema)" + } + }, + "required": ["featureId", "value"], + "additionalProperties": false + } } }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "Identifier for the branch" - }, - "ratio": { - "type": "integer", - "description": "Relative ratio of population for the branch (e.g. if branch A=1 and branch B=3, branch A would get 25% of the population)", - "default": 1 - }, - "features": { - "type": "array", - "items": { - "type": "object", - "properties": { - "featureId": { - "type": "string", - "description": "The identifier for the feature flag" - }, - "enabled": { - "type": "boolean", - "description": "This can be used to turn the whole feature on/off" - }, - "value": { - "type": "object", - "additionalProperties": {}, - "description": "Optional extra params for the feature (this should be validated against a schema)" - } - }, - "required": [ - "featureId", - "value" - ] - }, - "description": "An array of feature configurations" - } - }, - "required": [ - "slug", - "ratio", - "features" - ] - } - } - ], + "required": ["slug", "ratio"], + "additionalProperties": false + }, "description": "Branch configuration for the experiment" }, "targeting": { - "type": [ - "string", - "null" - ], + "type": "string", "description": "JEXL expression used to filter experiments based on locale, geo, etc." }, "startDate": { - "type": [ - "string", - "null" - ], - "description": "Actual publish date of the experiment Note that this value is expected to be null in Remote Settings.", - "format": "date" + "type": ["string", "null"], + "description": "Actual publish date of the experiment\nNote that this value is expected to be null in Remote Settings.", + "format": "date-time" }, "endDate": { - "type": [ - "string", - "null" - ], - "description": "Actual end date of the experiment. Note that this value is expected to be null in Remote Settings.", - "format": "date" + "type": ["string", "null"], + "description": "Actual end date of the experiment.\nNote that this value is expected to be null in Remote Settings.", + "format": "date-time" }, "proposedDuration": { - "type": "integer", - "description": "Duration of the experiment from the start date in days. Note that this property is only used during the analysis phase (not by the SDK)" + "type": "number", + "description": "Duration of the experiment from the start date in days.\nNote that this value is expected to be null in Remote Settings.\nin Remote Settings." }, "proposedEnrollment": { - "type": "integer", - "description": "This represents the number of days that we expect to enroll new users. Note that this property is only used during the analysis phase (not by the SDK)" + "type": "number", + "description": "Duration of enrollment from the start date in days" }, "referenceBranch": { - "type": [ - "string", - "null" - ], - "description": "The slug of the reference branch (that is, which branch we consider \"control\")" + "type": ["string", "null"], + "description": "The slug of the reference branch" }, "filter_expression": { "type": "string", - "description": "This is NOT used by Nimbus, but has special functionality in Remote Settings. See https://remote-settings.readthedocs.io/en/latest/target-filters.html#how" + "description": "This is NOT used by Nimbus, but has special functionality in Remote Settings.\nSee https://remote-settings.readthedocs.io/en/latest/target-filters.html#how" + }, + "featureIds": { + "type": "array", + "items": [{ "type": "string" }], + "description": "Array of strings corresponding to the branch features in the enrollment." } }, "required": [ - "schemaVersion", "slug", "id", - "appName", - "appId", - "channel", + "application", "userFacingName", "userFacingDescription", "isEnrollmentPaused", "bucketConfig", + "probeSets", "branches", "startDate", "endDate", "proposedEnrollment", - "referenceBranch" + "referenceBranch", + "featureIds" ], - "description": "The experiment definition accessible to: 1. The Nimbus SDK via Remote Settings 2. Jetstream via the Experimenter API" + "additionalProperties": true, + "description": "The experiment definition accessible to:\n1. The Nimbus SDK via Remote Settings\n2. Jetstream via the Experimenter API" } } } diff --git a/toolkit/components/nimbus/test/NimbusTestUtils.jsm b/toolkit/components/nimbus/test/NimbusTestUtils.jsm index c268f18fed03..f0c25aa96095 100644 --- a/toolkit/components/nimbus/test/NimbusTestUtils.jsm +++ b/toolkit/components/nimbus/test/NimbusTestUtils.jsm @@ -15,7 +15,6 @@ XPCOMUtils.defineLazyModuleGetters(this, { _ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm", ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm", ExperimentStore: "resource://nimbus/lib/ExperimentStore.jsm", - NimbusFeatures: "resource://nimbus/ExperimentAPI.jsm", NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm", FileTestUtils: "resource://testing-common/FileTestUtils.jsm", _RemoteSettingsExperimentLoader: @@ -85,7 +84,7 @@ const ExperimentTestUtils = { async validateExperiment(experiment) { const schema = ( await fetchSchema( - "resource://nimbus/schemas/NimbusExperiment.schema.json" + "resource://testing-common/NimbusExperiment.schema.json" ) ).NimbusExperiment; @@ -110,9 +109,9 @@ const ExperimentTestUtils = { async validateEnrollment(enrollment) { const schema = ( await fetchSchema( - "resource://nimbus/schemas/NimbusEnrollment.schema.json" + "resource://testing-common/NimbusEnrollment.schema.json" ) - ).NimbusEnrollment; + ).NimbusExperiment; // We still have single feature experiment recipes for backwards // compatibility testing but we don't do schema validation @@ -132,9 +131,9 @@ const ExperimentTestUtils = { async validateRollouts(rollout) { const schema = ( await fetchSchema( - "resource://nimbus/schemas/NimbusEnrollment.schema.json" + "resource://testing-common/NimbusEnrollment.schema.json" ) - ).NimbusEnrollment; + ).NimbusExperiment; return this._validator( schema, @@ -142,26 +141,6 @@ const ExperimentTestUtils = { `Rollout configuration ${rollout.slug} is not valid` ); }, - /** - * Add features for tests. - * - * These features will only be visible to the JS Nimbus client. The native - * Nimbus client will have no access. - * - * @params features A list of |_NimbusFeature|s. - * - * @returns A cleanup function to remove the features once the test has completed. - */ - addTestFeatures(...features) { - for (const feature of features) { - NimbusFeatures[feature.featureId] = feature; - } - return () => { - for (const { featureId } of features) { - delete NimbusFeatures[featureId]; - } - }; - }, }; const ExperimentFakes = { @@ -330,8 +309,8 @@ const ExperimentFakes = { slug: "treatment", features: [ { - featureId: "testFeature", - value: { testInt: 123, enabled: true }, + featureId: "test-feature", + value: { title: "hello", enabled: true }, }, ], ...props, @@ -341,9 +320,8 @@ const ExperimentFakes = { experimentType: "NimbusTestUtils", userFacingName: "NimbusTestUtils", userFacingDescription: "NimbusTestUtils", - lastSeen: new Date().toJSON(), featureIds: props?.branch?.features?.map(f => f.featureId) || [ - "testFeature", + "test-feature", ], ...props, }; @@ -358,8 +336,8 @@ const ExperimentFakes = { slug: "treatment", features: [ { - featureId: "testFeature", - value: { testInt: 123, enabled: true }, + featureId: "test-feature", + value: { title: "hello", enabled: true }, }, ], ...props, @@ -369,10 +347,9 @@ const ExperimentFakes = { experimentType: "rollout", userFacingName: "NimbusTestUtils", userFacingDescription: "NimbusTestUtils", - lastSeen: new Date().toJSON(), featureIds: (props?.branch?.features || props?.features)?.map( f => f.featureId - ) || ["testFeature"], + ) || ["test-feature"], ...props, }; }, @@ -380,10 +357,6 @@ const ExperimentFakes = { return { // This field is required for populating remote settings id: NormandyUtils.generateUuid(), - schemaVersion: "1.7.0", - appName: "firefox_desktop", - appId: "firefox-desktop", - channel: "nightly", slug, isEnrollmentPaused: false, probeSets: [], @@ -396,20 +369,15 @@ const ExperimentFakes = { { slug: "control", ratio: 1, - features: [ - { - featureId: "testFeature", - value: { testInt: 123, enabled: true }, - }, - ], + features: [{ featureId: "test-feature", value: { enabled: true } }], }, { slug: "treatment", ratio: 1, features: [ { - featureId: "testFeature", - value: { testInt: 123, enabled: true }, + featureId: "test-feature", + value: { title: "hello", enabled: true }, }, ], }, @@ -424,7 +392,7 @@ const ExperimentFakes = { userFacingName: "Nimbus recipe", userFacingDescription: "NimbusTestUtils recipe", featureIds: props?.branches?.[0].features?.map(f => f.featureId) || [ - "testFeature", + "test-feature", ], ...props, }; diff --git a/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js b/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js index c3667e04e7e7..b3fe2ea9b8f9 100644 --- a/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js +++ b/toolkit/components/nimbus/test/browser/browser_experiment_evaluate_jexl.js @@ -10,7 +10,7 @@ const { ExperimentFakes } = ChromeUtils.import( "resource://testing-common/NimbusTestUtils.jsm" ); -add_setup(async function setup() { +add_task(async function setup() { await SpecialPowers.pushPrefEnv({ set: [ ["messaging-system.log", "all"], @@ -79,15 +79,25 @@ add_task(async function test_evaluate_active_experiments_activeExperiments() { const slug = "foo" + Math.random(); // Init the store before we use it await ExperimentManager.onStartup(); - - let recipe = ExperimentFakes.recipe(slug); - recipe.branches[0].slug = "mochitest-active-foo"; - delete recipe.branches[1]; - let { enrollmentPromise, doExperimentCleanup, - } = ExperimentFakes.enrollmentHelper(recipe); + } = ExperimentFakes.enrollmentHelper( + ExperimentFakes.recipe(slug, { + branches: [ + { + slug: "mochitest-active-foo", + features: [ + { + enabled: true, + featureId: "foo", + value: null, + }, + ], + }, + ], + }) + ); await enrollmentPromise; diff --git a/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js b/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js index ea7d6675979a..c7a1d41d0b61 100644 --- a/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js +++ b/toolkit/components/nimbus/test/browser/browser_experiment_single_feature_enrollment.js @@ -13,6 +13,7 @@ const { ExperimentAPI, NimbusFeatures } = ChromeUtils.import( const SINGLE_FEATURE_RECIPE = { appId: "firefox-desktop", appName: "firefox_desktop", + application: "firefox-desktop", arguments: {}, branches: [ { diff --git a/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js index 8d74a962432c..31ff418cf4f9 100644 --- a/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js +++ b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js @@ -11,9 +11,6 @@ const { NimbusFeatures, ExperimentAPI, } = ChromeUtils.import("resource://nimbus/ExperimentAPI.jsm"); -const { ExperimentTestUtils } = ChromeUtils.import( - "resource://testing-common/NimbusTestUtils.jsm" -); const { ExperimentManager } = ChromeUtils.import( "resource://nimbus/lib/ExperimentManager.jsm" ); @@ -67,7 +64,6 @@ const REMOTE_CONFIGURATION_FOO = ExperimentFakes.recipe("foo-rollout", { branches: [ { slug: "foo-rollout-branch", - ratio: 1, features: [ { featureId: "foo", @@ -85,7 +81,6 @@ const REMOTE_CONFIGURATION_BAR = ExperimentFakes.recipe("bar-rollout", { branches: [ { slug: "bar-rollout-branch", - ratio: 1, features: [ { featureId: "bar", @@ -132,10 +127,6 @@ add_task(async function test_remote_fetch_and_ready() { "setExperimentInactive" ); - registerCleanupFunction( - ExperimentTestUtils.addTestFeatures(fooInstance, barInstance) - ); - Assert.equal( fooInstance.getVariable("remoteValue"), undefined, @@ -301,11 +292,6 @@ add_task(async function test_finalizeRemoteConfigs_cleanup() { const featureBar = new ExperimentFeature("bar", { foo: { description: "mochitests" }, }); - - registerCleanupFunction( - ExperimentTestUtils.addTestFeatures(featureFoo, featureBar) - ); - let fooCleanup = await ExperimentFakes.enrollWithRollout( { featureId: "foo", diff --git a/toolkit/components/nimbus/test/unit/test_ExperimentAPI.js b/toolkit/components/nimbus/test/unit/test_ExperimentAPI.js index 57b98cc5f712..23511f234083 100644 --- a/toolkit/components/nimbus/test/unit/test_ExperimentAPI.js +++ b/toolkit/components/nimbus/test/unit/test_ExperimentAPI.js @@ -165,12 +165,8 @@ add_task(async function test_getExperiment_feature() { const expected = ExperimentFakes.experiment("foo", { branch: { slug: "treatment", + value: { title: "hi" }, features: [{ featureId: "cfr", enabled: true, value: null }], - feature: { - featureId: "unused-feature-id-for-legacy-support", - enabled: false, - value: {}, - }, }, }); @@ -332,8 +328,8 @@ add_task(async function test_getAllBranches_featureIdAccessor() { ); branches.forEach(branch => { Assert.equal( - branch.testFeature.featureId, - "testFeature", + branch["test-feature"].featureId, + "test-feature", "Should use the experimentBranchAccessor proxy getter" ); }); diff --git a/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature.js b/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature.js index 10a58b775e32..5e7180a0ad53 100644 --- a/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature.js +++ b/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature.js @@ -100,7 +100,6 @@ add_task(async function test_ExperimentFeature_isEnabled_default_over_remote() { const { manager, sandbox } = await setupForExperimentFeature(); const rollout = ExperimentFakes.rollout("foo-rollout", { branch: { - slug: "slug", features: [ { featureId: "foo", @@ -455,31 +454,15 @@ add_task(async function test_onUpdate_before_store_ready() { add_task(async function test_ExperimentFeature_test_ready_late() { const { manager, sandbox } = await setupForExperimentFeature(); const stub = sandbox.stub(); + sandbox + .stub(manager.store, "getAllRollouts") + .returns([ExperimentFakes.rollout("foo")]); + await manager.onStartup(); const featureInstance = new ExperimentFeature( "test-feature", FAKE_FEATURE_MANIFEST ); - - const rollout = ExperimentFakes.rollout("foo", { - branch: { - slug: "slug", - features: [ - { - featureId: featureInstance.featureId, - value: { - title: "hello", - enabled: true, - }, - }, - ], - }, - }); - - sandbox.stub(manager.store, "getAllRollouts").returns([rollout]); - - await manager.onStartup(); - featureInstance.onUpdate(stub); await featureInstance.ready(); diff --git a/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature_getVariable.js b/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature_getVariable.js index b453d5f9463d..7b6562830607 100644 --- a/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature_getVariable.js +++ b/toolkit/components/nimbus/test/unit/test_ExperimentAPI_ExperimentFeature_getVariable.js @@ -91,7 +91,6 @@ add_task(async function test_ExperimentFeature_getVariable_precedence() { const prefName = TEST_VARIABLES.items.fallbackPref; const rollout = ExperimentFakes.rollout(`${FEATURE_ID}-rollout`, { branch: { - slug: "slug", features: [ { featureId: FEATURE_ID, @@ -166,7 +165,6 @@ add_task(async function test_ExperimentFeature_getVariable_partial_values() { const instance = createInstanceWithVariables(TEST_VARIABLES); const rollout = ExperimentFakes.rollout(`${FEATURE_ID}-rollout`, { branch: { - slug: "slug", features: [ { featureId: FEATURE_ID, diff --git a/toolkit/components/nimbus/test/unit/test_ExperimentAPI_NimbusFeatures.js b/toolkit/components/nimbus/test/unit/test_ExperimentAPI_NimbusFeatures.js index 437e2871494d..d360148556ac 100644 --- a/toolkit/components/nimbus/test/unit/test_ExperimentAPI_NimbusFeatures.js +++ b/toolkit/components/nimbus/test/unit/test_ExperimentAPI_NimbusFeatures.js @@ -7,16 +7,20 @@ const { Cu.importGlobalProperties(["fetch"]); -XPCOMUtils.defineLazyGetter(this, "fetchSchema", () => { - return fetch("resource://nimbus/schemas/NimbusEnrollment.schema.json", { - credentials: "omit", - }).then(rsp => rsp.json()); +XPCOMUtils.defineLazyGetter(this, "fetchSchema", async () => { + const response = await fetch( + "resource://testing-common/NimbusEnrollment.schema.json" + ); + const schema = await response.json(); + if (!schema) { + throw new Error("Failed to load ExperimentFeatureRemote schema"); + } + return schema.definitions.NimbusExperiment; }); const NON_MATCHING_ROLLOUT = Object.freeze( ExperimentFakes.rollout("non-matching-rollout", { branch: { - slug: "slug", features: [ { featureId: "aboutwelcome", @@ -29,7 +33,6 @@ const NON_MATCHING_ROLLOUT = Object.freeze( const MATCHING_ROLLOUT = Object.freeze( ExperimentFakes.rollout("matching-rollout", { branch: { - slug: "slug", features: [ { featureId: "aboutwelcome", @@ -149,7 +152,6 @@ add_task(async function test_features_over_feature() { const rollout_features_and_feature = Object.freeze( ExperimentFakes.rollout("matching-rollout", { branch: { - slug: "slug", feature: { featureId: "aboutwelcome", value: { enabled: false }, @@ -166,7 +168,6 @@ add_task(async function test_features_over_feature() { const rollout_just_feature = Object.freeze( ExperimentFakes.rollout("matching-rollout", { branch: { - slug: "slug", feature: { featureId: "aboutwelcome", value: { enabled: false }, diff --git a/toolkit/components/nimbus/test/unit/test_ExperimentManager_enroll.js b/toolkit/components/nimbus/test/unit/test_ExperimentManager_enroll.js index e8d6fb715bf4..61be1e4c0a44 100644 --- a/toolkit/components/nimbus/test/unit/test_ExperimentManager_enroll.js +++ b/toolkit/components/nimbus/test/unit/test_ExperimentManager_enroll.js @@ -58,8 +58,6 @@ add_task(async function test_add_to_store() { NormandyTestUtils.isUuid(experiment.enrollmentId), "should add a valid enrollmentId" ); - - manager.unenroll("foo", "test-cleanup"); }); add_task(async function test_add_rollout_to_store() { @@ -93,8 +91,6 @@ add_task(async function test_add_rollout_to_store() { "should choose a branch from the recipe.branches" ); Assert.equal(experiment.isRollout, true, "should have .isRollout"); - - manager.unenroll("rollout-slug", "test-cleanup"); }); add_task( @@ -129,8 +125,6 @@ add_task( true, "should call sendEnrollmentTelemetry after an enrollment" ); - - manager.unenroll("foo", "test-cleanup"); } ); @@ -201,16 +195,14 @@ add_task(async function test_setRolloutActive_sendEnrollmentTelemetry_called() { "Should send telemetry with expected values" ); - manager.unenroll("rollout", "test-cleanup"); - globalSandbox.restore(); }); -// /** -// * Failure cases: -// * - slug conflict -// * - group conflict -// */ +/** + * Failure cases: + * - slug conflict + * - group conflict + */ add_task(async function test_failure_name_conflict() { const manager = ExperimentFakes.manager(); @@ -237,8 +229,6 @@ add_task(async function test_failure_name_conflict() { true, "should send failure telemetry if a conflicting experiment exists" ); - - manager.unenroll("foo", "test-cleanup"); }); add_task(async function test_failure_group_conflict() { @@ -286,8 +276,6 @@ add_task(async function test_failure_group_conflict() { true, "should send failure telemetry if a feature conflict exists" ); - - manager.unenroll("foo", "test-cleanup"); }); add_task(async function test_rollout_failure_group_conflict() { @@ -321,8 +309,6 @@ add_task(async function test_rollout_failure_group_conflict() { true, "should send failure telemetry if a feature conflict exists" ); - - manager.unenroll("rollout-enrollment", "test-cleanup"); }); add_task(async function test_rollout_experiment_no_conflict() { @@ -350,8 +336,6 @@ add_task(async function test_rollout_experiment_no_conflict() { manager.sendFailureTelemetry.notCalled, "Should send failure telemetry if a feature conflict exists" ); - - manager.unenroll("rollout-enrollment", "test-cleanup"); }); add_task(async function test_sampling_check() { @@ -508,8 +492,6 @@ add_task(async function test_forceEnroll_cleanup() { "Enrolled in forced experiment" ); - manager.unenroll(`optin-${forcedRecipe.slug}`, "test-cleanup"); - sandbox.restore(); }); @@ -567,7 +549,6 @@ add_task(async function test_featuremanifest_enum() { "Enrollment was validated and stored" ); - manager.unenroll(recipe.slug, "test-cleanup"); manager = ExperimentFakes.manager(); await manager.onStartup(); @@ -601,28 +582,20 @@ add_task(async function test_featureIds_is_stored() { const recipe = ExperimentFakes.recipe("featureIds"); // Ensure we get enrolled recipe.bucketConfig.count = recipe.bucketConfig.total; - const store = ExperimentFakes.store(); - const manager = ExperimentFakes.manager(store); + const manager = ExperimentFakes.manager(); await manager.onStartup(); - const { - enrollmentPromise, - doExperimentCleanup, - } = ExperimentFakes.enrollmentHelper(recipe, { manager }); - - await enrollmentPromise; + await manager.enroll(recipe, "test_featureIds_is_stored"); Assert.ok(manager.store.addEnrollment.calledOnce, "experiment is stored"); let [enrollment] = manager.store.addEnrollment.firstCall.args; Assert.ok("featureIds" in enrollment, "featureIds is stored"); Assert.deepEqual( enrollment.featureIds, - ["testFeature"], + ["test-feature"], "Has expected value" ); - - await doExperimentCleanup(); }); add_task(async function experiment_and_rollout_enroll_and_cleanup() { diff --git a/toolkit/components/nimbus/test/unit/test_ExperimentManager_lifecycle.js b/toolkit/components/nimbus/test/unit/test_ExperimentManager_lifecycle.js index f368286fc690..64bbe7598c41 100644 --- a/toolkit/components/nimbus/test/unit/test_ExperimentManager_lifecycle.js +++ b/toolkit/components/nimbus/test/unit/test_ExperimentManager_lifecycle.js @@ -15,10 +15,6 @@ add_task(async function test_onStartup_setExperimentActive_called() { sandbox.stub(manager, "setExperimentActive"); sandbox.stub(manager.store, "init").resolves(); sandbox.stub(manager.store, "getAll").returns(experiments); - sandbox - .stub(manager.store, "get") - .callsFake(slug => experiments.find(expt => expt.slug === slug)); - sandbox.stub(manager.store, "set"); const active = ["foo", "bar"].map(ExperimentFakes.experiment); @@ -55,10 +51,6 @@ add_task(async function test_onStartup_setRolloutActive_called() { const active = ["foo", "bar"].map(ExperimentFakes.rollout); sandbox.stub(manager.store, "getAll").returns(active); - sandbox - .stub(manager.store, "get") - .callsFake(slug => active.find(e => e.slug === slug)); - sandbox.stub(manager.store, "set"); await manager.onStartup(); @@ -90,16 +82,16 @@ add_task(async function test_startup_unenroll() { await enrollmentPromise; const manager = ExperimentFakes.manager(store); - const unenrollSpy = sandbox.spy(manager, "unenroll"); + const unenrollStub = sandbox.stub(manager, "unenroll"); await manager.onStartup(); Assert.ok( - unenrollSpy.calledOnce, + unenrollStub.calledOnce, "Unenrolled from active experiment if user opt out is true" ); Assert.ok( - unenrollSpy.calledWith("startup_unenroll", "studies-opt-out"), + unenrollStub.calledWith("startup_unenroll", "studies-opt-out"), "Called unenroll for expected recipe" ); @@ -158,8 +150,6 @@ add_task(async function test_onRecipe_enroll() { true, "should add recipe to the store" ); - - manager.unenroll(fooRecipe.slug, "test-cleanup"); }); add_task(async function test_onRecipe_update() { @@ -187,8 +177,6 @@ add_task(async function test_onRecipe_update() { true, "should call .updateEnrollment() if the recipe has already been enrolled" ); - - manager.unenroll(fooRecipe.slug, "test-cleanup"); }); add_task(async function test_onRecipe_rollout_update() { @@ -311,7 +299,7 @@ add_task(async function test_onFinalize_unenroll() { experimentType: "unittest", userFacingName: "foo", userFacingDescription: "foo", - lastSeen: new Date().toJSON(), + lastSeen: Date.now().toLocaleString(), source: "test", }); await manager.store.addEnrollment(recipe0); @@ -359,7 +347,7 @@ add_task(async function test_onFinalize_unenroll_mismatch() { experimentType: "unittest", userFacingName: "foo", userFacingDescription: "foo", - lastSeen: new Date().toJSON(), + lastSeen: Date.now().toLocaleString(), source: "test", }); await manager.store.addEnrollment(recipe0); diff --git a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js index 683bc1893eb3..f68c74bba939 100644 --- a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js +++ b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js @@ -156,8 +156,6 @@ add_task(async function test_updateRecipes_someMismatch() { ok( loader.manager.onFinalize.calledWith("rs-loader", { recipeMismatches: [FAIL_FILTER_RECIPE.slug], - invalidRecipes: [], - invalidBranches: [], }), "should call .onFinalize with the recipes that failed targeting" ); diff --git a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js index 7dcca6bed036..a330946ab95f 100644 --- a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js +++ b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader_updateRecipes.js @@ -6,9 +6,6 @@ const { ExperimentFakes } = ChromeUtils.import( const { FirstStartup } = ChromeUtils.import( "resource://gre/modules/FirstStartup.jsm" ); -const { PanelTestProvider } = ChromeUtils.import( - "resource://activity-stream/lib/PanelTestProvider.jsm" -); add_task(async function test_updateRecipes_activeExperiments() { const manager = ExperimentFakes.manager(); @@ -47,271 +44,3 @@ add_task(async function test_updateRecipes_isFirstRun() { Assert.ok(onRecipe.calledOnce, "Should match first run"); }); - -add_task(async function test_updateRecipes_invalidFeatureId() { - const manager = ExperimentFakes.manager(); - const sandbox = sinon.createSandbox(); - const loader = ExperimentFakes.rsLoader(); - loader.manager = manager; - - const badRecipe = ExperimentFakes.recipe("foo", { - branches: [ - { - slug: "control", - ratio: 1, - features: [ - { - featureId: "invalid-feature-id", - value: { hello: "world" }, - }, - ], - }, - { - slug: "treatment", - ratio: 1, - features: [ - { - featureId: "invalid-feature-id", - value: { hello: "goodbye" }, - }, - ], - }, - ], - }); - - const onRecipe = sandbox.stub(manager, "onRecipe"); - sinon.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]); - sandbox.stub(manager.store, "ready").resolves(); - sandbox.stub(manager.store, "getAllActive").returns([]); - - await loader.init(); - ok(onRecipe.notCalled, "No recipes"); -}); - -add_task(async function test_updateRecipes_invalidFeatureValue() { - const manager = ExperimentFakes.manager(); - const sandbox = sinon.createSandbox(); - const loader = ExperimentFakes.rsLoader(); - loader.manager = manager; - - const badRecipe = ExperimentFakes.recipe("foo", { - branches: [ - { - slug: "control", - ratio: 1, - features: [ - { - featureId: "spotlight", - enabled: true, - value: { - id: "test-spotlight-invalid-1", - }, - }, - ], - }, - { - slug: "treatment", - ratio: 1, - features: [ - { - featureId: "spotlight", - enabled: true, - value: { - id: "test-spotlight-invalid-2", - }, - }, - ], - }, - ], - }); - - const onRecipe = sandbox.stub(manager, "onRecipe"); - sinon.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]); - sandbox.stub(manager.store, "ready").resolves(); - sandbox.stub(manager.store, "getAllActive").returns([]); - - await loader.init(); - ok(onRecipe.notCalled, "No recipes"); -}); - -add_task(async function test_updateRecipes_invalidRecipe() { - const manager = ExperimentFakes.manager(); - const sandbox = sinon.createSandbox(); - const loader = ExperimentFakes.rsLoader(); - loader.manager = manager; - - const badRecipe = ExperimentFakes.recipe("foo"); - delete badRecipe.slug; - - const onRecipe = sandbox.stub(manager, "onRecipe"); - sinon.stub(loader.remoteSettingsClient, "get").resolves([badRecipe]); - sandbox.stub(manager.store, "ready").resolves(); - sandbox.stub(manager.store, "getAllActive").returns([]); - - await loader.init(); - ok(onRecipe.notCalled, "No recipes"); -}); - -add_task(async function test_updateRecipes_invalidRecipeAfterUpdate() { - const manager = ExperimentFakes.manager(); - const loader = ExperimentFakes.rsLoader(); - loader.manager = manager; - - const recipe = ExperimentFakes.recipe("foo"); - const badRecipe = { ...recipe }; - delete badRecipe.branches; - - sinon.stub(loader, "setTimer"); - sinon.stub(manager, "onRecipe"); - sinon.stub(manager, "onFinalize"); - - sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]); - sinon.stub(manager.store, "ready").resolves(); - sinon.spy(loader, "updateRecipes"); - - await loader.init(); - - ok(loader.updateRecipes.calledOnce, "should call .updateRecipes"); - equal(loader.manager.onRecipe.callCount, 1, "should call .onRecipe once"); - ok( - loader.manager.onRecipe.calledWith(recipe, "rs-loader"), - "should call .onRecipe with argument data" - ); - equal(loader.manager.onFinalize.callCount, 1, "should call .onFinalize once"); - ok( - loader.manager.onFinalize.calledWith("rs-loader", { - recipeMismatches: [], - invalidRecipes: [], - invalidBranches: [], - }), - "should call .onFinalize with no mismatches or invalid recipes" - ); - - info("Replacing recipe with an invalid one"); - - loader.remoteSettingsClient.get.resolves([badRecipe]); - - await loader.updateRecipes("timer"); - equal( - loader.manager.onRecipe.callCount, - 1, - "should not have called .onRecipe again" - ); - equal( - loader.manager.onFinalize.callCount, - 2, - "should have called .onFinalize again" - ); - - ok( - loader.manager.onFinalize.secondCall.calledWith("rs-loader", { - recipeMismatches: [], - invalidRecipes: ["foo"], - invalidBranches: [], - }), - "should call .onFinalize with an invalid recipe" - ); -}); - -add_task(async function test_updateRecipes_invalidBranchAfterUpdate() { - const message = await PanelTestProvider.getMessages().then(msgs => - msgs.find(m => m.id === "SPOTLIGHT_MESSAGE_93") - ); - - const manager = ExperimentFakes.manager(); - const loader = ExperimentFakes.rsLoader(); - loader.manager = manager; - - const recipe = ExperimentFakes.recipe("foo", { - branches: [ - { - slug: "control", - ratio: 1, - features: [ - { - featureId: "spotlight", - value: { ...message }, - }, - ], - }, - { - slug: "treatment", - ratio: 1, - features: [ - { - featureId: "spotlight", - value: { ...message }, - }, - ], - }, - ], - }); - - const badRecipe = { - ...recipe, - branches: [ - { ...recipe.branches[0] }, - { - ...recipe.branches[1], - features: [ - { - ...recipe.branches[1].features[0], - value: { ...message }, - }, - ], - }, - ], - }; - delete badRecipe.branches[1].features[0].value.template; - - sinon.stub(loader, "setTimer"); - sinon.stub(manager, "onRecipe"); - sinon.stub(manager, "onFinalize"); - - sinon.stub(loader.remoteSettingsClient, "get").resolves([recipe]); - sinon.stub(manager.store, "ready").resolves(); - sinon.spy(loader, "updateRecipes"); - - await loader.init(); - - ok(loader.updateRecipes.calledOnce, "should call .updateRecipes"); - equal(loader.manager.onRecipe.callCount, 1, "should call .onRecipe once"); - ok( - loader.manager.onRecipe.calledWith(recipe, "rs-loader"), - "should call .onRecipe with argument data" - ); - equal(loader.manager.onFinalize.callCount, 1, "should call .onFinalize once"); - ok( - loader.manager.onFinalize.calledWith("rs-loader", { - recipeMismatches: [], - invalidRecipes: [], - invalidBranches: [], - }), - "should call .onFinalize with no mismatches or invalid recipes" - ); - - info("Replacing recipe with an invalid one"); - - loader.remoteSettingsClient.get.resolves([badRecipe]); - - await loader.updateRecipes("timer"); - equal( - loader.manager.onRecipe.callCount, - 1, - "should not have called .onRecipe again" - ); - equal( - loader.manager.onFinalize.callCount, - 2, - "should have called .onFinalize again" - ); - - ok( - loader.manager.onFinalize.secondCall.calledWith("rs-loader", { - recipeMismatches: [], - invalidRecipes: [], - invalidBranches: ["foo"], - }), - "should call .onFinalize with an invalid branch" - ); -}); diff --git a/toolkit/content/jar.mn b/toolkit/content/jar.mn index d340c5c73589..723377bfcca8 100644 --- a/toolkit/content/jar.mn +++ b/toolkit/content/jar.mn @@ -114,5 +114,4 @@ toolkit.jar: content/global/gmp-sources/widevinecdm.json (gmp-sources/widevinecdm.json) # Third party files - content/global/third_party/d3/d3.js (/third_party/js/d3/d3.js) - content/global/third_party/cfworker/json-schema.js (/third_party/js/cfworker/json-schema.js) + content/global/third_party/d3/d3.js (/third_party/js/d3/d3.js) diff --git a/toolkit/content/license.html b/toolkit/content/license.html index 37bb0543e5a9..51d070e4d235 100644 --- a/toolkit/content/license.html +++ b/toolkit/content/license.html @@ -77,7 +77,6 @@
  • bspatch License
  • byteorder License
  • Cairo Component Licenses
  • -
  • cfworker License
  • Chromium License
  • CodeMirror License
  • CRYPTOGAMS License
  • @@ -2351,35 +2350,6 @@ WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -
    - -

    cfworker License

    -

    This license applies to the file toolkit/content/third_party/cfworker/json-schema.js

    - -
    -MIT License
    -
    -Copyright (c) 2020 Jeremy Danyow
    -
    -Permission is hereby granted, free of charge, to any person obtaining a copy
    -of this software and associated documentation files (the "Software"), to deal
    -in the Software without restriction, including without limitation the rights
    -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    -copies of the Software, and to permit persons to whom the Software is
    -furnished to do so, subject to the following conditions:
    -
    -The above copyright notice and this permission notice shall be included in all
    -copies or substantial portions of the Software.
    -
    -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    -SOFTWARE.
    -
    -

    Chromium License

    diff --git a/toolkit/modules/JsonSchema.jsm b/toolkit/modules/JsonSchema.jsm deleted file mode 100644 index 0e1689a10991..000000000000 --- a/toolkit/modules/JsonSchema.jsm +++ /dev/null @@ -1,122 +0,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/. */ - -"use strict"; - -/** - * A facade around @cfworker/json-schema that provides additional formats and - * convenience methods whil executing inside a sandbox. - */ - -const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); - -const sandbox = new Cu.Sandbox(null, { - wantComponents: false, - wantGlobalProperties: ["URL"], -}); - -Services.scriptloader.loadSubScript( - "chrome://global/content/third_party/cfworker/json-schema.js", - sandbox -); - -/** - * A JSON Schema string format for URLs intended to go through Services.urlFormatter. - */ -Cu.exportFunction( - function validateMozUrlFormat(input) { - try { - const formatted = Services.urlFormatter.formatURL(input); - return Cu.waiveXrays(sandbox.fastFormat).uri(formatted); - } catch { - return false; - } - }, - sandbox.fastFormat, - { defineAs: "moz-url-format" } -); - -// initialBaseURI defaults to github.com/cfworker, which will be confusing. -Cu.evalInSandbox( - `this.initialBaseURI = initialBaseURI = new URL("http://mozilla.org");`, - sandbox -); - -/** - * A JSONSchema validator that performs validation inside a sandbox. - */ -class Validator { - #inner; - - /** - * Create a new validator. - * - * @param {object} schema The schema to validate with. - * @param {string} draft The draft to validate against. Should - * be one of "4", "7", "2019-09". - * @param {boolean} shortCircuit Whether or not the validator should return - * after a single error occurs. - */ - constructor(schema, draft = "2019-09", shortCircuit = true) { - this.#inner = Cu.waiveXrays( - new sandbox.Validator(Cu.cloneInto(schema, sandbox), draft, shortCircuit) - ); - } - - /** - * Validate the instance against the known schemas. - * - * @param {object} instance The instance to validate. - * - * @return {object} An object with |valid| and |errors| keys that indicates - * the success of validation. - */ - validate(instance) { - return this.#inner.validate(Cu.cloneInto(instance, sandbox)); - } - - /** - * Add a schema to the validator. - * - * @param {object} schema A JSON schema object. - * @param {string} id An optional ID to identify the schema if it does not - * provide an |$id| field. - */ - addSchema(schema, id) { - this.#inner.addSchema(Cu.cloneInto(schema, sandbox), id); - } -} - -/** - * A wrapper around validate that provides some options as an object - * instead of positional arguments. - * - * @param {object} instance The instance to validate. - * @param {object} schema The JSON schema to validate against. - * @param {object} options Options for the validator. - * @param {string} options.draft The draft to validate against. Should - * be one of "4", "7", "2019-09". - * @param {boolean} options.shortCircuit Whether or not the validator should - * return after a single error occurs. - * - * @returns {object} An object with |valid| and |errors| keys that indicates the - * success of validation. - */ -function validate( - instance, - schema, - { draft = "2019-09", shortCircuit = true } = {} -) { - const clonedSchema = Cu.cloneInto(schema, sandbox); - - return sandbox.validate( - Cu.cloneInto(instance, sandbox), - clonedSchema, - draft, - sandbox.dereference(clonedSchema), - shortCircuit - ); -} - -const EXPORTED_SYMBOLS = ["Validator", "validate", "sandbox"]; diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build index 265e9431ba9d..05efa70f9f9e 100644 --- a/toolkit/modules/moz.build +++ b/toolkit/modules/moz.build @@ -183,7 +183,6 @@ EXTRA_JS_MODULES += [ "InlineSpellCheckerContent.jsm", "Integration.jsm", "JSONFile.jsm", - "JsonSchema.jsm", "KeywordUtils.jsm", "LayoutUtils.jsm", "Log.jsm", diff --git a/toolkit/modules/tests/xpcshell/test_JsonSchema.js b/toolkit/modules/tests/xpcshell/test_JsonSchema.js deleted file mode 100644 index 352d04ce4a2e..000000000000 --- a/toolkit/modules/tests/xpcshell/test_JsonSchema.js +++ /dev/null @@ -1,86 +0,0 @@ -/* Any copyright is dedicated to the Public Domain. - * http://creativecommons.org/publicdomain/zero/1.0/ */ - -const JsonSchema = ChromeUtils.import("resource://gre/modules/JsonSchema.jsm"); - -add_task(function test_basicSchema() { - info("Testing validation of a basic schema"); - const schema = { - type: "object", - properties: { - id: { type: "number" }, - }, - required: ["id"], - }; - - const validator = new JsonSchema.Validator(schema); - - Assert.deepEqual( - JsonSchema.validate({ id: 123 }, schema), - { valid: true, errors: [] }, - "Validation of basic schemas with validate()" - ); - - Assert.deepEqual( - validator.validate({ id: 123 }, schema), - { valid: true, errors: [] }, - "Validation of basic schemas with Validator" - ); - - Assert.ok( - !JsonSchema.validate({}, schema).valid, - "Validation of basic schemas with validate()" - ); - Assert.ok( - !validator.validate({}).valid, - "Validation of basic schemas with Validator" - ); -}); - -add_task(function test_mozUrlFormat() { - info("Testing custom string format 'moz-url-format'"); - const schema = { - type: "string", - format: "moz-url-format", - }; - - { - const obj = "https://www.mozilla.org/%LOCALE%/"; - Assert.deepEqual( - JsonSchema.validate(obj, schema), - { valid: true, errors: [] }, - "Substitution of a valid variable validates" - ); - } - - { - const obj = "https://mozilla.org/%BOGUS%/"; - - Assert.equal( - Services.urlFormatter.formatURL(obj), - obj, - "BOGUS is an invalid variable for the URL formatter service" - ); - - Assert.ok( - !JsonSchema.validate(obj, { type: "string", format: "uri" }).valid, - "A moz-url-format string does not validate as a URI" - ); - - Assert.deepEqual( - JsonSchema.validate(obj, schema), - { - valid: false, - errors: [ - { - instanceLocation: "#", - keyword: "format", - keywordLocation: "#/format", - error: `String does not match format "moz-url-format".`, - }, - ], - }, - "Substitution of an invalid variable does not validate" - ); - } -}); diff --git a/toolkit/modules/tests/xpcshell/xpcshell.ini b/toolkit/modules/tests/xpcshell/xpcshell.ini index e40d3b870d52..2395a5981a19 100644 --- a/toolkit/modules/tests/xpcshell/xpcshell.ini +++ b/toolkit/modules/tests/xpcshell/xpcshell.ini @@ -36,7 +36,6 @@ tags = remote-settings [test_Integration.js] [test_jsesc.js] [test_JSONFile.js] -[test_JsonSchema.js] [test_Log.js] [test_MatchURLFilters.js] [test_NewTabUtils.js]