fix: prepare browser smoke fixture before launch (#3333)

## Summary
- prepares emitted dashboard CSS and binds the fixture server before
starting Chrome
- prevents cold client builds from consuming the supervised browser
lifetime
- adds a regression that holds browser launch until fixture preparation
resolves

## Test plan
- `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest
run --project dashboard-app-quality-foundation-ui --pool=vmThreads
--maxWorkers=1 --silent=passed-only --reporter=dot
app/__tests__/browser-layout-smoke-fixture.test.ts`
- `pnpm --filter @fusion/dashboard typecheck`
- `pnpm --filter @fusion/dashboard build`
- `FUSION_BROWSER_SMOKE_REQUIRE=1 pnpm --filter @fusion/dashboard
test:browser-smoke`
- `pnpm exec eslint packages/dashboard/scripts/browser-layout-smoke.mjs
packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved browser smoke test setup to ensure fixture preparation
completes before the browser launches.
* Added cleanup handling when browser startup fails, preventing leftover
test resources.
* Preserved the primary browser launch error when cleanup also fails,
while recording the cleanup issue.

* **Tests**
* Added coverage for fixture startup order, launch failures, and cleanup
behavior.
  * Preserved existing HTML fixture validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Phil Larson
2026-08-04 22:03:52 -07:00
committed by GitHub
parent fa3df0f6bd
commit 3071018593
2 changed files with 100 additions and 6 deletions

View File

@@ -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<typeof fixture>((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 [

View File

@@ -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 });
}
}
}