inspector/scripts/lib/headless-browser.test.mjs
cliffhall 93bc7df360 fix(smoke): assert the requested engine is the one launched; fix the remedy
Three findings, all from Copilot's suppressed block.

1. The tests never checked that loadBrowser launches the engine it was
   asked for. The failure-path stand-in rejects identically for every
   engine, so loadBrowser(root, "firefox") could have called
   chromium.launch() and still produced a correctly Firefox-labelled
   error. The smokes could not catch it either: the Firefox gate runs
   after Chromium is already installed, so a mis-dispatched launch would
   SUCCEED and report Firefox coverage that never happened.

   Added a success-path test with distinct per-engine spies asserting the
   requested engine is invoked, that nothing else is, and that its handle
   is what comes back. Mutation-verified: hard-coding playwright.chromium
   fails it.

2. The launch-failure message told the reader to run
   `npx playwright install --with-deps <engine>`, which is wrong now that
   the npm scripts run from the repo root rather than clients/web -- I
   introduced that when I removed the `cd`. Playwright is pinned in
   clients/web, so a bare root-level npx can fetch a different version and
   install a browser revision the pinned one still cannot launch. It now
   points at `npm run smoke:web:<engine>` first, and scopes the raw
   command to clients/web with the reason.

   That message named smoke:web:webkit, which did not exist -- only the
   chromium and firefox scripts did. Added it, and a test asserting every
   supported engine has one, since a remedy nobody can run is worse than
   no remedy. Mutation-verified.

3. AGENTS.md said npm run ci "mirrors" the workflow and then, two
   sentences later, that it is "a superset, not a mirror". It now leads
   with strict superset, which is the topology this PR is documenting.

The README and AGENTS.md remediation wording is corrected the same way as
the runtime message, so a reader gets the same advice wherever they find
it.

Addresses Copilot review 5030668906 on #2133.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 09:33:21 -04:00

284 lines
11 KiB
JavaScript

/**
* Unit tests for the headless-browser engine selection (#2086).
*
* The smokes cannot check this themselves: each one resolves exactly one engine
* per process and then spends its whole run inside the happy path, so the branch
* that matters most — an unrecognized `SMOKE_BROWSER` — is dead code from their
* point of view. It is also the branch whose failure is silent rather than loud:
* a fallback to Chromium there would report a green run to a caller who asked
* for another engine — coverage claimed that never ran.
*
* `loadBrowser`'s failure branches are covered here too, through its injectable
* `loadPlaywright`. The smokes cannot reach them by construction —
* `install-smoke-browser` fetches the binary before the smoke runs, so a passing
* smoke says nothing about what a failing one would print. And that message is
* the deliverable: #2086's acceptance criterion is that a missing browser fails
* naming *that engine* and its own `playwright install` command, which is
* exactly the kind of string that rots into naming the wrong one.
*
* The successful path is covered here too, with distinct per-engine spies — the
* failure-path stand-in rejects identically for every engine, so it cannot tell
* a correct dispatch from a mis-dispatch that happens to print the right label.
* Only a launch against a REAL binary is left to the smokes.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
BROWSER_ENV_VAR,
DEFAULT_BROWSER,
SUPPORTED_BROWSERS,
attachPageDiagnostics,
loadBrowser,
resolveBrowserName,
} from "./headless-browser.mjs";
describe("resolveBrowserName", () => {
it("defaults to chromium when the variable is unset", () => {
assert.equal(resolveBrowserName({}), DEFAULT_BROWSER);
});
it("rejects a present-but-empty value instead of defaulting", () => {
// This is the case worth being strict about. `SMOKE_BROWSER="$ENGINE"` with
// ENGINE unset — or a CI expression naming a key that does not exist — sets
// the variable to "" rather than removing it, so defaulting here would
// silently run Chromium for a caller who asked for something else. That is
// the same false-coverage outcome the unrecognized-value branch exists to
// stop. Only an ABSENT variable may select the default.
for (const raw of ["", " ", "\t\n"]) {
assert.throws(
() => resolveBrowserName({ [BROWSER_ENV_VAR]: raw }),
(err) => {
assert.match(err.message, /set but empty/);
// Names the actual remedy — "unset it", which is different from
// "set it to nothing" and is the whole point here.
assert.match(err.message, /did not resolve/);
assert.match(err.message, /Unset it/);
return true;
},
`expected ${JSON.stringify(raw)} to be rejected`,
);
}
});
it("accepts every supported engine", () => {
for (const name of SUPPORTED_BROWSERS) {
assert.equal(resolveBrowserName({ [BROWSER_ENV_VAR]: name }), name);
}
});
it("normalizes surrounding whitespace and case", () => {
assert.equal(
resolveBrowserName({ [BROWSER_ENV_VAR]: " WebKit " }),
"webkit",
);
});
it("rejects an unrecognized engine rather than falling back", () => {
assert.throws(
() => resolveBrowserName({ [BROWSER_ENV_VAR]: "safari" }),
// The message must quote what was asked for AND list what is accepted —
// "safari" is the most likely typo here and its fix is not guessable.
(err) =>
/safari/.test(err.message) &&
SUPPORTED_BROWSERS.every((name) => err.message.includes(name)),
);
});
});
describe("loadBrowser", () => {
/** A Playwright stand-in whose every engine rejects on launch. */
const launchAlwaysFails = (message) => () => ({
chromium: { launch: () => Promise.reject(new Error(message)) },
firefox: { launch: () => Promise.reject(new Error(message)) },
webkit: { launch: () => Promise.reject(new Error(message)) },
});
it("rejects an unsupported engine before touching Playwright", async () => {
let loaded = false;
await assert.rejects(
() =>
loadBrowser("/nonexistent-repo-root", "safari", {
loadPlaywright: () => {
loaded = true;
return {};
},
}),
/unsupported browser "safari"/,
);
// Pins the ORDER, not just the message: validating first is what makes the
// error name the real mistake instead of a downstream resolution failure.
assert.equal(loaded, false);
});
it("launches the engine that was asked for, and returns it", async () => {
// The load-bearing assertion, and the one the failure-path tests below
// CANNOT make (Copilot, #2133): their stand-in rejects identically for every
// engine, so `loadBrowser(root, "firefox")` could call `chromium.launch()`
// and still produce a correctly Firefox-labelled error. The smokes cannot
// catch that either — the Firefox gate runs after Chromium is already
// installed, so a mis-dispatched launch would succeed and report Firefox
// coverage that never happened. Only distinct per-engine spies pin it.
const launched = [];
const spyPlaywright = () =>
Object.fromEntries(
SUPPORTED_BROWSERS.map((name) => [
name,
{
launch: async (options) => {
launched.push({ name, options });
return { engine: name };
},
},
]),
);
for (const name of SUPPORTED_BROWSERS) {
launched.length = 0;
const browser = await loadBrowser("/repo", name, {
loadPlaywright: spyPlaywright,
});
assert.deepEqual(
launched.map((l) => l.name),
[name],
`loadBrowser(…, "${name}") must launch ${name} and nothing else`,
);
// The returned handle is that engine's, not some other engine's.
assert.deepEqual(browser, { engine: name });
assert.deepEqual(launched[0].options, { headless: true });
}
});
it("names the engine that failed, and its own install command", async () => {
// The whole point of #2086's acceptance criterion: a reader whose WebKit
// binary is missing must not be sent to install chromium.
for (const name of SUPPORTED_BROWSERS) {
await assert.rejects(
() =>
loadBrowser("/repo", name, {
loadPlaywright: launchAlwaysFails("Executable doesn't exist"),
}),
(err) => {
assert.match(err.message, new RegExp(`^${name} failed to launch`));
// Names a remedy that works from where the caller is: the npm
// script, and the raw command scoped to clients/web (Playwright is
// pinned there; a bare root-level `npx playwright` can fetch a
// different version and install a revision this cannot launch).
assert.match(err.message, new RegExp(`npm run smoke:web:${name}`));
assert.match(err.message, /from clients\/web/);
assert.match(
err.message,
new RegExp(
`--with-deps ${name}\\\`(?![\\s\\S]*--with-deps (?!${name}))`,
),
);
// The underlying cause survives, so the reader can tell a missing
// binary from a sandbox/permissions problem.
assert.match(err.message, /Executable doesn't exist/);
// And no OTHER engine is named anywhere in the message.
for (const other of SUPPORTED_BROWSERS.filter((b) => b !== name)) {
assert.ok(
!err.message.includes(other),
`message for ${name} must not mention ${other}: ${err.message}`,
);
}
return true;
},
);
}
});
it("sends an unresolvable Playwright to `npm install`, not `playwright install`", async () => {
// Two different failures with two different remedies. Suggesting
// `playwright install` here would be actively wrong — it fetches browser
// binaries and cannot install the missing npm package.
await assert.rejects(
() =>
loadBrowser("/repo", "firefox", {
loadPlaywright: () => {
throw new Error("Cannot find module 'playwright'");
},
}),
(err) => {
assert.match(err.message, /could not resolve the Playwright package/);
assert.match(err.message, /npm install/);
assert.ok(
!/playwright install/.test(err.message),
`must not suggest \`playwright install\` for a missing package: ${err.message}`,
);
assert.match(err.message, /Cannot find module 'playwright'/);
return true;
},
);
});
});
describe("attachPageDiagnostics", () => {
/** Minimal Playwright `page` stand-in: records handlers, replays events. */
function fakePage() {
const handlers = {};
return {
on: (event, fn) => {
(handlers[event] ??= []).push(fn);
},
emit: (event, arg) => handlers[event]?.forEach((fn) => fn(arg)),
};
}
const consoleMessage = (type, text) => ({
type: () => type,
text: () => text,
});
it("splits fatal console errors from benign noise", () => {
const page = fakePage();
const diagnostics = attachPageDiagnostics(page);
page.emit("console", consoleMessage("error", "Uncaught (in promise) boom"));
page.emit(
"console",
consoleMessage("error", "Failed to fetch dynamically imported module x"),
);
// The two shapes that used to flake CI, and must stay diagnostics.
page.emit(
"console",
consoleMessage("error", "Failed to load resource: net::ERR_FAILED"),
);
page.emit(
"console",
consoleMessage("error", "Warning: each child needs a key"),
);
// Non-error console output is ignored entirely.
page.emit("console", consoleMessage("log", "Uncaught looking but a log"));
assert.deepEqual(diagnostics.fatalConsole(), [
"Uncaught (in promise) boom",
"Failed to fetch dynamically imported module x",
]);
assert.deepEqual(diagnostics.benignConsole(), [
"Failed to load resource: net::ERR_FAILED",
"Warning: each child needs a key",
]);
});
it("counts every pageerror as fatal, whatever it was thrown as", () => {
const page = fakePage();
const diagnostics = attachPageDiagnostics(page);
page.emit("pageerror", new Error("sync boom"));
page.emit("pageerror", "thrown as a string");
page.emit("console", consoleMessage("error", "benign"));
assert.deepEqual(diagnostics.pageErrors, [
"sync boom",
"thrown as a string",
]);
assert.deepEqual(diagnostics.fatal(), ["sync boom", "thrown as a string"]);
});
it("reports nothing on a clean page", () => {
const diagnostics = attachPageDiagnostics(fakePage());
assert.deepEqual(diagnostics.fatal(), []);
assert.deepEqual(diagnostics.benignConsole(), []);
});
});