From 31954a9ea6e8fdae78b3c83ccb26ab6496d1b576 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:16:56 +0200 Subject: [PATCH 1/3] chore: bump react-doctor to 0.9.11 and clear new findings Pin the CI action to v2.2.8 and the local engine to 0.9.11. Replace SPA hash anchors with navigateHash controls and drop unused exports so the full scan stays clean. --- .github/workflows/react-doctor.yml | 4 ++-- gui/README.md | 2 +- gui/package.json | 4 ++-- .../components/provider-workspace/ProviderRail.tsx | 2 +- gui/src/icons.tsx | 1 - gui/src/pages/CodexAuth.tsx | 11 +++++++++-- gui/src/pages/Startup.tsx | 5 ++++- gui/src/pages/dashboard-overview-head.tsx | 5 +++-- gui/src/pages/models-provider-hints.tsx | 5 ++++- gui/src/styles.css | 6 ++++++ tests/ci-workflows.test.ts | 6 +++--- 11 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 23e0a4777a..39ef252336 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -45,12 +45,12 @@ jobs: with: fetch-depth: 0 - - uses: millionco/react-doctor@938008119a288f2fb47c66a69cd9279a21f31784 # v2.2.7 + - uses: millionco/react-doctor@01820bb4fd4d0a4aebcd8df2b2a143a098649cb2 # v2.2.8 with: directory: gui # Pin the npm engine — the action wrapper would otherwise fetch # react-doctor@latest, silently skewing CI from the local pinned runs. - version: "0.9.3" + version: "0.9.11" # Fail the job on any finding (errors or warnings). blocking: warning comment: false diff --git a/gui/README.md b/gui/README.md index d46ca34a5e..10b2209168 100644 --- a/gui/README.md +++ b/gui/README.md @@ -49,6 +49,6 @@ bun run setup:hooks # pre-push runs doctor when gui/ changed | Tool | Role | |------|------| | **ESLint** (`bun run lint`) | Hard gate in CI and expected before merge | -| **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.3 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding | +| **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.11 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding | Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage. diff --git a/gui/package.json b/gui/package.json index bc6b9624ef..78876e88e4 100644 --- a/gui/package.json +++ b/gui/package.json @@ -9,8 +9,8 @@ "lint": "eslint .", "test": "bun test tests", "lint:i18n": "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", + "doctor": "npx --yes react-doctor@0.9.11 --verbose --scope changed --base origin/main --no-telemetry", + "doctor:full": "npx --yes react-doctor@0.9.11 --verbose --scope full --no-telemetry", "preview": "vite preview" }, "dependencies": { diff --git a/gui/src/components/provider-workspace/ProviderRail.tsx b/gui/src/components/provider-workspace/ProviderRail.tsx index af12944219..db6964f8de 100644 --- a/gui/src/components/provider-workspace/ProviderRail.tsx +++ b/gui/src/components/provider-workspace/ProviderRail.tsx @@ -66,7 +66,7 @@ export function ProviderIcon({ name, adapter, baseUrl, cls }: { * presets and custom providers). Hue is derived deterministically from the * provider id so the same provider always gets the same color. */ -export function ProviderFallbackMark({ name, label }: { name: string; label: string }) { +function ProviderFallbackMark({ name, label }: { name: string; label: string }) { const hue = [...name].reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % 360; const initial = (label.trim()[0] ?? name[0] ?? "?").toUpperCase(); return ( diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 2705f679f0..bed8492599 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -38,7 +38,6 @@ export const IconKey = (p: P) => (); export const IconTicket = (p: P) => (); -export const IconRoute = (p: P) => (); export const IconLink = (p: P) => (); export const IconSun = (p: P) => (); export const IconMoon = (p: P) => (); diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index ea71df89b4..b1999f31f4 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -3,6 +3,7 @@ import { useT } from "../i18n/shared"; import CodexAccountPool from "../components/CodexAccountPool"; import DefaultModeRequestUserInputSetting from "../components/DefaultModeRequestUserInputSetting"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; +import { navigateHash } from "../hash-routing"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; @@ -46,7 +47,10 @@ export function OpenAiAccountModeBanner({ )} {state === "direct" && (

- {t("codexAuth.accountModeDirectDesc")} {t("codexAuth.openProviders")} + {t("codexAuth.accountModeDirectDesc")}{" "} +

)} {(state === "absent" || state === "disabled") && ( @@ -59,7 +63,10 @@ export function OpenAiAccountModeBanner({ )} {state === "invalid" && (

- {t("codexAuth.openaiMissing")} {t("codexAuth.openProviders")} + {t("codexAuth.openaiMissing")}{" "} +

)} diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index 95b18d3848..8bb03166c0 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconRefresh } from "../icons"; import { type TFn, useI18n } from "../i18n/shared"; +import { navigateHash } from "../hash-routing"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { Notice } from "../ui"; import { useDataSurface } from "../data-surface"; @@ -288,7 +289,9 @@ export default function Startup({ apiBase }: { apiBase: string }) {

{t("startup.subtitle")}

- {t("startup.backToDashboard")} + diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 31e85eb1a1..05a8530474 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -2,6 +2,7 @@ import { IconAlert, IconInfo } from "../icons"; import { type TKey, useT } from "../i18n/shared"; import { formatTokens } from "../format-tokens"; import { formatUptime } from "../formatUptime"; +import { navigateHash } from "../hash-routing"; import type { useDashboardData } from "./use-dashboard-data"; type Dash = ReturnType; @@ -85,7 +86,7 @@ export function DashboardOverviewHead({
{startupHealth ? ( - + ) : ( ); diff --git a/gui/src/styles.css b/gui/src/styles.css index b222f914f4..09b012b970 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -804,6 +804,8 @@ a.btn, a.btn:hover { text-decoration: none; } .stat .value.mono { font-family: var(--font-code); font-size: var(--text-subtitle); } .startup-health-bar { + box-sizing: border-box; + width: 100%; min-height: var(--control-lg); margin: calc(-1 * var(--space-3)) 0 var(--space-6); padding: 0 var(--space-3); @@ -813,10 +815,14 @@ a.btn, a.btn:hover { text-decoration: none; } min-width: 0; color: var(--muted); background: var(--hover); + border: none; border-block: 1px solid var(--border-soft); text-decoration: none; + font: inherit; font-size: var(--text-control); line-height: var(--leading-ui); + text-align: start; + cursor: pointer; transition: background var(--motion-fast), color var(--motion-fast); } .startup-health-bar:hover { background: var(--raised); color: var(--text); } diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index fa6ca5cc76..3fc90d89c7 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -4629,13 +4629,13 @@ describe("GitHub Actions hardening", () => { const workflow = await readText(".github/workflows/react-doctor.yml"); expect(workflow).toContain("actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8"); - expect(workflow).toContain("millionco/react-doctor@938008119a288f2fb47c66a69cd9279a21f31784"); + expect(workflow).toContain("millionco/react-doctor@01820bb4fd4d0a4aebcd8df2b2a143a098649cb2"); expect(workflow).not.toMatch( /^\s*-\s+uses:\s+\S+@(?![0-9a-f]{40}(?=[ \t]*(?:#.*)?$))\S+/m, ); // Engine pin: the action wrapper would fetch react-doctor@latest without it. - expect(workflow).toContain('version: "0.9.3"'); + expect(workflow).toContain('version: "0.9.11"'); // Action pin must accept CLI JSON schemaVersion 3 (baseline reports from 0.9.x). // v2.1.0's ensure-json-report only knew schemas 1–2 and failed every PR scan. @@ -4657,7 +4657,7 @@ describe("GitHub Actions hardening", () => { const rootPkg = await readText("package.json"); const doctorConfig = await readText("gui/doctor.config.json"); - expect(guiPkg).toContain("react-doctor@0.9.3"); + expect(guiPkg).toContain("react-doctor@0.9.11"); expect(guiPkg).not.toContain("react-doctor@latest"); expect(rootPkg).not.toContain("react-doctor@latest"); expect(doctorConfig).toContain('"blocking": "warning"'); From 46f8f86723bdfe94d372e7fb802863acec4dbcac Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:43:49 +0200 Subject: [PATCH 2/3] test(gui): update assertions for navigateHash provider links Hash anchors became link-btn controls for react-doctor; point tests at the new markup and select Startup Refresh by label. --- gui/tests/codex-auth-provider-enable.test.tsx | 3 ++- gui/tests/models-empty-provider.test.tsx | 8 ++++---- gui/tests/startup-usage-loading-race.test.tsx | 8 +++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/gui/tests/codex-auth-provider-enable.test.tsx b/gui/tests/codex-auth-provider-enable.test.tsx index bf482c0b6a..216b21edf6 100644 --- a/gui/tests/codex-auth-provider-enable.test.tsx +++ b/gui/tests/codex-auth-provider-enable.test.tsx @@ -59,7 +59,8 @@ test("noncanonical disabled OpenAI rows do not offer built-in recovery", () => { ); expect(html).toContain("The built-in OpenAI provider is not configured."); - expect(html).toContain('href="#providers"'); + expect(html).toContain('class="link-btn"'); + expect(html).toContain("Open Providers"); expect(html).not.toContain("Enable OpenAI"); expect(html).not.toContain("Your OpenAI accounts are still available"); }); diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index bee7f6ef7b..442e96bf17 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -534,7 +534,7 @@ async function withCursorDiscoveryServer( test("empty live-discovery provider renders endpoint guidance and a settings link", () => { const html = renderHint(true, { status: "ok" }); expect(html).toContain("No models were discovered"); - expect(html).toContain('href="#providers"'); + expect(html).toContain('class="link-btn"'); expect(html).toContain("Open provider settings"); expect(html).not.toContain("Discovery failed"); }); @@ -545,7 +545,7 @@ test("failed HTTP discovery renders an amber status badge and reason", () => { expect(html).toContain("HTTP 401"); expect(html).toContain('class="badge badge-amber"'); expect(html).toContain('role="status"'); - expect(html).toContain('href="#providers"'); + expect(html).toContain('class="link-btn"'); }); test("failed discovery renders each server-owned reason without provider detail", () => { @@ -584,7 +584,7 @@ test("HTTP 401 discovery exposes HTTP status and badge", async () => { const html = renderHint(true, discovery); expect(html).toContain("Discovery failed"); expect(html).toContain("HTTP 401"); - expect(html).toContain('href="#providers"'); + expect(html).toContain('class="link-btn"'); }); test("destination-blocked discovery exposes blocked status and badge", async () => { @@ -614,7 +614,7 @@ test("destination-blocked discovery exposes blocked status and badge", async () const html = renderHint(true, discovery); expect(html).toContain("Discovery failed"); expect(html).toContain("blocked by the destination policy"); - expect(html).toContain('href="#providers"'); + expect(html).toContain('class="link-btn"'); }); test("invalid JSON or malformed model data exposes invalid-response status and badge", async () => { diff --git a/gui/tests/startup-usage-loading-race.test.tsx b/gui/tests/startup-usage-loading-race.test.tsx index f465af1280..bf90d2487a 100644 --- a/gui/tests/startup-usage-loading-race.test.tsx +++ b/gui/tests/startup-usage-loading-race.test.tsx @@ -133,15 +133,17 @@ test("an aborted Startup fetch must not clear loading while its replacement is i await settle(); expect(container.textContent).toContain("Checking startup protection"); - const refresh = container.querySelector("button.btn"); - expect(refresh?.disabled).toBe(true); + const refresh = Array.from(container.querySelectorAll("button.btn")) + .find(button => (button.textContent ?? "").includes("Refresh")); + expect(refresh).toBeTruthy(); + expect(refresh!.disabled).toBe(true); await act(async () => { gates[1]!.resolve(FRESH); await Promise.resolve(); }); await waitFor(() => !(container.textContent ?? "").includes("Checking startup protection")); - expect(refresh?.disabled).toBe(false); + expect(refresh!.disabled).toBe(false); await act(async () => { root.unmount(); }); container.remove(); From ef8b242a0fef6d1223fc7eea92e4881730abd4bb Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:53:26 +0200 Subject: [PATCH 3/3] test(catalog): retry vacuous post-approval seam races on lock loss When both children lose the config lock before approval, the suite proved nothing about catalog serialization and failed macOS CI. Retry those vacuous runs until a process reaches the seam. --- .../codex-retained-root-serialization.test.ts | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index b7bebef5d8..a934ce3651 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -479,21 +479,47 @@ test("two processes at the post-approval management seam serialize instead of in console.log(JSON.stringify({ status: response.status, catalogRefresh: body.catalogRefresh })); `; - const children = (["a", "b"] as const).map(marker => Bun.spawn( - [process.execPath, "--eval", routeScript(marker)], - { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }, - )); - - const results = await Promise.all(children.map(async child => { - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - return { exitCode, stdout, stderr }; - })); - - for (const result of results) { + const isPreApprovalLoss = (stderr: string): boolean => + stderr.includes("CONFIG_MUTATION_LOCK_UNAVAILABLE") + || (stderr.includes("EEXIST") && stderr.includes("createOwnership")) + || /database (?:is|table is) locked/i.test(stderr) + || stderr.includes("SQLITE_BUSY"); + + // On macOS CI both children can still lose the config lock before approval even + // after the warm-up — that proves nothing about catalog serialization. Retry + // vacuous runs until at least one process reaches the post-approval seam. + const attemptDeadline = Date.now() + 20_000; + let results: Array<{ exitCode: number; stdout: string; stderr: string }> | undefined; + while (Date.now() < attemptDeadline) { + for (const marker of ["a", "b"] as const) { + rmSync(`${barrier}-${marker}`, { force: true }); + } + writeFileSync(catalogPath, seeded); + + const children = (["a", "b"] as const).map(marker => Bun.spawn( + [process.execPath, "--eval", routeScript(marker)], + { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }, + )); + + results = await Promise.all(children.map(async child => { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; + })); + + if (results.some(result => result.exitCode === 0)) break; + + for (const result of results) { + expect({ preApproval: isPreApprovalLoss(result.stderr), stderr: result.stderr }) + .toMatchObject({ preApproval: true }); + } + } + + expect(results).toBeDefined(); + for (const result of results!) { // A process can lose a race BEFORE approval and never reach the seam at all. // The known cases come from `saveConfigPreservingClaudeCode`: the config mutation // lock is already held, two cold processes create the ownership file at once, or @@ -508,11 +534,8 @@ test("two processes at the post-approval management seam serialize instead of in // message as "busy" rather than a database fault, so treating it as a seam failure // here contradicted the product code and turned ordinary contention into a red build. if (result.exitCode !== 0) { - const preApproval = result.stderr.includes("CONFIG_MUTATION_LOCK_UNAVAILABLE") - || (result.stderr.includes("EEXIST") && result.stderr.includes("createOwnership")) - || /database (?:is|table is) locked/i.test(result.stderr) - || result.stderr.includes("SQLITE_BUSY"); - expect({ preApproval, stderr: result.stderr }).toMatchObject({ preApproval: true }); + expect({ preApproval: isPreApprovalLoss(result.stderr), stderr: result.stderr }) + .toMatchObject({ preApproval: true }); continue; } const parsed = JSON.parse(result.stdout.trim()) as { @@ -530,12 +553,12 @@ test("two processes at the post-approval management seam serialize instead of in // At least one process must have gotten through to the seam, or this test would // be vacuous — two config-lock losers prove nothing about catalog serialization. - expect(results.some(r => r.exitCode === 0)).toBe(true); + expect(results!.some(r => r.exitCode === 0)).toBe(true); // At least one process must reach a real commit, or the race proves nothing: // the adapter is total, so a seam that only ever failed would still answer 2xx // with a typed disposition and satisfy every assertion above. - const dispositions = results + const dispositions = results! .filter(r => r.exitCode === 0) .map(r => (JSON.parse(r.stdout.trim()) as { catalogRefresh: { status: string } }).catalogRefresh.status); expect(dispositions).toContain("committed");