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>
225 lines
8 KiB
JavaScript
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(), []);
|
|
});
|
|
});
|