Source code
Revision control
Copy as Markdown
Other Tools
/* Any copyright is dedicated to the Public Domain.
"use strict";
let sandbox;
Services.scriptloader.loadSubScript(
this
);
ChromeUtils.defineESModuleGetters(this, {
CONTEXTUAL_SERVICES_PING_TYPES:
"resource:///modules/PartnerLinkAttribution.sys.mjs",
QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
Region: "resource://gre/modules/Region.sys.mjs",
UrlbarProviderQuickSuggest:
"moz-src:///browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs",
});
ChromeUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => {
const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule(
);
module.init(this);
return module;
});
ChromeUtils.defineLazyGetter(this, "MerinoTestUtils", () => {
const { MerinoTestUtils: module } = ChromeUtils.importESModule(
);
module.init(this);
return module;
});
ChromeUtils.defineLazyGetter(this, "GeolocationTestUtils", () => {
const { GeolocationTestUtils: module } = ChromeUtils.importESModule(
);
module.init(this);
return module;
});
ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => {
return Cc["@mozilla.org/places/frecency-recalculator;1"].getService(
Ci.nsIObserver
).wrappedJSObject;
});
registerCleanupFunction(async () => {
// Ensure the popup is always closed at the end of each test to avoid
// interfering with the next test.
await UrlbarTestUtils.promisePopupClose(window);
});
/**
* Updates the Top Sites feed.
*
* @param {Function} condition
* A callback that returns true after Top Sites are successfully updated.
* @param {boolean} searchShortcuts
* True if Top Sites search shortcuts should be enabled.
*/
async function updateTopSites(condition, searchShortcuts = false) {
// Toggle the pref to clear the feed cache and force an update.
await SpecialPowers.pushPrefEnv({
set: [
[
"browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear",
"",
],
["browser.newtabpage.activity-stream.feeds.system.topsites", false],
["browser.newtabpage.activity-stream.feeds.system.topsites", true],
[
"browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
searchShortcuts,
],
],
});
// Wait for the feed to be updated.
await TestUtils.waitForCondition(() => {
let sites = AboutNewTab.getTopSites();
return condition(sites);
}, "Waiting for top sites to be updated");
let feed = AboutNewTab.activityStream?.store?.feeds.get(
"feeds.system.topsites"
);
await feed?._latestRefreshPromise;
}
/**
* Call this in your setup task if you use `doQuickSuggestPingTest()`.
*
* @param {object} options
* Options
* @param {Array} options.remoteSettingsRecords
* See `QuickSuggestTestUtils.ensureQuickSuggestInit()`.
* @param {Array} options.merinoSuggestions
* See `QuickSuggestTestUtils.ensureQuickSuggestInit()`.
* @param {Array} options.config
* See `QuickSuggestTestUtils.ensureQuickSuggestInit()`.
*/
async function initQuickSuggestPingTest({
remoteSettingsRecords,
merinoSuggestions = null,
config = QuickSuggestTestUtils.DEFAULT_CONFIG,
}) {
await SpecialPowers.pushPrefEnv({
set: [
// Switch-to-tab results can sometimes appear after the test clicks a help
// button and closes the new tab, which interferes with the expected
// indexes of quick suggest results, so disable them.
["browser.urlbar.suggest.openpage", false],
["browser.urlbar.quicksuggest.ampTopPickCharThreshold", 0],
],
});
await PlacesUtils.history.clear();
await PlacesUtils.bookmarks.eraseEverything();
await UrlbarTestUtils.formHistory.clear();
// Add a mock engine so we don't hit the network.
await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
await QuickSuggestTestUtils.ensureQuickSuggestInit({
remoteSettingsRecords,
merinoSuggestions,
config,
});
}
/**
* Main entry point for testing the `quick-suggest` ping. It tests impressions,
* clicks, and commands.
*
* @param {object} options
* Options
* @param {number} options.index
* The expected index of the suggestion in the results list.
* @param {object} options.suggestion
* The suggestion being tested.
* @param {object} options.impressionOnly
* The expected impression ping when only an impression is triggered for the
* suggestion (no engagement or dismissal). It should be an object that maps
* the camelCased ping keys to their expected values.
* @param {Array} options.click
* An array of expected pings (in the order they're expected) when the
* suggestion is clicked.
* @param {Array} options.commands
* Each element in this array is an object that describes the expected pings
* for a result menu command. Each object must have the following properties:
* {string|Array} command
* A command name or array; this is passed directly to
* `UrlbarTestUtils.openResultMenuAndClickItem()` as the `commandOrArray`
* arg, so see its documentation for details.
* {Array} pings
* An array of expected pings (in the order they're expected) when the
* command is triggered.
* @param {string} options.providerName
* The name of the provider that is expected to create the UrlbarResult for
* the suggestion.
* @param {Function} options.teardown
* If given, this function will be called after each selectable test. If
* picking an element causes side effects that need to be cleaned up before
* starting the next selectable test, they can be cleaned up here.
* @param {Function} options.showSuggestion
* This function should open the view and show the suggestion.
*/
async function doQuickSuggestPingTest({
index,
suggestion,
impressionOnly,
click,
commands,
providerName = UrlbarProviderQuickSuggest.name,
teardown = null,
showSuggestion = () =>
UrlbarTestUtils.promiseAutocompleteResultPopup({
window,
// If the suggestion is a mock remote settings suggestion, it will have a
// `keywords` property. Otherwise the suggestion object must be a Merino
// suggestion, and the search string doesn't matter in that case because
// the mock Merino server will be set up to return suggestions regardless.
value: suggestion.keywords?.[0] || "test",
fireInputEvent: true,
}),
}) {
Assert.ok(Region.home, "Sanity check: Region should be non-null/empty");
let allExpectedPings = [
impressionOnly,
...click,
...commands.map(({ pings }) => pings),
].flat();
for (let ping of allExpectedPings) {
ping.country = Region.home;
}
await doImpressionOnlyTest({
index,
suggestion,
providerName,
showSuggestion,
expectedPing: impressionOnly,
});
await doClickTest({
suggestion,
providerName,
showSuggestion,
index,
expectedPings: click,
});
for (let command of commands) {
await doCommandTest({
suggestion,
providerName,
showSuggestion,
index,
commandOrArray: command.command,
expectedPings: command.pings,
});
if (teardown) {
info("Calling teardown");
await teardown();
info("Finished teardown");
}
}
}
/**
* Helper for `doQuickSuggestPingTest()` that does an impression-only test.
*
* @param {object} options
* Options
* @param {number} options.index
* The expected index of the suggestion in the results list.
* @param {object} options.suggestion
* The suggestion being tested.
* @param {string} options.providerName
* The name of the provider that is expected to create the UrlbarResult for
* the suggestion.
* @param {object} options.expectedPing
* The expected impression ping.
* @param {Function} options.showSuggestion
* This function should open the view and show the suggestion.
*/
async function doImpressionOnlyTest({
index,
suggestion,
providerName,
expectedPing,
showSuggestion,
}) {
info("Starting impression-only test");
let expectedPings = [expectedPing];
let gleanPingCount = watchQuickSuggestPings(expectedPings);
info("Showing suggestion");
await showSuggestion();
// Get the suggestion row.
let row = await validateSuggestionRow(index, suggestion, providerName);
if (!row) {
Assert.ok(
false,
"Couldn't get suggestion row, stopping impression-only test"
);
return;
}
// We need to get a different selectable row so we can pick it to trigger
// impression-only telemetry. For simplicity we'll look for a row that will
// load a URL when picked. We'll also verify no other rows are from the
// expected provider.
let otherRow;
let rowCount = UrlbarTestUtils.getResultCount(window);
for (let i = 0; i < rowCount; i++) {
if (i != index) {
let r = await UrlbarTestUtils.waitForAutocompleteResultAt(window, i);
Assert.notEqual(
r.result.providerName,
providerName,
"No other row should be from expected provider: index = " + i
);
if (
!otherRow &&
(r.result.payload.url ||
(r.result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
(r.result.payload.query || r.result.payload.suggestion))) &&
r.hasAttribute("row-selectable")
) {
otherRow = r;
}
}
}
if (!otherRow) {
Assert.ok(
false,
"Couldn't get a different selectable row with a URL, stopping impression-only test"
);
return;
}
// Pick the different row. Assumptions:
// * The middle of the row is selectable
// * Picking the row will load a page
info("Clicking different row and waiting for view to close");
let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
await UrlbarTestUtils.promisePopupClose(window, () =>
EventUtils.synthesizeMouseAtCenter(otherRow, {})
);
info("Waiting for page to load after clicking different row");
await loadPromise;
// The pings are sent asynchronously, so we wait until we've seen them all
// be sent.
await TestUtils.waitForCondition(() => {
return expectedPings.length == gleanPingCount.value;
}, "Submitted one Glean ping per expected ping");
// Clean up.
await PlacesUtils.history.clear();
await UrlbarTestUtils.formHistory.clear();
info("Finished impression-only test");
}
/**
* Helper for `doQuickSuggestPingTest()` that clicks a suggestion's row.
*
* @param {object} options
* Options
* @param {number} options.index
* The expected index of the suggestion in the results list.
* @param {object} options.suggestion
* The suggestion being tested.
* @param {string} options.providerName
* The name of the provider that is expected to create the UrlbarResult for
* the suggestion.
* @param {object} options.expectedPings
* An array of expected pings (in the order they're expected).
* @param {Function} options.showSuggestion
* This function should open the view and show the suggestion.
*/
async function doClickTest({
index,
suggestion,
providerName,
expectedPings,
showSuggestion,
}) {
info("Starting click test");
let gleanPingCount = watchQuickSuggestPings(expectedPings);
info("Showing suggestion");
await showSuggestion();
let row = await validateSuggestionRow(index, suggestion, providerName);
if (!row) {
Assert.ok(false, "Couldn't get suggestion row, stopping click test");
return;
}
// We assume clicking the row will load a page in the current browser.
let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
info("Clicking row");
EventUtils.synthesizeMouseAtCenter(row, {});
info("Waiting for load");
await loadPromise;
await TestUtils.waitForTick();
// The pings are sent asynchronously, so we wait until we've seen them all
// be sent.
await TestUtils.waitForCondition(() => {
return expectedPings.length == gleanPingCount.value;
}, "Submitted one Glean ping per expected ping");
await PlacesUtils.history.clear();
info("Finished click test");
}
/**
* Helper for `doQuickSuggestPingTest()` that clicks a result menu command.
*
* @param {object} options
* Options
* @param {number} options.index
* The expected index of the suggestion in the results list.
* @param {object} options.suggestion
* The suggestion being tested.
* @param {string} options.providerName
* The name of the provider that is expected to create the UrlbarResult for
* the suggestion.
* @param {string|Array} options.commandOrArray
* A command name or array; this is passed directly to
* `UrlbarTestUtils.openResultMenuAndClickItem()` as the `commandOrArray` arg,
* so see its documentation for details.
* @param {object} options.expectedPings
* An array of expected pings (in the order they're expected).
* @param {Function} options.showSuggestion
* This function should open the view and show the suggestion.
*/
async function doCommandTest({
index,
suggestion,
providerName,
commandOrArray,
expectedPings,
showSuggestion,
}) {
info("Starting command test: " + JSON.stringify({ commandOrArray }));
let gleanPingCount = watchQuickSuggestPings(expectedPings);
info("Showing suggestion");
await showSuggestion();
let row = await validateSuggestionRow(index, suggestion, providerName);
if (!row) {
Assert.ok(false, "Couldn't get suggestion row, stopping click test");
return;
}
let command =
typeof commandOrArray == "string"
? commandOrArray
: commandOrArray[commandOrArray.length - 1];
let loadPromise;
if (command == "help" || command == "manage") {
// We assume clicking this command will load a page in a new tab.
loadPromise = BrowserTestUtils.waitForNewTab(gBrowser);
}
info("Clicking command");
await UrlbarTestUtils.openResultMenuAndClickItem(window, commandOrArray, {
resultIndex: index,
openByMouse: true,
});
if (loadPromise) {
info("Waiting for load");
await loadPromise;
await TestUtils.waitForTick();
if (command == "help" || command == "manage") {
info("Closing help or manage tab");
BrowserTestUtils.removeTab(gBrowser.selectedTab);
}
}
// The pings are sent asynchronously, so we wait until we've seen them all
// be sent.
await TestUtils.waitForCondition(() => {
return expectedPings.length == gleanPingCount.value;
}, "Submitted one Glean ping per expected ping");
if (command == "dismiss") {
await QuickSuggest.clearDismissedSuggestions();
}
await PlacesUtils.history.clear();
info("Finished command test: " + JSON.stringify({ commandOrArray }));
}
/**
* Do test the "Manage" result menu item.
*
* @param {object} options
* Options
* @param {number} options.index
* The index of the suggestion that will be checked in the results list.
* @param {number} options.input
* The input value on the urlbar.
*/
async function doManageTest({ index, input }) {
await BrowserTestUtils.withNewTab({ gBrowser }, async browser => {
await UrlbarTestUtils.promiseAutocompleteResultPopup({
window,
value: input,
});
const managePage = "about:preferences#search";
let onManagePageLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
managePage
);
// Click the command.
await UrlbarTestUtils.openResultMenuAndClickItem(window, "manage", {
resultIndex: index,
});
await onManagePageLoaded;
Assert.equal(
browser.currentURI.spec,
managePage,
"The manage page is loaded"
);
await UrlbarTestUtils.promisePopupClose(window);
});
}
/**
* Gets a row in the view, which is assumed to be open, and asserts that it's a
* particular quick suggest row. If it is, the row is returned. If it's not,
* null is returned.
*
* @param {number} index
* The expected index of the quick suggest row.
* @param {object} suggestion
* The expected suggestion.
* @param {string} providerName
* The name of the provider that is expected to create the UrlbarResult for
* the suggestion.
* @returns {Element}
* If the row is the expected suggestion, the row element is returned.
* Otherwise null is returned.
*/
async function validateSuggestionRow(index, suggestion, providerName) {
let rowCount = UrlbarTestUtils.getResultCount(window);
Assert.less(
index,
rowCount,
"Expected suggestion row index should be < row count"
);
if (rowCount <= index) {
return null;
}
let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, index);
Assert.equal(
row.result.providerName,
providerName,
"Expected suggestion row should be from expected provider"
);
Assert.equal(
row.result.payload.url,
suggestion.url,
"The suggestion row should represent the expected suggestion"
);
if (
row.result.providerName != providerName ||
row.result.payload.url != suggestion.url
) {
return null;
}
return row;
}
function watchQuickSuggestPings(pings) {
let countObject = { value: 0 };
let checkPing = (ping, next) => {
countObject.value++;
assertQuickSuggestPing(ping);
if (next) {
GleanPings.quickSuggest.testBeforeNextSubmit(next);
}
};
// Build the chain of `testBeforeNextSubmit`s backwards.
let next = undefined;
pings
.slice()
.reverse()
.forEach(ping => {
next = checkPing.bind(null, ping, next);
});
if (next) {
GleanPings.quickSuggest.testBeforeNextSubmit(next);
}
return countObject;
}
function assertQuickSuggestPing(expectedPing) {
let expectedKeys = [
"pingType",
"country",
"matchType",
"advertiser",
"blockId",
"improveSuggestExperience",
"position",
"suggestedIndex",
"suggestedIndexRelativeToGroup",
"requestId",
"source",
"contextId",
];
Assert.ok(
expectedPing.pingType,
"Sanity check: The expected ping should have a 'pingType'"
);
switch (expectedPing.pingType) {
case CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION:
expectedKeys.push("isClicked", "reportingUrl");
break;
case CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION:
expectedKeys.push("reportingUrl");
break;
case CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK:
expectedKeys.push("iabCategory");
break;
}
let expectedValueOverrides = {
contextId: expectedPingContextId(),
};
for (let key of expectedKeys) {
Assert.ok(
expectedPing.hasOwnProperty(key),
"Sanity check: The expected ping should have key: " + key
);
Assert.ok(
key in Glean.quickSuggest,
"The actual ping should have key: " + key
);
let expectedValue = expectedValueOverrides.hasOwnProperty(key)
? expectedValueOverrides[key]
: expectedPing[key];
if (expectedValue === undefined || expectedValue === "") {
// The value is specifically not set in this case, which ends up recording
// a null value in the actual ping.
Assert.strictEqual(
Glean.quickSuggest[key].testGetValue(),
null,
"The actual ping should have a null value for key: " + key
);
} else {
Assert.strictEqual(
Glean.quickSuggest[key].testGetValue(),
expectedValue,
"The actual ping should have the correct value for key: " + key
);
}
}
}
function expectedPingContextId() {
// `contextId` in the `quick-suggest` pings should always be the value in this
// pref, a UUID, but with any braces stripped.
return Services.prefs
.getCharPref("browser.contextual-services.contextId")
.replace(/\{|\}/g, "");
}