Source code
Revision control
Copy as Markdown
Other Tools
/* Any copyright is dedicated to the Public Domain.
"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",
SearchEngine: "resource://gre/modules/SearchEngine.sys.mjs",
SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
SearchUtils: "resource://gre/modules/SearchUtils.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";
let engineSelector;
/**
* 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 SearchUtils.ENGINES_URLS)) {
throw new Error(`Invalid value for SEARCH_CONFIG`);
}
const url = SearchUtils.ENGINES_URLS[SEARCH_CONFIG];
const response = await fetch(url);
const config = await response.json();
SearchTestUtils.setRemoteSettingsConfig(config.data);
}
}
/**
* 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.
*
* The configuration for each test is represented by an object having the
* following properties:
*
* - identifier (string)
* The identifier for the search engine under test.
* - default (object)
* An inclusion/exclusion configuration (see below) to detail when this engine
* should be listed as default.
*
* The inclusion/exclusion configuration is represented as an object having the
* following properties:
*
* - included (array)
* An optional array of region/locale pairs.
* - excluded (array)
* An optional array of region/locale pairs.
*
* If the object is empty, the engine is assumed not to be part of any locale/region
* pair.
* If the object has `excluded` but not `included`, then the engine is assumed to
* be part of every locale/region pair except for where it matches the exclusions.
*
* The region/locale pairs are represented as an object having the following
* properties:
*
* - region (array)
* An array of two-letter region codes.
* - locale (object)
* A locale object which may consist of:
* - matches (array)
* An array of locale strings which should exactly match the locale.
* - startsWith (array)
* An array of locale strings which the locale should start with.
*/
class SearchConfigTest {
/**
* @param {object} config
* The initial configuration for this test, see above.
*/
constructor(config = {}) {
this._config = config;
}
/**
* 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();
// Disable region checks.
Services.prefs.setBoolPref("browser.search.geoSpecificDefaults", false);
// Enable separatePrivateDefault testing. We test with this on, as we have
// separate tests for ensuring the normal = private when this is off.
Services.prefs.setBoolPref(
SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
true
);
Services.prefs.setBoolPref(
SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
true
);
await Services.search.init();
// We must use the engine selector that the search service has created (if
// it has), as remote settings can only easily deal with us loading the
// configuration once - after that, it tries to access the network.
engineSelector =
Services.search.wrappedJSObject._engineSelector ||
new SearchEngineSelector();
// Note: we don't use the helper function here, so that we have at least
// one message output per process.
Assert.ok(
Services.search.isInitialized,
"Should have correctly initialized the search service"
);
}
/**
* 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 = await this._getEngines(region, locale);
this._assertEngineRules([engines[0]], region, locale, "default");
const isPresent = this._assertAvailableEngines(region, locale, engines);
if (isPresent) {
this._assertEngineDetails(region, locale, engines);
}
}
}
}
async _getEngines(region, locale) {
let configs = await engineSelector.fetchEngineConfiguration({
locale,
region: region || "default",
channel: SearchUtils.MODIFIED_APP_CHANNEL,
});
return SearchTestUtils.searchConfigToEngines(configs.engines);
}
/**
* @returns {Set} the list of regions for the tests to run with.
*/
get _regions() {
// TODO: The legacy configuration worked with null as an unknown region,
// for the search engine selector, we expect "default" but apply the
// fallback in _getEngines. Once we remove the legacy configuration, we can
// simplify this.
if (TEST_DEBUG) {
return new Set(["by", "cn", "kz", "us", "ru", "tr", null]);
}
return [...Services.intl.getAvailableLocaleDisplayNames("region"), null];
}
/**
* @returns {Array} 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 match a section of the configuration.
*
* @param {object} section
* The configuration section 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 {Array} engines
* The list of engines to check.
* @param {string} identifier
* The identifier to look for in the list.
* @param {boolean} exactMatch
* Whether to use an exactMatch for the identifier.
* @returns {Engine}
* Returns the engine if found, null otherwise.
*/
_findEngine(engines, identifier, exactMatch) {
return engines.find(engine =>
exactMatch
? engine.identifier == identifier
: engine.identifier.startsWith(identifier)
);
}
/**
* Asserts whether the engines rules defined in the configuration are met.
*
* @param {Array} engines
* The list of engines to check.
* @param {string} region
* The two-letter region code.
* @param {string} locale
* The two-letter locale code.
* @param {string} section
* The section of the configuration to check.
* @returns {boolean}
* Returns true if the engine is expected to be present, false otherwise.
*/
_assertEngineRules(engines, region, locale, section) {
const infoString = `region: "${region}" locale: "${locale}"`;
const config = this._config[section];
const hasIncluded = "included" in config;
const hasExcluded = "excluded" in config;
const identifierIncluded = !!this._findEngine(
engines,
this._config.identifier,
this._config.identifierExactMatch ?? false
);
// If there's not included/excluded, then this shouldn't be the default anywhere.
if (section == "default" && !hasIncluded && !hasExcluded) {
this.assertOk(
!identifierIncluded,
`Should not be ${section} for any locale/region,
currently set for ${infoString}`
);
return false;
}
// If there's no included section, we assume the engine is default everywhere
// and we should apply the exclusions instead.
let included =
hasIncluded &&
this._localeRegionInSection(config.included, region, locale);
let excluded =
hasExcluded &&
this._localeRegionInSection(config.excluded, region, locale);
if (
(included && (!hasExcluded || !excluded)) ||
(!hasIncluded && hasExcluded && !excluded)
) {
this.assertOk(
identifierIncluded,
`Should be ${section} for ${infoString}`
);
return true;
}
this.assertOk(
!identifierIncluded,
`Should not be ${section} for ${infoString}`
);
return false;
}
/**
* Asserts whether the engine is correctly set as default or not.
*
* @param {string} region
* The two-letter region code.
* @param {string} locale
* The two-letter locale code.
*/
_assertDefaultEngines(region, locale) {
this._assertEngineRules(
[Services.search.appDefaultEngine],
region,
locale,
"default"
);
// At the moment, this uses the same section as the normal default, as
// we don't set this differently for any region/locale.
this._assertEngineRules(
[Services.search.appPrivateDefaultEngine],
region,
locale,
"default"
);
}
/**
* 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 {Array} engines
* The current visible engines.
* @returns {boolean}
* Returns true if the engine is expected to be present, false otherwise.
*/
_assertAvailableEngines(region, locale, engines) {
return this._assertEngineRules(engines, region, locale, "available");
}
/**
* Asserts the engine follows various rules.
*
* @param {string} region
* The two-letter region code.
* @param {string} locale
* The two-letter locale code.
* @param {Array} engines
* The current visible engines.
*/
_assertEngineDetails(region, locale, engines) {
const details = this._config.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,
`Should have just one details section for region: ${region} locale: ${locale}`
);
const engine = this._findEngine(
engines,
this._config.identifier,
this._config.identifierExactMatch ?? false
);
this.assertOk(engine, "Should have an engine present");
if (this._config.aliases) {
this.assertDeepEqual(
engine.aliases,
this._config.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);
if (rule.searchUrlCode || rule.suggestUrlCode) {
this._assertCorrectUrlCode(location, engine, rule);
}
if (rule.aliases) {
this.assertDeepEqual(
engine.aliases,
rule.aliases,
"Should have the correct aliases for the engine"
);
}
if (rule.required_aliases) {
this.assertOk(
rule.required_aliases.every(a => engine.aliases.includes(a)),
"Should have the required aliases for the engine"
);
}
if (rule.telemetryId) {
this.assertEqual(
engine.telemetryId,
rule.telemetryId,
`Should have the correct telemetryId ${location}.`
);
}
}
}
/**
* Asserts whether the engine is using the correct domains or not.
*
* @param {string} location
* Debug string with locale + region information.
* @param {object} engine
* The engine being tested.
* @param {object} rules
* Rules to test.
*/
_assertCorrectDomains(location, engine, rules) {
this.assertOk(
rules.domain,
`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 (this._config.noSuggestionsURL || rules.noSuggestionsURL) {
this.assertOk(!submission, "Should not have a submission url");
} else if (this._config.suggestionUrlBase) {
this.assertEqual(
submission.uri.prePath + submission.uri.filePath,
this._config.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 {object} engine
* The engine being tested.
* @param {object} 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.searchUrlCodeNotInQuery) {
const submission = engine.getSubmission("test", URLTYPE_SEARCH_HTML);
this.assertOk(
submission.uri.includes(rule.searchUrlCodeNotInQuery),
`Expected "${rule.searchUrlCodeNotInQuery}" 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`
);
}
}