Source code
Revision control
Copy as Markdown
Other Tools
/* Any copyright is dedicated to the Public Domain.
"use strict";
// Render time: a stale captive-portal reading triggers an authoritative,
// time-bounded re-check; the CTA is suppressed (action:none, reason:
// connectivity_unconfirmed) unless we come back affirmatively online, and a
// fresh reading is trusted without a re-check. Click time: if connectivity has
// dropped since render, the search is aborted (offline message shown) and the
// drop is recorded once as search_cta_click_aborted[connectivity_lost] — a
// click-time outcome, deliberately not a page-load reason.
const CTA_PREF = "browser.netError.searchCTA.enabled";
const CPS = Ci.nsICaptivePortalService;
const CONNECTIVITY_TOPIC = "network:captive-portal-connectivity";
// An empty path falls back to the registrable domain (host action), so the CTA
// shows whenever connectivity allows it.
add_setup(async function () {
stubSearchCTASupportedEngine();
await SearchTestUtils.installSearchExtension(
{
name: "MozSearchCTAConnectivity",
search_url_get_params: "q={searchTerms}",
},
{ setAsDefault: true }
);
await SpecialPowers.pushPrefEnv({ set: [[CTA_PREF, true]] });
registerCleanupFunction(() => Services.io.setConnectivityForTesting(true));
});
/**
* Wait for the CTA decision to land and flush telemetry, so the counters the
* parent recorded during that decision are readable in this process.
*
* @param {MozBrowser} browser The browser showing the error page.
*/
async function waitForCtaResolved(browser) {
await waitForSettledNetErrorCard(browser);
await Services.fog.testFlushAllChildren();
}
const reason = label =>
Glean.securityUiNeterror.searchCtaReason[label].testGetValue();
const shown = () => Glean.securityUiNeterror.searchCtaShown.testGetValue();
/**
* A fake captive-portal service with a controllable state, reading age and
* re-check behavior, so the guard can be exercised without real detection.
*
* @param {object} [options]
* @param {number} [options.state] An nsICaptivePortalService state constant.
* @param {number} [options.lastChecked] Age of the reading in milliseconds. 0
* is treated as fresh, and a large value forces the stale path.
* @param {Function} [options.onRecheck] Called when the CTA asks for an
* authoritative re-check, to simulate its outcome.
* @returns {object} A stand-in for nsICaptivePortalService.
*/
function fakeCPS({ state = CPS.NOT_CAPTIVE, lastChecked = 0, onRecheck } = {}) {
return {
state,
lastChecked,
recheckCaptivePortal() {
onRecheck?.();
},
};
}
// A reading inside the freshness window is trusted as-is: the CTA shows and
// no authoritative re-check is triggered, so the common case costs nothing.
add_task(async function test_freshReadingShowsCtaWithoutRecheck() {
Services.fog.testResetFOG();
Services.io.setConnectivityForTesting(true);
const sandbox = sinon.createSandbox();
const cps = fakeCPS({ state: CPS.NOT_CAPTIVE, lastChecked: 0 });
const recheckSpy = sandbox.spy(cps, "recheckCaptivePortal");
sandbox
.stub(NetErrorParent.prototype, "getCaptivePortalService")
.returns(cps);
try {
const { tab, browser } = await loadDnsNotFoundPage(FAILED_URL);
await waitForCtaResolved(browser);
is(shown(), 1, "CTA shown on a fresh reading");
is(
reason("connectivity_unconfirmed"),
null,
"nothing recorded on the fresh path"
);
ok(!recheckSpy.called, "no re-check when the reading is already fresh");
BrowserTestUtils.removeTab(tab);
} finally {
sandbox.restore();
}
});
// A stale reading triggers a re-check, and when that comes back offline the CTA
// is suppressed rather than inviting a search that cannot run.
add_task(async function test_staleReadingRechecksOfflineSuppressesCta() {
Services.fog.testResetFOG();
Services.io.setConnectivityForTesting(true);
const sandbox = sinon.createSandbox();
// Stale reading; the authoritative re-check comes back offline.
const cps = fakeCPS({
state: CPS.NOT_CAPTIVE,
lastChecked: 999999,
onRecheck() {
Services.io.setConnectivityForTesting(false);
Services.obs.notifyObservers(null, CONNECTIVITY_TOPIC, "captive");
},
});
sandbox
.stub(NetErrorParent.prototype, "getCaptivePortalService")
.returns(cps);
try {
const { tab, browser } = await loadDnsNotFoundPage(FAILED_URL);
await waitForCtaResolved(browser);
is(
reason("connectivity_unconfirmed"),
1,
"connectivity-unconfirmed recorded"
);
is(shown(), null, "CTA not shown when the re-check resolves offline");
await SpecialPowers.spawn(browser, [], () => {
const card =
content.document.querySelector("net-error-card").wrappedJSObject;
is(card.searchCTAButton, null, "no Search button after offline re-check");
});
BrowserTestUtils.removeTab(tab);
} finally {
Services.io.setConnectivityForTesting(true);
sandbox.restore();
}
});
// A re-check that never completes must not hang the CTA. The bounded timeout
// wins and the CTA is suppressed, which is the fail-safe direction.
add_task(async function test_recheckTimeoutSuppressesCta() {
Services.fog.testResetFOG();
Services.io.setConnectivityForTesting(true);
await SpecialPowers.pushPrefEnv({
set: [["browser.netError.searchCTA.connectivityRecheckTimeoutMs", 100]],
});
const sandbox = sinon.createSandbox();
// Stale reading whose re-check never completes: the bounded timeout wins.
const cps = fakeCPS({ state: CPS.UNKNOWN, lastChecked: 999999 });
sandbox
.stub(NetErrorParent.prototype, "getCaptivePortalService")
.returns(cps);
try {
const { tab, browser } = await loadDnsNotFoundPage(FAILED_URL);
await waitForCtaResolved(browser);
is(
reason("connectivity_unconfirmed"),
1,
"connectivity-unconfirmed recorded on timeout"
);
is(shown(), null, "CTA not shown when the re-check times out");
BrowserTestUtils.removeTab(tab);
} finally {
sandbox.restore();
await SpecialPowers.popPrefEnv();
}
});
// Connectivity dropping after the CTA was shown is a click-time outcome, not a
// page-load decision: the search is aborted with an offline message, the click
// is still counted, and no page-load reason is recorded.
add_task(async function test_clickTimeConnectivityDropAbortsSearch() {
Services.fog.testResetFOG();
Services.io.setConnectivityForTesting(true);
const sandbox = sinon.createSandbox();
// Fresh + online at render so the CTA shows.
sandbox
.stub(NetErrorParent.prototype, "getCaptivePortalService")
.returns(fakeCPS({ state: CPS.NOT_CAPTIVE, lastChecked: 0 }));
try {
const { tab, browser } = await loadDnsNotFoundPage(FAILED_URL);
await SpecialPowers.spawn(browser, [], async () => {
const doc = content.document;
const card = await ContentTaskUtils.waitForCondition(
() => doc.querySelector("net-error-card")?.wrappedJSObject
);
await ContentTaskUtils.waitForCondition(() => card.searchCTAButton);
});
// Drop connectivity and wait for the content process to observe it.
Services.io.setConnectivityForTesting(false);
await SpecialPowers.spawn(browser, [], async () => {
await ContentTaskUtils.waitForCondition(() => !Services.io.connectivity);
});
let openedTab = false;
const onTabOpen = () => {
openedTab = true;
};
gBrowser.tabContainer.addEventListener("TabOpen", onTabOpen);
await SpecialPowers.spawn(browser, [], async () => {
const card =
content.document.querySelector("net-error-card").wrappedJSObject;
card.searchCTAButton.click();
await ContentTaskUtils.waitForCondition(
() => card.searchCTAOfflineMessage,
"the offline message replaces the Search button"
);
is(card.searchCTAButton, null, "Search button is gone after the abort");
});
gBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen);
await Services.fog.testFlushAllChildren();
ok(!openedTab, "no search tab opened when connectivity dropped");
is(
Glean.securityUiNeterror.searchCtaClicked.testGetValue(),
1,
"the click is still counted"
);
is(
Glean.securityUiNeterror.searchCtaClickAborted.connectivity_lost.testGetValue(),
1,
"the aborted click is recorded as a click-time outcome"
);
is(
reason("connectivity_lost"),
null,
"an aborted click adds no page-load reason"
);
is(
reason("connectivity_unconfirmed"),
null,
"and is not confused with load-time suppression"
);
BrowserTestUtils.removeTab(tab);
} finally {
Services.io.setConnectivityForTesting(true);
sandbox.restore();
}
});