The root `ci` script was never what runs in GitHub CI, and the name worked
against us three ways: it collides with the built-in `npm ci` (which does
something else entirely and is one keystroke away), it invites the
cross-engine passes into a workflow by looking like the thing a workflow
should call, and it blurs "GitHub CI" and "the local gate" in every doc
that names it.
- Rename `ci` → `local:gate` and `ci:storybook` → `local:storybook`, with
no back-compat alias: `npm run ci` now fails with npm's missing-script
error, which prints the available scripts.
- Add `scripts/lib/workflow-gate.mjs` + its `test:scripts` suite, which
runs over the repo's real `.github/workflows/**` and fails on an
invocation of a `local:*` script, of a non-Chromium engine pass (derived
from SUPPORTED_BROWSERS), or of `smoke:web:engine`, or on `SMOKE_BROWSER`
set to anything but a literal `chromium`. It does not forbid
`npm run smoke`, `smoke:web:chromium` or `smoke:tui` — those belong in CI
and are there today. Comments are not scanned, so the workflow can still
explain itself. This is the durable half; the rest is prose.
- Document the two-tier model once, in the root README ("Two tiers: GitHub
CI and the local gate"), and point the other docs at it rather than
restating the split.
- Sweep the ~40 stale references across README, AGENTS.md,
.github/copilot-instructions.md, the client READMEs, the roadmap doc and
the script headers. AGENTS.md and copilot-instructions.md change
together, per the mirror rule.
No gate changes what it actually runs.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
197 lines
8.1 KiB
JavaScript
197 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Headless-browser boot smoke for the prod web client (#1615).
|
|
*
|
|
* `smoke:web` (scripts/smoke-web.mjs) only asserts that `GET /` serves the SPA
|
|
* HTML with the injected auth token — it never executes the React app, so a
|
|
* regression that only manifests when the bundle *runs* slips through. The
|
|
* canonical example (#1612): the browser bundle transitively value-imported a
|
|
* Node-only module (`store-io` → `atomically` → `stubborn-fs` → `node:process`),
|
|
* pulling a Node built-in into the browser graph and crashing the app to a blank
|
|
* page at runtime. Unit/integration tests run in node / happy-dom, never a real
|
|
* browser bundle, so none of them caught it.
|
|
*
|
|
* The assertion: the prod bundle **boots and paints its first meaningful frame
|
|
* (the "Add Servers" control) with no uncaught page error.** That is how a
|
|
* Node-only module reaching the browser bundle actually manifests under Vite:
|
|
* the excluded module is replaced by an empty stub, and the first call into it
|
|
* (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a
|
|
* `TypeError` that aborts app mount — an uncaught `pageerror` here.
|
|
*
|
|
* What this does NOT rely on: the literal `Module "…" has been externalized`
|
|
* string. Under Vite 8 that is a **build-time** warning (surfaced by
|
|
* `vite build`, i.e. `npm run build`), not a runtime message — the shipped stub
|
|
* is a silent `module.exports = {}`. So the browser never sees that string; the
|
|
* load-bearing signal is the uncaught `TypeError`. (Corollary: an externalized
|
|
* import that is never *called* ships a harmless empty object and is invisible
|
|
* to this smoke by design — an unused Node import doesn't crash the app.)
|
|
*
|
|
* Two channels carry the failure. A *synchronous* uncaught exception (the
|
|
* CASE-1 shape above — a stub call during module init) fires `pageerror`. Its
|
|
* *async* twin — the same `TypeError` reached through an `await`/`.then()`, or a
|
|
* failed dynamic import (this app lazy-loads chunks) — is NOT a `pageerror`; it
|
|
* arrives on the **console** channel as `Uncaught (in promise) …` /
|
|
* `Failed to fetch dynamically imported module`. Both are hard failures, and
|
|
* both channels are read on every engine rather than branching per browser.
|
|
*
|
|
* Every *other* `console.error` is NOT a hard failure: the console is also where
|
|
* benign things a boot smoke shouldn't fail on land — a failed
|
|
* subresource load (e.g. the Google-Fonts `<link>` in index.html on a
|
|
* network-restricted box) or a React key/prop warning. Those are printed as
|
|
* diagnostics. The `Uncaught` / dynamic-import prefixes are unambiguous — a
|
|
* font/CDN miss reads `Failed to load resource: net::ERR_…` and a React warning
|
|
* never starts with `Uncaught` — so hard-failing on them can't reintroduce that
|
|
* flake.
|
|
*
|
|
* Launching the browser (and resolving Playwright from clients/web, which has
|
|
* its own gotcha — see `lib/headless-browser.mjs`) is delegated to that module,
|
|
* which is also where `SMOKE_BROWSER` picks the engine: `chromium` (the
|
|
* default), `firefox`, or `webkit` (#2086). GitHub CI runs this in **Chromium**
|
|
* only; `npm run local:gate`, the local pre-push gate, also runs it in **Firefox** via
|
|
* `smoke:web:firefox`; **WebKit is on demand only**. This smoke passes in all
|
|
* three — it is the two App smokes that fail under WebKit (see their headers).
|
|
*
|
|
* The engine question here is narrower than in the App smokes — this asserts a clean
|
|
* first paint, i.e. that the shipped bundle's syntax and API level are
|
|
* *reachable* on the engine at all, rather than anything about the sandbox — but
|
|
* it is real, and it is nearly free to include.
|
|
*
|
|
* Expects `clients/web/dist` and `clients/launcher/build` to be built first —
|
|
* the validate / CI ordering guarantees this.
|
|
*/
|
|
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, resolve } from "node:path";
|
|
import { startProdWebServer } from "./lib/prod-web-server.mjs";
|
|
import {
|
|
attachPageDiagnostics,
|
|
loadBrowser,
|
|
resolveBrowserName,
|
|
} from "./lib/headless-browser.mjs";
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
// Resolved before the web server is started, so an unsupported SMOKE_BROWSER
|
|
// fails immediately. Every message carries the engine, so a failure names which
|
|
// one broke.
|
|
let BROWSER;
|
|
try {
|
|
BROWSER = resolveBrowserName();
|
|
} catch (err) {
|
|
console.error(
|
|
`smoke:web:browser FAILED — ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const LABEL = `smoke:web:browser [${BROWSER}]`;
|
|
|
|
const HOST = "127.0.0.1";
|
|
// Distinct from smoke:web's SMOKE_WEB_PORT so overriding one doesn't make both
|
|
// back-to-back smokes bind the same port (→ EADDRINUSE on the second).
|
|
const PORT = process.env.SMOKE_WEB_BROWSER_PORT ?? "6298";
|
|
const TOKEN = "smoke-web-browser-token";
|
|
|
|
const server = startProdWebServer({
|
|
host: HOST,
|
|
port: PORT,
|
|
token: TOKEN,
|
|
label: LABEL,
|
|
});
|
|
let browser = null;
|
|
|
|
async function shutdown() {
|
|
if (browser) {
|
|
try {
|
|
await browser.close();
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
browser = null;
|
|
}
|
|
await server.stop();
|
|
}
|
|
|
|
async function fail(message) {
|
|
console.error(`${LABEL} FAILED — ${message}`);
|
|
await shutdown();
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
await server.waitForReady();
|
|
browser = await loadBrowser(repoRoot, BROWSER);
|
|
const page = await browser.newPage();
|
|
|
|
// Uncaught (synchronous) page errors are a hard failure — a Node-only module
|
|
// reaching the browser bundle surfaces here as a TypeError when its empty stub
|
|
// is called during module init. Console errors are diagnostic only EXCEPT the
|
|
// async half of that same crash class; see the header comment, and the shared
|
|
// helper for the split.
|
|
const diagnostics = attachPageDiagnostics(page);
|
|
|
|
const render = async () => {
|
|
// Token is injected into index.html by the prod server, so a bare `/` load
|
|
// authenticates without a query param.
|
|
const response = await page.goto(server.baseUrl, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30_000,
|
|
});
|
|
if (!response || !response.ok()) {
|
|
throw new Error(
|
|
`GET / returned HTTP ${response ? response.status() : "no response"}`,
|
|
);
|
|
}
|
|
// First meaningful frame: the always-present "Add Servers" control.
|
|
await page
|
|
.getByRole("button", { name: /Add Servers/ })
|
|
.waitFor({ state: "visible", timeout: 30_000 });
|
|
// Settle window: let lazily-evaluated chunks that throw a tick after first
|
|
// paint surface before we assert a clean boot. networkidle is best-effort
|
|
// (the Google-Fonts request may never idle on a restricted network).
|
|
await page
|
|
.waitForLoadState("networkidle", { timeout: 5_000 })
|
|
.catch(() => {});
|
|
await delay(500);
|
|
};
|
|
|
|
// Race the render against launcher death so a mid-load server crash is
|
|
// reported as the real cause instead of a 30s render timeout.
|
|
try {
|
|
await Promise.race([server.whenChildExits(), render()]);
|
|
} catch (err) {
|
|
const notes = [
|
|
...diagnostics.pageErrors,
|
|
...diagnostics.consoleErrors.map((m) => `console: ${m}`),
|
|
];
|
|
await fail(
|
|
`${err instanceof Error ? err.message : String(err)}${
|
|
notes.length ? ` — page diagnostics: ${notes.join("; ")}` : ""
|
|
}`,
|
|
);
|
|
}
|
|
|
|
// Hard failures: any uncaught (sync) page error, plus console errors that are
|
|
// the async half of the class (unhandled rejection / failed dynamic import).
|
|
const fatal = diagnostics.fatal();
|
|
if (fatal.length > 0) {
|
|
await fail(`app logged uncaught error(s): ${fatal.join("; ")}`);
|
|
}
|
|
|
|
// Non-fatal console errors: surface them so a real problem isn't invisible,
|
|
// without failing the smoke on benign subresource/warning noise.
|
|
const benign = diagnostics.benignConsole();
|
|
if (benign.length > 0) {
|
|
console.log(
|
|
`${LABEL} note — ${benign.length} non-fatal console error(s): ${benign.join("; ")}`,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`${LABEL} OK — app booted at ${server.baseUrl}, rendered "Add Servers" with no uncaught errors (sync page error or unhandled rejection)`,
|
|
);
|
|
await shutdown();
|
|
process.exit(0);
|
|
} catch (err) {
|
|
await fail(err instanceof Error ? err.message : String(err));
|
|
}
|