diff --git a/.changeset/flaky-chrome-launch.md b/.changeset/flaky-chrome-launch.md new file mode 100644 index 00000000..faa6810b --- /dev/null +++ b/.changeset/flaky-chrome-launch.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/cli": patch +--- + +`render_page` on Node: a Chrome that hangs or crashes on a cold start is launched once more with a fresh profile instead of failing the render. diff --git a/packages/cli/src/render-chrome.ts b/packages/cli/src/render-chrome.ts index f9f72bc0..b464d07a 100644 --- a/packages/cli/src/render-chrome.ts +++ b/packages/cli/src/render-chrome.ts @@ -90,8 +90,9 @@ export function chromeRenderer( ); const endpoint = new Promise((resolve, reject) => { let err = ""; - // A cold start on a busy CI runner can take well over 10 s; a warm one is under a second. - const timer = setTimeout(() => reject(new Error("Chrome did not start")), 30_000); + // A warm start is under a second, a cold one on a busy CI runner can take over 10 s; one that + // hangs past this is killed and launched once more (session()). + const timer = setTimeout(() => reject(new Error("Chrome did not start")), 20_000); child.stderr?.on("data", (chunk: Buffer) => { err += chunk.toString(); const m = /DevTools listening on (ws:\/\/\S+)/.exec(err); @@ -108,15 +109,24 @@ export function chromeRenderer( }); endpoint.catch(() => {}); chrome = { process: child, endpoint, dir }; - process.once("exit", () => child.kill()); + const killOnExit = () => child.kill(); + process.once("exit", killOnExit); + child.once("exit", () => process.off("exit", killOnExit)); return chrome; }; return { async session() { clearTimeout(idle); - const { endpoint } = chrome ?? start(); - const ws = new WebSocket(await endpoint); + let url: string; + try { + url = await (chrome ?? start()).endpoint; + } catch { + // A stuck or crashed cold start: one fresh launch with a new profile. + stop(); + url = await start().endpoint; + } + const ws = new WebSocket(url); await new Promise((resolve, reject) => { ws.addEventListener("open", () => resolve(), { once: true }); ws.addEventListener("error", () => reject(new Error("could not reach Chrome")), { diff --git a/packages/cli/test/render-chrome.test.ts b/packages/cli/test/render-chrome.test.ts index 8ef7a9f4..df3fabc3 100644 --- a/packages/cli/test/render-chrome.test.ts +++ b/packages/cli/test/render-chrome.test.ts @@ -72,7 +72,8 @@ describe.skipIf(!chrome)("render_page checks in Chrome", () => { expect(check("landmarks")?.examples).toEqual( expect.arrayContaining(["@1440: the page — 0 page headers (one is right)"]), ); - }, 60_000); + // The first test starts Chrome: up to two 20 s launch attempts (render-chrome.ts) plus the render. + }, 120_000); it("checks the chrome: skip link, landmarks, a transparent header's contrast, focus under it", async () => { dir ??= await mkdtemp(path.join(os.tmpdir(), "cloudhal-render-test-")); @@ -280,3 +281,24 @@ describe("localRender", () => { expect(localRender({ CHROME_PATH: "/nonexistent/chrome" })).toBeUndefined(); }); }); + +describe("chromeRenderer", () => { + it.skipIf(process.platform === "win32")( + "launches a Chrome that fails to start once more, with a new profile", + async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-render-retry-")); + const log = path.join(tmp, "launches"); + const fake = path.join(tmp, "chrome"); + await writeFile(fake, `#!/bin/sh\necho "$@" >> "${log}"\nexit 1\n`, { mode: 0o755 }); + try { + await expect(chromeRenderer(fake).session()).rejects.toThrow("Chrome exited"); + const launches = (await readFile(log, "utf8")).trim().split("\n"); + expect(launches).toHaveLength(2); + const profile = (line?: string) => /--user-data-dir=(\S+)/.exec(line ?? "")?.[1]; + expect(profile(launches[0])).not.toBe(profile(launches[1])); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index d8ceec21..0a3099bd 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -11,5 +11,8 @@ export default defineConfig({ environmentOptions: { happyDOM: { settings: { disableIframePageLoading: true } } }, include: ["test/**/*.test.tsx"], setupFiles: ["./test/setup.ts"], + // The first test to open a lazy kit (the calendar, charts) pays its cold import; on a CI runner + // shared with the heavy suites that went past Vitest's 5 s default. Same limit as Studio. + testTimeout: 20_000, }, });