diff --git a/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts b/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts index c379c64b55..b3022262e9 100644 --- a/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts +++ b/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts @@ -1,7 +1,73 @@ -import { describe, expect, it } from "vitest"; -import { createSmokeHtml } from "../../scripts/browser-layout-smoke.mjs"; +import { describe, expect, it, vi } from "vitest"; +import { createSmokeHtml, prepareBrowserSmoke } from "../../scripts/browser-layout-smoke.mjs"; describe("browser layout smoke fixture", () => { + /* + FNXC:DashboardBrowserSmoke 2026-08-04-12:24: + Client CSS preparation may run a multi-minute build, so it must finish before Chrome's supervised lifetime begins. Otherwise the 60-second browser cap can expire before the fixture or any named geometry assertion is reached. + */ + it("prepares the fixture before starting the supervised browser lifetime", async () => { + const events: string[] = []; + const fixture = { server: {}, url: "http://127.0.0.1:1234/" }; + const launched = { browser: {}, userDataDir: "/tmp/browser-smoke", wsUrl: "ws://browser" }; + let resolveFixture!: (value: typeof fixture) => void; + const fixtureReady = new Promise((resolve) => { + resolveFixture = resolve; + }); + + const preparing = prepareBrowserSmoke("/browser", { + startFixture: async () => { + events.push("fixture:start"); + const result = await fixtureReady; + events.push("fixture:ready"); + return result; + }, + launch: async () => { + events.push("browser:launch"); + return launched; + }, + }); + + await Promise.resolve(); + expect(events).toEqual(["fixture:start"]); + + resolveFixture(fixture); + await expect(preparing).resolves.toEqual({ fixture, launched }); + expect(events).toEqual(["fixture:start", "fixture:ready", "browser:launch"]); + }); + + /* + FNXC:DashboardBrowserSmoke 2026-08-04-13:29: + A browser launch failure remains the primary diagnostic even when fixture cleanup also fails. Cleanup must still receive the prepared fixture, and its secondary failure must remain observable without replacing the launch error. + */ + it("preserves a browser launch failure when fixture cleanup also fails", async () => { + const fixture = { server: null as never, url: "http://127.0.0.1:1234/" }; + const launchError = new Error("browser launch failed"); + const cleanupError = new Error("fixture cleanup failed"); + const closeFixture = vi.fn(async () => { + throw cleanupError; + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await expect(prepareBrowserSmoke("/browser", { + startFixture: async () => fixture, + launch: async () => { + throw launchError; + }, + closeFixture, + })).rejects.toBe(launchError); + expect(closeFixture).toHaveBeenCalledOnce(); + expect(closeFixture).toHaveBeenCalledWith(fixture); + expect(warn).toHaveBeenCalledWith( + "[dashboard-browser-smoke] fixture cleanup after browser launch failure also failed:", + cleanupError, + ); + } finally { + warn.mockRestore(); + } + }); + it("includes standalone and embedded Git Manager shell fixtures", () => { const html = createSmokeHtml(); for (const hook of [ diff --git a/packages/dashboard/scripts/browser-layout-smoke.mjs b/packages/dashboard/scripts/browser-layout-smoke.mjs index 271ec3459d..5c2b104a2f 100644 --- a/packages/dashboard/scripts/browser-layout-smoke.mjs +++ b/packages/dashboard/scripts/browser-layout-smoke.mjs @@ -797,6 +797,32 @@ async function startFixtureServer() { }; } +/* +FNXC:DashboardBrowserSmoke 2026-08-04-12:24: +Prepare emitted client CSS and bind the fixture server before starting Chrome's supervised 60-second lifetime. A cold client build can take several minutes on supported development hosts; that build time must not consume the browser's geometry-check budget or kill Chrome before the first named assertion. +*/ +export async function prepareBrowserSmoke(executable, { + startFixture = startFixtureServer, + launch = launchBrowser, + closeFixture = (fixture) => closeServer(fixture.server), +} = {}) { + const fixture = await startFixture(); + try { + const launched = await launch(executable); + return { fixture, launched }; + } catch (error) { + try { + await closeFixture(fixture); + } catch (cleanupError) { + console.warn( + "[dashboard-browser-smoke] fixture cleanup after browser launch failure also failed:", + cleanupError, + ); + } + throw error; + } +} + async function findBrowserExecutable() { const envCandidates = [ process.env.FUSION_BROWSER_SMOKE_BROWSER, @@ -2019,11 +2045,11 @@ async function main() { } log("using local browser; this fixture smoke checks real CSS layout but does not replace full dashboard E2E coverage."); - const launched = await launchBrowser(executable); let fixture; + let launched; let page; try { - fixture = await startFixtureServer(); + ({ fixture, launched } = await prepareBrowserSmoke(executable)); page = await createPage(launched.wsUrl); await runSmokeChecks(page, fixture.url); } finally { @@ -2031,8 +2057,10 @@ async function main() { if (fixture) { await closeServer(fixture.server); } - await stopBrowser(launched.browser); - await rm(launched.userDataDir, { recursive: true, force: true }); + if (launched) { + await stopBrowser(launched.browser); + await rm(launched.userDataDir, { recursive: true, force: true }); + } } }