inspector/scripts/lib/headless-browser.test.mjs
cliffhall 32cb4c63a7 fix(smoke): reject an empty SMOKE_BROWSER; test the installer's argument logic
Two findings from Copilot's suppressed block.

1. resolveBrowserName treated a present-but-empty SMOKE_BROWSER as unset
   and returned chromium. The test that covered it even documented WHY
   that is dangerous — GitHub Actions renders an undefined matrix key as
   "" rather than omitting the variable — and then asserted the unsafe
   behavior. A misspelled matrix key would run chromium inside a job named
   for another engine and report green: the same false-coverage outcome
   the unrecognized-value branch exists to prevent, reached by a typo
   instead of a bad value.

   Only an absent variable now selects the default. Empty throws, naming
   the likely cause and the actual remedy ('unset it', which is not the
   same as setting it to nothing). The workflow always renders a real
   value, so nothing in CI is affected.

2. install-smoke-browser.mjs had no tests. Its I/O half runs on every
   smoke and fails loudly; its argument half does not — every caller
   passes nothing or the literal 'chromium', so the precedence rule is
   asserted by nothing at runtime. It matters: pack:verify passes chromium
   BECAUSE it launches chromium, and if the environment won,
   SMOKE_BROWSER=webkit npm run pack:verify would install one engine and
   launch another, surfacing as a misleading "Executable doesn't exist".

   resolveRequestedBrowser is now exported and pure; execution moved
   behind main() with the entrypoint guard verify-typecheck-coverage.mjs
   uses, so importing the module for tests cannot trigger an install.

Both mutation-verified: restoring either behavior fails the tests that
claim to cover it.

Addresses the suppressed comments on Copilot's fifth review of #2133.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 22:52:02 -04:00

237 lines
8.7 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 under a job labelled
* "webkit", claiming coverage 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.
*
* Only the successful launch is left to the smokes, since it needs a real binary.
*/
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. A CI expression that resolves
// to nothing (`SMOKE_BROWSER: ${{ matrix.browsr }}`, an undefined key) sets
// the variable to "" rather than removing it — so defaulting here would run
// Chromium inside a job named for another engine, which is the very
// 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 likely cause and the actual remedy — "unset it", which is
// different from "set it to nothing" and is the whole point here.
assert.match(err.message, /matrix/);
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("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`));
assert.match(
err.message,
new RegExp(
`npx playwright install --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(), []);
});
});