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