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
4 changes: 2 additions & 2 deletions .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion gui/src/components/provider-workspace/ProviderRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 0 additions & 1 deletion gui/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export const IconKey = (p: P) => (<svg {...S(p)}><circle cx="7.5" cy="15.5" r="4

export const IconLock = (p: P) => (<svg {...S(p)}><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>);
export const IconTicket = (p: P) => (<svg {...S(p)}><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/></svg>);
export const IconRoute = (p: P) => (<svg {...S(p)}><circle cx="6" cy="19" r="2"/><circle cx="18" cy="5" r="2"/><path d="M8 19h4a6 6 0 0 0 6-6V7M8 19a6 6 0 0 1 6-6"/></svg>);
export const IconLink = (p: P) => (<svg {...S(p)}><path d="M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 0 1 0 10h-2M8 12h8"/></svg>);
export const IconSun = (p: P) => (<svg {...S(p)}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>);
export const IconMoon = (p: P) => (<svg {...S(p)}><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>);
Expand Down
11 changes: 9 additions & 2 deletions gui/src/pages/CodexAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -46,7 +47,10 @@ export function OpenAiAccountModeBanner({
)}
{state === "direct" && (
<p className="card-sub openai-account-mode-banner__desc">
{t("codexAuth.accountModeDirectDesc")} <a href="#providers">{t("codexAuth.openProviders")}</a>
{t("codexAuth.accountModeDirectDesc")}{" "}
<button type="button" className="link-btn" onClick={() => navigateHash("providers")}>
{t("codexAuth.openProviders")}
</button>
</p>
)}
{(state === "absent" || state === "disabled") && (
Expand All @@ -59,7 +63,10 @@ export function OpenAiAccountModeBanner({
)}
{state === "invalid" && (
<p className="card-sub openai-account-mode-banner__desc">
{t("codexAuth.openaiMissing")} <a href="#providers">{t("codexAuth.openProviders")}</a>
{t("codexAuth.openaiMissing")}{" "}
<button type="button" className="link-btn" onClick={() => navigateHash("providers")}>
{t("codexAuth.openProviders")}
</button>
</p>
)}
</div>
Expand Down
5 changes: 4 additions & 1 deletion gui/src/pages/Startup.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -288,7 +289,9 @@ export default function Startup({ apiBase }: { apiBase: string }) {
<p className="page-sub startup-page-sub">{t("startup.subtitle")}</p>
</div>
<div className="startup-page-head-actions">
<a className="btn btn-ghost btn-sm" href="#dashboard">{t("startup.backToDashboard")}</a>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => navigateHash("dashboard")}>
{t("startup.backToDashboard")}
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => refresh()} disabled={loading}>
<IconRefresh /> {t("startup.refresh")}
</button>
Expand Down
5 changes: 3 additions & 2 deletions gui/src/pages/dashboard-overview-head.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useDashboardData>;
Expand Down Expand Up @@ -85,7 +86,7 @@ export function DashboardOverviewHead({

<div className="startup-health-slot" aria-live="polite">
{startupHealth ? (
<a className="startup-health-bar" href="#startup">
<button type="button" className="startup-health-bar" onClick={() => navigateHash("startup")}>
<span className={`dot ${startupHealth === "error" ? "dot-red" : startupHealth === "at-risk" ? "dot-amber" : "dot-green"}`} aria-hidden="true" />
<span className="startup-health-bar__summary">
{t(startupHealth === "error"
Expand All @@ -96,7 +97,7 @@ export function DashboardOverviewHead({
? "startup.summary.protected"
: "startup.summary.native")}
</span>
</a>
</button>
) : (
<div className="startup-health-bar startup-health-bar--pending" aria-hidden="true">
<span className="dot dot-amber" />
Expand Down
5 changes: 4 additions & 1 deletion gui/src/pages/models-provider-hints.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IconInfo } from "../icons";
import { useT } from "../i18n/shared";
import { navigateHash } from "../hash-routing";
import type { ProviderDiscoverySummary } from "../models-groups";
import { discoveryFailureLabel } from "./models-shared";

Expand All @@ -22,7 +23,9 @@ export function EmptyProviderHint({
{failed
? `${discoveryFailureLabel(t, failed)} `
: `${t(liveModels ? "models.emptyDiscovery" : "models.emptyDiscoveryDisabled")} `}
<a href="#providers">{t("models.openProviderSettings")}</a>
<button type="button" className="link-btn" onClick={() => navigateHash("providers")}>
{t("models.openProviderSettings")}
</button>
</span>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions gui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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); }
Expand Down
3 changes: 2 additions & 1 deletion gui/tests/codex-auth-provider-enable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment on lines +62 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Restore coverage for the provider-navigation contract.

The new assertions check styling and, in one case, text. They do not prove that the same control navigates to providers. A wrong or missing navigateHash handler can pass all these tests.

  • gui/tests/codex-auth-provider-enable.test.tsx#L62-L63: Render the invalid-provider control interactively, scope the label assertion to button.link-btn, click it, and assert #providers or navigateHash("providers").
  • gui/tests/models-empty-provider.test.tsx#L537-L537: Apply the same interactive navigation assertion to the empty live-discovery case.
  • gui/tests/models-empty-provider.test.tsx#L548-L548: Apply the same interactive navigation assertion to the failed HTTP discovery case.
  • gui/tests/models-empty-provider.test.tsx#L587-L587: Apply the same interactive navigation assertion to the HTTP 401 discovery case.
  • gui/tests/models-empty-provider.test.tsx#L617-L617: Apply the same interactive navigation assertion to the blocked discovery case.
📍 Affects 2 files
  • gui/tests/codex-auth-provider-enable.test.tsx#L62-L63 (this comment)
  • gui/tests/models-empty-provider.test.tsx#L537-L537
  • gui/tests/models-empty-provider.test.tsx#L548-L548
  • gui/tests/models-empty-provider.test.tsx#L587-L587
  • gui/tests/models-empty-provider.test.tsx#L617-L617
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gui/tests/codex-auth-provider-enable.test.tsx` around lines 62 - 63, Restore
provider-navigation coverage in
gui/tests/codex-auth-provider-enable.test.tsx:62-63 and
gui/tests/models-empty-provider.test.tsx:537-537, 548-548, 587-587, and 617-617
by rendering each invalid or empty-provider control interactively, scoping the
label assertion to button.link-btn, clicking the control, and asserting
navigation to `#providers` or invocation of navigateHash("providers").

expect(html).not.toContain("Enable OpenAI");
expect(html).not.toContain("Your OpenAI accounts are still available");
});
8 changes: 4 additions & 4 deletions gui/tests/models-empty-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ async function withCursorDiscoveryServer<T>(
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");
});
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
8 changes: 5 additions & 3 deletions gui/tests/startup-usage-loading-race.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>("button.btn");
expect(refresh?.disabled).toBe(true);
const refresh = Array.from(container.querySelectorAll<HTMLButtonElement>("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();
Expand Down
6 changes: 3 additions & 3 deletions tests/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"');
Expand Down
67 changes: 45 additions & 22 deletions tests/codex-retained-root-serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment on lines +482 to +486

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 \
  -e 'withConfigMutationLockSync|ConfigMutationLockError|CONFIG_MUTATION_LOCK_UNAVAILABLE|createOwnership|SQLITE_BUSY|database (is|table is) locked|convergeCodexCatalog' \
  src tests/codex-retained-root-serialization.test.ts

Repository: lidge-jun/opencodex

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the tests and nearby source without running repository code.
wc -l tests/codex-retained-root-serialization.test.ts
sed -n '220,255p' tests/codex-retained-root-serialization.test.ts
sed -n '428,548p' tests/codex-retained-root-serialization.test.ts

printf '\n--- source matches (focused) ---\n'
rg -n -C 8 \
  'class ConfigMutationLockError|export class ConfigMutationLockError|const ERROR_CODE_CONFIG_MUTATION_LOCK|CONFIG_MUTATION_LOCK_UNAVAILABLE|function withConfigMutationLockSync|export function withConfigMutationLockSync|configGenerationFailureReason|database is locked|SQLITE_BUSY' \
  src/config.ts src/storage src --glob '!**/*.png' --glob '!**/*.jpg' --glob '!**/*.gif' | sed -n '1,220p'

printf '\n--- catalog convergence imports/calls near test ---\n'
sed -n '1,80p' src/codex/convergence.ts 2>/dev/null || true

Repository: lidge-jun/opencodex

Length of output: 23380


Make pre-approval classification phase-specific.

isPreApprovalLoss in tests/codex-retained-root-serialization.test.ts lines 482-486 only inspects stderr, so it matches generic SQLite lock text from later catalog-convergence SQLite access as well as config-lock acquisition before approval. The retry loop can retry a real post-approval convergeCodexCatalog() failure and pass on a later attempt. Use a structured config-lock error code or an explicit phase marker; keep catalog-convergence failures from matching the pre-approval predicate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/codex-retained-root-serialization.test.ts` around lines 482 - 486, The
isPreApprovalLoss predicate currently classifies generic SQLite lock errors from
post-approval catalog convergence as pre-approval failures. Update the retry
flow and isPreApprovalLoss to use a structured config-lock error code or
explicit phase marker from the config-lock acquisition path, while ensuring
convergeCodexCatalog failures never satisfy the pre-approval predicate.


// 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
Expand All @@ -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 {
Expand All @@ -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");
Expand Down
Loading