Source code
Revision control
Copy as Markdown
Other Tools
/* Any copyright is dedicated to the Public Domain.
"use strict";
const CTA_PREF = "browser.netError.searchCTA.enabled";
// no-op here and these tests don't depend on real captive-portal state. The
// freshness window is an int pref compared against the reading's age in
// milliseconds, so int32 max is the largest value that can be stored and is
// effectively "never stale".
const FRESHNESS_PREF = "browser.netError.searchCTA.connectivityFreshnessMs";
const ALWAYS_FRESH = 2147483647;
// A .com host (not a reserved TLD, so it passes host-viability from bug
// 2055651) with a www subdomain, to verify the query falls back to the
// registrable domain (the subdomain is dropped).
const FAILED_HOST = "www.doesnotexist-searchcta.com";
const REGISTRABLE_DOMAIN = "doesnotexist-searchcta.com";
add_setup(async function () {
stubSearchCTASupportedEngine();
// A deterministic default engine so the search submission URL is known.
await SearchTestUtils.installSearchExtension(
{
name: "MozSearchCTA",
search_url_get_params: "q={searchTerms}",
},
{ setAsDefault: true }
);
});
/**
* Load a synthetic online dnsNotFound page for FAILED_HOST with the search CTA
* pref set, run a task against its browser, then close the tab and restore the
* pref. The pref is pushed before the load because the card reads it during
* page setup.
*
* @param {boolean} enabled Value for browser.netError.searchCTA.enabled.
* @param {Function} taskFn Async callback receiving the error page's browser.
*/
async function withDnsNotFoundPage(enabled, taskFn) {
await SpecialPowers.pushPrefEnv({
set: [
[CTA_PREF, enabled],
[FRESHNESS_PREF, ALWAYS_FRESH],
],
});
const { tab, browser } = await loadNetErrorPage("dnsNotFound", FAILED_HOST);
try {
await taskFn(browser);
} finally {
BrowserTestUtils.removeTab(tab);
await SpecialPowers.popPrefEnv();
}
}
add_task(async function test_ctaRendersWhenEnabled() {
await withDnsNotFoundPage(true, async browser => {
await waitForSettledNetErrorCard(browser);
await SpecialPowers.spawn(
browser,
[REGISTRABLE_DOMAIN],
async registrableDomain => {
const doc = content.document;
const card = doc.querySelector("net-error-card").wrappedJSObject;
is(
card.errorTitle.getAttribute("data-l10n-id"),
"neterror-search-cta-title",
"Search CTA heading is shown"
);
ok(card.reloadButton, "Reload button is present");
const searchButton = card.searchCTAButton;
ok(searchButton, "Search button renders once the parent responds");
// moz-button exposes the new-tab hint as the internal button's title,
// which is also its accessible description. Compare against the value
// Fluent resolves for .tooltiptext, so a break in the .tooltiptext ->
// title plumbing still fails here without pinning the English string.
const [buttonMessage] = await doc.l10n.formatMessages([
{ id: "neterror-search-cta-search-button" },
]);
const expectedTitle = buttonMessage.attributes.find(
attribute => attribute.name === "tooltiptext"
).value;
const innerButton =
searchButton.shadowRoot.querySelector("#main-button");
is(
innerButton.getAttribute("title"),
expectedTitle,
"Search button is described for the new-tab action"
);
is(
searchButton.shadowRoot.querySelector("img").getAttribute("src"),
"chrome://global/skin/icons/search-glass.svg",
"Search button uses the unbranded fallback icon"
);
is(
card.errorIntro.getAttribute("data-l10n-args"),
JSON.stringify({ domain: registrableDomain }),
"Intro names the registrable domain, not the full host"
);
const hint = card.shadowRoot.querySelector(
'[data-l10n-id="neterror-search-cta-hint-search-query"]'
);
ok(hint, "The search hint names a query rather than generic wording");
is(
hint.getAttribute("data-l10n-args"),
JSON.stringify({ query: registrableDomain }),
"The hint names the exact query Search will submit"
);
// The one wait left in this task: Fluent applies its DOM overlay after
// the card has already finished rendering, so the emphasized query
// appears later than the attributes asserted above.
const emphasized = await ContentTaskUtils.waitForCondition(
() => hint.querySelector("strong"),
"Fluent's DOM overlay renders the emphasized query"
);
ok(
emphasized.textContent.includes(registrableDomain),
`The emphasized text contains the query (got "${emphasized.textContent}")`
);
is(
card.tryAgainButton,
null,
"The original Try Again button is replaced by Reload"
);
// Reload stands in for a Try Again button that has an accesskey, so
// both CTA buttons need their own rather than silently dropping it.
// moz-button maps accessKey onto its internal button, so assert there
// to cover the whole Fluent -> host -> inner button path. The letters
// are localizable, so only require that they exist and do not collide.
const accessKeyOf = button =>
button.shadowRoot
.querySelector("#main-button")
.getAttribute("accesskey");
const searchKey = accessKeyOf(searchButton);
const reloadKey = accessKeyOf(card.reloadButton);
ok(searchKey, "Search button has an accesskey");
ok(reloadKey, "Reload button has an accesskey");
isnot(searchKey, reloadKey, "The two accesskeys do not collide");
is(
card.shadowRoot
.querySelector(".img-container img")
.getAttribute("src"),
"chrome://global/skin/illustrations/security-error.svg",
"The CTA page shows the security illustration"
);
}
);
});
});
add_task(async function test_searchClickOpensNewTab() {
await withDnsNotFoundPage(true, async browser => {
const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
await waitForSettledNetErrorCard(browser, {
clickQuery: "searchCTAButton",
});
const searchTab = await newTabPromise;
is(
searchTab.linkedBrowser.currentURI.spec,
SEARCH_URL,
"Search opens a new tab with the default-engine query for the domain"
);
is(gBrowser.selectedTab, searchTab, "The search tab is brought forward");
ok(
browser.currentURI.spec.startsWith("about:neterror"),
"The failed error page stays open behind it"
);
BrowserTestUtils.removeTab(searchTab);
});
});
// With no Search button to name a query, the hint falls back to generic
// wording rather than naming a query the user cannot act on.
add_task(async function test_genericHintWhenNoSearchButton() {
const sandbox = sinon.createSandbox();
sandbox.stub(NetErrorParent.prototype, "getSearchCTAInfo").resolves({
action: SEARCH_CTA_ACTIONS.NONE,
query: "",
reason: SEARCH_CTA_REASONS.HOST_UNUSABLE,
domain: FAILED_HOST,
hasEngine: false,
});
try {
await withDnsNotFoundPage(true, async browser => {
await waitForSettledNetErrorCard(browser);
await SpecialPowers.spawn(browser, [], async () => {
const card =
content.document.querySelector("net-error-card").wrappedJSObject;
ok(
card.reloadButton,
"The redesigned page still renders with Reload only"
);
is(card.searchCTAButton, null, "No Search button to name a query");
ok(
card.shadowRoot.querySelector(
'[data-l10n-id="neterror-search-cta-hint-search"]'
),
"The hint uses generic wording"
);
is(
card.shadowRoot.querySelector(
'[data-l10n-id="neterror-search-cta-hint-search-query"]'
),
null,
"No query is named when none will be searched"
);
});
});
} finally {
sandbox.restore();
}
});
// The CTA layout puts Search first, so initial focus belongs there rather than
// on Reload, which would leave keyboard users tabbing backwards.
add_task(async function test_searchButtonTakesInitialFocus() {
await withDnsNotFoundPage(true, async browser => {
await waitForSettledNetErrorCard(browser);
await SpecialPowers.spawn(browser, [], async () => {
const card =
content.document.querySelector("net-error-card").wrappedJSObject;
// focusPrimaryButton() runs on its own async path after the render, so
// the focus move can still land after the card has settled.
ok(
await ContentTaskUtils.waitForCondition(
() => card.shadowRoot.activeElement === card.searchCTAButton
),
"The Search button takes initial focus, not Reload"
);
});
});
});
// Reload stands in for Try Again on this page, so the existing retry signal
// must keep firing or it looks like retries stopped when the CTA shipped.
add_task(async function test_reloadRecordsTryAgainTelemetry() {
Services.fog.testResetFOG();
await withDnsNotFoundPage(true, async browser => {
await waitForSettledNetErrorCard(browser, { clickQuery: "reloadButton" });
await Services.fog.testFlushAllChildren();
const events = Glean.securityUiNeterror.clickTryAgainButton.testGetValue();
is(events?.length, 1, "Reload records one click_try_again_button event");
});
});
// Same reasoning for the learn-more link. Without its telemetry attributes it
// still renders and still navigates, so the only symptom is the event quietly
add_task(async function test_learnMoreRecordsClickTelemetry() {
Services.fog.testResetFOG();
await withDnsNotFoundPage(true, async browser => {
const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
await waitForSettledNetErrorCard(browser, { clickQuery: "learnMoreLink" });
const learnMoreTab = await newTabPromise;
await Services.fog.testFlushAllChildren();
const events = Glean.securityUiNeterror.clickLearnMoreLink.testGetValue();
is(events?.length, 1, "Learn more records one click_learn_more_link event");
BrowserTestUtils.removeTab(learnMoreTab);
});
});
// net-error-card.mjs reads the CTA pref at module scope and is shared with
// about:certerror, which has its own RemotePageAccessManager allowlist. If the
// pref is missing there the read throws, aboutNetError.mjs fails to evaluate,
// and every cert error page renders blank regardless of the pref's value.
add_task(async function test_certErrorPageRendersWithCtaEnabled() {
await SpecialPowers.pushPrefEnv({
set: [
[CTA_PREF, true],
["security.certerrors.felt-privacy-v1", true],
],
});
const tab = await openErrorPage(BAD_CERT);
try {
await waitForSettledNetErrorCard(tab.linkedBrowser);
await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
const card =
content.document.querySelector("net-error-card").wrappedJSObject;
ok(card.errorTitle, "The cert error page has a heading");
is(card.searchCTAButton, null, "No Search button on a cert error page");
is(card.reloadButton, null, "No Reload button on a cert error page");
});
} finally {
BrowserTestUtils.removeTab(tab);
await SpecialPowers.popPrefEnv();
}
});
add_task(async function test_noCtaWhenDisabled() {
await withDnsNotFoundPage(false, async browser => {
await waitForSettledNetErrorCard(browser);
await SpecialPowers.spawn(browser, [], async () => {
const card =
content.document.querySelector("net-error-card").wrappedJSObject;
ok(
card.tryAgainButton,
"The standard dnsNotFound page is shown with the pref off"
);
is(card.searchCTAButton, null, "No Search button with the pref off");
is(card.reloadButton, null, "No Reload button with the pref off");
});
});
});