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/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(); 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"'); 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");