From 0f85c89c914403fa7b6e6e80614f1c15d0839dd6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 15:25:48 +0900 Subject: [PATCH] test(ci): make platform fixtures deterministic and pin lint runtime --- gui/package.json | 4 +- .../release-pipeline-contract.test.ts | 16 +- .../cli/cli-codex-cli-update-boundary.test.ts | 54 +++-- .../codex-runtime-macos-desktop.test.ts | 28 ++- tests/providers/zcode-client.test.ts | 14 +- .../server/model-costs-pricing-cache.test.ts | 14 +- tests/usage/usage-filter-index.test.ts | 188 +++++++++++++++--- 7 files changed, 251 insertions(+), 67 deletions(-) diff --git a/gui/package.json b/gui/package.json index c4cdc1b7c93..aea96590387 100644 --- a/gui/package.json +++ b/gui/package.json @@ -6,9 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build && vite build -c vite.classic.config.ts", - "lint": "oxlint .", + "lint": "bun run --bun oxlint .", "test": "bun ../scripts/test-with-pinned-bun.ts --isolate tests", - "lint:i18n": "oxlint src/pages src/components src/App.tsx src/main.tsx src/ui.tsx src/provider-workspace-data.ts", + "lint:i18n": "bun run --bun oxlint src/pages src/components src/App.tsx src/main.tsx src/ui.tsx src/provider-workspace-data.ts", "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", diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index 3ae4ff29637..d7d5ef799b0 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -714,13 +714,18 @@ describe("release pipeline contract", () => { shellTest("Bash can run Node on a temporary checker file", () => { const cwd = mkdtempSync(join(tmpdir(), "ocx-bash-checker-")); try { - const result = Bun.spawnSync([bash!, "--noprofile", "--norc", "-e", "-c", - `cat > "$RUNNER_TEMP/check.cjs" <<'NODE'\nprocess.stdout.write('checker-ran');\nNODE\nnode "$RUNNER_TEMP/check.cjs"`], { + const checker = String.raw`const invalid = /[\x00-\x1f\x7f/\\]/; +if (invalid.test('app.zip') || !invalid.test('dir\\app.zip') || !invalid.test('\0')) throw Error('checker source changed'); +process.stdout.write('checker-ran');`; + writeFileSync(join(cwd, "check.sh"), + `cat > "$RUNNER_TEMP/check.cjs" <<'NODE'\n${checker}\nNODE\nnode "$RUNNER_TEMP/check.cjs"`); + const result = Bun.spawnSync([bash!, "--noprofile", "--norc", "-e", "./check.sh"], { cwd, env: releaseTestEnv({ PATH: releaseShellPath(dirname(Bun.which("node")!), bash!, process.platform === "win32"), RUNNER_TEMP: "." }), timeout: 3_000, maxBuffer: 16_384, }); expect(result.exitCode, result.stderr.toString()).toBe(0); expect(result.stdout.toString()).toBe("checker-ran"); + expect(readFileSync(join(cwd, "check.cjs"), "utf8")).toBe(`${checker}\n`); } finally { removeTreeWithRetry(cwd); } }); @@ -735,7 +740,8 @@ describe("release pipeline contract", () => { writeFileSync(join(cwd, "dist/release/app.zip"), "archive"); const digest = createHash("sha256").update("archive").digest("hex"); writeFileSync(join(cwd, "verification/receipt.json"), JSON.stringify({ assets: { "app.zip": digest } })); - const result = Bun.spawnSync([bash!, "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", ` + // A script file preserves heredoc backslashes across Windows process argument parsing. + writeFileSync(join(cwd, "attach.sh"), ` gh() { printf 'gh:%s\\n' "$*" >&2 case "$1:$2" in @@ -754,7 +760,9 @@ describe("release pipeline contract", () => { } ${run} printf 'attach-completed\\n' - `], { cwd, env: releaseTestEnv({ PATH: releaseShellPath(dirname(Bun.which("node")!), bash!, process.platform === "win32"), RUNNER_TEMP: ".", RELEASE_VERSION: "2.65.0", DRAFT_STATE: "true", + `); + const result = Bun.spawnSync([bash!, "--noprofile", "--norc", "-e", "-o", "pipefail", "./attach.sh"], + { cwd, env: releaseTestEnv({ PATH: releaseShellPath(dirname(Bun.which("node")!), bash!, process.platform === "win32"), RUNNER_TEMP: ".", RELEASE_VERSION: "2.65.0", DRAFT_STATE: "true", RELEASE_INFO: '{"isDraft":true,"assets":[]}', INITIAL_STATUS: "0", FINAL_INFO: JSON.stringify({ isDraft: true, assets: [{ name: "app.zip", state: "uploaded", digest: `sha256:${digest}` }] }), UPLOAD_STATUS: "0", VIEW_STATUS: "0", EDIT_STATUS: "0", ...env }), diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts index 98a54b84a01..9ffd8f83784 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -257,8 +257,11 @@ describe("Codex CLI selection inspection boundary", () => { dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-9.9.9.tgz" } }); const backingMetadata = JSON.stringify({ name: "@openai/codex", version: backingVersion, dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-" + backingVersion + ".tgz" } }); + const npmTrace = join(root, "npm-view.trace"); + writeFileSync(npmTrace, ""); writeFileSync(join(fakeBin, "npm"), [ "#!/bin/sh", + `printf '%s\\n' "$*" >> '${npmTrace.replace(/'/g, "'\\''")}'`, 'case "$*" in', ` *codex@${backingVersion}*--json*) printf '%s\\n' '${backingMetadata}'; exit 0 ;;`, ` *--json*) printf '%s\\n' '${mainMetadata}'; exit 0 ;;`, @@ -282,24 +285,36 @@ describe("Codex CLI selection inspection boundary", () => { OCX_NO_DESKTOP_HANDOFF: "1", OPENCODEX_BUN_PATH: bun!, }; - const runPlan = (env: NodeJS.ProcessEnv = planEnv) => spawnSync(node!, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { - cwd: repoRoot(), encoding: "utf8", timeout: 30_000, - env, - }); + const runPlan = (phase: string, env: NodeJS.ProcessEnv = planEnv) => { + writeFileSync(npmTrace, ""); + const startedAt = performance.now(); + const result = spawnSync(node!, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { + cwd: repoRoot(), encoding: "utf8", timeout: 30_000, env, + }); + // Keep timeout evidence before afterEach removes the owned prefix. A + // completed JSON report distinguishes plan computation from exit/stdio + // settlement; npm calls show whether registry resolution was reached. + const diagnostic = JSON.stringify({ phase, elapsedMs: performance.now() - startedAt, + pid: result.pid, status: result.status, signal: result.signal, + error: result.error?.message, errorCode: (result.error as NodeJS.ErrnoException | undefined)?.code, + stdout: result.stdout?.slice(-4_096), stderr: result.stderr?.slice(-4_096), + npmCalls: readFileSync(npmTrace, "utf8").slice(-4_096) }); + return { ...result, diagnostic }; + }; // The real process scan sees every Codex session on this host, and the plan // rightly refuses while one runs. That refusal still reports the content // digest, so the digest is the host-independent binding; plan ids are // compared as well whenever both runs found an idle host (as on CI). const bound = (result: ReturnType) => { - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); + expect(result.error, result.diagnostic).toBeUndefined(); + expect(result.status, result.diagnostic).toBe(0); const plan = JSON.parse(result.stdout); - expect(plan.managed).toBe(true); - expect(plan.installContentDigest).toMatch(/^[0-9a-f]{64}$/); + expect(plan.managed, result.diagnostic).toBe(true); + expect(plan.installContentDigest, result.diagnostic).toMatch(/^[0-9a-f]{64}$/); if (!plan.applicable) { - expect(plan.refusal).toBe("blocked_active_session"); - expect(plan.session.state).toBe("active"); + expect(plan.refusal, result.diagnostic).toBe("blocked_active_session"); + expect(plan.session.state, result.diagnostic).toBe("active"); } return { digest: plan.installContentDigest as string, planId: plan.applicable ? plan.planId as string : null }; }; @@ -311,10 +326,10 @@ describe("Codex CLI selection inspection boundary", () => { expect(b.digest).not.toBe(a.digest); if (a.planId !== null && b.planId !== null) expect(b.planId).not.toBe(a.planId); }; - const first = bound(runPlan()); + const first = bound(runPlan("baseline")); // Re-running on untouched bytes reproduces the same plan. - expectSame(first, bound(runPlan())); + expectSame(first, bound(runPlan("unchanged"))); // An oversized NODE_PATH cannot overflow the serialized launch context: // the launcher drops the optional lookup seed instead, so the snapshot @@ -322,7 +337,7 @@ describe("Codex CLI selection inspection boundary", () => { // unaffected, and the same plan is produced — only lanes that would need // the dropped seed become unverifiable. 80KiB stays under Linux's // per-string MAX_ARG_STRLEN so the spawn itself is not the refusal. - expectSame(first, bound(runPlan({ ...planEnv, NODE_PATH: "/d".repeat(40 * 1024) }))); + expectSame(first, bound(runPlan("oversized-node-path", { ...planEnv, NODE_PATH: "/d".repeat(40 * 1024) }))); // A same-length byte change to the entrypoint is a different install. The // mtime is restored too, so only content can drive the plan-id change. @@ -337,7 +352,7 @@ describe("Codex CLI selection inspection boundary", () => { utimesSync(path, stat.atime, stat.mtime); }; mutate(entrypoint, "synthetic fixture", "synthetic FIXTURE"); - const entryPlan = bound(runPlan()); + const entryPlan = bound(runPlan("entrypoint-same-length-mutation")); expectChanged(first, entryPlan); // A same-length change to the vendored native backing is a different plan too. @@ -347,16 +362,17 @@ describe("Codex CLI selection inspection boundary", () => { "vendor", selected.triple, "bin", "codex", ); mutate(installedBackingExe, "native-backing", "native-BACKING"); - expectChanged(entryPlan, bound(runPlan())); + expectChanged(entryPlan, bound(runPlan("backing-same-length-mutation"))); // Removing the required backing makes the plan refuse, never silently rebind. // This refusal precedes the session scan, so it holds on every host. rmSync(join(prefix, "lib", "node_modules", "@openai", backingPkgName), { recursive: true, force: true }); - const removed = runPlan(); - expect(removed.status).toBe(0); + const removed = runPlan("required-backing-removed"); + expect(removed.error, removed.diagnostic).toBeUndefined(); + expect(removed.status, removed.diagnostic).toBe(0); const removedPlan = JSON.parse(removed.stdout); - expect(removedPlan.applicable).toBe(false); - expect(removedPlan.refusal).toBe("install_content_unverified"); + expect(removedPlan.applicable, removed.diagnostic).toBe(false); + expect(removedPlan.refusal, removed.diagnostic).toBe("install_content_unverified"); }, 60_000); }); diff --git a/tests/codex-integration/codex-runtime-macos-desktop.test.ts b/tests/codex-integration/codex-runtime-macos-desktop.test.ts index 4624dd0d235..a067a97ef81 100644 --- a/tests/codex-integration/codex-runtime-macos-desktop.test.ts +++ b/tests/codex-integration/codex-runtime-macos-desktop.test.ts @@ -327,8 +327,22 @@ describe("bundled app catalog consumer", () => { * selection on the bare `codex` sentinel. The readiness probe then asked a command that does * not exist while a real, trusted Codex sat inside the app. */ -// The virtual app paths below are POSIX; on Windows the exec boundary treats them as relative. -describe.skipIf(process.platform === "win32")("app-bundled CLI fallback for connect readiness (#8360)", () => { +describe("app-bundled CLI fallback for connect readiness (#8360)", () => { + function bundledFixture() { + const configDir = mkdtempSync(join(tmpdir(), "ocx-app-catalog-")); + roots.push(configDir); + // Only configDir touches the host filesystem. The simulated darwin exec boundary + // needs POSIX-absolute app paths even when this test runs on Windows. + const home = "/fixture/home"; + const versions = new Map(); + const deps: ResolveCodexRuntimeDeps = { + platform: "darwin", env: { HOME: home, PATH: "" }, configDir, + existsSync: path => versions.has(path), executable: path => versions.has(path), + realpath: path => path, readBundleIdentifier: () => null, + macBundleTrustReason: () => null, macBundleTrustReasonAsync: async () => null, + }; + return { deps, versions, current: `${home}/Applications/ChatGPT.app/${CURRENT}` }; + } /** A catalog with a distinctive ladder, so a warm fixture value cannot pass by accident. */ const bundledCatalog = JSON.stringify({ models: [{ slug: "gpt-5.6-sol", @@ -346,7 +360,7 @@ describe.skipIf(process.platform === "win32")("app-bundled CLI fallback for conn } test("an app-bundled-only layout yields the bundled ladder and reports ready", () => { - const f = fixture(); + const f = bundledFixture(); // The bundle identity is not classifiable (the shape an app update can leave behind), so // discovery declines the app candidate and the resolver ends on the bare sentinel. f.deps.readBundleIdentifier = () => null; @@ -367,7 +381,7 @@ describe.skipIf(process.platform === "win32")("app-bundled CLI fallback for conn }); test("app locations that do not exist leave the ladder unverified instead of inventing one", () => { - const f = fixture(); + const f = bundledFixture(); f.deps.readBundleIdentifier = () => null; const spawned: string[] = []; const exec = bundledOnlyExec(f.current, spawned); @@ -382,7 +396,7 @@ describe.skipIf(process.platform === "win32")("app-bundled CLI fallback for conn }); test("a resolved absolute command is probed as given, without widening to app candidates", () => { - const f = fixture(); + const f = bundledFixture(); const standalone = "/fixture/bin/codex"; f.versions.set(standalone, "0.167.0"); f.versions.set(f.current, "0.166.0-alpha.6"); @@ -399,7 +413,7 @@ describe.skipIf(process.platform === "win32")("app-bundled CLI fallback for conn }); test("a selected runnable bare command keeps its own answer instead of borrowing the app's ladder", async () => { - const f = fixture(); + const f = bundledFixture(); f.versions.set(f.current, "0.166.0-alpha.6"); const spawned: string[] = []; // Only the app answers. The selected bare command was runnable when it was resolved (it has a @@ -422,7 +436,7 @@ describe.skipIf(process.platform === "win32")("app-bundled CLI fallback for conn }); test("the async bundled-catalog driver widens a bare sentinel the same way", async () => { - const f = fixture(); + const f = bundledFixture(); f.versions.set(f.current, "0.166.0-alpha.6"); const spawned: string[] = []; const exec = bundledOnlyExec(f.current, spawned); diff --git a/tests/providers/zcode-client.test.ts b/tests/providers/zcode-client.test.ts index 86e3e3a27fc..11c86c89b4c 100644 --- a/tests/providers/zcode-client.test.ts +++ b/tests/providers/zcode-client.test.ts @@ -106,10 +106,16 @@ describe("ZCode client config", () => { }); test("resolves the data-dir override and the documented v2 destination", () => { - expect(zcodeHomeDir({}, "/home/u")).toBe(join("/home/u", ".zcode")); - expect(zcodeConfigPath({}, "/home/u")).toBe(join("/home/u", ".zcode", "v2", "config.json")); - expect(zcodeConfigPath({ ZCODE_DATA_DIR: join(tmpdir(), "elsewhere") }, "/home/u")).toBe(join(join(tmpdir(), "elsewhere"), "v2", "config.json")); - expect(() => zcodeConfigPath({ ZCODE_DATA_DIR: "relative" }, "/home/u")).toThrow(ClientPathError); + const reads: string[] = []; + const missingBootstrap = { readText: (path: string) => { + reads.push(path); + return { kind: "missing" as const }; + } }; + expect(zcodeHomeDir({}, "/home/u", missingBootstrap)).toBe(join("/home/u", ".zcode")); + expect(zcodeConfigPath({}, "/home/u", missingBootstrap)).toBe(join("/home/u", ".zcode", "v2", "config.json")); + expect(zcodeConfigPath({ ZCODE_DATA_DIR: join(tmpdir(), "elsewhere") }, "/home/u", missingBootstrap)).toBe(join(join(tmpdir(), "elsewhere"), "v2", "config.json")); + expect(() => zcodeConfigPath({ ZCODE_DATA_DIR: "relative" }, "/home/u", missingBootstrap)).toThrow(ClientPathError); + expect(reads).toEqual(Array(2).fill(join("/home/u", ".zcode", "v2", "setting.json"))); }); }); diff --git a/tests/server/model-costs-pricing-cache.test.ts b/tests/server/model-costs-pricing-cache.test.ts index e23c1b26e1b..437b1e522f8 100644 --- a/tests/server/model-costs-pricing-cache.test.ts +++ b/tests/server/model-costs-pricing-cache.test.ts @@ -4,12 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { forgetHardenedSecretPath, hardenSecretPath } from "../../src/lib/windows-secret-acl"; import { startServer } from "../../src/server"; import { getFilteredUsageAggregate, getUsageAggregate, resetUsageAggregateCacheForTests } from "../../src/server/management/usage-aggregate-cache"; import { getUsageSummaryCacheEntry, resetUsageSummaryCacheForTests } from "../../src/server/management/usage-summary-cache"; import type { OcxConfig, PromptLengthPricing } from "../../src/types"; import { estimateRequestCost } from "../../src/usage/cost"; -import { resetUsageReadCacheForTests, type PersistedUsageEntry } from "../../src/usage/log"; +import { currentUsageLogRevision, resetUsageReadCacheForTests, type PersistedUsageEntry } from "../../src/usage/log"; import { refreshUserCostOverlays, resetPreservedDiskOnlyProvidersForTests } from "../../src/usage/user-cost-overlays"; import { stopUserCostOverlayReconciler } from "../../src/usage/user-cost-overlay-reconciler"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -50,6 +51,7 @@ afterEach(() => { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; removeTreeWithRetry(home); + forgetHardenedSecretPath(join(home, "usage.jsonl")); }); test("policy-only API writes refresh warm estimate, summary and aggregate caches; reset restores catalog", async () => { @@ -59,12 +61,15 @@ test("policy-only API writes refresh warm estimate, summary and aggregate caches const ledgerPath = join(home, "usage.jsonl"); const ledger = `${JSON.stringify(entry)}\n`; writeFileSync(ledgerPath, ledger); + // Later startup config loads can re-harden the home after the initial flush. + // Protect this fixture from inherited-ACE rewrites that move NTFS ChangeTime. + hardenSecretPath(ledgerPath, { required: true }); const server = startServer(0); try { - // On Windows startServer's loadConfig starts an unawaited directory ACL harden whose - // inherited-ACE rewrite moves usage.jsonl's ChangeTime; the aggregate cache then rightly - // rebuilds. Settle it before asserting warm-cache reuse. + // Drain the current startup ACL work before warming any caches. await flushConfigDirHardeningAndReaps(home); + const ledgerRevision = currentUsageLogRevision(); + expect(ledgerRevision).not.toBeNull(); const summaryUrl = new URL("/api/usage?range=all", server.url); const priceUrl = new URL("/api/providers/openai/model-costs", server.url); const summarize = async () => { @@ -140,6 +145,7 @@ test("policy-only API writes refresh warm estimate, summary and aggregate caches expect(refreshedFiltered.accumulator).not.toBe(filtered.accumulator); expect(refreshedFiltered.accumulator.summarize("all", now).summary.estimatedCostUsd).toBeCloseTo(total, 12); expect(readFileSync(ledgerPath, "utf8")).toBe(ledger); + expect(currentUsageLogRevision()).toEqual(ledgerRevision); previousTotal = total; } const reset = estimateRequestCost(INPUT)!; diff --git a/tests/usage/usage-filter-index.test.ts b/tests/usage/usage-filter-index.test.ts index 3272cbd3d51..a859ea317f9 100644 --- a/tests/usage/usage-filter-index.test.ts +++ b/tests/usage/usage-filter-index.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; import { appendFileSync, - chmodSync, existsSync, mkdtempSync, readFileSync, @@ -589,37 +589,147 @@ describe("filtered usage index crash and multi-writer safety", () => { expect(indexGenerations()).toHaveLength(1); }); - test("two processes rebuilding the same ledger both report every row", async () => { + test.each(["indexed", "streaming"])("two processes rebuilding the same ledger both report every row (%s)", async mode => { + const failSecondPublish = mode === "streaming"; const rows = Array.from({ length: 30_000 }, (_, index) => syntheticRow(index)); writeRows(rows); const expected = rows.filter(row => row.provider === "openai").length; const scriptPath = join(testDir, "child.ts"); writeFileSync(scriptPath, ` import { getFilteredUsageAggregate } from ${JSON.stringify(repoPath("src/server/management/usage-aggregate-cache.ts"))}; -const result = await getFilteredUsageAggregate({ provider: "openai" }); -const summary = result.accumulator.summarize("all", Date.now()); -console.log(JSON.stringify({ requests: summary.summary.requests, candidates: result.filterIndex?.candidateRows ?? null })); +import { setUsageFilterIndexTestHooksForTests } from ${JSON.stringify(repoPath("src/usage/filter-index.ts"))}; +let registrations = 0; +let publishes = 0; +let faults = 0; +function boundary(phase: string, generation: string, command: string) { + return new Promise((resolve, reject) => { + process.once("message", message => message === command + ? resolve() : reject(new Error("unexpected command: " + message))); + process.send!({ phase, generation }); + }); +} +setUsageFilterIndexTestHooksForTests({ + afterStagingRegistered: async ({ generation }) => { + registrations += 1; + await boundary("registered", generation, "ingest"); + }, + beforePublish: async ({ stagedGeneration }) => { + publishes += 1; + await boundary("staged", stagedGeneration, "publish"); + if (process.argv[2] === "fault") { + faults += 1; + throw new Error("injected index failure before publish"); + } + }, +}); +try { + const result = await getFilteredUsageAggregate({ provider: "openai" }); + const summary = result.accumulator.summarize("all", Date.now()); + console.log(JSON.stringify({ + requests: summary.summary.requests, + candidates: result.filterIndex?.candidateRows ?? null, + totalRows: result.filterIndex?.totalRows ?? null, + registrations, publishes, faults, + })); +} finally { + setUsageFilterIndexTestHooksForTests(null); + process.disconnect!(); +} `); - const children = [0, 1].map(() => Bun.spawn([process.execPath, scriptPath], { - env: { ...process.env, OPENCODEX_HOME: testDir }, - stdout: "pipe", - stderr: "pipe", - })); - const outputs = await Promise.all(children.map(async child => { - const code = await child.exited; - const out = await new Response(child.stdout).text(); - const err = await new Response(child.stderr).text(); - return { code, out, err }; - })); - for (const output of outputs) { - expect(output.err).toBe(""); - expect(output.code).toBe(0); - const line = output.out.trim().split("\n").at(-1) ?? ""; - expect(JSON.parse(line)).toEqual({ requests: expected, candidates: expected }); + function startWriter(fault: boolean) { + const boundaries = { + registered: Promise.withResolvers(), + staged: Promise.withResolvers(), + }; + const child = Bun.spawn([process.execPath, scriptPath, fault ? "fault" : "indexed"], { + env: { ...process.env, OPENCODEX_HOME: testDir }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + ipc(message: { phase: keyof typeof boundaries; generation: string }) { + boundaries[message.phase].resolve(message.generation); + }, + }); + const output = Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + return { + child, + output, + waitFor: (phase: keyof typeof boundaries) => Promise.race([ + boundaries[phase].promise, + output.then(([code, out, err]) => { + throw new Error(`writer exited before ${phase}: ${code}\n${out}\n${err}`); + }), + ]), + }; } - expect(indexGenerations()).toHaveLength(1); - expect(indexRowCount(publishedGeneration())).toBe(rows.length); - }); + const first = startWriter(false); + let second: ReturnType | undefined; + try { + const firstGeneration = await first.waitFor("registered"); + // Start the competitor after schema creation, while the first owns a live lease. + second = startWriter(failSecondPublish); + const secondGeneration = await second.waitFor("registered"); + expect(secondGeneration).not.toBe(firstGeneration); + first.child.send("ingest"); + expect(await first.waitFor("staged")).toBe(firstGeneration); + second.child.send("ingest"); + expect(await second.waitFor("staged")).toBe(secondGeneration); + expect(indexRowCount(firstGeneration)).toBe(rows.length); + expect(indexRowCount(secondGeneration)).toBe(rows.length); + + first.child.send("publish"); + const firstOutput = await first.output; + expect(firstOutput[0]).toBe(0); + expect(firstOutput[2]).toBe(""); + expect(JSON.parse(firstOutput[1].trim())).toEqual({ + requests: expected, candidates: expected, totalRows: rows.length, + registrations: 1, publishes: 1, faults: 0, + }); + // Publishing must preserve the competitor's complete staging and live lease. + expect(publishedGeneration()).toBe(firstGeneration); + expect(indexRowCount(secondGeneration)).toBe(rows.length); + const probe = indexDatabase(); + try { + expect(probe.query("SELECT generation, pid FROM usage_filter_index_writers").all()) + .toEqual([{ generation: secondGeneration, pid: second.child.pid }]); + } finally { + probe.close(); + } + + // The loser either reads the winner after the fence, or streams after the explicit + // index-side fault. Neither outcome may undercount the authoritative ledger. + second.child.send("publish"); + const secondOutput = await second.output; + expect(secondOutput[0]).toBe(0); + expect(secondOutput[2]).toBe(""); + expect(JSON.parse(secondOutput[1].trim())).toEqual({ + requests: expected, + candidates: failSecondPublish ? null : expected, + totalRows: failSecondPublish ? null : rows.length, + registrations: 1, publishes: 1, faults: failSecondPublish ? 1 : 0, + }); + expect(publishedGeneration()).toBe(firstGeneration); + expect(indexGenerations()).toEqual([firstGeneration]); + expect(indexRowCount(firstGeneration)).toBe(rows.length); + expect(publishedOffset()).toBe(statSync(ledgerPath()).size); + const settled = indexDatabase(); + try { + expect(settled.query("SELECT generation FROM usage_filter_index_generations").all()) + .toEqual([{ generation: firstGeneration }]); + expect(settled.query("SELECT generation FROM usage_filter_index_writers").all()).toEqual([]); + } finally { + settled.close(); + } + } finally { + for (const writer of [first, second]) { + if (!writer) continue; + if (writer.child.exitCode === null) writer.child.kill(); + await writer.output; + } + } + }, 15_000); test("crashed and abandoned staging never accumulates", async () => { const rows = Array.from({ length: 3000 }, (_, index) => syntheticRow(index)); @@ -1079,13 +1189,28 @@ describe("filtered usage index availability", () => { const rows = Array.from({ length: 5 }, (_, index) => syntheticRow(index)); writeRows(rows); const expected = rows.filter(row => row.provider === "openai").length; - chmodSync(testDir, 0o555); + const indexPath = usageFilterIndexPath(testDir); + const originalOpen = fs.openSync; + let deniedCreates = 0; + // Windows directory chmod does not deny creation. Fail the actual index-file + // create boundary while leaving the authoritative ledger readable on every OS. + const openSpy = spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { + if (path === indexPath && flags === "wx") { + deniedCreates += 1; + throw Object.assign(new Error("index creation denied"), { code: "EACCES" }); + } + return originalOpen(path, flags, mode); + }); try { const result = await getFilteredUsageAggregate({ provider: "openai" }); expect(summary(result).summary.requests).toBe(expected); expect(result.filterIndex).toBeUndefined(); + expect(deniedCreates).toBe(1); + for (const suffix of ["", "-wal", "-shm", "-journal"]) { + expect(existsSync(`${indexPath}${suffix}`)).toBe(false); + } } finally { - chmodSync(testDir, 0o755); + openSpy.mockRestore(); } }); @@ -1134,8 +1259,16 @@ describe("filtered usage index availability", () => { const rows = Array.from({ length: 5 }, (_, index) => syntheticRow(index)); writeRows(rows); const indexPath = usageFilterIndexPath(testDir); + let removed = false; setUsageFilterIndexTestHooksForTests({ - afterPublish: ({ dbPath }) => { rmSync(dbPath, { force: true }); }, + // afterPublish still holds the writer open, so Windows can refuse deletion. + // afterRefresh runs after it closes and before the reader opens the index. + afterRefresh: ({ dbPath }) => { + expect(dbPath).toBe(indexPath); + expect(existsSync(dbPath)).toBe(true); + rmSync(dbPath, { force: true }); + removed = true; + }, }); try { const result = await getFilteredUsageAggregate({ provider: "openai" }); @@ -1145,6 +1278,7 @@ describe("filtered usage index availability", () => { } finally { setUsageFilterIndexTestHooksForTests(null); } + expect(removed).toBe(true); expect(existsSync(indexPath)).toBe(false); });