/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { AppConstants: "resource://gre/modules/AppConstants.sys.mjs", ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", Region: "resource://gre/modules/Region.sys.mjs", RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", SearchEngine: "moz-src:///toolkit/components/search/SearchEngine.sys.mjs", SearchEngineSelector: "moz-src:///toolkit/components/search/SearchEngineSelector.sys.mjs", SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs", sinon: "resource://testing-common/Sinon.sys.mjs", updateAppInfo: "resource://testing-common/AppInfo.sys.mjs", }); /** * @import {AppProvidedConfigEngine} from "moz-src:///toolkit/components/search/ConfigSearchEngine.sys.mjs" */ const GLOBAL_SCOPE = this; const TEST_DEBUG = Services.env.get("TEST_DEBUG"); const URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; const URLTYPE_SEARCH_HTML = "text/html"; const ENGINES_URLS = { "prod-main": "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/search-config/records", "prod-preview": "https://firefox.settings.services.mozilla.com/v1/buckets/main-preview/collections/search-config/records", "stage-main": "https://firefox.settings.services.allizom.org/v1/buckets/main/collections/search-config/records", "stage-preview": "https://firefox.settings.services.allizom.org/v1/buckets/main-preview/collections/search-config/records", }; /** * This function is used to override the remote settings configuration * if the SEARCH_CONFIG environment variable is set. This allows testing * against a remote server. */ async function maybeSetupConfig() { const SEARCH_CONFIG = Services.env.get("SEARCH_CONFIG"); if (SEARCH_CONFIG) { if (!(SEARCH_CONFIG in ENGINES_URLS)) { throw new Error(`Invalid value for SEARCH_CONFIG`); } const url = ENGINES_URLS[SEARCH_CONFIG]; const response = await fetch(url); const config = await response.json(); SearchTestUtils.setRemoteSettingsConfig(config.data); } } /** * @typedef {object} RegionLocaleDetails * @property {string[]} [regions] * The regions where the search engine should be available. Regions are typically * displayed as lower case. If no regions are specified, it is assumed that * any region will match. * @property {string[]} [locales] * The locales where the search engine should be available. If no locales are * specified, it is assumed that any locales will match. */ /** * @typedef {object} DeploymentDetails * Where the search engine should be deployed to. * * If neither included nor excluded are specified, the search engine should * not be available anywhere. * * If excluded is specified and included is not specified, the search engine * should be available everywhere. * * @property {RegionLocaleDetails[]} [included] * Where the search engine should be available. * @property {RegionLocaleDetails[]} [excluded] * Where the search engine should not be available. */ /** * @typedef {object} EngineRuleDetails * @property {string} domain * The expected domain of the search URL. * @property {string} telemetryId * The expected telemetry ID of the search engine. This is deprecated, but * we still test it. * @property {string} [partnerCode] * The expected partner code property of the search engine. * @property {string} [searchUrlCode] * The expected parameter name and value for the partner code in the search URL. * @property {string} [searchUrlParamNotInQuery] * When specified, this parameter should not be included in the query part of * the search URL. * @property {string} [suggestUrlCode] * Expected parameter name and value within the suggestion URL, e.g. for checking * regional parameters. * @property {string[]} [aliases] * The expected aliases for the search engine. * @property {string[]} [required_aliases] * Required aliases for the search engine. These aliases must be associated * with the engine, but additional aliases may also be supplied. * @property {boolean} [noSuggestionsURL] * Set to true when there are no suggestions available for this search engine. */ /** * @typedef {object} SearchConfigTestDetails * Details for the search configuration tests for a single engine. * @property {string} [identifier] * The identifier for the search engine under test. If not specified then * `identifierStartsWith` must be specified. * @property {string} [identifierStartsWith] * The prefix of the identifier of the search engine under test. This is used * for search engines that have different set ups per locale/region and have * different identifiers as a result, e.g. wikipedia, eBay. * @property {string[]} [aliases] * The expected aliases for the search engine. * @property {DeploymentDetails} default * Details of where the engine should be listed as default. * @property {DeploymentDetails} available * Details of where the engine should be listed as available. * @property {string} [suggestionUrlBase] * The base URL for search suggestion lookup. * @property {boolean} [noSuggestionsURL] * Set to true when there are no suggestions available for this search engine. * @property {(EngineRuleDetails & DeploymentDetails)[]} details * Specific details of the test for checking URL details and telemetry information. */ /** * Processes the configuration to get the search engines for the specified * region/locale. * * @param {SearchEngineSelector} engineSelector * @param {string} region * @param {string} locale * @returns {Promise<{engines: AppProvidedConfigEngine[], appDefaultEngineId: string}>} */ async function getEngines(engineSelector, region, locale) { let configs = await engineSelector.fetchEngineConfiguration({ locale, region, channel: SearchUtils.MODIFIED_APP_CHANNEL, }); return { engines: await SearchTestUtils.searchConfigToEngines(configs.engines), appDefaultEngineId: configs.appDefaultEngineId, }; } /** * This class implements the test harness for search configuration tests. * These tests are designed to ensure that the correct search engines are * loaded for the various region/locale configurations. */ class SearchConfigTest { /** * @type {?SearchEngineSelector} */ #engineSelector; /** * @type {SearchConfigTestDetails[]} */ #testDetails; /** * @param {SearchConfigTestDetails[]} testDetails * An array of configurations for testing multiple engines. */ constructor(testDetails) { this.#testDetails = testDetails; } /** * Sets up the test. * * @param {string} [version] * The version to simulate for running the tests. */ async setup(version = "42.0") { updateAppInfo({ name: "firefox", ID: "xpcshell@tests.mozilla.org", version, platformVersion: version, }); await maybeSetupConfig(); this.#engineSelector = new SearchEngineSelector(); } /** * Runs the test. */ async run() { const locales = await this.getLocales(); const regions = this._regions; // We loop on region and then locale, so that we always cause a re-init // when updating the requested/available locales. for (let region of regions) { for (let locale of locales) { const { engines, appDefaultEngineId } = await getEngines( this.#engineSelector, region, locale ); // Test each configuration in this single iteration for (let testDetails of this.#testDetails) { this._assertEngineRules( engines.filter(e => e.id == appDefaultEngineId), region, locale, "default", testDetails ); const isPresent = this._assertAvailableEngines( region, locale, engines, testDetails ); if (isPresent) { this._assertEngineDetails(region, locale, engines, testDetails); } } } } } /** * @returns {Set} the list of regions for the tests to run with. */ get _regions() { if (TEST_DEBUG) { return new Set(["by", "cn", "kz", "us", "ru", "tr", "default"]); } return new Set([ ...Services.intl.getAvailableLocaleDisplayNames("region"), "default", ]); } /** * @returns {Promise} the list of locales for the tests to run with. */ async getLocales() { if (TEST_DEBUG) { return ["be", "en-US", "kk", "tr", "ru", "zh-CN", "ach", "unknown"]; } const data = await IOUtils.readUTF8(do_get_file("all-locales").path); // "en-US" is not in all-locales as it is the default locale // add it manually to ensure it is tested. let locales = [...data.split("\n").filter(e => e != ""), "en-US"]; // BCP47 requires all variants are 5-8 characters long. Our // build sytem uses the short `mac` variant, this is invalid, and inside // the app we turn it into `ja-JP-macos` locales = locales.map(l => (l == "ja-JP-mac" ? "ja-JP-macos" : l)); // The locale sometimes can be unknown or a strange name, e.g. if the updater // is disabled, it may be "und", add one here so we know what happens if we // hit it. locales.push("unknown"); return locales; } /** * Determines if a locale/region pair matches a section of the test details. * * @param {RegionLocaleDetails[]} section * The region/locale details to match against. * @param {string} region * The two-letter region code. * @param {string} locale * The two-letter locale code. * @returns {boolean} * True if the locale/region pair matches the section. */ _localeRegionInSection(section, region, locale) { for (const { regions, locales } of section) { // If we only specify a regions or locales section then // it is always considered included in the other section. const inRegions = !regions || regions.includes(region); const inLocales = !locales || locales.includes(locale); if (inRegions && inLocales) { return true; } } return false; } /** * Helper function to find an engine from within a list. * * @param {AppProvidedConfigEngine[]} engines * The list of engines to check. * @param {SearchConfigTestDetails} testDetails * The details of the test. * @returns {AppProvidedConfigEngine} * Returns the engine if found, null otherwise. */ _findEngine(engines, testDetails) { return engines.find(engine => testDetails.identifier ? engine.id == testDetails.identifier : engine.id.startsWith(testDetails.identifierStartsWith) ); } /** * Asserts whether the engines rules in the test section are met for the * associated engine. * * @param {AppProvidedConfigEngine[]} engines * The list of engines to check. * @param {string} region * The two-letter region code. * @param {string} locale * The two-letter locale code. * @param {"default" | "available"} section * The section of the test to check. * @param {SearchConfigTestDetails} testDetails * The test details to use. * @returns {boolean} * Returns true if the engine is expected to be present, false otherwise. */ _assertEngineRules(engines, region, locale, section, testDetails) { const infoString = `region: "${region}" locale: "${locale}"`; const testSection = testDetails[section]; const hasIncluded = "included" in testSection; const hasExcluded = "excluded" in testSection; const identifierIncluded = !!this._findEngine(engines, testDetails); // If there's not included/excluded, then this shouldn't be the default anywhere. if (section == "default" && !hasIncluded && !hasExcluded) { this.assertOk( !identifierIncluded, `${testDetails.identifier} should not be ${section} for any locale/region, currently set for ${infoString}` ); return false; } // If there's no included section and no excluded, then we assume the // engine is not available anywhere. If there's no included section, but an // exluded section we assume it is available everywhere apart from the // exclusions. let included = hasIncluded && this._localeRegionInSection(testSection.included, region, locale); let excluded = hasExcluded && this._localeRegionInSection(testSection.excluded, region, locale); if ( (included && (!hasExcluded || !excluded)) || (!hasIncluded && hasExcluded && !excluded) ) { this.assertOk( identifierIncluded, `${testDetails.identifier} should be ${section} for ${infoString}` ); return true; } this.assertOk( !identifierIncluded, `${testDetails.identifier} should not be ${section} for ${infoString}` ); return false; } /** * Asserts whether the engine is correctly available or not. * * @param {string} region * The two-letter region code. * @param {string} locale * The two-letter locale code. * @param {AppProvidedConfigEngine[]} engines * The current visible engines. * @param {SearchConfigTestDetails} testDetails * The test details to use. * @returns {boolean} * Returns true if the engine is expected to be present, false otherwise. */ _assertAvailableEngines(region, locale, engines, testDetails) { return this._assertEngineRules( engines, region, locale, "available", testDetails ); } /** * Asserts the engine follows various rules. * * @param {string} region * The two-letter region code. * @param {string} locale * The two-letter locale code. * @param {AppProvidedConfigEngine[]} engines * The current visible engines. * @param {SearchConfigTestDetails} testDetails * The test details to use. */ _assertEngineDetails(region, locale, engines, testDetails) { const details = testDetails.details.filter(value => { const included = this._localeRegionInSection( value.included, region, locale ); const excluded = value.excluded && this._localeRegionInSection(value.excluded, region, locale); return included && !excluded; }); this.assertEqual( details.length, 1, `${testDetails.identifier} should have just one details section for region: ${region} locale: ${locale}` ); const engine = this._findEngine(engines, testDetails); this.assertOk( engine, "${testDetails.identifier} should have an engine present" ); if (testDetails.aliases) { this.assertDeepEqual( engine.aliases, testDetails.aliases, "Should have the correct aliases for the engine" ); } const location = `in region:${region}, locale:${locale}`; for (const rule of details) { this._assertCorrectDomains(location, engine, rule, testDetails); this._assertCorrectUrlCode(location, engine, rule); if ("aliases" in rule) { this.assertDeepEqual( engine.aliases, rule.aliases, "Should have the correct aliases for the engine" ); } if ("required_aliases" in rule) { this.assertOk( rule.required_aliases.every(a => engine.aliases.includes(a)), "Should have the required aliases for the engine" ); } if ("telemetryId" in rule) { this.assertEqual( engine.telemetryId, rule.telemetryId, `Should have the correct telemetryId ${location}.` ); } if ("partnerCode" in rule) { this.assertEqual( engine.partnerCode, rule.partnerCode, `Should have the correct partnerCode ${location}.` ); } } } /** * Asserts whether the engine is using the correct domains or not. * * @param {string} location * Debug string with locale + region information. * @param {AppProvidedConfigEngine} engine * The engine being tested. * @param {EngineRuleDetails & DeploymentDetails} rules * Rules to test. * @param {SearchConfigTestDetails} testDetails * The test details to use. */ _assertCorrectDomains(location, engine, rules, testDetails) { this.assertOk( rules.domain, `${testDetails.identifier} should have an expectedDomain for the engine ${location}` ); let submission = engine.getSubmission("test", URLTYPE_SEARCH_HTML); this.assertOk( submission.uri.host.endsWith(rules.domain), `Should have the correct domain for type: ${URLTYPE_SEARCH_HTML} ${location}. Got "${submission.uri.host}", expected to end with "${rules.domain}".` ); submission = engine.getSubmission("test", URLTYPE_SUGGEST_JSON); if (testDetails.noSuggestionsURL || rules.noSuggestionsURL) { this.assertOk(!submission, "Should not have a submission url"); } else if (testDetails.suggestionUrlBase) { this.assertEqual( submission.uri.prePath + submission.uri.filePath, testDetails.suggestionUrlBase, `Should have the correct domain for type: ${URLTYPE_SUGGEST_JSON} ${location}.` ); this.assertOk( submission.uri.query.includes(rules.suggestUrlCode), `Should have the code in the uri` ); } } /** * Asserts whether the engine is using the correct URL codes or not. * * @param {string} location * Debug string with locale + region information. * @param {AppProvidedConfigEngine} engine * The engine being tested. * @param {EngineRuleDetails & DeploymentDetails} rule * Rules to test. */ _assertCorrectUrlCode(location, engine, rule) { if (rule.searchUrlCode) { const submission = engine.getSubmission("test", URLTYPE_SEARCH_HTML); this.assertOk( submission.uri.query.split("&").includes(rule.searchUrlCode), `Expected "${rule.searchUrlCode}" in search url "${submission.uri.spec}"` ); } if (rule.searchUrlParamNotInQuery) { const submission = engine.getSubmission("test", URLTYPE_SEARCH_HTML); this.assertOk( !submission.uri.query.includes(rule.searchUrlParamNotInQuery), `Expected "${rule.searchUrlParamNotInQuery}" should not be in search url "${submission.uri.spec}"` ); } if (rule.suggestUrlCode) { const submission = engine.getSubmission("test", URLTYPE_SUGGEST_JSON); this.assertOk( submission.uri.query.split("&").includes(rule.suggestUrlCode), `Expected "${rule.suggestUrlCode}" in suggestion url "${submission.uri.spec}"` ); } } /** * Helper functions which avoid outputting test results when there are no * failures. These help the tests to run faster, and avoid clogging up the * python test runner process. */ assertOk(value, message) { if (!value || TEST_DEBUG) { Assert.ok(value, message); } } assertEqual(actual, expected, message) { if (actual != expected || TEST_DEBUG) { Assert.equal(actual, expected, message); } } assertDeepEqual(actual, expected, message) { if (!ObjectUtils.deepEqual(actual, expected)) { Assert.deepEqual(actual, expected, message); } } } async function checkUISchemaValid(configSchema, uiSchema) { for (let key of Object.keys(configSchema.properties)) { Assert.ok( uiSchema["ui:order"].includes(key), `Should have ${key} listed at the top-level of the ui schema` ); } }