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