From 60ba5d7db76b6ec70d5c4ef1ab976ae06077c7af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 16:08:36 +0000 Subject: [PATCH] test(gui): isolate dashboard suite so GitHub CI gets past GUI tests Shared-global bun test leaked happy-dom and React 19 window.event updates across files, which failed dozens of tests on GHA. Run isolated workers (cap 4), retry only failed files, and fire mutation timeouts from the test instead of a 30ms wall clock. Co-authored-by: pavelhov --- .github/workflows/ci.yml | 6 +- .github/workflows/cross-platform.yml | 2 +- docs-site/src/content/docs/contributing.md | 1 + gui/AGENTS.md | 7 +- gui/bunfig.toml | 6 + gui/package.json | 3 +- gui/scripts/test.ts | 180 ++++++++++++++++++++ gui/tests/add-codex-account-oauth.test.tsx | 70 +++++--- gui/tests/apikeys-mutation-timeout.test.tsx | 22 ++- gui/tests/gui-test-runner.test.ts | 45 +++++ gui/tests/preload.ts | 47 +++++ structure/06_docs-and-release.md | 2 +- 12 files changed, 356 insertions(+), 35 deletions(-) create mode 100644 gui/bunfig.toml create mode 100644 gui/scripts/test.ts create mode 100644 gui/tests/gui-test-runner.test.ts create mode 100644 gui/tests/preload.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02b4305c7a..b200f580a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,8 +64,12 @@ jobs: - name: GUI i18n lint run: cd gui && bun run lint:i18n + # Isolated workers: GUI tests install happy-dom on globalThis. A shared-global + # `bun test tests` lets a late React 19 update throw on window.event and poison + # later files (red on GHA, green on a fast laptop). The runner retries only + # failed files. - name: GUI tests - run: cd gui && bun test tests + run: cd gui && bun run test # Nor is the build optional. Tests that fetch the served dashboard read # the session bootstrap meta tags out of `gui/dist/index.html`, so without diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 711b455009..0659890149 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -59,7 +59,7 @@ jobs: - name: GUI tests working-directory: gui - run: bun test tests + run: bun run test - name: Test suite run: bun run test diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7f7f216797..0e3f2c7abe 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -29,6 +29,7 @@ scripts so local commands match CI: bun run typecheck # strict TypeScript check bun run test # complete tests/ suite bun test tests/router.test.ts # focused test file +cd gui && bun run test # dashboard tests (isolated workers) bun run build:gui # Vite GUI build + package preparation bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets diff --git a/gui/AGENTS.md b/gui/AGENTS.md index e7ce909b90..9e23f9b3a2 100644 --- a/gui/AGENTS.md +++ b/gui/AGENTS.md @@ -45,11 +45,16 @@ Run all of the following for every functional `gui/` change: ```bash cd gui -bun test tests +bun run test bun run lint bun run build ``` +`bun run test` is the suite CI runs: isolated workers (cap 4) so happy-dom files +do not share `globalThis`, with a retry of only the failed files. A single file +is `bun test tests/.test.ts`. Do not recover a red suite by rerunning +everything at a lower worker count. + After any UI-copy or locale change, also run: ```bash diff --git a/gui/bunfig.toml b/gui/bunfig.toml new file mode 100644 index 0000000000..93de2ca579 --- /dev/null +++ b/gui/bunfig.toml @@ -0,0 +1,6 @@ +# GUI tests install happy-dom onto globalThis.document / window. A bare +# `bun test tests` shares one global across every file; use `bun run test` +# (isolated workers) for the suite. Single-file `bun test tests/foo.test.tsx` +# is fine. This preload keeps React 19's window.event read from throwing. +[test] +preload = ["./tests/preload.ts"] diff --git a/gui/package.json b/gui/package.json index e9977793f4..a3d0b72b86 100644 --- a/gui/package.json +++ b/gui/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "bunx --bun eslint .", - "test": "bun test tests", + "test": "bun scripts/test.ts", + "test:raw": "bun test tests", "lint:i18n": "bunx --bun eslint src/pages src/components src/App.tsx src/ui.tsx", "doctor": "npx --yes react-doctor@0.9.3 --verbose --scope changed --base origin/main --no-telemetry", "doctor:full": "npx --yes react-doctor@0.9.3 --verbose --scope full --no-telemetry", diff --git a/gui/scripts/test.ts b/gui/scripts/test.ts new file mode 100644 index 0000000000..e7d257accc --- /dev/null +++ b/gui/scripts/test.ts @@ -0,0 +1,180 @@ +/** + * GUI suite runner. + * + * Happy-dom tests mutate globalThis.document/window. Bun's default shared-global + * run is fast locally and red on slower GHA: a late React 19 update after + * afterEach then throws on `window.event` and poisons later files. `--parallel` + * implies `--isolate` (fresh global per file) and is the default here, capped + * like the proxy suite at min(4, CPU). Failed files retry once on a single + * worker — never a full-suite rerun. + * + * Override with CCX_TEST_PARALLEL_WORKERS and CCX_TEST_RETRY. + */ +import { availableParallelism } from "node:os"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, relative } from "node:path"; + +const GUI_ROOT = join(import.meta.dir, ".."); +const TEST_ROOT = "tests"; + +export function resolveWorkerCount( + raw = process.env.CCX_TEST_PARALLEL_WORKERS, + cpuCount = availableParallelism(), +): number { + if (raw === undefined || raw.trim() === "") return Math.max(1, Math.min(4, cpuCount)); + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error( + `CCX_TEST_PARALLEL_WORKERS must be a positive integer, received ${JSON.stringify(raw)}`, + ); + } + return parsed; +} + +export function resolveRetryCount(raw = process.env.CCX_TEST_RETRY): number { + if (raw === undefined || raw.trim() === "") return 1; + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`CCX_TEST_RETRY must be a non-negative integer, received ${JSON.stringify(raw)}`); + } + return parsed; +} + +/** File paths from a Bun junit report that had failures or errors. */ +export function failedFilesFromJunit(xml: string): string[] { + const files = new Set(); + for (const match of xml.matchAll(/]*)>/g)) { + const attrs = match[1] ?? ""; + const failures = Number(/\bfailures="(\d+)"/.exec(attrs)?.[1] ?? 0); + const errors = Number(/\berrors="(\d+)"/.exec(attrs)?.[1] ?? 0); + if (failures + errors === 0) continue; + const file = /\bfile="([^"]+)"/.exec(attrs)?.[1]; + if (file) files.add(file); + } + return [...files].sort((a, b) => a.localeCompare(b)); +} + +function displayPath(file: string): string { + const rel = relative(GUI_ROOT, file); + return rel && !rel.startsWith("..") ? rel : file; +} + +function resolveSuiteFile(file: string): string { + return isAbsolute(file) ? file : join(GUI_ROOT, file); +} + +function spawnTest(args: string[], junitPath?: string): Promise { + const env = { ...process.env, TZ: process.env.TZ || "UTC" }; + const command = [ + process.execPath, + "test", + ...args, + ...(junitPath ? ["--reporter=junit", `--reporter-outfile=${junitPath}`] : []), + ]; + const child = Bun.spawn(command, { + cwd: GUI_ROOT, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + return child.exited.then(code => code ?? 1); +} + +async function runQueue(files: string[], workers: number, label: string): Promise { + const failed: string[] = []; + let next = 0; + const workerCount = Math.min(workers, Math.max(1, files.length)); + async function worker(): Promise { + for (;;) { + const index = next; + next += 1; + if (index >= files.length) return; + const file = files[index]!; + const code = await spawnTest(["--isolate", file]); + const status = code === 0 ? "ok" : "FAIL"; + console.warn(`[gui:test] ${label} ${index + 1}/${files.length} ${status} ${displayPath(file)}`); + if (code !== 0) failed.push(file); + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return failed; +} + +async function main(): Promise { + const requested = process.argv.slice(2); + const workers = resolveWorkerCount(); + const retryCount = resolveRetryCount(); + const scratch = mkdtempSync(join(tmpdir(), "ccx-gui-test-")); + const junitPath = join(scratch, "junit.xml"); + + try { + const patterns = requested.length > 0 ? requested : [TEST_ROOT]; + console.warn( + `[gui:test] ${patterns.join(" ")} across ${workers} worker(s) ` + + `(CCX_TEST_PARALLEL_WORKERS / CCX_TEST_RETRY to override)`, + ); + const startedAt = Date.now(); + const code = await spawnTest([`--parallel=${workers}`, ...patterns], junitPath); + + let failures: string[] = []; + if (code !== 0) { + let xml = ""; + try { + xml = readFileSync(junitPath, "utf8"); + } catch { + console.error("[gui:test] suite failed and no junit report was written; not rerunning the whole suite."); + process.exitCode = 1; + return; + } + failures = failedFilesFromJunit(xml).map(resolveSuiteFile); + if (failures.length === 0) { + console.error("[gui:test] suite failed but junit listed no failed files; not rerunning the whole suite."); + process.exitCode = 1; + return; + } + } + + const recovered: string[] = []; + if (failures.length > 0 && retryCount > 0) { + console.warn( + `[gui:test] ${failures.length} file(s) failed; retrying only those files (${retryCount} pass(es), 1 worker):`, + ); + for (const file of failures) console.warn(` ${displayPath(file)}`); + const stillFailing = new Set(failures); + for (let attempt = 1; attempt <= retryCount; attempt += 1) { + const retryFailures = await runQueue([...stillFailing], 1, `retry ${attempt}/${retryCount}`); + const failedThisPass = new Set(retryFailures); + for (const file of stillFailing) { + if (!failedThisPass.has(file)) recovered.push(file); + } + stillFailing.clear(); + for (const file of failedThisPass) stillFailing.add(file); + if (stillFailing.size === 0) break; + } + failures = [...stillFailing]; + } + + const minutes = ((Date.now() - startedAt) / 60_000).toFixed(1); + if (recovered.length > 0) { + console.warn(`[gui:test] recovered after retry (${recovered.length} file(s)):`); + for (const file of recovered) console.warn(` ${displayPath(file)}`); + } + if (failures.length > 0) { + console.error(`[gui:test] ${failures.length} file(s) failed after ${minutes} min:`); + for (const file of failures) console.error(` ${displayPath(file)}`); + console.error("[gui:test] rerun only the failed files, not the entire suite:"); + console.error(` bun run test ${failures.map(displayPath).join(" ")}`); + process.exitCode = 1; + return; + } + console.warn(`[gui:test] passed in ${minutes} min`); + } finally { + try { rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } + } +} + +if (import.meta.main) { + await main(); +} diff --git a/gui/tests/add-codex-account-oauth.test.tsx b/gui/tests/add-codex-account-oauth.test.tsx index 5f2a9408c5..49b8f91f20 100644 --- a/gui/tests/add-codex-account-oauth.test.tsx +++ b/gui/tests/add-codex-account-oauth.test.tsx @@ -132,29 +132,49 @@ test("StrictMode remount clears the reauth latch and starts OAuth again", async }); test("slow login-status polls stay single-flight and abort on unmount", async () => { - await mountProbe(false); - await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); - - // Wait past two interval ticks while the first status response is still held. - await act(async () => { await new Promise((r) => setTimeout(r, 4500)); }); - - const statusCalls = calls.filter((c) => c.path.includes("/api/codex-auth/login-status")); - expect(statusCalls.length).toBe(1); - expect(statusHolders.length).toBe(1); - - const inFlightSignal = statusCalls[0]?.signal; - expect(inFlightSignal).toBeTruthy(); - - if (root) { - const current = root; - await act(async () => { current.unmount(); }); - root = null; + let poll: (() => void) | null = null; + const previousSetInterval = globalThis.setInterval; + const previousClearInterval = globalThis.clearInterval; + Object.defineProperty(globalThis, "setInterval", { + configurable: true, + value: (callback: () => void) => { + poll = callback; + return 1; + }, + }); + Object.defineProperty(globalThis, "clearInterval", { + configurable: true, + value: () => {}, + }); + try { + await mountProbe(false); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); + + // Two ticks while the first status response is still held: in-flight + // must stay a single fetch, not a second poll. + await act(async () => { void poll?.(); void poll?.(); }); + + const statusCalls = calls.filter((c) => c.path.includes("/api/codex-auth/login-status")); + expect(statusCalls.length).toBe(1); + expect(statusHolders.length).toBe(1); + + const inFlightSignal = statusCalls[0]?.signal; + expect(inFlightSignal).toBeTruthy(); + + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + + expect(inFlightSignal!.aborted).toBe(true); + + // A late tick must not open a second in-flight poll after cleanup. + await act(async () => { poll?.(); }); + const statusAfter = calls.filter((c) => c.path.includes("/api/codex-auth/login-status")); + expect(statusAfter.length).toBe(1); + } finally { + Object.defineProperty(globalThis, "setInterval", { configurable: true, value: previousSetInterval }); + Object.defineProperty(globalThis, "clearInterval", { configurable: true, value: previousClearInterval }); } - - expect(inFlightSignal!.aborted).toBe(true); - - // A late tick must not open a second in-flight poll after cleanup. - await act(async () => { await new Promise((r) => setTimeout(r, 2500)); }); - const statusAfter = calls.filter((c) => c.path.includes("/api/codex-auth/login-status")); - expect(statusAfter.length).toBe(1); -}, { timeout: 20_000 }); +}); diff --git a/gui/tests/apikeys-mutation-timeout.test.tsx b/gui/tests/apikeys-mutation-timeout.test.tsx index 23da07e28f..676de6c3b2 100644 --- a/gui/tests/apikeys-mutation-timeout.test.tsx +++ b/gui/tests/apikeys-mutation-timeout.test.tsx @@ -16,6 +16,8 @@ let restoreGlobals: (() => void) | undefined; let previousLanguageDescriptor: PropertyDescriptor | undefined; let previousAbortTimeout: PropertyDescriptor | undefined; let testWindow: Window; +/** Bound AbortSignal.timeout is released by the test, not a wall-clock 30ms. */ +let pendingTimeoutAbort: (() => void) | undefined; const AUTH_MATRIX = [ { endpoint: "/v1/responses", bearer: "rejected", dedicated: "required", xApiKey: "rejected" }, @@ -46,13 +48,16 @@ beforeEach(() => { // `fetch` — waiting the real 15s would only prove the clock works, at the // price of half a minute on every suite run. The timer itself is covered in // tests/bounded-fetch.test.ts. + pendingTimeoutAbort = undefined; previousAbortTimeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout"); Object.defineProperty(AbortSignal, "timeout", { configurable: true, value: (ms: number) => { expect(ms).toBe(15_000); const controller = new AbortController(); - setTimeout(() => controller.abort(new DOMException("TimeoutError", "TimeoutError")), 30); + pendingTimeoutAbort = () => { + controller.abort(new DOMException("TimeoutError", "TimeoutError")); + }; return controller.signal; }, }); @@ -95,6 +100,15 @@ async function tick(ms = 0): Promise { }); } +async function fireMutationTimeout(): Promise { + expect(pendingTimeoutAbort).toBeTruthy(); + await act(async () => { + pendingTimeoutAbort!(); + await Promise.resolve(); + }); + await tick(); +} + /** A mutation that is accepted and then abandoned; only the abort signal ends it. */ function installStallingFetch(seen: { aborted: boolean }): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -180,8 +194,7 @@ test("a delete that never answers gives navigation back instead of locking it", expect(backButton(container).disabled).toBe(true); // The bound fires and the page is usable again — no reload required. - await tick(120); - await tick(); + await fireMutationTimeout(); expect(seen.aborted).toBe(true); expect(backButton(container).disabled).toBe(false); @@ -222,8 +235,7 @@ test("a rename that never answers releases the lock and keeps the draft", async expect(backButton(container).disabled).toBe(true); - await tick(120); - await tick(); + await fireMutationTimeout(); expect(seen.aborted).toBe(true); expect(backButton(container).disabled).toBe(false); diff --git a/gui/tests/gui-test-runner.test.ts b/gui/tests/gui-test-runner.test.ts new file mode 100644 index 0000000000..7fb5931a06 --- /dev/null +++ b/gui/tests/gui-test-runner.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { failedFilesFromJunit, resolveRetryCount, resolveWorkerCount } from "../scripts/test"; + +describe("GUI test runner", () => { + test("defaults to a bounded worker count and validates overrides", () => { + expect(resolveWorkerCount(undefined, 10)).toBe(4); + expect(resolveWorkerCount(undefined, 2)).toBe(2); + expect(resolveWorkerCount("8", 10)).toBe(8); + expect(() => resolveWorkerCount("0", 10)).toThrow("positive integer"); + expect(() => resolveWorkerCount("1.5", 10)).toThrow("positive integer"); + expect(() => resolveWorkerCount("x", 10)).toThrow("positive integer"); + }); + + test("retries failed files once by default and validates overrides", () => { + expect(resolveRetryCount(undefined)).toBe(1); + expect(resolveRetryCount("0")).toBe(0); + expect(resolveRetryCount("2")).toBe(2); + expect(() => resolveRetryCount("-1")).toThrow("non-negative integer"); + expect(() => resolveRetryCount("1.5")).toThrow("non-negative integer"); + }); + + test("happy-dom Window exposes event for React 19 update priority", async () => { + const { Window } = await import("happy-dom"); + const win = new Window({ url: "http://localhost/" }); + expect("event" in win).toBe(true); + expect((win as { event?: unknown }).event).toBeUndefined(); + win.close(); + }); + + test("junit parser names only files that actually failed", () => { + const xml = ` + + + + + + + Expected true + + + +`; + expect(failedFilesFromJunit(xml)).toEqual(["tests/fail.test.tsx", "tests/load.test.ts"]); + }); +}); diff --git a/gui/tests/preload.ts b/gui/tests/preload.ts new file mode 100644 index 0000000000..0ee7790017 --- /dev/null +++ b/gui/tests/preload.ts @@ -0,0 +1,47 @@ +/** + * GUI test preload. Happy-dom Windows omit the IE `window.event` field React 19 + * reads in `resolveUpdatePriority`. A late `dispatchSetState` after a test + * restores `globalThis.window` to undefined then throws, and in a shared-global + * `bun test tests` run that poisons every later file — the GHA-only failure + * mode. Stub the field on every Window, and leave a tiny window object behind + * after each file so a stray tick cannot throw. + */ +import { afterEach } from "bun:test"; +import { Window } from "happy-dom"; + +if (!Object.prototype.hasOwnProperty.call(Window.prototype, "event")) { + Object.defineProperty(Window.prototype, "event", { + configurable: true, + writable: true, + value: undefined, + }); +} + +process.env.TZ ??= "UTC"; + +const WINDOW_EVENT_STUB: { event: undefined } = { event: undefined }; + +afterEach(() => { + const current = (globalThis as { window?: { event?: unknown } | null }).window; + if (current == null || typeof current !== "object") { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: WINDOW_EVENT_STUB, + }); + return; + } + if (!Object.prototype.hasOwnProperty.call(current, "event")) { + try { + Object.defineProperty(current, "event", { + configurable: true, + writable: true, + value: undefined, + }); + } catch { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: WINDOW_EVENT_STUB, + }); + } + } +}); diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index f6eb4bfae2..3e17fd4dc2 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -109,7 +109,7 @@ bun x tsc --noEmit bun run privacy:scan cd gui && bun run lint cd gui && bun run lint:i18n -cd gui && bun test tests +cd gui && bun run test cd gui && bun run build bun run test:parallel bun run src/cli/index.ts help