From 8368528846bdc26f3ac45596e10c8dc53d1d5880 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:27:57 +0800 Subject: [PATCH 1/4] refactor: extract report command --- lib/codex-manager.ts | 262 +---------------- lib/codex-manager/commands/report.ts | 339 ++++++++++++++++++++++ test/codex-manager-report-command.test.ts | 97 +++++++ 3 files changed, 447 insertions(+), 251 deletions(-) create mode 100644 lib/codex-manager/commands/report.ts create mode 100644 test/codex-manager-report-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4ed0bea2..4f7b6825 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,5 +1,4 @@ import { existsSync, promises as fs } from "node:fs"; -import { dirname, resolve } from "node:path"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; import { @@ -37,6 +36,7 @@ import { } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; +import { runReportCommand } from "./codex-manager/commands/report.js"; import { runFeaturesCommand, runStatusCommand, @@ -2275,13 +2275,6 @@ interface FixCliOptions { model: string; } -interface ReportCliOptions { - live: boolean; - json: boolean; - model: string; - outPath?: string; -} - interface VerifyFlaggedCliOptions { dryRun: boolean; json: boolean; @@ -2572,79 +2565,6 @@ function parseDoctorArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function printReportUsage(): void { - console.log( - [ - "Usage:", - " codex auth report [--live] [--json] [--model ] [--out ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - " --out Write JSON report to a file path", - ].join("\n"), - ); -} - -function parseReportArgs(args: string[]): ParsedArgsResult { - const options: ReportCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - if (arg === "--out") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --out" }; - } - options.outPath = value; - i += 1; - continue; - } - if (arg.startsWith("--out=")) { - const value = arg.slice("--out=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --out" }; - } - options.outPath = value; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - function serializeForecastResults( results: ForecastAccountResult[], liveQuotaByIndex: Map< @@ -2946,175 +2866,6 @@ async function runForecast(args: string[]): Promise { return 0; } -async function runReport(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printReportUsage(); - return 0; - } - - const parsedArgs = parseReportArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printReportUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const storagePath = getStoragePath(); - const storage = await loadAccounts(); - const now = Date.now(); - const accountCount = storage?.accounts.length ?? 0; - const activeIndex = storage ? resolveActiveIndex(storage, "codex") : 0; - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map< - number, - Awaited> - >(); - const probeErrors: string[] = []; - - if (storage && options.live) { - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || account.enabled === false) continue; - - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail( - refreshResult.message, - refreshResult.reason, - ), - }); - continue; - } - - const accountId = - account.accountId ?? extractAccountId(refreshResult.access); - if (!accountId) { - probeErrors.push( - `${formatAccountLabel(account, i)}: missing accountId for live probe`, - ); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId, - accessToken: refreshResult.access, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - } - - const forecastResults = storage - ? evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })), - ) - : []; - const forecastSummary = summarizeForecast(forecastResults); - const recommendation = recommendForecastAccount(forecastResults); - const enabledCount = storage - ? storage.accounts.filter((account) => account.enabled !== false).length - : 0; - const disabledCount = Math.max(0, accountCount - enabledCount); - const coolingCount = storage - ? storage.accounts.filter( - (account) => - typeof account.coolingDownUntil === "number" && - account.coolingDownUntil > now, - ).length - : 0; - const rateLimitedCount = storage - ? storage.accounts.filter( - (account) => !!formatRateLimitEntry(account, now, "codex"), - ).length - : 0; - - const report = { - command: "report", - generatedAt: new Date(now).toISOString(), - storagePath, - model: options.model, - liveProbe: options.live, - accounts: { - total: accountCount, - enabled: enabledCount, - disabled: disabledCount, - coolingDown: coolingCount, - rateLimited: rateLimitedCount, - }, - activeIndex: accountCount > 0 ? activeIndex + 1 : null, - forecast: { - summary: forecastSummary, - recommendation, - probeErrors, - accounts: serializeForecastResults( - forecastResults, - liveQuotaByIndex, - refreshFailures, - ), - }, - }; - - if (options.outPath) { - const outputPath = resolve(process.cwd(), options.outPath); - await fs.mkdir(dirname(outputPath), { recursive: true }); - await fs.writeFile( - outputPath, - `${JSON.stringify(report, null, 2)}\n`, - "utf-8", - ); - } - - if (options.json) { - console.log(JSON.stringify(report, null, 2)); - return 0; - } - - console.log(`Report generated at ${report.generatedAt}`); - console.log(`Storage: ${report.storagePath}`); - console.log( - `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, - ); - if (report.activeIndex !== null) { - console.log(`Active account: ${report.activeIndex}`); - } - console.log( - `Forecast: ${report.forecast.summary.ready} ready, ${report.forecast.summary.delayed} delayed, ${report.forecast.summary.unavailable} unavailable`, - ); - if (report.forecast.recommendation.recommendedIndex !== null) { - console.log( - `Recommendation: account ${report.forecast.recommendation.recommendedIndex + 1} (${report.forecast.recommendation.reason})`, - ); - } else { - console.log(`Recommendation: ${report.forecast.recommendation.reason}`); - } - if (options.outPath) { - console.log(`Report written: ${resolve(process.cwd(), options.outPath)}`); - } - if (report.forecast.probeErrors.length > 0) { - console.log(`Probe notes: ${report.forecast.probeErrors.length}`); - } - return 0; -} - type FixOutcome = | "healthy" | "disabled-hard-failure" @@ -5563,7 +5314,16 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runBest(rest); } if (command === "report") { - return runReport(rest); + return runReportCommand(rest, { + setStoragePath, + getStoragePath, + loadAccounts, + resolveActiveIndex, + queuedRefresh, + fetchCodexQuotaSnapshot, + formatRateLimitEntry, + normalizeFailureDetail, + }); } if (command === "fix") { return runFix(rest); diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts new file mode 100644 index 00000000..388666c8 --- /dev/null +++ b/lib/codex-manager/commands/report.ts @@ -0,0 +1,339 @@ +import { promises as fs } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { extractAccountId, formatAccountLabel } from "../../accounts.js"; +import { + evaluateForecastAccounts, + type ForecastAccountResult, + recommendForecastAccount, + summarizeForecast, +} from "../../forecast.js"; +import { + type CodexQuotaSnapshot, + formatQuotaSnapshotLine, +} from "../../quota-probe.js"; +import type { AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +interface ReportCliOptions { + live: boolean; + json: boolean; + model: string; + outPath?: string; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +export interface ReportCommandDeps { + setStoragePath: (path: string | null) => void; + getStoragePath: () => string; + loadAccounts: () => Promise; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + queuedRefresh: (refreshToken: string) => Promise; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + formatRateLimitEntry: ( + account: AccountStorageV3["accounts"][number], + now: number, + family: "codex", + ) => string | null; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; + getCwd?: () => string; + writeFile?: (path: string, contents: string) => Promise; +} + +function printReportUsage(logInfo: (message: string) => void): void { + logInfo( + [ + "Usage: codex auth report [--live] [--json] [--model MODEL] [--out PATH]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + " --out Write JSON report to a file path", + ].join("\n"), + ); +} + +function parseReportArgs(args: string[]): ParsedArgsResult { + const options: ReportCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + continue; + } + if (arg === "--out") { + const value = args[i + 1]; + if (!value) return { ok: false, message: "Missing value for --out" }; + options.outPath = value; + i += 1; + continue; + } + if (arg.startsWith("--out=")) { + const value = arg.slice("--out=".length).trim(); + if (!value) return { ok: false, message: "Missing value for --out" }; + options.outPath = value; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +function serializeForecastResults( + results: ForecastAccountResult[], + liveQuotaByIndex: Map, + refreshFailures: Map, +): Array<{ + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAccountResult["availability"]; + riskScore: number; + riskLevel: ForecastAccountResult["riskLevel"]; + waitMs: number; + reasons: string[]; + liveQuota?: { + status: number; + planType?: string; + activeLimit?: number; + model: string; + summary: string; + }; + refreshFailure?: TokenFailure; +}> { + return results.map((result) => { + const liveQuota = liveQuotaByIndex.get(result.index); + return { + index: result.index, + label: result.label, + isCurrent: result.isCurrent, + availability: result.availability, + riskScore: result.riskScore, + riskLevel: result.riskLevel, + waitMs: result.waitMs, + reasons: result.reasons, + liveQuota: liveQuota + ? { + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: formatQuotaSnapshotLine(liveQuota), + } + : undefined, + refreshFailure: refreshFailures.get(result.index), + }; + }); +} + +async function defaultWriteFile(path: string, contents: string): Promise { + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(path, contents, "utf-8"); +} + +export async function runReportCommand( + args: string[], + deps: ReportCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + printReportUsage(logInfo); + return 0; + } + + const parsedArgs = parseReportArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + printReportUsage(logInfo); + return 1; + } + const options = parsedArgs.options; + + deps.setStoragePath(null); + const storagePath = deps.getStoragePath(); + const storage = await deps.loadAccounts(); + const now = deps.getNow?.() ?? Date.now(); + const accountCount = storage?.accounts.length ?? 0; + const activeIndex = storage ? deps.resolveActiveIndex(storage, "codex") : 0; + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map(); + const probeErrors: string[] = []; + + if (storage && options.live) { + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || account.enabled === false) continue; + + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } + + const accountId = + account.accountId ?? extractAccountId(refreshResult.access); + if (!accountId) { + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); + continue; + } + + try { + const liveQuota = await deps.fetchCodexQuotaSnapshot({ + accountId, + accessToken: refreshResult.access, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } + } + } + + const forecastResults = storage + ? evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })), + ) + : []; + const forecastSummary = summarizeForecast(forecastResults); + const recommendation = recommendForecastAccount(forecastResults); + const enabledCount = storage + ? storage.accounts.filter((account) => account.enabled !== false).length + : 0; + const disabledCount = Math.max(0, accountCount - enabledCount); + const coolingCount = storage + ? storage.accounts.filter( + (account) => + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now, + ).length + : 0; + const rateLimitedCount = storage + ? storage.accounts.filter( + (account) => !!deps.formatRateLimitEntry(account, now, "codex"), + ).length + : 0; + + const report = { + command: "report", + generatedAt: new Date(now).toISOString(), + storagePath, + model: options.model, + liveProbe: options.live, + accounts: { + total: accountCount, + enabled: enabledCount, + disabled: disabledCount, + coolingDown: coolingCount, + rateLimited: rateLimitedCount, + }, + activeIndex: accountCount > 0 ? activeIndex + 1 : null, + forecast: { + summary: forecastSummary, + recommendation, + probeErrors, + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), + }, + }; + + const cwd = deps.getCwd?.() ?? process.cwd(); + if (options.outPath) { + const outputPath = resolve(cwd, options.outPath); + await (deps.writeFile ?? defaultWriteFile)( + outputPath, + `${JSON.stringify(report, null, 2)}\n`, + ); + } + + if (options.json) { + logInfo(JSON.stringify(report, null, 2)); + return 0; + } + + logInfo(`Report generated at ${report.generatedAt}`); + logInfo(`Storage: ${report.storagePath}`); + logInfo( + `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, + ); + if (report.activeIndex !== null) { + logInfo(`Active account: ${report.activeIndex}`); + } + logInfo( + `Forecast: ${report.forecast.summary.ready} ready, ${report.forecast.summary.delayed} delayed, ${report.forecast.summary.unavailable} unavailable`, + ); + if (report.forecast.recommendation.recommendedIndex !== null) { + logInfo( + `Recommendation: account ${report.forecast.recommendation.recommendedIndex + 1} (${report.forecast.recommendation.reason})`, + ); + } else { + logInfo(`Recommendation: ${report.forecast.recommendation.reason}`); + } + if (options.outPath) { + logInfo(`Report written: ${resolve(cwd, options.outPath)}`); + } + if (report.forecast.probeErrors.length > 0) { + logInfo(`Probe notes: ${report.forecast.probeErrors.length}`); + } + return 0; +} diff --git a/test/codex-manager-report-command.test.ts b/test/codex-manager-report-command.test.ts new file mode 100644 index 00000000..c50c7e83 --- /dev/null +++ b/test/codex-manager-report-command.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type ReportCommandDeps, + runReportCommand, +} from "../lib/codex-manager/commands/report.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + expiresAt: 10, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], + }; +} + +function createDeps( + overrides: Partial = {}, +): ReportCommandDeps { + return { + setStoragePath: vi.fn(), + getStoragePath: vi.fn(() => "/mock/openai-codex-accounts.json"), + loadAccounts: vi.fn(async () => createStorage()), + resolveActiveIndex: vi.fn(() => 0), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-token-1", + refresh: "refresh-token-1", + expires: 100, + idToken: "id-token-1", + })), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + formatRateLimitEntry: vi.fn(() => null), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + getCwd: vi.fn(() => "/repo"), + writeFile: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe("runReportCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + + const result = await runReportCommand(["--help"], deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining("Usage: codex auth report"), + ); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + + const result = await runReportCommand(["--bogus"], deps); + + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bogus"); + }); + + it("writes json report output when requested", async () => { + const deps = createDeps(); + + const result = await runReportCommand( + ["--json", "--out", "report.json"], + deps, + ); + + expect(result).toBe(0); + expect(deps.writeFile).toHaveBeenCalledWith( + expect.stringContaining("report.json"), + expect.stringContaining('"command": "report"'), + ); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"forecast"'), + ); + }); +}); From 3a845ef7b6da23f6f8c04211c6d69a073d00fa8e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:02:35 +0800 Subject: [PATCH 2/4] fix: make report output writes retry-safe --- lib/codex-manager/commands/report.ts | 34 +++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts index 388666c8..224a68cd 100644 --- a/lib/codex-manager/commands/report.ts +++ b/lib/codex-manager/commands/report.ts @@ -13,6 +13,7 @@ import { } from "../../quota-probe.js"; import type { AccountStorageV3 } from "../../storage.js"; import type { TokenFailure, TokenResult } from "../../types.js"; +import { sleep } from "../../utils.js"; interface ReportCliOptions { live: boolean; @@ -25,6 +26,8 @@ type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; +const RETRYABLE_WRITE_CODES = new Set(["EBUSY", "EPERM"]); + export interface ReportCommandDeps { setStoragePath: (path: string | null) => void; getStoragePath: () => string; @@ -52,6 +55,11 @@ export interface ReportCommandDeps { writeFile?: (path: string, contents: string) => Promise; } +function isRetryableWriteError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && RETRYABLE_WRITE_CODES.has(code); +} + function printReportUsage(logInfo: (message: string) => void): void { logInfo( [ @@ -165,7 +173,31 @@ function serializeForecastResults( async function defaultWriteFile(path: string, contents: string): Promise { await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(path, contents, "utf-8"); + const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + await fs.writeFile(tempPath, contents, "utf-8"); + let moved = false; + try { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rename(tempPath, path); + moved = true; + return; + } catch (error) { + if (!isRetryableWriteError(error) || attempt >= 4) { + throw error; + } + await sleep(10 * 2 ** attempt); + } + } + } finally { + if (!moved) { + try { + await fs.unlink(tempPath); + } catch { + // Best-effort temp cleanup. + } + } + } } export async function runReportCommand( From 7502a9e4b16365a6caa6b90f4c2d6349115a812d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:59:59 +0800 Subject: [PATCH 3/4] fix: clean up report temp files on write failure --- lib/codex-manager/commands/report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts index 224a68cd..55d006fe 100644 --- a/lib/codex-manager/commands/report.ts +++ b/lib/codex-manager/commands/report.ts @@ -174,9 +174,9 @@ function serializeForecastResults( async function defaultWriteFile(path: string, contents: string): Promise { await fs.mkdir(dirname(path), { recursive: true }); const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; - await fs.writeFile(tempPath, contents, "utf-8"); let moved = false; try { + await fs.writeFile(tempPath, contents, "utf-8"); for (let attempt = 0; attempt < 5; attempt += 1) { try { await fs.rename(tempPath, path); From 2371577e534819344ccae8ddaa331f076de2ddeb Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:19:40 +0800 Subject: [PATCH 4/4] test: harden report command path assertions --- test/codex-manager-report-command.test.ts | 182 ++++++++++++++++++++-- 1 file changed, 170 insertions(+), 12 deletions(-) diff --git a/test/codex-manager-report-command.test.ts b/test/codex-manager-report-command.test.ts index c50c7e83..17644da1 100644 --- a/test/codex-manager-report-command.test.ts +++ b/test/codex-manager-report-command.test.ts @@ -5,22 +5,24 @@ import { } from "../lib/codex-manager/commands/report.js"; import type { AccountStorageV3 } from "../lib/storage.js"; -function createStorage(): AccountStorageV3 { +function createStorage( + accounts: AccountStorageV3["accounts"] = [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + expiresAt: 10, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], +): AccountStorageV3 { return { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "one@example.com", - refreshToken: "refresh-token-1", - accessToken: "access-token-1", - expiresAt: 10, - addedAt: 1, - lastUsed: 1, - enabled: true, - }, - ], + accounts, }; } @@ -94,4 +96,160 @@ describe("runReportCommand", () => { expect.stringContaining('"forecast"'), ); }); + + it("covers live probe refresh failures, missing account ids, and probe errors", async () => { + const deps = createDeps({ + loadAccounts: vi.fn(async () => + createStorage([ + { + email: "refresh-fail@example.com", + refreshToken: "refresh-fail", + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + { + email: "missing-id@example.com", + refreshToken: "missing-id", + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + { + email: "probe-error@example.com", + refreshToken: "probe-error", + accountId: "acct-probe-error", + addedAt: 3, + lastUsed: 3, + enabled: true, + }, + { + email: "ok@example.com", + refreshToken: "ok-refresh", + accountId: "acct-ok", + addedAt: 4, + lastUsed: 4, + enabled: true, + }, + ]), + ), + resolveActiveIndex: vi.fn(() => 3), + queuedRefresh: vi.fn(async (refreshToken: string) => { + if (refreshToken === "refresh-fail") { + return { + type: "error", + reason: "auth-failure", + message: "token expired", + }; + } + return { + type: "success", + access: + refreshToken === "missing-id" + ? "not-a-jwt" + : `access-${refreshToken}`, + refresh: refreshToken, + expires: 100, + idToken: `id-${refreshToken}`, + }; + }), + fetchCodexQuotaSnapshot: vi.fn(async ({ accountId }) => { + if (accountId === "acct-probe-error") { + throw new Error("quota endpoint down"); + } + return { + status: 200, + model: "gpt-5-codex", + planType: "pro", + primary: {}, + secondary: {}, + }; + }), + }); + + const result = await runReportCommand(["--live", "--json"], deps); + + expect(result).toBe(0); + expect(deps.fetchCodexQuotaSnapshot).toHaveBeenCalledTimes(2); + const jsonOutput = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)?.[0] ?? "{}", + ) as { + forecast: { + probeErrors: string[]; + accounts: Array<{ refreshFailure?: { message?: string }; liveQuota?: { planType?: string } }>; + }; + }; + expect(jsonOutput.forecast.probeErrors).toEqual( + expect.arrayContaining([ + expect.stringContaining("missing accountId for live probe"), + expect.stringContaining("quota endpoint down"), + ]), + ); + expect(jsonOutput.forecast.accounts[0]?.refreshFailure?.message).toBe( + "token expired", + ); + expect(jsonOutput.forecast.accounts[3]?.liveQuota?.planType).toBe("pro"); + }); + + it("prints a human-readable report and announces the output path", async () => { + const deps = createDeps(); + + const result = await runReportCommand(["--out", "report.json"], deps); + + expect(result).toBe(0); + const [[writtenPath, writtenReport]] = ( + deps.writeFile as ReturnType + ).mock.calls; + expect(String(writtenPath).replaceAll("\\", "/")).toContain( + "/repo/report.json", + ); + expect(String(writtenReport)).toContain('"command": "report"'); + const infoLines = (deps.logInfo as ReturnType).mock.calls.map( + ([message]) => String(message).replaceAll("\\", "/"), + ); + expect(infoLines.some((line) => line.includes("Accounts: 1 total"))).toBe( + true, + ); + expect( + infoLines.some((line) => line.includes("Recommendation: account 1")), + ).toBe(true); + expect( + infoLines.some( + (line) => + line.startsWith("Report written: ") && + line.endsWith("/repo/report.json"), + ), + ).toBe(true); + }); + + it("reports an empty storage snapshot when no accounts are loaded", async () => { + const deps = createDeps({ + loadAccounts: vi.fn(async () => null), + }); + + const result = await runReportCommand(["--json"], deps); + + expect(result).toBe(0); + expect(deps.resolveActiveIndex).not.toHaveBeenCalled(); + const jsonOutput = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)?.[0] ?? "{}", + ) as { + accounts: { total: number }; + activeIndex: number | null; + }; + expect(jsonOutput.accounts.total).toBe(0); + expect(jsonOutput.activeIndex).toBeNull(); + }); + + it("surfaces write failures from the injected file writer", async () => { + const deps = createDeps({ + writeFile: vi.fn(async () => { + throw new Error("disk full"); + }), + }); + + await expect( + runReportCommand(["--json", "--out", "report.json"], deps), + ).rejects.toThrow("disk full"); + }); });