Source code

Revision control

Copy as Markdown

Other Tools

/* Any copyright is dedicated to the Public Domain.
ChromeUtils.defineESModuleGetters(this, {
ADLINK_CHECK_TIMEOUT_MS:
CustomizableUITestUtils:
SearchSERPDomainToCategoriesMap:
SERPCategorizationRecorder: "resource:///modules/SearchSERPTelemetry.sys.mjs",
SPA_ADLINK_CHECK_TIMEOUT_MS:
TELEMETRY_CATEGORIZATION_KEY:
});
ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => {
const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
);
module.init(this);
return module;
});
ChromeUtils.defineLazyGetter(this, "searchCounts", () => {
return Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
});
ChromeUtils.defineLazyGetter(this, "SEARCH_AD_CLICK_SCALARS", () => {
const sources = [
...BrowserSearchTelemetry.KNOWN_SEARCH_SOURCES.values(),
"unknown",
];
return [
...sources.map(v => `browser.search.withads.${v}`),
...sources.map(v => `browser.search.adclicks.${v}`),
];
});
ChromeUtils.defineLazyGetter(this, "gCryptoHash", () => {
return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
});
// For use with categorization.
const APP_MAJOR_VERSION = parseInt(Services.appinfo.version).toString();
const CHANNEL = SearchUtils.MODIFIED_APP_CHANNEL;
const REGION = Region.home;
let gCUITestUtils = new CustomizableUITestUtils(window);
SearchTestUtils.init(this);
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// sharedData messages are only passed to the child on idle. Therefore
// we wait for a few idles to try and ensure the messages have been able
// to be passed across and handled.
async function waitForIdle() {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
}
}
function getPageUrl(useAdPage = false) {
let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html";
}
function getSERPUrl(page, organic = false) {
let url =
getRootDirectory(gTestPath).replace(
) + page;
return `${url}?s=test${organic ? "" : "&abc=ff"}`;
}
async function typeInSearchField(browser, text, fieldName) {
await SpecialPowers.spawn(
browser,
[[fieldName, text]],
async function ([contentFieldName, contentText]) {
// Put the focus on the search box.
let searchInput = content.document.getElementById(contentFieldName);
searchInput.focus();
searchInput.value = contentText;
}
);
}
async function searchInSearchbar(inputText, win = window) {
await new Promise(r => waitForFocus(r, win));
let sb = win.BrowserSearch.searchBar;
// Write the search query in the searchbar.
sb.focus();
sb.value = inputText;
sb.textbox.controller.startSearch(inputText);
// Wait for the popup to show.
await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown");
// And then for the search to complete.
await TestUtils.waitForCondition(
() =>
sb.textbox.controller.searchStatus >=
Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH,
"The search in the searchbar must complete."
);
return sb.textbox.popup;
}
// Ad links are processed after a small delay. We need to allow tests to wait
// for that before checking telemetry, otherwise the received values may be
// too small in some cases.
function promiseWaitForAdLinkCheck() {
return new Promise(resolve =>
/* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */
setTimeout(resolve, ADLINK_CHECK_TIMEOUT_MS)
);
}
async function assertSearchSourcesTelemetry(
expectedHistograms,
expectedScalars
) {
let histSnapshot = {};
let scalars = {};
// This used to rely on the implied 100ms initial timer of
// TestUtils.waitForCondition. See bug 1515466.
await new Promise(resolve => setTimeout(resolve, 100));
await TestUtils.waitForCondition(() => {
histSnapshot = searchCounts.snapshot();
return (
Object.getOwnPropertyNames(histSnapshot).length ==
Object.getOwnPropertyNames(expectedHistograms).length
);
}, "should have the correct number of histograms");
if (Object.entries(expectedScalars).length) {
await TestUtils.waitForCondition(() => {
scalars =
Services.telemetry.getSnapshotForKeyedScalars("main", false).parent ||
{};
return Object.getOwnPropertyNames(expectedScalars).every(
scalar => scalar in scalars
);
}, "should have the expected keyed scalars");
}
Assert.equal(
Object.getOwnPropertyNames(histSnapshot).length,
Object.getOwnPropertyNames(expectedHistograms).length,
"Should only have one key"
);
for (let [key, value] of Object.entries(expectedHistograms)) {
Assert.ok(
key in histSnapshot,
`Histogram should have the expected key: ${key}`
);
Assert.equal(
histSnapshot[key].sum,
value,
`Should have counted the correct number of visits for ${key}`
);
}
for (let [name, value] of Object.entries(expectedScalars)) {
Assert.ok(name in scalars, `Scalar ${name} should have been added.`);
Assert.deepEqual(
scalars[name],
value,
`Should have counted the correct number of visits for ${name}`
);
}
for (let name of SEARCH_AD_CLICK_SCALARS) {
Assert.equal(
name in scalars,
name in expectedScalars,
`Should have matched ${name} in scalars and expectedScalars`
);
}
}
function resetTelemetry() {
searchCounts.clear();
Services.telemetry.clearScalars();
Services.fog.testResetFOG();
SERPCategorizationRecorder.testReset();
}
/**
* First checks that we get the correct number of recorded Glean impression events
* and the recorded Glean impression events have the correct keys and values.
*
* Then it checks that there are the the correct engagement events associated with the
* impression events.
*
* @param {Array} expectedEvents The expected impression events whose keys and
* values we use to validate the recorded Glean impression events.
*/
function assertSERPTelemetry(expectedEvents) {
// Do a deep copy of impressions in case the input is using constants, as
// we insert impression id into the expected events to make it easier to
// run Assert.deepEqual() on the expected and actual result.
expectedEvents = JSON.parse(JSON.stringify(expectedEvents));
// A single test might run assertImpressionEvents more than once
// so the Set needs to be cleared or else the impression event
// check will throw.
const impressionIdsSet = new Set();
let recordedImpressions = Glean.serp.impression.testGetValue() ?? [];
Assert.equal(
recordedImpressions.length,
expectedEvents.length,
"Number of impressions matches expected events."
);
// Assert the impression events.
for (let [idx, expectedEvent] of expectedEvents.entries()) {
let impressionId = recordedImpressions[idx].extra.impression_id;
Assert.ok(
UUID_REGEX.test(impressionId),
"Impression has an impression_id with a valid UUID."
);
Assert.ok(
!impressionIdsSet.has(impressionId),
"Impression has a unique impression_id."
);
impressionIdsSet.add(impressionId);
// If we want to use deepEqual checks, we have to add the impressionId
// to each impression since they are randomly generated at runtime.
expectedEvent.impression.impression_id = impressionId;
Assert.deepEqual(
recordedImpressions[idx].extra,
expectedEvent.impression,
"Matching SERP impression values."
);
// Once the impression check is sufficient, add the impression_id to
// each of the expected engagements, ad impressions, and abandonments for
// deep equal checks.
if (expectedEvent.engagements) {
for (let expectedEngagment of expectedEvent.engagements) {
expectedEngagment.impression_id = impressionId;
}
}
if (expectedEvent.adImpressions) {
for (let adImpression of expectedEvent.adImpressions) {
adImpression.impression_id = impressionId;
}
}
if (expectedEvent.abandonment) {
expectedEvent.abandonment.impression_id = impressionId;
}
}
// Group engagement events into separate array fetchable by their
// impression_id.
let recordedEngagements = Glean.serp.engagement.testGetValue() ?? [];
let idToEngagements = new Map();
let totalExpectedEngagements = 0;
for (let recordedEngagement of recordedEngagements) {
let impressionId = recordedEngagement.extra.impression_id;
Assert.ok(impressionId, "Engagement event has impression_id.");
let arr = idToEngagements.get(impressionId) ?? [];
arr.push(recordedEngagement.extra);
idToEngagements.set(impressionId, arr);
}
// Assert the engagement events.
for (let expectedEvent of expectedEvents) {
let impressionId = expectedEvent.impression.impression_id;
let expectedEngagements = expectedEvent.engagements;
if (expectedEngagements) {
let recorded = idToEngagements.get(impressionId);
Assert.deepEqual(
recorded,
expectedEngagements,
"Matching engagement value."
);
totalExpectedEngagements += expectedEngagements.length;
}
}
Assert.equal(
recordedEngagements.length,
totalExpectedEngagements,
"Number of engagements"
);
let recordedAdImpressions = Glean.serp.adImpression.testGetValue() ?? [];
let idToAdImpressions = new Map();
let totalExpectedAdImpressions = 0;
// The list of ad impressions are contained in a flat list. Separate them
// into arrays organized by impressionId to make it easier to determine if
// the page load that matches the expected ads on the page.
for (let recordedAdImpression of recordedAdImpressions) {
let impressionId = recordedAdImpression.extra.impression_id;
Assert.ok(impressionId, "Ad impression has impression_id");
let arr = idToAdImpressions.get(impressionId) ?? [];
arr.push(recordedAdImpression.extra);
idToAdImpressions.set(impressionId, arr);
}
for (let expectedEvent of expectedEvents) {
let impressionId = expectedEvent.impression.impression_id;
let expectedAdImpressions = expectedEvent.adImpressions ?? [];
if (expectedAdImpressions.length) {
let recorded = idToAdImpressions.get(impressionId) ?? {};
Assert.deepEqual(
recorded,
expectedAdImpressions,
"Matching ad impression value."
);
}
totalExpectedAdImpressions += expectedAdImpressions.length;
}
Assert.equal(
recordedAdImpressions.length,
totalExpectedAdImpressions,
"Recorded and expected ad impression counts match."
);
// Assert abandonment events.
let recordedAbandonments = Glean.serp.abandonment.testGetValue() ?? [];
let idTorecordedAbandonments = new Map();
let totalExpectedrecordedAbandonments = 0;
for (let recordedAbandonment of recordedAbandonments) {
let impressionId = recordedAbandonment.extra.impression_id;
Assert.ok(impressionId, "Abandonment event has an impression_id.");
idTorecordedAbandonments.set(impressionId, recordedAbandonment.extra);
}
for (let expectedEvent of expectedEvents) {
let impressionId = expectedEvent.impression.impression_id;
let expectedAbandonment = expectedEvent.abandonment;
if (expectedAbandonment) {
let recorded = idTorecordedAbandonments.get(impressionId);
Assert.deepEqual(
recorded,
expectedAbandonment,
"Matching abandonment value."
);
}
totalExpectedrecordedAbandonments += expectedAbandonment ? 1 : 0;
}
Assert.equal(
recordedAbandonments.length,
totalExpectedrecordedAbandonments,
"Recorded and expected abandonment counts match."
);
}
async function openSerpInNewTab(url, expectedAds = true) {
let promise;
if (expectedAds) {
promise = waitForPageWithAdImpressions();
}
let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
await promise;
let cleanup = async () => {
await BrowserTestUtils.removeTab(tab);
resetTelemetry();
};
return { tab, cleanup };
}
async function synthesizePageAction({
selector,
event = {},
tab,
expectEngagement = true,
} = {}) {
let promise;
if (expectEngagement) {
promise = waitForPageWithAction();
} else {
// Wait roughly around how much it might take for a possible page action
// to be registered in telemetry.
/* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */
promise = new Promise(resolve => setTimeout(resolve, 50));
}
await BrowserTestUtils.synthesizeMouseAtCenter(
selector,
event,
tab.linkedBrowser
);
await promise;
}
function assertCategorizationValues(expectedResults) {
let actualResults = Glean.serp.categorization.testGetValue() ?? [];
Assert.equal(
actualResults.length,
expectedResults.length,
"Should have the correct number of categorization impressions."
);
if (!expectedResults.length) {
return;
}
// We use keys in the result vs. Assert.deepEqual to make it easier to
// identify exact discrepancies in comparisons, because it can be tedious to
// parse a giant list of values.
let keys = new Set();
for (let expected of expectedResults) {
for (let key in expected) {
keys.add(key);
}
}
for (let actual of actualResults) {
for (let key in actual.extra) {
keys.add(key);
}
}
keys = Array.from(keys);
for (let index = 0; index < expectedResults.length; ++index) {
info(`Checking categorization at index: ${index}`);
let expected = expectedResults[index];
let actual = actualResults[index].extra;
Assert.ok(
Number(actual?.organic_num_domains) <=
CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE,
"Number of organic domains categorized should not exceed threshold."
);
Assert.ok(
Number(actual?.sponsored_num_domains) <=
CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE,
"Number of sponsored domains categorized should not exceed threshold."
);
for (let key of keys) {
Assert.equal(
actual[key],
expected[key],
`Actual and expected values for ${key} should match.`
);
}
}
}
function waitForPageWithAction() {
return TestUtils.topicObserved("reported-page-with-action");
}
function waitForPageWithAdImpressions() {
return TestUtils.topicObserved("reported-page-with-ad-impressions");
}
function waitForPageWithCategorizedDomains() {
return TestUtils.topicObserved("reported-page-with-categorized-domains");
}
function waitForSingleCategorizedEvent() {
return TestUtils.topicObserved("recorded-single-categorization-event");
}
function waitForAllCategorizedEvents() {
return TestUtils.topicObserved("recorded-all-categorization-events");
}
function waitForDomainToCategoriesUpdate() {
return TestUtils.topicObserved("domain-to-categories-map-update-complete");
}
function waitForDomainToCategoriesInit() {
return TestUtils.topicObserved("domain-to-categories-map-init");
}
function waitForDomainToCategoriesUninit() {
return TestUtils.topicObserved("domain-to-categories-map-uninit");
}
registerCleanupFunction(async () => {
await PlacesUtils.history.clear();
});
async function mockRecordWithAttachment({ id, version, filename, mapping }) {
// Get the bytes of the file for the hash and size for attachment metadata.
let buffer = new TextEncoder().encode(JSON.stringify(mapping)).buffer;
let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(
Ci.nsIArrayBufferInputStream
);
stream.setData(buffer, 0, buffer.byteLength);
// Generate a hash.
let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
Ci.nsICryptoHash
);
hasher.init(Ci.nsICryptoHash.SHA256);
hasher.updateFromStream(stream, -1);
let hash = hasher.finish(false);
hash = Array.from(hash, (_, i) =>
("0" + hash.charCodeAt(i).toString(16)).slice(-2)
).join("");
let record = {
id,
version,
attachment: {
hash,
location: `main-workspace/search-categorization/${filename}`,
filename,
size: buffer.byteLength,
mimetype: "application/json",
},
};
let attachment = {
record,
blob: new Blob([buffer]),
};
return { record, attachment };
}
async function resetCategorizationCollection(record) {
const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY);
await client.attachments.cacheImpl.delete(record.id);
await client.db.clear();
await client.db.importChanges({}, Date.now());
}
const MOCK_ATTACHMENT_VALUES = {
"abc.com": [2, 95],
"abc.org": [4, 90],
"def.com": [2, 78, 4, 10],
"def.org": [4, 90],
"foobar.org": [3, 90],
};
const CONVERTED_ATTACHMENT_VALUES = convertDomainsToHashes(
MOCK_ATTACHMENT_VALUES
);
function convertDomainsToHashes(domainsToCategories) {
let newObj = {};
for (let [key, value] of Object.entries(domainsToCategories)) {
gCryptoHash.init(gCryptoHash.SHA256);
let bytes = new TextEncoder().encode(key);
gCryptoHash.update(bytes, key.length);
let hash = gCryptoHash.finish(true);
newObj[hash] = value;
}
return newObj;
}
async function insertRecordIntoCollection() {
const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY);
const db = client.db;
await db.clear();
let { record, attachment } = await mockRecordWithAttachment({
id: "example_id",
version: 1,
filename: "domain_category_mappings.json",
mapping: CONVERTED_ATTACHMENT_VALUES,
});
await db.create(record);
await client.attachments.cacheImpl.set(record.id, attachment);
await db.importChanges({}, Date.now());
return { record, attachment };
}
async function insertRecordIntoCollectionAndSync() {
let { record } = await insertRecordIntoCollection();
registerCleanupFunction(async () => {
await resetCategorizationCollection(record);
});
await syncCollection(record);
}
async function syncCollection(record) {
let arrayWithRecord = record ? [record] : [];
await RemoteSettings(TELEMETRY_CATEGORIZATION_KEY).emit("sync", {
data: {
current: arrayWithRecord,
created: arrayWithRecord,
updated: [],
deleted: [],
},
});
}
async function initSinglePageAppTest() {
/* import-globals-from head-spa.js */
Services.scriptloader.loadSubScript(
this
);
const BASE_PROVIDER = {
telemetryId: "example1",
searchPageRegexp:
/^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetrySinglePageApp/,
queryParamNames: ["s"],
codeParamName: "abc",
taggedCodes: ["ff"],
adServerAttributes: ["mozAttr"],
extraAdServersRegexps: [
/^https:\/\/example\.com\/ad/,
/^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/redirect_ad/,
],
components: [
{
included: {
parent: {
selector: "#searchbox-container",
},
related: {
selector: "#searchbox-suggestions",
},
children: [
{
selector: "#searchbox",
},
],
},
topDown: true,
type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
},
{
type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
default: true,
},
],
isSPA: true,
defaultPageQueryParam: {
key: "page",
value: "web",
},
};
const SPA_PROVIDER_INFO = [
BASE_PROVIDER,
{
...BASE_PROVIDER,
telemetryId: "example2",
// Use example.com instead of example.org so that we have two providers
// with different TLD's and won't share the same web process.
searchPageRegexp:
/^https:\/\/example.com\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetrySinglePageApp/,
},
];
SearchSERPTelemetry.overrideSearchTelemetryForTests(SPA_PROVIDER_INFO);
await waitForIdle();
// Shorten delay to avoid potential TV timeouts.
Services.ppmm.sharedData.set(SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT, 100);
registerCleanupFunction(function () {
Services.ppmm.sharedData.set(
SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT,
SPA_ADLINK_CHECK_TIMEOUT_MS
);
});
}