inspector/scripts/lib/headless-browser.test.mjs
cliffhall 4ab0139de9 test(smoke): cover loadBrowser's failure branches; fix a stale helper comment
Two findings from Copilot's suppressed block.

1. The launch-failure message is #2086's acceptance criterion — a missing
   browser must name THAT engine and its own install command — and it had
   no test. The smokes cannot supply one: install-smoke-browser fetches
   the binary first, so a passing smoke says nothing about what a failing
   one prints, and that message is the deliverable. loadBrowser now takes
   an injectable loadPlaywright purely to open that seam.

   The test asserts per engine that the message leads with that engine,
   names --with-deps <that engine> and no other, and preserves the
   underlying cause. Verified by mutation: hard-coding 'chromium' in the
   message fails it. It also pins the two branches apart — an unresolvable
   package must say
> @modelcontextprotocol/inspector@2.3.0 postinstall
> node scripts/install-clients.mjs

[install-clients] Installing dependencies for clients/web...

up to date, audited 583 packages in 614ms

198 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
[install-clients] Installing dependencies for clients/cli...

up to date, audited 237 packages in 496ms

69 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
[install-clients] Installing dependencies for clients/tui...

up to date, audited 314 packages in 518ms

102 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
[install-clients] Installing dependencies for clients/launcher...

up to date, audited 160 packages in 449ms

50 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

up to date, audited 281 packages in 3s

108 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities and must NOT say ,
   which fetches binaries and cannot fix a missing package.

   The unsupported-engine test now also asserts loadPlaywright was never
   called, pinning the ORDER rather than only the message: validating
   first is what makes the error name the real mistake.

2. prod-web-server.mjs's header still described the  prefix this PR removed. Corrected, keeping why
   deriving paths from import.meta.url is still right, and adding the
   elicitation smoke to the list of callers it had also outgrown.

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

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

225 lines
8 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("treats an empty or whitespace-only value as unset", () => {
// A CI expression that resolves to nothing (`SMOKE_BROWSER: ${{ … }}` with
// an undefined matrix key) sets the variable to "" rather than removing it.
for (const raw of ["", " "]) {
assert.equal(
resolveBrowserName({ [BROWSER_ENV_VAR]: raw }),
DEFAULT_BROWSER,
);
}
});
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(), []);
});
});