From 3ff9ac5ae42de252ffab7c94094b6f9a9fc5517f Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 12:33:21 +0700 Subject: [PATCH 01/12] Add mocked-IPC proof build for browser UI capture --- .gitignore | 1 + apps/desktop-tauri/package.json | 2 + apps/desktop-tauri/proof.html | 15 + .../src/proof/fixtures/bridgeData.ts | 232 +++++++++++++ .../src/proof/fixtures/common.ts | 218 ++++++++++++ .../src/proof/fixtures/scenarios.ts | 318 ++++++++++++++++++ apps/desktop-tauri/src/proof/install.ts | 21 ++ .../desktop-tauri/src/proof/isolation.test.ts | 23 ++ apps/desktop-tauri/src/proof/locale.ts | 31 ++ apps/desktop-tauri/src/proof/main.tsx | 19 ++ apps/desktop-tauri/src/proof/params.ts | 40 +++ apps/desktop-tauri/src/proof/render.test.tsx | 74 ++++ apps/desktop-tauri/src/proof/router.test.ts | 136 ++++++++ apps/desktop-tauri/src/proof/router.ts | 253 ++++++++++++++ apps/desktop-tauri/vite.config.ts | 53 ++- 15 files changed, 1421 insertions(+), 15 deletions(-) create mode 100644 apps/desktop-tauri/proof.html create mode 100644 apps/desktop-tauri/src/proof/fixtures/bridgeData.ts create mode 100644 apps/desktop-tauri/src/proof/fixtures/common.ts create mode 100644 apps/desktop-tauri/src/proof/fixtures/scenarios.ts create mode 100644 apps/desktop-tauri/src/proof/install.ts create mode 100644 apps/desktop-tauri/src/proof/isolation.test.ts create mode 100644 apps/desktop-tauri/src/proof/locale.ts create mode 100644 apps/desktop-tauri/src/proof/main.tsx create mode 100644 apps/desktop-tauri/src/proof/params.ts create mode 100644 apps/desktop-tauri/src/proof/render.test.tsx create mode 100644 apps/desktop-tauri/src/proof/router.test.ts create mode 100644 apps/desktop-tauri/src/proof/router.ts diff --git a/.gitignore b/.gitignore index c24ff2f978f..5f5b2e23a36 100755 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ tmpclaude-* *.swo node_modules/ dist/ +dist-proof/ nul # Local scripts and env files diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json index 3bbaacadbad..01b82d10ab3 100644 --- a/apps/desktop-tauri/package.json +++ b/apps/desktop-tauri/package.json @@ -6,10 +6,12 @@ "type": "module", "scripts": { "dev": "vite", + "dev:proof": "vite --mode proof", "check-locale": "node scripts/check-locale-drift.mjs", "lint": "oxlint src", "prebuild": "pnpm run check-locale", "build": "tsc --noEmit && vite build", + "build:proof": "tsc --noEmit && vite build --mode proof", "preview": "vite preview", "check:anti-slop": "tsc --noEmit -p tsconfig.anti-slop.json", "test": "vitest run", diff --git a/apps/desktop-tauri/proof.html b/apps/desktop-tauri/proof.html new file mode 100644 index 00000000000..2da0e80b9ce --- /dev/null +++ b/apps/desktop-tauri/proof.html @@ -0,0 +1,15 @@ + + + + + + CodexBar Desktop (proof) + + +
+ + + diff --git a/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts new file mode 100644 index 00000000000..de71fcbbc1f --- /dev/null +++ b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts @@ -0,0 +1,232 @@ +import type { + AppInfoBridge, + CostCoverageCounts, + CurrencyRatesSnapshot, + HooksConfigSnapshot, + ProviderChartData, + ProviderDetail, + ProviderSummary, + ProviderUsageSnapshot, + SpendContract, + UpdateStatePayload, + UsageSpendRow, + UsageSpendSummary, +} from "../../types/bridge"; +import type { ProofScenario } from "./scenarios"; +import { dayKey } from "./common"; + +/** Synthetic local spend per provider id; providers absent here have no local logs. */ +const LOCAL_SPEND: Record = { + codex: { daily: 1.84, tokensPerDay: 412_000, model: "gpt-5-codex" }, + claude: { daily: 2.37, tokensPerDay: 289_000, model: "claude-sonnet-4" }, +}; + +const EMPTY_COVERAGE: CostCoverageCounts = { priced: 0, unpriced: 0, unmetered: 0, estimated: 0 }; + +export function proofUpdateState(): UpdateStatePayload { + return { + status: "idle", + version: null, + error: null, + progress: null, + releaseUrl: null, + canDownload: false, + canApply: false, + lastCheckedAt: null, + }; +} + +export function proofAppInfo(version: string): AppInfoBridge { + return { + name: "CodexBar", + version, + buildNumber: "proof", + buildDate: "2026-01-01", + updateChannel: "stable", + tagline: "May your tokens never run out.", + }; +} + +export function proofCurrencyRates(preferredCode: string): CurrencyRatesSnapshot { + return { + rates: { USD: 1, EUR: 0.92, GBP: 0.79, JPY: 151.4 }, + supportedCodes: ["USD", "EUR", "GBP", "JPY"], + preferredCode, + }; +} + +export function proofHooksConfig(): HooksConfigSnapshot { + return { + enabled: false, + settingsEnabled: false, + fileEnabled: true, + rules: [], + path: "C:\\Users\\proof\\AppData\\Roaming\\CodexBar\\hooks.json", + error: null, + maxRules: 32, + maxArguments: 16, + }; +} + +export function providerSummaries(scenario: ProofScenario): ProviderSummary[] { + const enabled = new Set(scenario.settings.enabledProviders); + return scenario.catalog.map((entry, order) => ({ + id: entry.id, + displayName: entry.displayName, + enabled: enabled.has(entry.id), + order, + })); +} + +export function providerDetail( + scenario: ProofScenario, + providerId: string, +): ProviderDetail { + const entry = scenario.catalog.find((candidate) => candidate.id === providerId); + const snapshot: ProviderUsageSnapshot | undefined = scenario.providers.find( + (candidate) => candidate.providerId === providerId, + ); + return { + id: providerId, + displayName: entry?.displayName ?? snapshot?.displayName ?? providerId, + enabled: scenario.settings.enabledProviders.includes(providerId), + autoResumeAfterQuotaReset: false, + autoResumeSupported: false, + optionalDetailsSupported: false, + optionalDetailsEnabled: false, + email: snapshot?.accountEmail ?? null, + plan: snapshot?.planName ?? null, + authType: snapshot ? snapshot.sourceLabel : null, + sourceLabel: snapshot?.sourceLabel ?? null, + organization: snapshot?.accountOrganization ?? null, + lastUpdated: snapshot?.updatedAt ?? null, + session: snapshot?.primary ?? null, + weekly: snapshot?.secondary ?? null, + primaryLabel: snapshot?.primaryLabel ?? null, + secondaryLabel: snapshot?.secondaryLabel ?? null, + modelSpecific: snapshot?.modelSpecific ?? null, + tertiary: snapshot?.tertiary ?? null, + extraRateWindows: snapshot?.extraRateWindows ?? [], + usageItems: [], + hiddenUsageItemIds: [], + cost: snapshot?.cost ?? null, + pace: snapshot?.pace ?? null, + lastError: snapshot?.error ?? null, + errorState: snapshot?.errorState ?? null, + dashboardUrl: `https://${providerId}.example.com/usage`, + statusPageUrl: null, + buyCreditsUrl: null, + hasSnapshot: snapshot !== undefined, + usageSource: "auto", + cookieSource: entry?.cookieDomain ? "auto" : null, + region: null, + }; +} + +function hasLocalSpend(scenario: ProofScenario, providerId: string): boolean { + return providerId in LOCAL_SPEND && scenario.settings.enabledProviders.includes(providerId); +} + +export function providerChartData( + scenario: ProofScenario, + providerId: string, + now: Date, +): ProviderChartData { + const spend = hasLocalSpend(scenario, providerId) ? LOCAL_SPEND[providerId] : undefined; + const days = spend ? Array.from({ length: 14 }, (_, index) => 13 - index) : []; + // A fixed wave keeps the bars varied but identical across reloads. + const factor = (daysAgo: number) => 0.55 + ((daysAgo * 37) % 10) / 10; + return { + providerId, + costHistory: days.map((daysAgo) => ({ + date: dayKey(now, daysAgo), + value: spend ? Number((spend.daily * factor(daysAgo)).toFixed(2)) : null, + })), + creditsHistory: [], + usageBreakdown: [], + localUsage: spend + ? { + todayCost: spend.daily, + thirtyDayCost: Number((spend.daily * 30).toFixed(2)), + thirtyDayTokens: spend.tokensPerDay * 30, + periodCost: Number((spend.daily * 30).toFixed(2)), + periodTokens: spend.tokensPerDay * 30, + reportingPeriod: scenario.settings.costReportingPeriod ?? "rolling:30", + latestTokens: spend.tokensPerDay, + topModel: spend.model, + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: now.getTime(), + } + : null, + tokensHistory: days.map((daysAgo) => ({ + date: dayKey(now, daysAgo), + tokens: spend ? Math.round(spend.tokensPerDay * factor(daysAgo)) : 0, + })), + tokensIncomplete: false, + }; +} + +export function spendContract(providerId: string, reportingPeriod: string): SpendContract { + return { + providerId, + historyDays: 30, + reportingPeriod, + knownCostUsd: null, + knownZero: false, + provenance: "listPriceEstimate", + priceCoverage: EMPTY_COVERAGE, + priceCoverageRatio: null, + historyCoverageEstablished: true, + tokenMix: { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheCreationTokens: null, + reasoningTokens: null, + }, + conversationCount: 0, + models: [], + projects: [], + conversations: [], + daily: [], + hourlyActivity: [], + projectSourceStatus: null, + customPricingActive: false, + imports: [], + }; +} + +export function usageSpendSummary(scenario: ProofScenario, now: Date): UsageSpendSummary { + const reportingPeriod = scenario.settings.spendDashboardPeriod ?? "rolling:30"; + const rows: UsageSpendRow[] = scenario.catalog + .filter((entry) => hasLocalSpend(scenario, entry.id)) + .map((entry) => { + const spend = LOCAL_SPEND[entry.id]; + return { + providerId: entry.id, + displayName: entry.displayName, + sevenDay: Number((spend.daily * 7).toFixed(2)), + thirtyDay: Number((spend.daily * 30).toFixed(2)), + sevenDayTokens: spend.tokensPerDay * 7, + thirtyDayTokens: spend.tokensPerDay * 30, + periodCost: Number((spend.daily * 30).toFixed(2)), + periodTokens: spend.tokensPerDay * 30, + currency: "USD", + source: "local", + includedInOverview: true, + daily: Array.from({ length: 7 }, (_, index) => ({ + day: dayKey(now, 6 - index), + amount: spend.daily, + })), + }; + }); + return { + rows, + contract: spendContract("all", reportingPeriod), + reportingPeriod, + reportingDay: dayKey(now, 0), + dashboardTimezone: "UTC", + costTrackingEnabled: scenario.settings.costUsageEnabled ?? true, + tokenActivity: [], + }; +} diff --git a/apps/desktop-tauri/src/proof/fixtures/common.ts b/apps/desktop-tauri/src/proof/fixtures/common.ts new file mode 100644 index 00000000000..3cfeaa00c52 --- /dev/null +++ b/apps/desktop-tauri/src/proof/fixtures/common.ts @@ -0,0 +1,218 @@ +import type { + ProviderCatalogEntry, + ProviderUsageSnapshot, + RateWindowSnapshot, + SettingsSnapshot, +} from "../../types/bridge"; + +const MINUTE_MS = 60_000; + +export function isoAfter(now: Date, minutes: number): string { + return new Date(now.getTime() + minutes * MINUTE_MS).toISOString(); +} + +export function isoBefore(now: Date, minutes: number): string { + return isoAfter(now, -minutes); +} + +/** Local `YYYY-MM-DD` for the day `daysAgo` before `now`. */ +export function dayKey(now: Date, daysAgo: number): string { + const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo); + const month = String(day.getMonth() + 1).padStart(2, "0"); + const date = String(day.getDate()).padStart(2, "0"); + return `${day.getFullYear()}-${month}-${date}`; +} + +export function rateWindow( + now: Date, + usedPercent: number, + windowMinutes: number | null, + resetsInMinutes: number | null, +): RateWindowSnapshot { + return { + usedPercent, + remainingPercent: Math.max(0, 100 - usedPercent), + windowMinutes, + resetsAt: resetsInMinutes === null ? null : isoAfter(now, resetsInMinutes), + resetDescription: null, + isExhausted: usedPercent >= 100, + reservePercent: null, + reserveDescription: null, + }; +} + +export interface SnapshotInput { + providerId: string; + displayName: string; + primary: RateWindowSnapshot; + primaryLabel: string; + updatedAt: string; +} + +export function providerSnapshot( + input: SnapshotInput, + overrides: Partial = {}, +): ProviderUsageSnapshot { + return { + providerId: input.providerId, + displayName: input.displayName, + primary: input.primary, + selectedMetric: input.primary, + primaryLabel: input.primaryLabel, + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "auto", + updatedAt: input.updatedAt, + error: null, + errorState: "ready", + pace: null, + accountOrganization: null, + trayStatusLabel: null, + fetchDurationMs: 420, + ...overrides, + }; +} + +/** A subset of the shipped catalog: enough for sidebars without listing every provider. */ +export const PROOF_PROVIDER_CATALOG: ProviderCatalogEntry[] = [ + { id: "codex", displayName: "Codex", cookieDomain: "chatgpt.com", cliAliases: ["openai"] }, + { id: "claude", displayName: "Claude", cookieDomain: "claude.ai" }, + { id: "cursor", displayName: "Cursor", cookieDomain: "cursor.com" }, + { id: "gemini", displayName: "Gemini", cookieDomain: null }, + { id: "copilot", displayName: "Copilot", cookieDomain: null }, + { id: "openrouter", displayName: "OpenRouter", cookieDomain: null }, + { id: "factory", displayName: "Factory", cookieDomain: "app.factory.ai" }, + { id: "kiro", displayName: "Kiro", cookieDomain: null }, + { id: "zai", displayName: "z.ai", cookieDomain: null }, + { id: "mistral", displayName: "Mistral", cookieDomain: null }, +]; + +export function catalogOrder(): string[] { + return PROOF_PROVIDER_CATALOG.map((entry) => entry.id); +} + +export function baseSettings(overrides: Partial = {}): SettingsSnapshot { + return { + preferredCurrencyCode: "AUTO", + enabledProviders: [], + providerOrder: catalogOrder(), + refreshIntervalSecs: 300, + adaptiveRefresh: false, + refreshAllProvidersOnMenuOpen: false, + statusChecksEnabled: true, + lowPowerMode: false, + lowPowerModePreference: "off", + startAtLogin: false, + startMinimized: false, + showNotifications: true, + soundEnabled: true, + notificationSoundTheme: "windows", + notificationSoundPaths: { + predictiveWarning: null, + highUsage: null, + criticalUsage: null, + exhausted: null, + statusIssue: null, + sessionDepleted: null, + sessionRestored: null, + }, + highUsageThreshold: 70, + criticalUsageThreshold: 90, + providerQuotaWarnings: {}, + predictivePaceWarningEnabled: false, + limitResetNotificationsEnabled: false, + credentialExpiryNotificationsEnabled: false, + sessionQuotaNotificationsEnabled: true, + showPace: true, + trayIconMode: "single", + stackedTrayTopProvider: null, + stackedTrayBottomProvider: null, + switcherShowsIcons: true, + menuBarShowsHighestUsage: false, + menuBarShowsPercent: false, + menuBarIconStyle: "bars", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + highlightMenuCardsOnHover: true, + workdayTickStyle: "subtle", + menuBarColorPace: false, + menuBarColorByProvider: false, + menuBarPaceReservePositive: false, + providersSortedAlphabetically: false, + providersEnabledOnly: false, + showAsUsed: true, + showAllTokenAccountsInMenu: false, + enableAnimations: false, + resetTimeRelative: true, + showResetWhenExhausted: false, + menuBarDisplayMode: "detailed", + overviewLayout: "detailed", + hidePersonalInfo: false, + updateChannel: "stable", + autoDownloadUpdates: false, + installUpdatesOnQuit: false, + globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, + codexCustomSessionsDirs: [], + antigravityAdditionalProfileHomes: [], + agentSessionsEnabled: false, + stayAwakeEnabled: false, + agentSessionSshHosts: [], + hooksEnabled: false, + httpProxyEnabled: false, + httpProxyUrl: "", + httpProxyUsername: "", + httpProxyPassword: "", + uiLanguage: "english", + uiLanguageFollowsSystem: false, + theme: "dark", + windowScalePercent: 100, + trayScalePercent: 100, + trayPanelAlwaysOnTop: false, + powertoysStatusPipeEnabled: false, + claudeAvoidKeychainPrompts: false, + claudeSwapEnabled: false, + claudeSwapExecutablePath: "", + codexSparkUsageVisible: true, + codexOpenaiWebExtras: false, + codexHistoricalTracking: true, + codexExternalOauthSourcesAllowed: false, + codexLocalSessionCostLedgerEnabled: true, + codexOpenaiWebBatterySaverEnabled: false, + openAiLiveUsageEnabled: true, + openAiModelRateLimits: [], + disableKeychainAccess: false, + wayfinderGatewayUrl: "", + providerMetrics: {}, + floatBarEnabled: true, + floatBarOpacity: 90, + floatBarScale: 100, + floatBarOrientation: "horizontal", + floatBarStyle: "floating", + floatBarClickThrough: false, + floatBarProviderIds: [], + floatBarDarkText: false, + floatBarShowResetInline: true, + floatBarShowCost: false, + promoteTrayIcon: false, + claudeDailyRoutinesUsageVisible: true, + claudeAllowReadingClaudeCodeCredentials: true, + alibabaTokenPlanRegion: "intl", + copilotSeatCreditEntitlement: null, + weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + openCodexUsageLogsEnabled: false, + hideNativeCodexCostWhenOpenCodexPresent: false, + costReportingPeriod: "rolling:30", + costUsageEnabled: true, + spendDashboardPeriod: "rolling:30", + spendDashboardHiddenSourceIds: [], + providerAccentColors: {}, + ...overrides, + }; +} diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts new file mode 100644 index 00000000000..efada024335 --- /dev/null +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -0,0 +1,318 @@ +import type { + ClaudeAccount, + CodexAccount, + CodexAccountsStateBridge, + CodexAccountUsageSnapshot, + ProviderCatalogEntry, + ProviderUsageSnapshot, + SettingsSnapshot, +} from "../../types/bridge"; +import { + PROOF_PROVIDER_CATALOG, + baseSettings, + isoAfter, + isoBefore, + providerSnapshot, + rateWindow, +} from "./common"; + +export const PROOF_SCENARIO_NAMES = [ + "default", + "two-codex-accounts", + "errors", + "empty", +] as const; + +export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; + +/** Everything the mocked bridge serves for one scenario. All data is synthetic. */ +export interface ProofScenario { + name: ProofScenarioName; + settings: SettingsSnapshot; + catalog: ProviderCatalogEntry[]; + providers: ProviderUsageSnapshot[]; + codexAccounts: CodexAccountsStateBridge; + claudeAccounts: ClaudeAccount[]; +} + +const SESSION_MINUTES = 300; +const WEEK_MINUTES = 10_080; +const MONTH_MINUTES = 43_200; +const DAY_MINUTES = 1_440; + +function codexSnapshot(now: Date, email: string): ProviderUsageSnapshot { + return providerSnapshot( + { + providerId: "codex", + displayName: "Codex", + primary: rateWindow(now, 34, SESSION_MINUTES, 142), + primaryLabel: "Session", + updatedAt: isoBefore(now, 1), + }, + { + secondary: rateWindow(now, 58, WEEK_MINUTES, 3 * DAY_MINUTES + 190), + secondaryLabel: "Weekly", + planName: "Plus", + accountEmail: email, + sourceLabel: "oauth", + pace: { + stage: "slightly_behind", + deltaPercent: 4, + willLastToReset: true, + etaSeconds: null, + expectedUsedPercent: 54, + actualUsedPercent: 58, + }, + }, + ); +} + +function claudeSnapshot(now: Date): ProviderUsageSnapshot { + return providerSnapshot( + { + providerId: "claude", + displayName: "Claude", + primary: rateWindow(now, 22, SESSION_MINUTES, 61), + primaryLabel: "Session (5h)", + updatedAt: isoBefore(now, 2), + }, + { + secondary: rateWindow(now, 41, WEEK_MINUTES, 5 * DAY_MINUTES + 30), + secondaryLabel: "Weekly", + planName: "Max", + accountEmail: "claude.user@example.com", + accountOrganization: "Example Studio", + sourceLabel: "oauth", + }, + ); +} + +function cursorSnapshot(now: Date, updatedMinutesAgo = 3): ProviderUsageSnapshot { + return providerSnapshot( + { + providerId: "cursor", + displayName: "Cursor", + primary: rateWindow(now, 62, MONTH_MINUTES, 12 * DAY_MINUTES), + primaryLabel: "Plan", + updatedAt: isoBefore(now, updatedMinutesAgo), + }, + { + planName: "Pro", + accountEmail: "cursor.user@example.com", + sourceLabel: "web", + }, + ); +} + +function geminiSnapshot(now: Date): ProviderUsageSnapshot { + return providerSnapshot( + { + providerId: "gemini", + displayName: "Gemini", + primary: rateWindow(now, 15, DAY_MINUTES, 9 * 60), + primaryLabel: "Daily", + updatedAt: isoBefore(now, 4), + }, + { + planName: "Free", + accountEmail: "gemini.user@example.com", + sourceLabel: "cli", + }, + ); +} + +function copilotSnapshot(now: Date): ProviderUsageSnapshot { + return providerSnapshot( + { + providerId: "copilot", + displayName: "Copilot", + primary: rateWindow(now, 48, MONTH_MINUTES, 18 * DAY_MINUTES), + primaryLabel: "Premium", + updatedAt: isoBefore(now, 5), + }, + { + planName: "Business", + accountEmail: "copilot.user@example.com", + sourceLabel: "api", + }, + ); +} + +function codexAccount( + now: Date, + id: string, + email: string, + source: CodexAccount["source"], + nickname: string | null, +): CodexAccount { + return { + id, + nickname, + emailHint: email, + authSubject: null, + providerAccountId: null, + codexHomePath: source === "ambient" ? "C:\\Users\\proof\\.codex" : `C:\\proof\\codex-homes\\${id}`, + source, + createdAt: isoBefore(now, 30 * DAY_MINUTES), + updatedAt: isoBefore(now, 60), + lastAuthenticatedAt: isoBefore(now, 2 * DAY_MINUTES), + }; +} + +function codexAccountUsage( + now: Date, + email: string, + plan: string, + sessionUsed: number, + weeklyUsed: number, +): CodexAccountUsageSnapshot { + return { + email, + providerAccountId: null, + plan, + allowed: true, + limitReached: false, + primaryWindow: { + usedPercent: sessionUsed, + resetAt: isoAfter(now, 142), + limitWindowSeconds: SESSION_MINUTES * 60, + }, + secondaryWindow: { + usedPercent: weeklyUsed, + resetAt: isoAfter(now, 3 * DAY_MINUTES + 190), + limitWindowSeconds: WEEK_MINUTES * 60, + }, + credits: null, + updatedAt: isoBefore(now, 1), + }; +} + +function singleCodexAccount(now: Date): CodexAccountsStateBridge { + const email = "dev@example.com"; + return { + accounts: [codexAccount(now, "proof-codex-ambient", email, "ambient", null)], + accountOrdinals: { "proof-codex-ambient": 1 }, + snapshots: { "proof-codex-ambient": codexAccountUsage(now, email, "Plus", 34, 58) }, + }; +} + +function claudeAccounts(now: Date): ClaudeAccount[] { + return [ + { + id: "proof-claude-1", + email: "claude.user@example.com", + organization: "Example Studio", + plan: "Max", + isActive: true, + isSaved: true, + usage: { + fiveHour: { usedPercent: 22, resetsAt: isoAfter(now, 61) }, + sevenDay: { usedPercent: 41, resetsAt: isoAfter(now, 5 * DAY_MINUTES + 30) }, + updatedAt: isoBefore(now, 2), + }, + }, + ]; +} + +function emptyCodexAccounts(): CodexAccountsStateBridge { + return { accounts: [], accountOrdinals: {}, snapshots: {} }; +} + +const DEFAULT_ENABLED = ["codex", "claude", "cursor", "gemini", "copilot"]; + +function defaultScenario(now: Date): ProofScenario { + return { + name: "default", + settings: baseSettings({ enabledProviders: DEFAULT_ENABLED }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [ + codexSnapshot(now, "dev@example.com"), + claudeSnapshot(now), + cursorSnapshot(now), + geminiSnapshot(now), + copilotSnapshot(now), + ], + codexAccounts: singleCodexAccount(now), + claudeAccounts: claudeAccounts(now), + }; +} + +function twoCodexAccountsScenario(now: Date): ProofScenario { + const work = "work@example.com"; + const personal = "personal@example.com"; + return { + name: "two-codex-accounts", + settings: baseSettings({ enabledProviders: ["codex", "claude"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [codexSnapshot(now, work), claudeSnapshot(now)], + codexAccounts: { + accounts: [ + codexAccount(now, "proof-codex-work", work, "ambient", "Work"), + codexAccount(now, "proof-codex-personal", personal, "managedByApp", "Personal"), + ], + accountOrdinals: { "proof-codex-work": 1, "proof-codex-personal": 2 }, + snapshots: { + "proof-codex-work": codexAccountUsage(now, work, "Plus", 34, 58), + "proof-codex-personal": codexAccountUsage(now, personal, "Pro", 9, 17), + }, + }, + claudeAccounts: claudeAccounts(now), + }; +} + +function errorsScenario(now: Date): ProofScenario { + const claude = providerSnapshot( + { + providerId: "claude", + displayName: "Claude", + primary: rateWindow(now, 0, SESSION_MINUTES, null), + primaryLabel: "Session (5h)", + updatedAt: isoBefore(now, 1), + }, + { + error: "Your Claude session has expired. Sign in again to resume usage updates.", + errorState: "expiredSession", + sourceLabel: "oauth", + }, + ); + return { + name: "errors", + settings: baseSettings({ enabledProviders: ["codex", "claude", "cursor"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [ + codexSnapshot(now, "dev@example.com"), + claude, + // Last good data from three hours ago: older than the 10-minute stale cutoff. + cursorSnapshot(now, 180), + ], + codexAccounts: singleCodexAccount(now), + claudeAccounts: [], + }; +} + +function emptyScenario(): ProofScenario { + return { + name: "empty", + settings: baseSettings({ enabledProviders: [] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [], + codexAccounts: emptyCodexAccounts(), + claudeAccounts: [], + }; +} + +const BUILDERS: Record ProofScenario> = { + default: defaultScenario, + "two-codex-accounts": twoCodexAccountsScenario, + errors: errorsScenario, + empty: emptyScenario, +}; + +export function isProofScenarioName(value: string): value is ProofScenarioName { + return (PROOF_SCENARIO_NAMES as readonly string[]).includes(value); +} + +/** Fixture timestamps are relative to `now` so reset countdowns stay plausible. */ +export function buildProofScenario(name: ProofScenarioName, now: Date = new Date()): ProofScenario { + return BUILDERS[name](now); +} diff --git a/apps/desktop-tauri/src/proof/install.ts b/apps/desktop-tauri/src/proof/install.ts new file mode 100644 index 00000000000..e52bcde87d0 --- /dev/null +++ b/apps/desktop-tauri/src/proof/install.ts @@ -0,0 +1,21 @@ +import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; +import { buildProofScenario } from "./fixtures/scenarios"; +import { PROOF_WINDOWS, windowLabel, type ProofParams } from "./params"; +import { createProofRouter, type ProofRouter } from "./router"; + +/** + * Replace the Tauri IPC with fixture data. Must run before App mounts: + * `getCurrentWebviewWindow()` reads the mocked window metadata. + */ +export function installProofMocks(params: ProofParams, now: Date = new Date()): ProofRouter { + const scenario = buildProofScenario(params.fixture, now); + if (params.theme) { + scenario.settings = { ...scenario.settings, theme: params.theme }; + } + const router = createProofRouter(scenario, { window: params.window, tab: params.tab, now }); + const current = windowLabel(params.window); + const others = PROOF_WINDOWS.map(windowLabel).filter((label) => label !== current); + mockWindows(current, ...others); + mockIPC(router.handle, { shouldMockEvents: true }); + return router; +} diff --git a/apps/desktop-tauri/src/proof/isolation.test.ts b/apps/desktop-tauri/src/proof/isolation.test.ts new file mode 100644 index 00000000000..fa20f6ef70c --- /dev/null +++ b/apps/desktop-tauri/src/proof/isolation.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const SRC = `${import.meta.dirname}/..`; + +function source(relative: string): string { + return readFileSync(`${SRC}/${relative}`, "utf8"); +} + +describe("production entry isolation", () => { + it.each(["main.tsx", "App.tsx", "lib/tauri.ts"])("%s never imports the proof bridge", (file) => { + const text = source(file); + expect(text).not.toMatch(/from\s+["'][^"']*\/proof(\/|["'])/); + expect(text).not.toMatch(/import\(\s*["'][^"']*\/proof(\/|["'])/); + expect(text).not.toContain("@tauri-apps/api/mocks"); + }); + + it("index.html still boots the production entry", () => { + const html = readFileSync(`${SRC}/../index.html`, "utf8"); + expect(html).toContain('src="/src/main.tsx"'); + expect(html).not.toContain("proof"); + }); +}); diff --git a/apps/desktop-tauri/src/proof/locale.ts b/apps/desktop-tauri/src/proof/locale.ts new file mode 100644 index 00000000000..5b16ed57151 --- /dev/null +++ b/apps/desktop-tauri/src/proof/locale.ts @@ -0,0 +1,31 @@ +import englishFtl from "../../../../rust/src/locale/en-US.ftl?raw"; +import { ALL_LOCALE_KEYS } from "../i18n/keys"; +import type { LanguageOption, LocaleStrings } from "../types/bridge"; + +/** Parse the shipped English Fluent file the way the Rust bridge flattens it. */ +export function parseFtlEntries(text: string): Record { + const entries: Record = {}; + for (const line of text.split(/\r?\n/)) { + const match = /^([A-Za-z][\w-]*)\s*=\s?(.*)$/.exec(line); + if (match) entries[match[1]] = match[2].replace(/\{ "\{\}" \}/g, "{}"); + } + return entries; +} + +let cached: LocaleStrings | null = null; + +/** English bundle; keys missing from the file fall back to the key name, as the UI does. */ +export function englishLocaleStrings(): LocaleStrings { + if (cached) return cached; + const parsed = parseFtlEntries(englishFtl); + const entries: Record = {}; + for (const key of ALL_LOCALE_KEYS) { + entries[key] = parsed[key] ?? key; + } + cached = { language: "english", entries: { ...parsed, ...entries } }; + return cached; +} + +export const PROOF_LANGUAGES: LanguageOption[] = [ + { value: "english", display: "English" }, +]; diff --git a/apps/desktop-tauri/src/proof/main.tsx b/apps/desktop-tauri/src/proof/main.tsx new file mode 100644 index 00000000000..f5825b189dd --- /dev/null +++ b/apps/desktop-tauri/src/proof/main.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { installProofMocks } from "./install"; +import { parseProofParams } from "./params"; +import "../styles.css"; +import "../surfaces/settings/settings-layout.css"; +import "../surfaces/menu-surface-layout.css"; +import "../surfaces/tray-card.css"; + +installProofMocks(parseProofParams(window.location.search)); + +// App is loaded only after the mocks exist so no module sees the bare IPC. +void import("../App").then(({ default: App }) => { + ReactDOM.createRoot(document.getElementById("root")!).render( + + + , + ); +}); diff --git a/apps/desktop-tauri/src/proof/params.ts b/apps/desktop-tauri/src/proof/params.ts new file mode 100644 index 00000000000..6fccea0dcbe --- /dev/null +++ b/apps/desktop-tauri/src/proof/params.ts @@ -0,0 +1,40 @@ +import type { ThemePreference } from "../types/bridge"; +import { FLOATBAR_WINDOW_LABEL } from "../floatbar/api"; +import { isProofScenarioName, type ProofScenarioName } from "./fixtures/scenarios"; + +export const PROOF_WINDOWS = ["main", "settings", "flyout", "floatbar"] as const; +export type ProofWindow = (typeof PROOF_WINDOWS)[number]; + +export interface ProofParams { + window: ProofWindow; + /** Raw settings tab request (`general`, `providers:codex`, ...); App parses it. */ + tab: string; + fixture: ProofScenarioName; + /** Overrides the fixture's saved theme when present. */ + theme: ThemePreference | null; +} + +function isProofWindow(value: string): value is ProofWindow { + return (PROOF_WINDOWS as readonly string[]).includes(value); +} + +function isThemePreference(value: string): value is ThemePreference { + return value === "dark" || value === "light" || value === "auto"; +} + +export function parseProofParams(search: string): ProofParams { + const params = new URLSearchParams(search); + const windowParam = params.get("window") ?? ""; + const fixture = params.get("fixture") ?? ""; + const theme = params.get("theme") ?? ""; + return { + window: isProofWindow(windowParam) ? windowParam : "main", + tab: params.get("tab") || "general", + fixture: isProofScenarioName(fixture) ? fixture : "default", + theme: isThemePreference(theme) ? theme : null, + }; +} + +export function windowLabel(window: ProofWindow): string { + return window === "floatbar" ? FLOATBAR_WINDOW_LABEL : window; +} diff --git a/apps/desktop-tauri/src/proof/render.test.tsx b/apps/desktop-tauri/src/proof/render.test.tsx new file mode 100644 index 00000000000..001f3b3d0f4 --- /dev/null +++ b/apps/desktop-tauri/src/proof/render.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, configure, render, screen } from "@testing-library/react"; +import { clearMocks } from "@tauri-apps/api/mocks"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseProofParams } from "./params"; +import type { ProofRouter } from "./router"; + +// Each case re-imports the whole App after vi.resetModules(); under a loaded +// full-suite run that cold import alone can exceed the default timeouts. +configure({ asyncUtilTimeout: 10_000 }); + +async function renderProof(search: string): Promise { + window.history.replaceState({}, "", `/proof.html${search}`); + // Fresh modules per render: surface and settings stores are module singletons. + vi.resetModules(); + const { installProofMocks } = await import("./install"); + const router = installProofMocks(parseProofParams(search)); + const { default: App } = await import("../App"); + render(); + return router; +} + +afterEach(async () => { + cleanup(); + // Unmount unlistens resolve asynchronously and still need the mocked internals. + await new Promise((resolve) => setTimeout(resolve, 0)); + clearMocks(); + window.history.replaceState({}, "", "/"); +}); + +describe("proof build renders App over the mocked bridge", { timeout: 20_000 }, () => { + it("shows the tray panel with the default providers in the main window", async () => { + const router = await renderProof("?window=main&fixture=default"); + expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0); + expect((await screen.findAllByText("Claude")).length).toBeGreaterThan(0); + expect(screen.queryByText("Bootstrap failed")).not.toBeInTheDocument(); + expect(router.unhandledCommands()).toEqual([]); + }); + + it("renders the flyout for the errors scenario", async () => { + await renderProof("?window=flyout&fixture=errors"); + expect((await screen.findAllByText("Cursor")).length).toBeGreaterThan(0); + }); + + it("renders Settings > General in the settings window", async () => { + const router = await renderProof("?window=settings&tab=general&fixture=default"); + expect(await screen.findByText("Start at login")).toBeInTheDocument(); + expect(router.unhandledCommands()).toEqual([]); + }); + + it("renders Settings > Providers with the enabled providers listed", async () => { + await renderProof("?window=settings&tab=providers&fixture=default"); + expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0); + expect((await screen.findAllByText("Claude")).length).toBeGreaterThan(0); + }); + + it("renders the Codex pane for two accounts", async () => { + await renderProof("?window=settings&tab=providers:codex&fixture=two-codex-accounts"); + expect((await screen.findAllByText(/work@example\.com/)).length).toBeGreaterThan(0); + expect((await screen.findAllByText(/personal@example\.com/)).length).toBeGreaterThan(0); + }); + + it("renders the main window with no providers enabled", async () => { + const router = await renderProof("?window=main&fixture=empty"); + await vi.waitFor( + () => { + expect(document.body.textContent).not.toBe(""); + expect(screen.queryByText("Loading shell contract…")).not.toBeInTheDocument(); + }, + { timeout: 10_000 }, + ); + expect(screen.queryByText("Codex")).not.toBeInTheDocument(); + expect(router.unhandledCommands()).toEqual([]); + }); +}); diff --git a/apps/desktop-tauri/src/proof/router.test.ts b/apps/desktop-tauri/src/proof/router.test.ts new file mode 100644 index 00000000000..c5ab3b4ef58 --- /dev/null +++ b/apps/desktop-tauri/src/proof/router.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + BootstrapState, + CurrentSurfaceState, + LocaleStrings, + ProviderDetail, + ProviderUsageSnapshot, + SettingsSnapshot, +} from "../types/bridge"; +import { PROOF_SCENARIO_NAMES, buildProofScenario } from "./fixtures/scenarios"; +import { parseProofParams } from "./params"; +import { UNHANDLED_COMMAND_WARNING, createProofRouter } from "./router"; + +const NOW = new Date("2026-03-10T12:00:00Z"); + +function routerFor(name: (typeof PROOF_SCENARIO_NAMES)[number], window: "main" | "settings" = "main") { + return createProofRouter(buildProofScenario(name, NOW), { window, tab: "providers", now: NOW }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("proof router", () => { + it.each(PROOF_SCENARIO_NAMES)("serves bootstrap, settings and providers for %s", (name) => { + const scenario = buildProofScenario(name, NOW); + const router = routerFor(name); + + const bootstrap = router.handle("get_bootstrap_state") as BootstrapState; + expect(bootstrap.settings.enabledProviders).toEqual(scenario.settings.enabledProviders); + expect(bootstrap.providers.length).toBeGreaterThan(0); + + const settings = router.handle("get_settings_snapshot") as SettingsSnapshot; + expect(settings).toEqual(scenario.settings); + + const cached = router.handle("get_cached_providers") as ProviderUsageSnapshot[]; + expect(cached.map((snapshot) => snapshot.providerId)).toEqual( + scenario.providers.map((snapshot) => snapshot.providerId), + ); + + const locale = router.handle("get_locale_strings", { language: null }) as LocaleStrings; + expect(locale.entries.TabGeneral).toBe("General"); + + const surface = router.handle("get_current_surface_state") as CurrentSurfaceState; + expect(surface.mode).toBe("trayPanel"); + }); + + it("keeps each provider's identity on its own snapshot", () => { + const providers = buildProofScenario("default", NOW).providers; + const emails = providers.map((snapshot) => snapshot.accountEmail).filter(Boolean); + expect(new Set(emails).size).toBe(emails.length); + for (const email of emails) expect(email).toMatch(/@example\.com$/); + }); + + it("covers the scenario shapes the screenshots rely on", () => { + expect(buildProofScenario("default", NOW).providers.map((p) => p.providerId)).toEqual( + expect.arrayContaining(["codex", "claude"]), + ); + expect(buildProofScenario("two-codex-accounts", NOW).codexAccounts.accounts).toHaveLength(2); + const errors = buildProofScenario("errors", NOW).providers; + expect(errors.some((snapshot) => snapshot.error !== null)).toBe(true); + expect( + errors.some((snapshot) => NOW.getTime() - Date.parse(snapshot.updatedAt) > 10 * 60_000), + ).toBe(true); + expect(buildProofScenario("empty", NOW).settings.enabledProviders).toEqual([]); + }); + + it("answers provider detail without leaking another provider's identity", () => { + const router = routerFor("default"); + const claude = router.handle("get_provider_detail", { providerId: "claude" }) as ProviderDetail; + expect(claude.email).toBe("claude.user@example.com"); + const openrouter = router.handle("get_provider_detail", { providerId: "openrouter" }) as ProviderDetail; + expect(openrouter.hasSnapshot).toBe(false); + expect(openrouter.email).toBeNull(); + }); + + it("applies settings patches to later reads", () => { + const router = routerFor("default"); + router.handle("update_settings", { patch: { theme: "light" } }); + expect((router.handle("get_settings_snapshot") as SettingsSnapshot).theme).toBe("light"); + }); + + it("reports the requested settings tab as the shell target", () => { + const router = createProofRouter(buildProofScenario("default", NOW), { + window: "settings", + tab: "providers:claude", + now: NOW, + }); + expect(router.handle("get_current_surface_state")).toEqual({ + mode: "settings", + target: { kind: "settings", tab: "providers:claude" }, + }); + }); + + it("returns null and warns once for an unknown command", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const router = routerFor("default"); + expect(router.handle("definitely_not_a_command")).toBeNull(); + expect(router.handle("definitely_not_a_command")).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(`${UNHANDLED_COMMAND_WARNING} definitely_not_a_command`); + expect(router.unhandledCommands()).toEqual(["definitely_not_a_command"]); + }); + + it("answers window and dialog plugin commands without warning", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const router = routerFor("default"); + expect(router.handle("plugin:window|set_size", {})).toBeNull(); + expect(router.handle("plugin:window|inner_size", {})).toEqual({ + width: window.innerWidth, + height: window.innerHeight, + }); + expect(router.handle("plugin:dialog|open", {})).toBeNull(); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("proof URL params", () => { + it("falls back to safe defaults", () => { + expect(parseProofParams("?window=nope&fixture=nope&theme=nope")).toEqual({ + window: "main", + tab: "general", + fixture: "default", + theme: null, + }); + }); + + it("reads window, tab, fixture and theme", () => { + expect(parseProofParams("?window=settings&tab=menu&fixture=errors&theme=light")).toEqual({ + window: "settings", + tab: "menu", + fixture: "errors", + theme: "light", + }); + }); +}); diff --git a/apps/desktop-tauri/src/proof/router.ts b/apps/desktop-tauri/src/proof/router.ts new file mode 100644 index 00000000000..9ac787f2a2f --- /dev/null +++ b/apps/desktop-tauri/src/proof/router.ts @@ -0,0 +1,253 @@ +import { emit } from "@tauri-apps/api/event"; +import { version as appVersion } from "../../package.json"; +import type { + BootstrapState, + CurrentSurfaceState, + SettingsSnapshot, + SettingsUpdate, +} from "../types/bridge"; +import { + parseSettingsTabRequest, + providersTabRequest, +} from "../surfaces/settings/settingsTabs"; +import type { ProofScenario } from "./fixtures/scenarios"; +import { + proofAppInfo, + proofCurrencyRates, + proofHooksConfig, + proofUpdateState, + providerChartData, + providerDetail, + providerSummaries, + spendContract, + usageSpendSummary, +} from "./fixtures/bridgeData"; +import { PROOF_LANGUAGES, englishLocaleStrings } from "./locale"; +import type { ProofWindow } from "./params"; + +export const UNHANDLED_COMMAND_WARNING = "[proof-mock] unhandled command:"; + +type Handler = (args: Record) => unknown; + +/** Plugin namespaces whose commands are window chrome or OS dialogs: inert in a browser. */ +const INERT_PLUGIN_PREFIXES = ["plugin:window|", "plugin:webview|", "plugin:dialog|", "plugin:event|"]; + +export interface ProofRouterOptions { + window: ProofWindow; + tab: string; + now?: Date; +} + +export interface ProofRouter { + handle: (cmd: string, payload?: unknown) => unknown; + /** Command names with a fixture-backed handler. */ + handledCommands: readonly string[]; + /** Commands that reached the fallback since this router was created. */ + unhandledCommands: () => string[]; + settings: () => SettingsSnapshot; +} + +function argsRecord(payload: unknown): Record { + return payload !== null && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : {}; +} + +function stringArg(args: Record, key: string): string { + const value = args[key]; + return typeof value === "string" ? value : ""; +} + +function emitSoon(event: string, payload: unknown): void { + window.setTimeout(() => { + void emit(event, payload).catch(() => {}); + }, 0); +} + +function windowPluginResult(cmd: string): unknown { + switch (cmd) { + case "plugin:window|inner_size": + case "plugin:window|outer_size": + return { width: window.innerWidth, height: window.innerHeight }; + case "plugin:window|inner_position": + case "plugin:window|outer_position": + return { x: 0, y: 0 }; + case "plugin:window|scale_factor": + return window.devicePixelRatio || 1; + case "plugin:window|is_visible": + case "plugin:window|is_focused": + return true; + default: + return null; + } +} + +export function createProofRouter( + scenario: ProofScenario, + options: ProofRouterOptions, +): ProofRouter { + const now = options.now ?? new Date(); + let settings: SettingsSnapshot = { ...scenario.settings }; + const warned = new Set(); + + const bootstrap = (): BootstrapState => ({ + contractVersion: "v1", + providers: scenario.catalog, + settings, + }); + + const surfaceState = (): CurrentSurfaceState => { + if (options.window === "settings") { + const request = parseSettingsTabRequest(options.tab); + const tab = request?.providerId + ? providersTabRequest(request.providerId) + : (request?.tab ?? "general"); + return { mode: "settings", target: { kind: "settings", tab } }; + } + return { mode: "trayPanel", target: { kind: "summary" } }; + }; + + const enabledProviders = () => + scenario.providers.filter((snapshot) => settings.enabledProviders.includes(snapshot.providerId)); + + const handlers: Record = { + get_bootstrap_state: bootstrap, + get_settings_snapshot: () => settings, + update_settings: (args) => { + const patch = argsRecord(args.patch) as SettingsUpdate; + settings = { ...settings, ...patch } as SettingsSnapshot; + return settings; + }, + get_provider_catalog: () => scenario.catalog, + get_provider_statuses: () => [], + reorder_providers: () => providerSummaries(scenario), + reorder_visible_providers: () => providerSummaries(scenario), + restore_default_provider_order: () => providerSummaries(scenario), + get_cached_providers: enabledProviders, + refresh_providers: () => { + const ids = enabledProviders().map((snapshot) => snapshot.providerId); + emitSoon("refresh-started", { providerIds: ids }); + emitSoon("refresh-complete", { + providerCount: ids.length, + errorCount: enabledProviders().filter((snapshot) => snapshot.error !== null).length, + }); + return null; + }, + refresh_providers_if_stale: () => null, + get_current_surface_state: surfaceState, + set_surface_mode: (args) => args.mode ?? "trayPanel", + get_locale_strings: () => englishLocaleStrings(), + set_ui_language: () => null, + get_available_languages: () => PROOF_LANGUAGES, + get_currency_rates: (args) => + proofCurrencyRates(stringArg(args, "preferredCurrencyCode") || "AUTO"), + get_update_state: proofUpdateState, + check_for_updates: proofUpdateState, + dismiss_update: proofUpdateState, + get_app_info: () => proofAppInfo(appVersion), + get_deepseek_pricing_status: () => null, + get_stay_awake_status: () => false, + get_system_accent_color: () => "#4c8bf5", + // A fixed 1080p desktop minus the taskbar: headless browsers report a + // small `screen`, which would cap the tray panel's auto-fit height. + get_work_area_rect: () => ({ x: 0, y: 0, width: 1920, height: 1032 }), + tray_visibility_status: () => ({ support: "supported", state: "promoted" }), + get_credential_storage_status: () => ({ + manualCookies: "Windows Credential Manager", + apiKeys: "Windows Credential Manager", + tokenAccounts: "Windows Credential Manager", + }), + get_provider_detail: (args) => providerDetail(scenario, stringArg(args, "providerId")), + get_provider_chart_data: (args) => + providerChartData(scenario, stringArg(args, "providerId"), now), + get_provider_local_usage_summary: (args) => + providerChartData(scenario, stringArg(args, "providerId"), now).localUsage, + get_usage_spend_summary: () => usageSpendSummary(scenario, now), + get_spend_contract: (args) => + spendContract(stringArg(args, "providerId"), settings.spendDashboardPeriod ?? "rolling:30"), + get_provider_cookie_source_options: () => [ + { value: "auto", label: "Automatic" }, + { value: "manual", label: "Manual" }, + { value: "off", label: "Off" }, + ], + get_provider_region_options: () => [], + get_provider_workspace_id: () => null, + get_provider_gateway_url: () => "", + get_provider_azure_api_version: () => null, + get_api_keys: () => [], + get_api_key_providers: () => [], + has_openrouter_management_api_key: () => false, + get_manual_cookies: () => [], + list_detected_browsers: () => [ + { browserType: "edge", displayName: "Microsoft Edge", profileCount: 1 }, + { browserType: "chrome", displayName: "Google Chrome", profileCount: 2 }, + ], + get_token_account_providers: () => [], + get_token_accounts: (args) => ({ + providerId: stringArg(args, "providerId"), + support: { + providerId: stringArg(args, "providerId"), + displayName: stringArg(args, "providerId"), + title: "Token accounts", + subtitle: "", + placeholder: "", + }, + accounts: [], + activeIndex: 0, + }), + get_codex_accounts_state: () => scenario.codexAccounts, + codex_accounts_list: () => scenario.codexAccounts.accounts, + codex_account_snapshots: () => scenario.codexAccounts.snapshots, + codex_account_fetch: (args) => scenario.codexAccounts.snapshots[stringArg(args, "id")] ?? null, + get_claude_accounts_state: () => scenario.claudeAccounts, + claude_reconciliation_state: () => null, + claude_swap_accounts_list: () => ({ + enabled: false, + executableConfigured: false, + accounts: [], + error: null, + }), + grok_accounts_list: () => [], + get_hooks_config: proofHooksConfig, + list_agent_sessions: () => ({ status: "disabled" }), + get_gemini_cli_signed_in: () => ({ signedIn: true, credentialsPath: null }), + get_vertexai_status: () => ({ hasCredentials: false, credentialsPath: null }), + list_jetbrains_detected_ides: () => [], + get_kiro_status: () => ({ available: false, hint: null }), + get_codex_workspaces_snapshot: () => null, + get_safe_diagnostics: () => "Proof build: mocked IPC, no diagnostics collected.", + // Window and shell side effects are no-ops in a browser. + dismiss_tray_panel: () => null, + begin_flyout_gesture: () => null, + end_flyout_gesture: () => null, + open_settings_window: () => null, + open_flyout_window: () => null, + close_settings_window: () => null, + reanchor_tray_panel: () => null, + reveal_tray_panel_window: () => null, + resize_float_bar: () => null, + open_external_url: () => null, + open_release_page: () => null, + open_provider_dashboard: () => null, + open_provider_status_page: () => null, + }; + + const handle = (cmd: string, payload?: unknown): unknown => { + const handler = handlers[cmd]; + if (handler) return handler(argsRecord(payload)); + if (cmd.startsWith("plugin:window|")) return windowPluginResult(cmd); + if (INERT_PLUGIN_PREFIXES.some((prefix) => cmd.startsWith(prefix))) return null; + if (!warned.has(cmd)) { + warned.add(cmd); + console.warn(`${UNHANDLED_COMMAND_WARNING} ${cmd}`); + } + return null; + }; + + return { + handle, + handledCommands: Object.keys(handlers), + unhandledCommands: () => [...warned], + settings: () => settings, + }; +} diff --git a/apps/desktop-tauri/vite.config.ts b/apps/desktop-tauri/vite.config.ts index caae813b64e..67b38fe60dc 100644 --- a/apps/desktop-tauri/vite.config.ts +++ b/apps/desktop-tauri/vite.config.ts @@ -1,19 +1,42 @@ /// -import { defineConfig } from "vite"; +import { fileURLToPath } from "node:url"; +import { defineConfig, searchForWorkspaceRoot } from "vite"; import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()], - server: { - host: "127.0.0.1", - port: 1420, - strictPort: true, - }, - clearScreen: false, - test: { - environment: "jsdom", - globals: true, - setupFiles: ["./src/test/setup.ts"], - include: ["src/**/*.{test,spec}.{ts,tsx}"], - }, +export default defineConfig(({ mode }) => { + // The mocked-IPC proof page is a separate entry so the desktop bundle never + // contains the mock bridge or its fixtures. + const proof = mode === "proof"; + // Vitest runs with mode "test" and imports the proof bridge in its tests. + const allowLocaleFiles = proof || mode === "test"; + return { + plugins: [react()], + server: { + host: "127.0.0.1", + port: 1420, + strictPort: true, + fs: allowLocaleFiles + ? { + // The proof bridge serves the shipped English strings from rust/src/locale. + allow: [ + searchForWorkspaceRoot(process.cwd()), + fileURLToPath(new URL("../../rust/src/locale", import.meta.url)), + ], + } + : undefined, + }, + build: proof + ? { + outDir: "dist-proof", + rollupOptions: { input: fileURLToPath(new URL("./proof.html", import.meta.url)) }, + } + : undefined, + clearScreen: false, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + }, + }; }); From 4d118548ac291d728b3ff140440b4c0586662369 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 12:33:21 +0700 Subject: [PATCH 02/12] Document mocked-IPC proof build --- docs/WINDOWS_PROOF.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index 9de894acd8e..414d07dc28b 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -176,3 +176,39 @@ Tick each entry as it is verified on the Windows target. [ ] runtime.update-banner-flow [ ] runtime.single-instance-mutex ``` + +--- + +## 5. Mocked-IPC proof build + +`apps/desktop-tauri/proof.html` runs the real React surfaces in any browser. +The Tauri bridge is replaced by `mockIPC` / `mockWindows` from +`@tauri-apps/api/mocks`. Commands are answered by synthetic fixtures in +`src/proof/fixtures/` (typed against `types/bridge.ts`). The desktop bundle +(`index.html` → `src/main.tsx`) never imports `src/proof`. + +```powershell +cd apps/desktop-tauri +pnpm run dev:proof # http://127.0.0.1:1420/proof.html +pnpm run build:proof # static output in dist-proof/ +``` + +URL parameters: + +| Param | Values | Default | +| --- | --- | --- | +| `window` | `main`, `settings`, `flyout`, `floatbar` | `main` | +| `tab` | any settings tab id, or `providers:` | `general` | +| `fixture` | `default`, `two-codex-accounts`, `errors`, `empty` | `default` | +| `theme` | `dark`, `light`, `auto` (overrides the fixture) | fixture value | + +Example: `proof.html?window=settings&tab=providers:codex&fixture=two-codex-accounts`. + +Commands without a fixture return `null` and log +`[proof-mock] unhandled command: ` once, so coverage gaps show up in +the console. Window, webview, dialog, and event plugin calls are inert. + +Screenshots from this build are labelled **"Chromium (Linux), mocked IPC"** +(or the browser actually used). They support layout, copy, rendered data, and +provider-siloing review; they do not replace the CUA proof for tray, theme, +float-bar, DWM, or WebView2 behaviour (see AGENTS.md). From 5f7fc4c434c4729b06ce7569773da4b147a93637 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 13:26:22 +0700 Subject: [PATCH 03/12] Add proof:check browser scenario runner and mock coverage test --- .gitignore | 1 + apps/desktop-tauri/package.json | 1 + apps/desktop-tauri/scripts/proof-check.mjs | 374 ++++++++++++++++++ .../scripts/proof-scenarios.json | 98 +++++ apps/desktop-tauri/src/proof/coverage.test.ts | 67 ++++ apps/desktop-tauri/src/proof/install.ts | 18 +- apps/desktop-tauri/src/proof/router.ts | 81 +++- .../desktop-tauri/src/test/node-builtins.d.ts | 5 + 8 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 apps/desktop-tauri/scripts/proof-check.mjs create mode 100644 apps/desktop-tauri/scripts/proof-scenarios.json create mode 100644 apps/desktop-tauri/src/proof/coverage.test.ts diff --git a/.gitignore b/.gitignore index 5f5b2e23a36..fcfc7d65314 100755 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ tmpclaude-* node_modules/ dist/ dist-proof/ +apps/desktop-tauri/.proof/ nul # Local scripts and env files diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json index 01b82d10ab3..c6e5efb2926 100644 --- a/apps/desktop-tauri/package.json +++ b/apps/desktop-tauri/package.json @@ -12,6 +12,7 @@ "prebuild": "pnpm run check-locale", "build": "tsc --noEmit && vite build", "build:proof": "tsc --noEmit && vite build --mode proof", + "proof:check": "node scripts/proof-check.mjs", "preview": "vite preview", "check:anti-slop": "tsc --noEmit -p tsconfig.anti-slop.json", "test": "vitest run", diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs new file mode 100644 index 00000000000..a57a14c71c7 --- /dev/null +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -0,0 +1,374 @@ +#!/usr/bin/env node +// Browser UI proof over the mocked-IPC proof build (docs/WINDOWS_PROOF.md §5). +// Starts `vite --mode proof` (or uses --url), drives Chrome/Edge/Chromium over +// CDP through scripts/proof-scenarios.json, prints PASS/FAIL per scenario and +// saves screenshots. Node built-ins only: fetch, WebSocket, child_process. +// +// pnpm proof:check headless, all scenarios +// pnpm proof:check -- --headed visible window, pauses on each screenshot +// pnpm proof:check -- --only tray scenarios whose name contains "tray" +// pnpm proof:check -- --url an already-served proof build (dev or dist-proof) +// pnpm proof:check -- --cdp attach to a running browser instead of launching one +// pnpm proof:check -- --browser browser binary (or env PROOF_BROWSER) +// pnpm proof:check -- --out screenshot folder (default .proof/screens) +// +// Exit codes: 0 all scenarios passed, 1 a scenario failed, 2 setup failed. + +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const appDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const STEP_TIMEOUT_MS = 8_000; +const PROOF_PORT = 1430; + +const READY_BY_WINDOW = { + main: ".tray-panel-reveal--ready", + flyout: ".tray-panel-reveal--ready", + settings: ".settings-surface", + floatbar: ".floatbar__pill, .floatbar__empty", +}; + +function parseArgs(argv) { + const opts = { headed: false, only: null, url: null, cdp: null, browser: null, out: null, hold: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const value = () => { + const next = argv[++i]; + if (next === undefined) fail(2, `${arg} needs a value`); + return next; + }; + if (arg === "--") continue; + else if (arg === "--headed") opts.headed = true; + else if (arg === "--only") opts.only = value(); + else if (arg === "--url") opts.url = value().replace(/\/$/, ""); + else if (arg === "--cdp") opts.cdp = value(); + else if (arg === "--browser") opts.browser = value(); + else if (arg === "--out") opts.out = value(); + else if (arg === "--hold") opts.hold = Number(value()); + else fail(2, `unknown option ${arg}`); + } + opts.hold ??= opts.headed ? 1_500 : 0; + opts.out = resolve(opts.out ?? join(appDir, ".proof", "screens")); + return opts; +} + +function fail(code, message) { + console.error(`[proof-check] ${message}`); + process.exit(code); +} + +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); + +function findBrowser(explicit) { + const candidates = [ + explicit, + process.env.PROOF_BROWSER, + ...(process.platform === "win32" + ? [ + `${process.env.ProgramFiles}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["ProgramFiles(x86)"]}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["ProgramFiles(x86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`, + `${process.env.ProgramFiles}\\Microsoft\\Edge\\Application\\msedge.exe`, + ] + : process.platform === "darwin" + ? [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ] + : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/microsoft-edge"]), + ]; + return candidates.find((path) => path && existsSync(path)) ?? null; +} + +async function waitForHttp(url, timeoutMs) { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + try { + if ((await fetch(url)).ok) return true; + } catch { + // not up yet + } + await sleep(250); + } + return false; +} + +async function startProofServer() { + const vite = join(appDir, "node_modules", "vite", "bin", "vite.js"); + if (!existsSync(vite)) fail(2, "vite is not installed; run pnpm install"); + const child = spawn( + process.execPath, + [vite, "--mode", "proof", "--port", String(PROOF_PORT), "--strictPort", "--host", "127.0.0.1"], + { cwd: appDir, stdio: ["ignore", "pipe", "pipe"] }, + ); + let log = ""; + child.stdout.on("data", (chunk) => (log += chunk)); + child.stderr.on("data", (chunk) => (log += chunk)); + const base = `http://127.0.0.1:${PROOF_PORT}`; + if (!(await waitForHttp(`${base}/proof.html`, 30_000))) { + child.kill(); + fail(2, `proof server did not start on port ${PROOF_PORT}:\n${log}`); + } + return { base, stop: () => child.kill() }; +} + +async function launchBrowser(opts) { + const binary = findBrowser(opts.browser); + if (!binary) fail(2, "no Chrome, Edge or Chromium found; pass --browser or set PROOF_BROWSER"); + // Reused across runs: an isolated profile with no logins, never the user's. + const profile = join(appDir, ".proof", "browser-profile"); + mkdirSync(profile, { recursive: true }); + const portFile = join(profile, "DevToolsActivePort"); + const args = [ + `--user-data-dir=${profile}`, + "--remote-debugging-port=0", + "--no-first-run", + "--no-default-browser-check", + // A headed window behind other windows would otherwise stop painting, + // and the tray panel waits on requestAnimationFrame before it reveals. + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling", + "--window-size=1000,1080", + ...(opts.headed ? [] : ["--headless=new", "--disable-gpu"]), + "about:blank", + ]; + const startedAt = Date.now(); + const child = spawn(binary, args, { stdio: "ignore" }); + const end = startedAt + 20_000; + while (Date.now() < end) { + await sleep(200); + try { + const [port, path] = readFileSync(portFile, "utf8").split(/\r?\n/); + const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); + if (version.webSocketDebuggerUrl?.endsWith(path)) { + return { ws: version.webSocketDebuggerUrl, child }; + } + } catch { + // the port file is written once the browser is listening + } + } + child.kill(); + fail(2, `browser did not open a debugging port: ${binary}`); +} + +class Cdp { + constructor(socket) { + this.socket = socket; + this.nextId = 0; + this.pending = new Map(); + this.listeners = []; + socket.addEventListener("message", (event) => { + const msg = JSON.parse(event.data); + if (msg.id !== undefined && this.pending.has(msg.id)) { + const { resolve: done, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`)); + else done(msg.result); + } else if (msg.method) { + for (const listener of this.listeners) listener(msg); + } + }); + } + + static async connect(url) { + const socket = new WebSocket(url); + await new Promise((done, reject) => { + socket.addEventListener("open", done, { once: true }); + socket.addEventListener("error", () => reject(new Error(`cannot connect to ${url}`)), { once: true }); + }); + return new Cdp(socket); + } + + send(method, params = {}, sessionId) { + const id = ++this.nextId; + this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + return new Promise((done, reject) => this.pending.set(id, { resolve: done, reject })); + } + + on(listener) { + this.listeners.push(listener); + } + + close() { + this.socket.close(); + } +} + +class Page { + constructor(cdp, sessionId) { + this.cdp = cdp; + this.sessionId = sessionId; + this.problems = []; + cdp.on((msg) => { + if (msg.sessionId !== sessionId) return; + if (msg.method === "Runtime.consoleAPICalled" && ["error", "warning"].includes(msg.params.type)) { + const text = msg.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" "); + this.problems.push(`console.${msg.params.type}: ${text.slice(0, 300)}`); + } else if (msg.method === "Runtime.exceptionThrown") { + const detail = msg.params.exceptionDetails; + this.problems.push(`exception: ${(detail.exception?.description ?? detail.text).slice(0, 300)}`); + } + }); + } + + send(method, params) { + return this.cdp.send(method, params, this.sessionId); + } + + async eval(expression) { + const result = await this.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? "evaluate failed"); + return result.result.value; + } + + async poll(expression, timeoutMs = STEP_TIMEOUT_MS) { + const end = Date.now() + timeoutMs; + for (;;) { + if (await this.eval(expression)) return true; + if (Date.now() > end) return false; + await sleep(150); + } + } +} + +const js = (value) => JSON.stringify(value); +const asList = (value) => (Array.isArray(value) ? value : [value]); + +// textContent rather than innerText: the app splits emails across elements, +// which innerText reads back with layout spaces in between. +const hasText = (text) => `document.body.textContent.includes(${js(text)})`; + +async function runStep(page, step, ctx) { + if (step.expectText !== undefined) { + for (const text of asList(step.expectText)) { + if (!(await page.poll(hasText(text)))) throw new Error(`text not found: ${js(text)}`); + } + } else if (step.expectNoText !== undefined) { + for (const text of asList(step.expectNoText)) { + if (!(await page.poll(`!${hasText(text)}`))) throw new Error(`text still present: ${js(text)}`); + } + } else if (step.expectSelector !== undefined) { + if (!(await page.poll(`!!document.querySelector(${js(step.expectSelector)})`))) { + throw new Error(`selector not found: ${step.expectSelector}`); + } + } else if (step.expectTheme !== undefined) { + const theme = await page.eval("document.documentElement.dataset.theme"); + if (theme !== step.expectTheme) throw new Error(`theme is ${js(theme)}, expected ${js(step.expectTheme)}`); + } else if (step.click !== undefined) { + // `near` picks, among same-label matches, the one whose closest ancestor + // containing that text is nearest (e.g. the Switch button of one account row). + const { selector, label, near = null } = step.click; + const point = await page.eval(`(() => { + const near = ${js(near)}; + const depth = (e) => { + let d = 0; + for (let n = e; n; n = n.parentElement, d++) if (n.textContent.includes(near)) return d; + return Infinity; + }; + const matches = [...document.querySelectorAll(${js(selector)})].filter( + (e) => (e.getAttribute("aria-label") || e.textContent || "").trim() === ${js(label)}); + const el = near === null + ? matches[0] + : matches.filter((e) => depth(e) < Infinity).sort((a, b) => depth(a) - depth(b))[0]; + if (!el) return null; + el.scrollIntoView({ block: "center" }); + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`); + if (!point) throw new Error(`nothing to click: ${selector} labelled ${js(label)}`); + for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) { + await page.send("Input.dispatchMouseEvent", { type, ...point, button: "left", clickCount: 1 }); + } + } else if (step.expectCall !== undefined) { + const { cmd, args = {} } = step.expectCall; + const found = await page.poll(`(window.__CODEXBAR_PROOF__?.calls() ?? []).some((call) => + call.cmd === ${js(cmd)} && Object.entries(${js(args)}).every(([k, v]) => JSON.stringify(call.args[k]) === JSON.stringify(v)))`); + if (!found) throw new Error(`backend call not made: ${cmd} ${js(args)}`); + } else if (step.screenshot !== undefined) { + await sleep(300); + let clip; + if (step.clip === "panel") { + // The browser window cannot resize, so crop to the height the tray + // panel committed for its window. + const height = await page.eval( + `parseFloat(document.querySelector(".menu-surface--tray")?.style.maxHeight || "0")`, + ); + if (height > 0) clip = { x: 0, y: 0, width: ctx.width, height: Math.ceil(height), scale: 1 }; + } + const shot = await page.send("Page.captureScreenshot", { format: "png", ...(clip ? { clip } : {}) }); + const file = join(ctx.out, `${step.screenshot}.png`); + writeFileSync(file, Buffer.from(shot.data, "base64")); + ctx.screenshots.push(file); + if (ctx.hold) await sleep(ctx.hold); + } else if (step.wait !== undefined) { + await sleep(step.wait); + } else { + throw new Error(`unknown step ${js(step)}`); + } +} + +async function runScenario(page, base, scenario, opts) { + const [width, height] = scenario.size; + const window = new URLSearchParams(scenario.query).get("window") ?? "main"; + const ready = READY_BY_WINDOW[window]; + if (!ready) throw new Error(`unknown window ${window}`); + page.problems = []; + await page.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false }); + await page.send("Page.navigate", { url: `${base}/proof.html?${scenario.query}` }); + if (!(await page.poll(`!!document.querySelector(${js(ready)})`, 20_000))) { + throw new Error(`surface never became ready (${ready})`); + } + const ctx = { out: opts.out, hold: opts.hold, width, screenshots: [] }; + for (const step of scenario.steps) await runStep(page, step, ctx); + const unhandled = await page.eval("window.__CODEXBAR_PROOF__?.unhandledCommands() ?? []"); + if (unhandled.length) throw new Error(`unhandled backend commands: ${unhandled.join(", ")}`); + const problems = page.problems.filter((line) => !/Download the React DevTools/.test(line)); + if (problems.length) throw new Error(problems.join("\n ")); + return ctx.screenshots; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const { scenarios } = JSON.parse(readFileSync(join(appDir, "scripts", "proof-scenarios.json"), "utf8")); + const selected = scenarios.filter((s) => !opts.only || s.name.includes(opts.only)); + if (!selected.length) fail(2, `no scenario matches ${js(opts.only)}`); + mkdirSync(opts.out, { recursive: true }); + + const server = opts.url ? null : await startProofServer(); + const base = opts.url ?? server.base; + const browser = opts.cdp ? null : await launchBrowser(opts); + const cdp = await Cdp.connect(opts.cdp ?? browser.ws); + const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); + const page = new Page(cdp, sessionId); + await page.send("Runtime.enable"); + await page.send("Page.enable"); + if (opts.headed) await page.send("Page.bringToFront"); + + let failed = 0; + try { + for (const scenario of selected) { + try { + const shots = await runScenario(page, base, scenario, opts); + console.log(`PASS ${scenario.name}${shots.length ? ` (${shots.length} screenshot${shots.length > 1 ? "s" : ""})` : ""}`); + } catch (error) { + failed++; + console.log(`FAIL ${scenario.name}\n ${error.message}`); + } + } + } finally { + await cdp.send("Target.closeTarget", { targetId }).catch(() => {}); + if (browser) await cdp.send("Browser.close").catch(() => browser.child.kill()); + cdp.close(); + server?.stop(); + } + console.log(`\n${selected.length - failed}/${selected.length} scenarios passed. Screenshots: ${opts.out}`); + process.exit(failed ? 1 : 0); +} + +main().catch((error) => fail(2, error.stack ?? String(error))); diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json new file mode 100644 index 00000000000..a2a2c5ff820 --- /dev/null +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -0,0 +1,98 @@ +{ + "scenarios": [ + { + "name": "tray-default", + "query": "window=main&fixture=default", + "size": [310, 1032], + "steps": [ + { "expectText": ["Usage & Spend", "dev@example.com", "claude.user@example.com"] }, + { "screenshot": "tray-default", "clip": "panel" }, + { "click": { "selector": ".provider-grid__item", "label": "Claude" } }, + { "expectNoText": "dev@example.com" }, + { "expectText": ["claude.user@example.com", "Switch Account..."] }, + { "screenshot": "tray-claude-detail", "clip": "panel" } + ] + }, + { + "name": "tray-errors", + "query": "window=main&fixture=errors", + "size": [310, 1032], + "steps": [ + { "expectText": ["Your Claude session has expired.", "cursor.user@example.com"] }, + { "screenshot": "tray-errors", "clip": "panel" } + ] + }, + { + "name": "tray-empty", + "query": "window=main&fixture=empty", + "size": [310, 1032], + "steps": [ + { "expectText": "No providers configured" }, + { "expectNoText": "@example.com" }, + { "screenshot": "tray-empty", "clip": "panel" } + ] + }, + { + "name": "flyout-default", + "query": "window=flyout&fixture=default", + "size": [310, 1032], + "steps": [ + { "expectText": "dev@example.com" }, + { "screenshot": "flyout-default", "clip": "panel" } + ] + }, + { + "name": "settings-general-dark", + "query": "window=settings&tab=general&theme=dark", + "size": [880, 620], + "steps": [ + { "expectTheme": "dark" }, + { "expectText": ["Refresh interval", "Start at login"] }, + { "screenshot": "settings-general-dark" }, + { "click": { "selector": "button", "label": "Menu" } }, + { "expectText": "Panel scale" }, + { "screenshot": "settings-menu-after-click" } + ] + }, + { + "name": "settings-general-light", + "query": "window=settings&tab=general&theme=light", + "size": [880, 620], + "steps": [ + { "expectTheme": "light" }, + { "expectText": "Refresh interval" }, + { "screenshot": "settings-general-light" } + ] + }, + { + "name": "settings-providers", + "query": "window=settings&tab=providers&theme=dark", + "size": [880, 620], + "steps": [ + { "expectText": ["Codex", "Claude"] }, + { "screenshot": "settings-providers" } + ] + }, + { + "name": "settings-codex-two-accounts", + "query": "window=settings&tab=providers:codex&fixture=two-codex-accounts&theme=dark", + "size": [880, 620], + "steps": [ + { "expectText": ["work@example.com", "personal@example.com"] }, + { "screenshot": "settings-codex-two-accounts" }, + { "click": { "selector": "button", "label": "Switch", "near": "personal@example.com" } }, + { "expectCall": { "cmd": "codex_account_switch", "args": { "id": "proof-codex-personal" } } } + ] + }, + { + "name": "floatbar", + "query": "window=floatbar", + "size": [520, 120], + "steps": [ + { "expectSelector": ".floatbar__pill" }, + { "expectText": "%" }, + { "screenshot": "floatbar" } + ] + } + ] +} diff --git a/apps/desktop-tauri/src/proof/coverage.test.ts b/apps/desktop-tauri/src/proof/coverage.test.ts new file mode 100644 index 00000000000..4b20e97bfc8 --- /dev/null +++ b/apps/desktop-tauri/src/proof/coverage.test.ts @@ -0,0 +1,67 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { buildProofScenario } from "./fixtures/scenarios"; +import { INERT_ACTIONS, createProofRouter } from "./router"; + +const SRC = `${import.meta.dirname}/..`; +const INVOKE_CALL = /\binvoke\s*(?:<[^>]*>)?\(\s*["']([A-Za-z0-9_]+)["']/g; + +function productionSources(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = `${dir}/${entry.name}`; + if (entry.isDirectory()) return entry.name === "proof" ? [] : productionSources(path); + return /\.tsx?$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name) ? [path] : []; + }); +} + +function invokedCommands(): Map { + const commands = new Map(); + for (const file of productionSources(SRC)) { + for (const match of readFileSync(file, "utf8").matchAll(INVOKE_CALL)) { + if (!commands.has(match[1])) commands.set(match[1], file.slice(SRC.length + 1)); + } + } + return commands; +} + +describe("proof router coverage", () => { + it("finds the frontend's invoke calls", () => { + expect(invokedCommands().size).toBeGreaterThan(50); + }); + + // A new backend command must either get a fixture handler or be declared an + // inert action, so the proof build never silently answers it with null. + it("handles or explicitly declares every command the frontend invokes", () => { + const known = new Set([...handledCommands(), ...INERT_ACTIONS]); + const missing = [...invokedCommands()] + .filter(([command]) => !known.has(command)) + .map(([command, file]) => `${command} (${file})`); + expect(missing).toEqual([]); + }); + + it("declares no inert action that also has a handler or is no longer invoked", () => { + const handled = new Set(handledCommands()); + const invoked = invokedCommands(); + expect(INERT_ACTIONS.filter((command) => handled.has(command))).toEqual([]); + expect(INERT_ACTIONS.filter((command) => !invoked.has(command))).toEqual([]); + }); + + it("records inert actions without warning", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const router = createProofRouter(buildProofScenario("default", new Date()), { + window: "main", + tab: "general", + }); + expect(router.handle("codex_account_switch", { id: "work" })).toBeNull(); + expect(router.calls()).toEqual([{ cmd: "codex_account_switch", args: { id: "work" } }]); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +function handledCommands(): readonly string[] { + return createProofRouter(buildProofScenario("default", new Date()), { + window: "main", + tab: "general", + }).handledCommands; +} diff --git a/apps/desktop-tauri/src/proof/install.ts b/apps/desktop-tauri/src/proof/install.ts index e52bcde87d0..94e6c9a7bb4 100644 --- a/apps/desktop-tauri/src/proof/install.ts +++ b/apps/desktop-tauri/src/proof/install.ts @@ -1,7 +1,19 @@ import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import { buildProofScenario } from "./fixtures/scenarios"; import { PROOF_WINDOWS, windowLabel, type ProofParams } from "./params"; -import { createProofRouter, type ProofRouter } from "./router"; +import { createProofRouter, type ProofCall, type ProofRouter } from "./router"; + +/** Read by `scripts/proof-check.mjs` over CDP to assert backend calls. */ +export interface ProofProbe { + calls: () => ProofCall[]; + unhandledCommands: () => string[]; +} + +declare global { + interface Window { + __CODEXBAR_PROOF__?: ProofProbe; + } +} /** * Replace the Tauri IPC with fixture data. Must run before App mounts: @@ -17,5 +29,9 @@ export function installProofMocks(params: ProofParams, now: Date = new Date()): const others = PROOF_WINDOWS.map(windowLabel).filter((label) => label !== current); mockWindows(current, ...others); mockIPC(router.handle, { shouldMockEvents: true }); + window.__CODEXBAR_PROOF__ = { + calls: router.calls, + unhandledCommands: router.unhandledCommands, + }; return router; } diff --git a/apps/desktop-tauri/src/proof/router.ts b/apps/desktop-tauri/src/proof/router.ts index 9ac787f2a2f..6e97136b80d 100644 --- a/apps/desktop-tauri/src/proof/router.ts +++ b/apps/desktop-tauri/src/proof/router.ts @@ -32,6 +32,74 @@ type Handler = (args: Record) => unknown; /** Plugin namespaces whose commands are window chrome or OS dialogs: inert in a browser. */ const INERT_PLUGIN_PREFIXES = ["plugin:window|", "plugin:webview|", "plugin:dialog|", "plugin:event|"]; +/** + * User actions (sign-ins, credential writes, OS side effects) the browser + * accepts as no-ops. They return null and are recorded in `calls()`, so a + * scenario can assert that a click reached the backend. Give one a real + * handler when a scenario needs its result. + */ +export const INERT_ACTIONS = [ + "add_token_account", + "apply_update", + "claude_account_add", + "claude_account_cancel_login", + "claude_account_remove", + "claude_account_save_current", + "claude_account_switch", + "claude_swap_account_reauthenticate", + "claude_swap_account_switch", + "codex_account_add", + "codex_account_reauthenticate", + "codex_account_remove", + "codex_account_restart_desktop", + "codex_account_switch", + "download_update", + "export_preferences", + "focus_agent_session", + "get_openai_live_usage", + "get_openai_org_rate_limits", + "grok_account_add", + "grok_account_cancel_login", + "grok_account_fetch", + "grok_account_reauthenticate", + "grok_account_remove", + "grok_account_save_current", + "grok_account_switch", + "import_browser_cookies", + "import_preferences", + "open_path", + "play_notification_sound", + "quit_app", + "register_global_shortcut", + "remove_api_key", + "remove_manual_cookie", + "remove_openrouter_management_api_key", + "remove_token_account", + "revoke_provider_credentials", + "save_hooks_config", + "set_active_token_account", + "set_api_key", + "set_jetbrains_ide_path", + "set_manual_cookie", + "set_openrouter_management_api_key", + "set_provider_auto_resume_after_quota_reset", + "set_provider_azure_api_version", + "set_provider_cookie_source", + "set_provider_gateway_url", + "set_provider_optional_details", + "set_provider_region", + "set_provider_usage_source", + "set_provider_workspace_id", + "trigger_provider_login", + "unregister_global_shortcut", + "write_usage_spend_export", +] as const; + +export interface ProofCall { + cmd: string; + args: Record; +} + export interface ProofRouterOptions { window: ProofWindow; tab: string; @@ -44,6 +112,8 @@ export interface ProofRouter { handledCommands: readonly string[]; /** Commands that reached the fallback since this router was created. */ unhandledCommands: () => string[]; + /** Every non-plugin command handled so far, in order. */ + calls: () => ProofCall[]; settings: () => SettingsSnapshot; } @@ -232,11 +302,17 @@ export function createProofRouter( open_provider_status_page: () => null, }; + const inertActions = new Set(INERT_ACTIONS); + const calls: ProofCall[] = []; + const handle = (cmd: string, payload?: unknown): unknown => { - const handler = handlers[cmd]; - if (handler) return handler(argsRecord(payload)); if (cmd.startsWith("plugin:window|")) return windowPluginResult(cmd); if (INERT_PLUGIN_PREFIXES.some((prefix) => cmd.startsWith(prefix))) return null; + const args = argsRecord(payload); + calls.push({ cmd, args }); + const handler = handlers[cmd]; + if (handler) return handler(args); + if (inertActions.has(cmd)) return null; if (!warned.has(cmd)) { warned.add(cmd); console.warn(`${UNHANDLED_COMMAND_WARNING} ${cmd}`); @@ -248,6 +324,7 @@ export function createProofRouter( handle, handledCommands: Object.keys(handlers), unhandledCommands: () => [...warned], + calls: () => [...calls], settings: () => settings, }; } diff --git a/apps/desktop-tauri/src/test/node-builtins.d.ts b/apps/desktop-tauri/src/test/node-builtins.d.ts index 0f66cc2a636..f2411e0827f 100644 --- a/apps/desktop-tauri/src/test/node-builtins.d.ts +++ b/apps/desktop-tauri/src/test/node-builtins.d.ts @@ -5,6 +5,11 @@ // supersede these). declare module "node:fs" { export function readFileSync(path: string, encoding: "utf8"): string; + export interface Dirent { + name: string; + isDirectory(): boolean; + } + export function readdirSync(path: string, options: { withFileTypes: true }): Dirent[]; } // Node 20.11+ / vite-node inject import.meta.dirname|filename; those types From 53ccd67bd139c4f7eeb6af08b9cbf5b2f7f284c4 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 13:26:22 +0700 Subject: [PATCH 04/12] Document proof:check --- docs/WINDOWS_PROOF.md | 45 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index 414d07dc28b..4d3c34f8f2e 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -204,9 +204,48 @@ URL parameters: Example: `proof.html?window=settings&tab=providers:codex&fixture=two-codex-accounts`. -Commands without a fixture return `null` and log -`[proof-mock] unhandled command: ` once, so coverage gaps show up in -the console. Window, webview, dialog, and event plugin calls are inert. +Every command the frontend invokes either has a fixture handler or is listed +in `INERT_ACTIONS` (`src/proof/router.ts`): user actions such as sign-ins, +credential writes, and OS side effects that return `null`. +`src/proof/coverage.test.ts` fails when a new `invoke("...")` has neither, so +the mocks cannot drift silently. An unknown command still returns `null` and +logs `[proof-mock] unhandled command: ` once. Window, webview, dialog, +and event plugin calls are inert. + +### Automated check: `pnpm proof:check` + +`scripts/proof-check.mjs` runs the scenarios in `scripts/proof-scenarios.json` +in Chrome, Edge, or Chromium over CDP and prints `PASS` / `FAIL` per scenario. +It uses Node built-ins only. + +```powershell +cd apps/desktop-tauri +pnpm proof:check # starts the proof server, headless browser +pnpm proof:check -- --headed # visible window, pauses on each screenshot +pnpm proof:check -- --only tray # scenarios whose name contains "tray" +pnpm proof:check -- --url # an already-served proof build (dev or dist-proof/) +pnpm proof:check -- --cdp # attach to a running or remote browser +``` + +Screenshots go to `apps/desktop-tauri/.proof/screens/` (git-ignored). The +browser runs on its own profile in `.proof/browser-profile/`, never a user +profile. Exit code `0` means every scenario passed, `1` means one failed, and +`2` means setup failed. + +A scenario names the URL query and the window size, then runs its steps in +order. Every scenario also fails on a console error or warning, an uncaught +exception, or an unhandled backend command. + +| Step | Checks | +| --- | --- | +| `expectText` / `expectNoText` | page text (string or list), polled up to 8 s | +| `expectSelector` | an element exists | +| `expectTheme` | `` | +| `click` | `{ selector, label, near? }`: a real mouse click on the element whose aria-label or text equals `label`; `near` picks the match closest to that text | +| `expectCall` | `{ cmd, args? }`: the frontend invoked that backend command (args are a subset match) | +| `screenshot` | saves `.png`; `"clip": "panel"` crops a tray panel to the height it committed for its window | + +Add or extend a scenario for the UI a PR changes. Screenshots from this build are labelled **"Chromium (Linux), mocked IPC"** (or the browser actually used). They support layout, copy, rendered data, and From 493a187b26b3e0c8ada2a6d2d529403a937da407 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 13:34:35 +0700 Subject: [PATCH 05/12] Document browser proof rule in AGENTS.md --- AGENTS.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68579880bd1..81aea8af962 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ pnpm run tauri:build ### UI validation with CUA ([trycua/cua](https://github.com/trycua/cua)) -Unit tests and `local-check` do **not** prove tray, settings, float bar, theme, or WebView2 behavior. For UI / tray / settings / float-bar / visual changes, agents **must** retest on a real Windows desktop build using **Cua Drivers** (background computer-use: click, type, screenshot, UIA) from the open-source [trycua/cua](https://github.com/trycua/cua) project. Docs: [cua.ai/docs](https://cua.ai/docs), driver install: [install guide](https://cua.ai/docs/how-to-guides/driver/install), CLI reference: [cua-driver CLI](https://cua.ai/docs/reference/cua-driver/cli-reference). +Unit tests and `local-check` do **not** prove tray, settings, float bar, theme, or WebView2 behavior. Changes confined to webview content may use the [browser proof](#browser-proof-pnpm-proofcheck) instead. For every other UI / tray / settings / float-bar / visual change, agents **must** retest on a real Windows desktop build using **Cua Drivers** (background computer-use: click, type, screenshot, UIA) from the open-source [trycua/cua](https://github.com/trycua/cua) project. Docs: [cua.ai/docs](https://cua.ai/docs), driver install: [install guide](https://cua.ai/docs/how-to-guides/driver/install), CLI reference: [cua-driver CLI](https://cua.ai/docs/reference/cua-driver/cli-reference). **Install (Windows PowerShell, from upstream README):** @@ -167,12 +167,21 @@ Then follow post-install instructions (permissions / accessibility as prompted). **Do not** treat Vitest/jsdom or `cargo test` alone as sufficient for tray icon, DWM, WebView2 theme, float bar z-order, or settings chrome. **Do not** open issues/PRs against trycua/cua unless the user explicitly asks; use it as tooling. +### Browser proof (`pnpm proof:check`) + +The React surfaces also run in a plain browser over mocked IPC (`apps/desktop-tauri/proof.html`, fixtures in `src/proof/`). `pnpm proof:check` (in `apps/desktop-tauri`) drives Chrome, Edge, or Chromium over CDP through `scripts/proof-scenarios.json` and prints PASS/FAIL per scenario plus screenshots. It runs on any OS, so subagents and CI can run it without a Windows desktop. Details: `docs/WINDOWS_PROOF.md` §5. + +- **Enough on its own** when the change only touches webview content: layout, copy / i18n, rendered data and states, settings pages and controls, provider siloing. Add or extend a scenario that covers the change, run `pnpm proof:check`, and attach the PASS output and screenshots labelled "Chromium, mocked IPC". +- **Still needs the CUA loop** when the change touches the tray icon or tray menu, window chrome, frame, size or position, DWM / WebView2 theme behavior under `auto`, float bar always-on-top or window behavior, blur-dismiss, or real provider data, cookies, or credential storage. When unsure, run both. +- **Mocks follow the bridge:** a new `invoke("...")` command needs a fixture handler or an `INERT_ACTIONS` entry in `src/proof/router.ts`; `src/proof/coverage.test.ts` fails otherwise. Fixtures stay synthetic (`example.com` identities, no real settings, cookies, tokens, or provider responses). +- **Headed runs** (`pnpm proof:check -- --headed`) use the runner's own isolated browser profile. Do not attach automation to the user's browser profile. + ### Off-machine proof (Cloudflare) Every Cloudflare runtime (Workers, Containers, Sandbox, Browser Rendering) is Linux. None can run the Tauri shell, WebView2, the tray, or DWM. Off-machine evidence supplements the CUA loop above; it never replaces it for tray, theme, float-bar, or window-chrome changes. - **What it can prove:** React surface layout, copy / i18n, rendered data, provider siloing, DOM and accessibility tree, and console errors (Browser Rendering's headless Chromium against a static frontend build); tray icon pixels from `codexbar::tray::{render_bar_icon_rgba, render_percent_icon_rgba}` exported without a desktop. -- **Frontend adapter:** mock the bridge with `mockIPC(handler, { shouldMockEvents: true })` and `mockWindows` from `@tauri-apps/api/mocks` (ships with `@tauri-apps/api`; no new dependency). The handler answers by command name from fixtures whose shape matches that command's return type in `types/bridge.ts`; usage-snapshot fixtures use the `CODEXBAR_SEED_USAGE_JSON` shape. Keep the mock out of the production bundle. +- **Frontend adapter:** the [browser proof](#browser-proof-pnpm-proofcheck) build. `pnpm build:proof` emits a static `dist-proof/`; `pnpm proof:check -- --url ` or `--cdp ` runs the same scenarios against a hosted copy or a remote browser. The mock stays out of the production bundle (`src/proof/isolation.test.ts`). - **Fixtures only:** never upload real settings, cookies, tokens, account emails, or provider responses. - **Labeling:** mark these screenshots in PRs as "Chromium (Linux), mocked IPC". Fonts, scrollbars, and DPI differ from WebView2. - **Windows-only checks away from the dev machine:** use a Windows host (CircleCI `windows` orb or a disposable Windows VM) running the same rebuild → proof mode → CDP + CUA loop. Cloudflare may front that host (`cloudflared` tunnel behind Cloudflare Access) and store its artifacts, but the proof itself comes from Windows. @@ -189,7 +198,8 @@ Every Cloudflare runtime (Workers, Containers, Sandbox, Browser Rendering) is Li - Screenshots / GIFs for UI changes (Windows) - Linked issue / reference when relevant - Hosted PR check exists: `ci/circleci: pr-check` is the primary Windows gate; `.github/workflows/pr-check.yml` is manual Blacksmith backup. Porting micro PRs targeting `port/upstream-*` use focused local evidence and intentionally skip automatic hosted Windows CI; same-repository `main`-bound PRs always get the hosted gate; fork PRs never do (see Fork PRs and reviews). -- UI / tray / settings / float-bar / visual PRs: **hybrid proof is the default** — CDP for actions and state assertions, CUA Driver ([trycua/cua](https://github.com/trycua/cua)) for real-pixel screenshots and native checks — after a **fresh local rebuild** — see [UI validation with CUA](#ui-validation-with-cua-trycuacua). If CUA cannot be used, explain why and attach equivalent manual proof (PR template checkboxes). +- Webview-only UI PRs may attach `pnpm proof:check` output and screenshots instead (see [Browser proof](#browser-proof-pnpm-proofcheck)). +- Other UI / tray / settings / float-bar / visual PRs: **hybrid proof is the default** — CDP for actions and state assertions, CUA Driver ([trycua/cua](https://github.com/trycua/cua)) for real-pixel screenshots and native checks — after a **fresh local rebuild** — see [UI validation with CUA](#ui-validation-with-cua-trycuacua). If CUA cannot be used, explain why and attach equivalent manual proof (PR template checkboxes). - Before non-trivial merge: thermo-nuclear structure review when the project process requires it. From f53f1732dd38e773ccf15fb7804e43602cceccf9 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 14:02:58 +0700 Subject: [PATCH 06/12] Use patched settings in proof fixture builders --- apps/desktop-tauri/src/proof/router.test.ts | 15 +++++++++++++++ apps/desktop-tauri/src/proof/router.ts | 16 +++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/desktop-tauri/src/proof/router.test.ts b/apps/desktop-tauri/src/proof/router.test.ts index c5ab3b4ef58..8c38a88dc38 100644 --- a/apps/desktop-tauri/src/proof/router.test.ts +++ b/apps/desktop-tauri/src/proof/router.test.ts @@ -80,6 +80,21 @@ describe("proof router", () => { expect((router.handle("get_settings_snapshot") as SettingsSnapshot).theme).toBe("light"); }); + it("keeps provider detail in step with patched enabled providers", () => { + const router = routerFor("default"); + const enabled = () => + (router.handle("get_provider_detail", { providerId: "claude" }) as ProviderDetail).enabled; + expect(enabled()).toBe(true); + const remaining = buildProofScenario("default", NOW).settings.enabledProviders.filter( + (id) => id !== "claude", + ); + router.handle("update_settings", { patch: { enabledProviders: remaining } }); + expect(enabled()).toBe(false); + expect( + (router.handle("get_cached_providers") as ProviderUsageSnapshot[]).map((s) => s.providerId), + ).not.toContain("claude"); + }); + it("reports the requested settings tab as the shell target", () => { const router = createProofRouter(buildProofScenario("default", NOW), { window: "settings", diff --git a/apps/desktop-tauri/src/proof/router.ts b/apps/desktop-tauri/src/proof/router.ts index 6e97136b80d..c21054dd1d0 100644 --- a/apps/desktop-tauri/src/proof/router.ts +++ b/apps/desktop-tauri/src/proof/router.ts @@ -177,6 +177,8 @@ export function createProofRouter( return { mode: "trayPanel", target: { kind: "summary" } }; }; + // Fixture builders read settings, so they get the patched copy. + const current = (): ProofScenario => ({ ...scenario, settings }); const enabledProviders = () => scenario.providers.filter((snapshot) => settings.enabledProviders.includes(snapshot.providerId)); @@ -190,9 +192,9 @@ export function createProofRouter( }, get_provider_catalog: () => scenario.catalog, get_provider_statuses: () => [], - reorder_providers: () => providerSummaries(scenario), - reorder_visible_providers: () => providerSummaries(scenario), - restore_default_provider_order: () => providerSummaries(scenario), + reorder_providers: () => providerSummaries(current()), + reorder_visible_providers: () => providerSummaries(current()), + restore_default_provider_order: () => providerSummaries(current()), get_cached_providers: enabledProviders, refresh_providers: () => { const ids = enabledProviders().map((snapshot) => snapshot.providerId); @@ -227,12 +229,12 @@ export function createProofRouter( apiKeys: "Windows Credential Manager", tokenAccounts: "Windows Credential Manager", }), - get_provider_detail: (args) => providerDetail(scenario, stringArg(args, "providerId")), + get_provider_detail: (args) => providerDetail(current(), stringArg(args, "providerId")), get_provider_chart_data: (args) => - providerChartData(scenario, stringArg(args, "providerId"), now), + providerChartData(current(), stringArg(args, "providerId"), now), get_provider_local_usage_summary: (args) => - providerChartData(scenario, stringArg(args, "providerId"), now).localUsage, - get_usage_spend_summary: () => usageSpendSummary(scenario, now), + providerChartData(current(), stringArg(args, "providerId"), now).localUsage, + get_usage_spend_summary: () => usageSpendSummary(current(), now), get_spend_contract: (args) => spendContract(stringArg(args, "providerId"), settings.spendDashboardPeriod ?? "rolling:30"), get_provider_cookie_source_options: () => [ From f71ff76c21c6c0973ade1950e241ce7ec4f2474d Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 14:02:58 +0700 Subject: [PATCH 07/12] Harden proof:check browser launch and CDP requests --- apps/desktop-tauri/scripts/proof-check.mjs | 60 ++++++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs index a57a14c71c7..d2536e1caf1 100644 --- a/apps/desktop-tauri/scripts/proof-check.mjs +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -16,11 +16,13 @@ import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const appDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const STEP_TIMEOUT_MS = 8_000; +const CDP_TIMEOUT_MS = 30_000; const PROOF_PORT = 1430; const READY_BY_WINDOW = { @@ -105,6 +107,8 @@ async function startProofServer() { [vite, "--mode", "proof", "--port", String(PROOF_PORT), "--strictPort", "--host", "127.0.0.1"], { cwd: appDir, stdio: ["ignore", "pipe", "pipe"] }, ); + // fail() exits the process directly; do not leave the server behind. + process.once("exit", () => child.kill()); let log = ""; child.stdout.on("data", (chunk) => (log += chunk)); child.stderr.on("data", (chunk) => (log += chunk)); @@ -122,10 +126,12 @@ async function launchBrowser(opts) { // Reused across runs: an isolated profile with no logins, never the user's. const profile = join(appDir, ".proof", "browser-profile"); mkdirSync(profile, { recursive: true }); - const portFile = join(profile, "DevToolsActivePort"); + // A fixed free port rather than port 0 + DevToolsActivePort: that file + // outlives the browser, so a stale copy could point at an older instance. + const port = await freePort(); const args = [ `--user-data-dir=${profile}`, - "--remote-debugging-port=0", + `--remote-debugging-port=${port}`, "--no-first-run", "--no-default-browser-check", // A headed window behind other windows would otherwise stop painting, @@ -137,25 +143,40 @@ async function launchBrowser(opts) { ...(opts.headed ? [] : ["--headless=new", "--disable-gpu"]), "about:blank", ]; - const startedAt = Date.now(); const child = spawn(binary, args, { stdio: "ignore" }); - const end = startedAt + 20_000; + let exited = false; + child.once("exit", () => (exited = true)); + const end = Date.now() + 20_000; while (Date.now() < end) { await sleep(200); try { - const [port, path] = readFileSync(portFile, "utf8").split(/\r?\n/); const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); - if (version.webSocketDebuggerUrl?.endsWith(path)) { - return { ws: version.webSocketDebuggerUrl, child }; - } + if (version.webSocketDebuggerUrl) return { ws: version.webSocketDebuggerUrl, child }; } catch { - // the port file is written once the browser is listening + // not listening yet + } + if (exited) { + fail( + 2, + `browser exited before opening its debugging port. Another browser may still be using ${profile}; close it and retry.`, + ); } } child.kill(); fail(2, `browser did not open a debugging port: ${binary}`); } +function freePort() { + return new Promise((done, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + server.close(() => done(port)); + }); + }); +} + class Cdp { constructor(socket) { this.socket = socket; @@ -173,6 +194,10 @@ class Cdp { for (const listener of this.listeners) listener(msg); } }); + socket.addEventListener("close", () => { + for (const { reject } of this.pending.values()) reject(new Error("browser connection closed")); + this.pending.clear(); + }); } static async connect(url) { @@ -187,7 +212,22 @@ class Cdp { send(method, params = {}, sessionId) { const id = ++this.nextId; this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); - return new Promise((done, reject) => this.pending.set(id, { resolve: done, reject })); + return new Promise((done, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP ${method} timed out after ${CDP_TIMEOUT_MS} ms`)); + }, CDP_TIMEOUT_MS); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + done(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + }); } on(listener) { From 561aa1c0a94763398e7e8d9cb5a5d7d7801f5be7 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 14:29:00 +0700 Subject: [PATCH 08/12] Report browser launch failures from proof:check --- apps/desktop-tauri/scripts/proof-check.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs index d2536e1caf1..57aaa796a49 100644 --- a/apps/desktop-tauri/scripts/proof-check.mjs +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -143,12 +143,22 @@ async function launchBrowser(opts) { ...(opts.headed ? [] : ["--headless=new", "--disable-gpu"]), "about:blank", ]; - const child = spawn(binary, args, { stdio: "ignore" }); + // A binary that cannot run makes spawn() throw (Windows: EFTYPE) or emit + // "error" rather than "exit"; both must reach the setup-error path. + let child; + try { + child = spawn(binary, args, { stdio: "ignore" }); + } catch (error) { + fail(2, `could not launch browser ${binary}: ${error.message}`); + } let exited = false; child.once("exit", () => (exited = true)); + let launchError = null; + child.once("error", (error) => (launchError = error)); const end = Date.now() + 20_000; while (Date.now() < end) { await sleep(200); + if (launchError) fail(2, `could not launch browser ${binary}: ${launchError.message}`); try { const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); if (version.webSocketDebuggerUrl) return { ws: version.webSocketDebuggerUrl, child }; From c5e7b8254467a1e6bfd88c78c6cfddea94fa79e2 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 14:53:15 +0700 Subject: [PATCH 09/12] Clean up proof:check after setup failures --- apps/desktop-tauri/scripts/proof-check.mjs | 28 +++++++++++++--------- docs/WINDOWS_PROOF.md | 6 ++++- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs index 57aaa796a49..55b72983e1d 100644 --- a/apps/desktop-tauri/scripts/proof-check.mjs +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -107,6 +107,7 @@ async function startProofServer() { [vite, "--mode", "proof", "--port", String(PROOF_PORT), "--strictPort", "--host", "127.0.0.1"], { cwd: appDir, stdio: ["ignore", "pipe", "pipe"] }, ); + child.once("error", (error) => fail(2, `proof server failed to start: ${error.message}`)); // fail() exits the process directly; do not leave the server behind. process.once("exit", () => child.kill()); let log = ""; @@ -392,16 +393,18 @@ async function main() { const server = opts.url ? null : await startProofServer(); const base = opts.url ?? server.base; const browser = opts.cdp ? null : await launchBrowser(opts); - const cdp = await Cdp.connect(opts.cdp ?? browser.ws); - const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); - const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); - const page = new Page(cdp, sessionId); - await page.send("Runtime.enable"); - await page.send("Page.enable"); - if (opts.headed) await page.send("Page.bringToFront"); - + let cdp; + let targetId; let failed = 0; try { + cdp = await Cdp.connect(opts.cdp ?? browser.ws); + ({ targetId } = await cdp.send("Target.createTarget", { url: "about:blank" })); + const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); + const page = new Page(cdp, sessionId); + await page.send("Runtime.enable"); + await page.send("Page.enable"); + if (opts.headed) await page.send("Page.bringToFront"); + for (const scenario of selected) { try { const shots = await runScenario(page, base, scenario, opts); @@ -412,9 +415,12 @@ async function main() { } } } finally { - await cdp.send("Target.closeTarget", { targetId }).catch(() => {}); - if (browser) await cdp.send("Browser.close").catch(() => browser.child.kill()); - cdp.close(); + if (cdp && targetId) await cdp.send("Target.closeTarget", { targetId }).catch(() => {}); + if (browser) { + if (cdp) await cdp.send("Browser.close").catch(() => browser.child.kill()); + else browser.child.kill(); + } + cdp?.close(); server?.stop(); } console.log(`\n${selected.length - failed}/${selected.length} scenarios passed. Screenshots: ${opts.out}`); diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index 4d3c34f8f2e..d1f450b2056 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -224,9 +224,13 @@ pnpm proof:check # starts the proof server, headless brow pnpm proof:check -- --headed # visible window, pauses on each screenshot pnpm proof:check -- --only tray # scenarios whose name contains "tray" pnpm proof:check -- --url # an already-served proof build (dev or dist-proof/) -pnpm proof:check -- --cdp # attach to a running or remote browser +pnpm proof:check -- --cdp # attach to a running browser on this host +pnpm proof:check -- --cdp --url # remote browser; must be reachable from it ``` +Without `--url`, the runner serves the proof build on `127.0.0.1`, which a +browser on another host cannot reach. + Screenshots go to `apps/desktop-tauri/.proof/screens/` (git-ignored). The browser runs on its own profile in `.proof/browser-profile/`, never a user profile. Exit code `0` means every scenario passed, `1` means one failed, and From 27bc9e5ac708b7d39b40e8895348673627abcab4 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 15:15:20 +0700 Subject: [PATCH 10/12] Fail fast on closed CDP socket and document remote CDP safety --- apps/desktop-tauri/scripts/proof-check.mjs | 4 ++++ docs/WINDOWS_PROOF.md | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs index 55b72983e1d..440999ebdbb 100644 --- a/apps/desktop-tauri/scripts/proof-check.mjs +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -221,6 +221,10 @@ class Cdp { } send(method, params = {}, sessionId) { + // A closed socket drops sends silently; fail now rather than at the timeout. + if (this.socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error(`CDP ${method}: browser connection closed`)); + } const id = ++this.nextId; this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); return new Promise((done, reject) => { diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index d1f450b2056..9cebbcd462b 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -231,6 +231,13 @@ pnpm proof:check -- --cdp --url # remote browser; must b Without `--url`, the runner serves the proof build on `127.0.0.1`, which a browser on another host cannot reach. +With `--cdp`, the runner drives whatever browser it is given, and its isolated +`.proof/browser-profile/` applies only to browsers it launches itself. Point +`--cdp` at a browser started with its own empty `--user-data-dir`, never one +with real logins. CDP has no authentication, so keep a remote browser's +debugging port bound to loopback and reach it through an SSH tunnel or an +authenticated bridge, not an open port. + Screenshots go to `apps/desktop-tauri/.proof/screens/` (git-ignored). The browser runs on its own profile in `.proof/browser-profile/`, never a user profile. Exit code `0` means every scenario passed, `1` means one failed, and From e9e536c6c0d2b32b3e432eb990ad844a86211ebf Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 20:17:11 +0700 Subject: [PATCH 11/12] Redact secrets in proof:check output --- apps/desktop-tauri/scripts/proof-check.mjs | 29 ++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs index 440999ebdbb..69e6bf1eb7b 100644 --- a/apps/desktop-tauri/scripts/proof-check.mjs +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -56,8 +56,33 @@ function parseArgs(argv) { return opts; } +// Mirrors rust/src/core/redactor.rs SecretRedactor. Output can carry a +// caller-supplied CDP URL (remote bridges often take ?token=) or page text. +const SECRET_PATTERNS = [ + [/(Bearer\s+)[^\s,;]+/gi, "$1[REDACTED]"], + [/((?:cookie|set-cookie)\s*:\s*)[^\r\n]+/gi, "$1[REDACTED]"], + [ + /([?&](?:token|code|device_code|verification_code|authorization_code|client_secret|api_key|access_token|refresh_token)=)[^&#\s]+/gi, + "$1[REDACTED]", + ], + [ + /("?(?:api_key|apiKey|token|access_token|refresh_token|id_token|oauth_token|device_code|verification_code|authorization_code|client_secret)"?\s*[:=]\s*")[^"]+"/gi, + '$1[REDACTED]"', + ], + [ + /(\b(?:api_key|access_token|refresh_token|id_token|oauth_token|device_code|verification_code|authorization_code|client_secret|secret|password)\b\s*[:=]\s*)[^\s,;}\]]+/gi, + "$1[REDACTED]", + ], + [/\b(?:sk|ghp|gho|github_pat|zai|nanogpt|openrouter|fk)-[A-Za-z0-9_-]{8,}\b/gi, "[REDACTED]"], + [/(\/\/)[^/@\s]+@/g, "$1[REDACTED]@"], +]; + +function redact(text) { + return SECRET_PATTERNS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), String(text)); +} + function fail(code, message) { - console.error(`[proof-check] ${message}`); + console.error(`[proof-check] ${redact(message)}`); process.exit(code); } @@ -415,7 +440,7 @@ async function main() { console.log(`PASS ${scenario.name}${shots.length ? ` (${shots.length} screenshot${shots.length > 1 ? "s" : ""})` : ""}`); } catch (error) { failed++; - console.log(`FAIL ${scenario.name}\n ${error.message}`); + console.log(`FAIL ${scenario.name}\n ${redact(error.message)}`); } } } finally { From 32644488f0e1d49d32cd8167913f3cd8c22b71f3 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 17 Oct 2026 20:17:11 +0700 Subject: [PATCH 12/12] Move proof spend fixtures into scenarios --- .../src/proof/fixtures/bridgeData.ts | 30 ++++++++----------- .../src/proof/fixtures/scenarios.ts | 21 +++++++++++++ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts index de71fcbbc1f..3cb5341aabc 100644 --- a/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts +++ b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts @@ -12,15 +12,9 @@ import type { UsageSpendRow, UsageSpendSummary, } from "../../types/bridge"; -import type { ProofScenario } from "./scenarios"; +import type { LocalSpendFixture, ProofScenario } from "./scenarios"; import { dayKey } from "./common"; -/** Synthetic local spend per provider id; providers absent here have no local logs. */ -const LOCAL_SPEND: Record = { - codex: { daily: 1.84, tokensPerDay: 412_000, model: "gpt-5-codex" }, - claude: { daily: 2.37, tokensPerDay: 289_000, model: "claude-sonnet-4" }, -}; - const EMPTY_COVERAGE: CostCoverageCounts = { priced: 0, unpriced: 0, unmetered: 0, estimated: 0 }; export function proofUpdateState(): UpdateStatePayload { @@ -123,8 +117,9 @@ export function providerDetail( }; } -function hasLocalSpend(scenario: ProofScenario, providerId: string): boolean { - return providerId in LOCAL_SPEND && scenario.settings.enabledProviders.includes(providerId); +function localSpend(scenario: ProofScenario, providerId: string): LocalSpendFixture | undefined { + if (!scenario.settings.enabledProviders.includes(providerId)) return undefined; + return scenario.localSpend.find((candidate) => candidate.providerId === providerId); } export function providerChartData( @@ -132,7 +127,7 @@ export function providerChartData( providerId: string, now: Date, ): ProviderChartData { - const spend = hasLocalSpend(scenario, providerId) ? LOCAL_SPEND[providerId] : undefined; + const spend = localSpend(scenario, providerId); const days = spend ? Array.from({ length: 14 }, (_, index) => 13 - index) : []; // A fixed wave keeps the bars varied but identical across reloads. const factor = (daysAgo: number) => 0.55 + ((daysAgo * 37) % 10) / 10; @@ -198,11 +193,11 @@ export function spendContract(providerId: string, reportingPeriod: string): Spen export function usageSpendSummary(scenario: ProofScenario, now: Date): UsageSpendSummary { const reportingPeriod = scenario.settings.spendDashboardPeriod ?? "rolling:30"; - const rows: UsageSpendRow[] = scenario.catalog - .filter((entry) => hasLocalSpend(scenario, entry.id)) - .map((entry) => { - const spend = LOCAL_SPEND[entry.id]; - return { + const rows: UsageSpendRow[] = scenario.catalog.flatMap((entry) => { + const spend = localSpend(scenario, entry.id); + if (!spend) return []; + return [ + { providerId: entry.id, displayName: entry.displayName, sevenDay: Number((spend.daily * 7).toFixed(2)), @@ -218,8 +213,9 @@ export function usageSpendSummary(scenario: ProofScenario, now: Date): UsageSpen day: dayKey(now, 6 - index), amount: spend.daily, })), - }; - }); + }, + ]; + }); return { rows, contract: spendContract("all", reportingPeriod), diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index efada024335..5316166a55d 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -33,6 +33,23 @@ export interface ProofScenario { providers: ProviderUsageSnapshot[]; codexAccounts: CodexAccountsStateBridge; claudeAccounts: ClaudeAccount[]; + /** Local-log spend; providers without an entry have no local logs. */ + localSpend: LocalSpendFixture[]; +} + +export interface LocalSpendFixture { + providerId: string; + daily: number; + tokensPerDay: number; + model: string; +} + +function codexSpend(): LocalSpendFixture { + return { providerId: "codex", daily: 1.84, tokensPerDay: 412_000, model: "gpt-5-codex" }; +} + +function claudeSpend(): LocalSpendFixture { + return { providerId: "claude", daily: 2.37, tokensPerDay: 289_000, model: "claude-sonnet-4" }; } const SESSION_MINUTES = 300; @@ -234,6 +251,7 @@ function defaultScenario(now: Date): ProofScenario { ], codexAccounts: singleCodexAccount(now), claudeAccounts: claudeAccounts(now), + localSpend: [codexSpend(), claudeSpend()], }; } @@ -257,6 +275,7 @@ function twoCodexAccountsScenario(now: Date): ProofScenario { }, }, claudeAccounts: claudeAccounts(now), + localSpend: [codexSpend(), claudeSpend()], }; } @@ -287,6 +306,7 @@ function errorsScenario(now: Date): ProofScenario { ], codexAccounts: singleCodexAccount(now), claudeAccounts: [], + localSpend: [codexSpend(), claudeSpend()], }; } @@ -298,6 +318,7 @@ function emptyScenario(): ProofScenario { providers: [], codexAccounts: emptyCodexAccounts(), claudeAccounts: [], + localSpend: [], }; }