Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cross-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion gui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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
Expand Down
6 changes: 6 additions & 0 deletions gui/bunfig.toml
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 2 additions & 1 deletion gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
180 changes: 180 additions & 0 deletions gui/scripts/test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
for (const match of xml.matchAll(/<testsuite\b([^>]*)>/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<number> {
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<string[]> {
const failed: string[] = [];
let next = 0;
const workerCount = Math.min(workers, Math.max(1, files.length));
async function worker(): Promise<void> {
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<void> {
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();
}
70 changes: 45 additions & 25 deletions gui/tests/add-codex-account-oauth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
22 changes: 17 additions & 5 deletions gui/tests/apikeys-mutation-timeout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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;
},
});
Expand Down Expand Up @@ -95,6 +100,15 @@ async function tick(ms = 0): Promise<void> {
});
}

async function fireMutationTimeout(): Promise<void> {
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) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading