From 46a9d3d44980603e1413c97192b5f2327987132a Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 5 Oct 2026 14:08:26 +0800 Subject: [PATCH] feat(mock): expand browser mock coverage for providers and edge cases Add realistic quota, credential, usage, OAuth, and error scenarios. Support in-memory mutations for imports, resets, cooldowns, and deletions. Add browser UI and runtime regression tests with mock usage documentation. --- package.json | 1 + src/mocks/README.md | 52 ++++++++++++ src/mocks/browserMockRuntime.ts | 139 ++++++++++++++++++------------- src/mocks/quotaMock.ts | 105 +++++++++++++++++++++++ tests/browser-mock-ui.cjs | 50 +++++++++++ tests/browserMockQuota.test.ts | 82 ++++++++++++++++++ tests/browserMockRuntime.test.ts | 46 ++++++++++ 7 files changed, 416 insertions(+), 59 deletions(-) create mode 100644 src/mocks/README.md create mode 100644 src/mocks/quotaMock.ts create mode 100644 tests/browser-mock-ui.cjs create mode 100644 tests/browserMockQuota.test.ts diff --git a/package.json b/package.json index aeed5d9..3ebfade 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:ui:api-access-layout": "node tests/api-access-layout-ui.cjs", "test:ui:responsive": "node tests/responsive-layout-ui.cjs", "test:ui:settings": "node tests/config-settings-ui.cjs", + "test:ui:browser-mock": "node tests/browser-mock-ui.cjs", "screenshots": "node scripts/capture-readme-screenshots.cjs", "tauri": "tauri" }, diff --git a/src/mocks/README.md b/src/mocks/README.md new file mode 100644 index 0000000..7440294 --- /dev/null +++ b/src/mocks/README.md @@ -0,0 +1,52 @@ +# Browser preview mocks + +Run `bun run dev` and open the Vite URL in a regular browser. Mock IPC is installed +only in browser development builds; Tauri and production builds use the real backend. +No backend or upstream account is required. Data is fictional and mutations are +in memory, isolated per page load. Reload to reset changes. + +Use the bottom toolbar or query parameters: + +| URL option | Scenario | +| --- | --- | +| `?mock=running` | Running core, configured providers, usage records and mixed credential states | +| `?mock=stopped` | Installed but stopped core; start it to explore the populated pages | +| `?mock=empty` | Uninstalled core with empty credentials, providers and usage; install/start it to inspect empty states | +| `?mock=error` | Main backend reads fail so error and retry UI can be inspected | +| `?mock=off` | Disable mock IPC | +| `?mock=running&mockDelay=500` | Delay each IPC call by 500 ms (maximum 5000 ms) | + +The selected scenario is remembered locally; an explicit `mock` query overrides it. + +## Credential and quota coverage + +The running dataset includes more than ten OAuth files, plus a runtime credential +which the authentication-file list excludes. It covers Codex, Claude, Antigravity, +Kimi, xAI and Devin. Examples include long account names and quota labels, multiple +quota windows, a disabled account, an exhausted account with model cooldown, +an expired OAuth token, an empty upstream quota response, unknown remaining quota, +a paid API account and an account with no recent requests. + +Quota replies pass through the normal application parsers. Codex and Claude reset +credits are tracked per account: consuming one decreases its balance and refreshes +its usage windows. Credential edits, enable/disable, cooldown reset, deletion, +JSON import/replacement and OAuth cancellation update the same in-memory state. +Malformed imports and missing credentials fail explicitly. Usage event filters +(time, model, provider, source, API key, failure/cancellation) run before pagination. + +Unknown IPC commands, management routes and upstream URLs fail explicitly instead +of returning fake success. Add new contracts to `browserMockRuntime.ts`, +`quotaMock.ts` or `pluginMock.ts` when implementing new backend-facing UI. +Other desktop operations may still return preview-only results; the mock does not +launch real apps, write credentials to disk or make upstream API requests. + +## Verification + +```sh +bun test tests/browserMockRuntime.test.ts tests/browserMockQuota.test.ts +node tests/browser-mock-ui.cjs +``` + +The browser test uses Playwright (and Microsoft Edge on Windows), opens the real +app, and checks quota rendering, pagination and credential mutations while blocking +external HTTP requests. Its screenshot is saved under `bin-work/`. diff --git a/src/mocks/browserMockRuntime.ts b/src/mocks/browserMockRuntime.ts index de70290..10dffe0 100644 --- a/src/mocks/browserMockRuntime.ts +++ b/src/mocks/browserMockRuntime.ts @@ -1,5 +1,6 @@ import { applyTemplateChanges, readTemplatePath, sameTemplateValue, type TemplateConfigChange } from '../services/templateConfig'; import { createPluginMock } from './pluginMock'; +import { createQuotaMock, createQuotaMockFiles } from './quotaMock'; export type BrowserMockScenario = 'running' | 'stopped' | 'empty' | 'error'; export type BrowserMockMode = BrowserMockScenario | 'off'; @@ -47,7 +48,7 @@ const SHARED_PROVIDER_FIELDS = new Set([ const clone = (value: Value): Value => structuredClone(value); const sleep = (delayMs: number) => delayMs > 0 - ? new Promise((resolve) => window.setTimeout(resolve, delayMs)) + ? new Promise((resolve) => globalThis.setTimeout(resolve, delayMs)) : Promise.resolve(); const asObject = (value: unknown): JsonObject => ( @@ -169,10 +170,10 @@ function createUsageEvents() { source: index % 2 === 0 ? 'codex' : 'claude-code', source_display: index % 2 === 0 ? 'Codex' : 'Claude Code', failed, - canceled: false, + canceled: index === 9, failure_status: failed ? 429 : 0, failure_body: failed ? 'Mock rate limit exceeded' : '', - provider: index % 2 === 0 ? 'OpenAI OAuth' : 'Claude OAuth', + provider: index % 2 === 0 ? 'openai-oauth' : 'claude-oauth', model: models[index % models.length], alias: '', reasoning_effort: index % 2 === 0 ? 'high' : 'medium', @@ -303,6 +304,7 @@ function createState(scenario: BrowserMockScenario) { modtime: Date.now() - 45 * 60_000, excluded_models: ['claude-legacy-*'], }, + ...createQuotaMockFiles(), { name: 'gemini-runtime', provider: 'gemini', @@ -498,9 +500,10 @@ function createState(scenario: BrowserMockScenario) { versionSource: { source: 'github', gitcodeAvailable: true, customMirrors: ['https://gh-proxy.example.com/'] }, // Native groups are authoritative; legacy routes only adapt their own view. providerConfig: Object.fromEntries(PROVIDER_SECTIONS.map((section) => [ - section, groupProviderRecords(section, legacyProviderConfig[section]), + section, scenario === 'empty' ? [] : groupProviderRecords(section, legacyProviderConfig[section]), ])) as Record, - authFiles, + authFiles: scenario === 'empty' ? [] : authFiles, + quotaApiCall: createQuotaMock(), oauthExcludedModels: { codex: [], claude: ['claude-legacy-*'], gemini: [] } as Record, oauthSessions: new Map(), analysis, @@ -631,7 +634,9 @@ function mockModelDefinitions() { }; } -function mockApiCall(body: JsonObject) { +function mockApiCall(state: BrowserMockState, body: JsonObject) { + const quota = state.quotaApiCall(body); + if (quota) return quota; const url = readString(body.url).toLowerCase(); if (url.includes('/models')) { return { @@ -645,47 +650,8 @@ function mockApiCall(body: JsonObject) { }, }; } - if (url.includes('/api/oauth/profile')) { - return { status_code: 200, body: { account: { has_claude_pro: true, has_claude_max: false } } }; - } - if (url.includes('wham/usage')) { - return { - status_code: 200, - body: { - rate_limit: { - primary_window: { used_percent: 28, reset_after_seconds: 7_200, limit_window_seconds: 18_000 }, - secondary_window: { used_percent: 42, reset_after_seconds: 345_600, limit_window_seconds: 604_800 }, - }, - }, - }; - } - if (url.includes('anthropic.com/api/oauth/usage')) { - return { - status_code: 200, - body: { - five_hour: { utilization: 24, resets_at: isoHoursAgo(-3) }, - seven_day: { utilization: 41, resets_at: isoHoursAgo(-72) }, - }, - }; - } - if (url.includes('retrieveuserquotasummary')) { - return { - status_code: 200, - body: { - groups: [{ display_name: 'Gemini Pro', buckets: [{ window: '5h', remaining_fraction: 0.74, reset_time: isoHoursAgo(-3) }] }], - }, - }; - } - if (url.includes('billing')) { - return { - status_code: 200, - body: { - periodType: 'weekly', - usagePercent: 36, - periodEnd: isoHoursAgo(-48), - productUsage: [{ product: 'Grok Code', usagePercent: 31 }], - }, - }; + if (!/\/(chat\/completions|responses|messages)(\?|$)/.test(url)) { + throw new Error(`Browser Mock upstream request not implemented: ${readString(body.method)} ${readString(body.url)}`); } return { status_code: 200, @@ -717,6 +683,18 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { ])), }; } + if (method === 'GET' && path === '/config/api-keys') { + return Object.fromEntries(PROVIDER_SECTIONS.map(section => [V8_PROVIDER_BY_SECTION[section], clone(state.providerConfig[section])])); + } + const legacySection = PROVIDER_SECTIONS.find(section => path === `/${section}`); + if (method === 'GET' && legacySection) { + return state.providerConfig[legacySection].flatMap(group => { + const { keys, ...shared } = group; + return legacySection === 'openai-compatibility' + ? [{ ...clone(shared), 'api-key-entries': clone(keys) }] + : asArray(keys).map(key => ({ ...clone(shared), ...clone(asObject(key)) })); + }); + } if (v8Provider) { const section = v8Provider[0] as ProviderSection; if (method === 'GET') return clone(state.providerConfig[section]); @@ -730,7 +708,8 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { } if (method === 'GET' && path === '/credentials/download') { const file = findAuthFile(state, readString(query.name)); - return clone(file ?? { name: query.name, excluded_models: [] }); + if (!file) throw new Error(`Browser Mock credential not found: ${readString(query.name)}`); + return clone(file); } if (method === 'GET' && path === '/credentials/models') return mockModelDefinitions(); if (method === 'GET' && path.startsWith('/routing/model-definitions/')) return mockModelDefinitions(); @@ -744,17 +723,30 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { if (method === 'PATCH' && (path === '/credentials/fields' || path === '/credentials/status')) { const patch = asObject(body); const file = findAuthFile(state, readString(patch.name)); - if (file) Object.assign(file, patch); - return clone(file ?? null); + if (!file) throw new Error(`Browser Mock credential not found: ${readString(patch.name)}`); + Object.assign(file, clone(patch)); + return { status: 'ok' }; + } + if (method === 'POST' && path === '/routing/cooldown/reset') { + const authIndex = readString(asObject(body).auth_index); + const file = state.authFiles.find(file => file.auth_index === authIndex); + if (!file) throw new Error(`Browser Mock credential not found: ${authIndex}`); + const models = asArray(file.cooldowns).map(item => readString(asObject(item).model_key)).filter(Boolean); + file.cooldowns = []; + return { status: 'ok', auth_index: authIndex, models }; } if (method === 'DELETE' && path === '/credentials') { const index = state.authFiles.findIndex((file) => file.name === query.name); - if (index >= 0) state.authFiles.splice(index, 1); + if (index < 0) throw new Error(`Browser Mock credential not found: ${readString(query.name)}`); + state.authFiles.splice(index, 1); return null; } - if (method === 'POST' && path === '/requests/api-call') return mockApiCall(asObject(body)); - if (method === 'DELETE' && path === '/oauth/session') return null; - return {}; + if (method === 'POST' && path === '/requests/api-call') return mockApiCall(state, asObject(body)); + if (method === 'DELETE' && path === '/oauth/session') { + state.oauthSessions.delete(readString(query.state)); + return null; + } + throw new Error(`Browser Mock management request not implemented: ${method} ${path}`); } const CORE_CONFIG_PATHS: Record = { @@ -810,6 +802,17 @@ export function createBrowserMockRuntime( delayMs = 0, ): BrowserMockRuntime { const state = createState(scenario); + if (scenario === 'empty') { + state.usageEvents = []; + state.usagePrices = []; + Object.assign(state.usageOverview, Object.fromEntries(Object.entries(state.usageOverview) + .filter(([, value]) => typeof value === 'number').map(([key]) => [key, 0]))); + state.usageOverview.timeline = []; + state.analysis = { models: [], providers: [], sources: [], apiKeys: [] }; + state.usageStorage.databaseSizeBytes = 0; + state.usageStorage.totalRecords = 0; + state.coreConfig.apiKeys = []; + } const pluginMock = createPluginMock(state); const emit = (event: string, payload: unknown) => emitEvent(event, clone(payload)); @@ -1018,7 +1021,13 @@ export function createBrowserMockRuntime( case 'management_request': return clone(managementResponse(state, payload)); case 'upload_auth_file': { const name = readString(payload.name) || 'uploaded-mock.json'; - state.authFiles.push({ name, provider: 'codex', type: 'codex', source: 'file', auth_index: `mock-upload-${state.authFiles.length + 1}`, disabled: false, priority: 0 }); + const parsed: unknown = JSON.parse(new TextDecoder().decode(Uint8Array.from(asArray(payload.data) as number[]))); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Browser Mock: credential must be a JSON object'); + const content = asObject(parsed); + const existing = findAuthFile(state, name); + const file = { ...clone(content), name, provider: readString(content.provider || content.type) || 'codex', source: 'file', + auth_index: existing?.auth_index ?? `mock-upload-${crypto.randomUUID()}`, modtime: Date.now() }; + if (existing) state.authFiles[state.authFiles.indexOf(existing)] = file; else state.authFiles.push(file); return { ok: true, name }; } case 'resolve_api_access_remarks': { @@ -1049,6 +1058,7 @@ export function createBrowserMockRuntime( case 'get_oauth_status': { const session = readString(payload.state); const provider = state.oauthSessions.get(session); + if (!provider) return { status: 'error', error: 'Unknown or canceled mock OAuth session' }; if (provider && !state.authFiles.some((file) => file.name === `${provider}-mock-login.json`)) { state.authFiles.push({ name: `${provider}-mock-login.json`, provider, type: provider, source: 'file', @@ -1071,13 +1081,24 @@ export function createBrowserMockRuntime( const query = asObject(payload.query); const page = Math.max(1, readNumber(query.page, 1)); const pageSize = Math.max(1, readNumber(query.page_size, 50)); + const events = state.usageEvents.filter(event => { + for (const key of ['model', 'provider', 'source', 'api_key_hash'] as const) { + if (query[key] && event[key] !== query[key]) return false; + } + for (const key of ['failed', 'canceled'] as const) { + if (typeof query[key] === 'boolean' && event[key] !== query[key]) return false; + } + const timestamp = Date.parse(event.timestamp); + return (!query.start || timestamp >= Date.parse(String(query.start))) + && (!query.end || timestamp <= Date.parse(String(query.end))); + }); const start = (page - 1) * pageSize; return { - items: clone(state.usageEvents.slice(start, start + pageSize)), - total: state.usageEvents.length, + items: clone(events.slice(start, start + pageSize)), + total: events.length, page, pageSize, - totalPages: Math.max(1, Math.ceil(state.usageEvents.length / pageSize)), + totalPages: Math.max(1, Math.ceil(events.length / pageSize)), }; } case 'get_usage_pricing': { @@ -1092,7 +1113,7 @@ export function createBrowserMockRuntime( estimatedCost: index === 0 ? 1.92 : 1.71, price: clone(price), })); - return { rows, totalCost: 3.63, totalRequests: 278, pricedRequests: 278, savedPrices: state.usagePrices.length }; + return { rows, totalCost: rows.length ? 3.63 : 0, totalRequests: rows.length ? 278 : 0, pricedRequests: rows.length ? 278 : 0, savedPrices: state.usagePrices.length }; } case 'get_usage_storage_settings': return clone(state.usageStorage); case 'save_usage_storage_settings': { diff --git a/src/mocks/quotaMock.ts b/src/mocks/quotaMock.ts new file mode 100644 index 0000000..5db9a06 --- /dev/null +++ b/src/mocks/quotaMock.ts @@ -0,0 +1,105 @@ +type RecordValue = Record; +const future = (hours: number) => new Date(Date.now() + hours * 3_600_000).toISOString(); + +/** Fictional accounts exercise pagination, long labels and independently failing quotas. */ +export function createQuotaMockFiles(): RecordValue[] { + const seeds: Array<[string, string, RecordValue]> = [ + ['antigravity', 'team-with-a-long-account-name', {}], + ['kimi', 'personal', {}], + ['xai', 'subscription', { user_id: 'mock-xai-user' }], + ['devin', 'team', {}], + ['codex', 'disabled', { disabled: true }], + ['codex', 'exhausted', { cooldowns: [{ scope: 'model', model_key: 'gpt-5.2-codex', reason: 'quota', http_status: 429, retry_at: future(1), remaining_seconds: 3600 }] }], + ['claude', 'expired', { status: 'error', status_message: 'Mock OAuth token has expired; sign in again.' }], + ['xai', 'paid-api', { using_api: true, prefix: 'paid' }], + ['antigravity', 'empty-quota', {}], + ['codex', 'unknown-quota', {}], + ['codex', 'idle', {}], + ]; + return seeds.map(([provider, label, extra], index) => ({ + name: `${provider}-${label}.json`, provider, type: provider, source: 'file', + auth_index: `mock-${provider}-${label}`, email: `${label}@example.test`, + account_id: `mock-account-${index}`, project_id: 'mock-project', + plan_type: 'pro', subscription_active_until: future(24 * 30), + disabled: false, status: 'active', priority: index % 3, + size: 1500 + index * 100, modtime: Date.now() - index * 60_000, + note: 'Fictional browser preview account. Changes stay in memory until reload.', + success: label === 'idle' ? 0 : 250 + index * 97, failed: label === 'idle' ? 0 : index, + recent_requests: label === 'idle' ? [] : Array.from({ length: 20 }, (_, bucket) => ({ + time: new Date(Date.now() - (19 - bucket) * 600_000).toISOString(), + success: bucket % 4 + 1, failed: bucket % 7 === 0 ? 1 : 0, + })), + ...extra, + })); +} + +/** Each runtime owns its own balances; refreshing or mutating one account cannot affect another. */ +export function createQuotaMock() { + const spent = new Map(); + const redeemed = new Set(); + return (body: RecordValue) => { + const url = new URL(String(body.url)); + const path = url.pathname.toLowerCase(); + const auth = String(body.authIndex ?? ''); + const used = spent.get(auth) ?? 0; + const remaining = Math.max(0, 2 - used); + const success = (payload: unknown) => ({ status_code: 200, header: { Date: new Date().toUTCString() }, body: payload }); + if (auth.endsWith('-expired')) return { status_code: 401, body: { error: { message: 'Mock OAuth token expired' } } }; + const consume = () => { + const data = JSON.parse(String(body.data || '{}')) as RecordValue; + const id = String(data.redeem_request_id ?? data.request_id ?? ''); + const key = `${auth}:${id}`; + if (id && redeemed.has(key)) return true; + if (!remaining) return false; + spent.set(auth, used + 1); + if (id) redeemed.add(key); + return true; + }; + if (path.endsWith('/rate-limit-reset-credits/consume')) { + if (body.method !== 'POST') throw new Error('Browser Mock: credit consumption requires POST'); + return consume() ? success({ status: 'ok' }) : { status_code: 409, body: { error: 'No reset credits remaining' } }; + } + if (path.endsWith('/rate-limit-reset-credits')) return success({ + available_count: remaining, applicable_available_count: remaining, + credits: Array.from({ length: remaining }, (_, index) => ({ id: `mock-credit-${index + used}`, reset_type: 'codex_rate_limits', status: 'available', expires_at: future(24 * (index + 1)) })), + }); + if (path.endsWith('/wham/usage')) return success({ + plan_type: 'pro', credits: { balance: '12.50', unlimited: false }, + rate_limit_reset_credits: { available_count: remaining }, + rate_limit: { + primary_window: { used_percent: auth.endsWith('-unknown-quota') ? null : used ? 0 : auth.endsWith('-exhausted') ? 100 : 28, reset_after_seconds: 7200, limit_window_seconds: 18000 }, + secondary_window: { used_percent: used ? 0 : 42, reset_after_seconds: 345600, limit_window_seconds: 604800 }, + }, + }); + if (path.endsWith('/api/oauth/profile')) return success({ account: { has_claude_pro: true }, organization: { uuid: '00000000-0000-4000-8000-000000000001' } }); + if (path.endsWith('/reset_rate_limits')) { + if (body.method !== 'POST') throw new Error('Browser Mock: quota reset requires POST'); + return success({ result: consume() ? 'reset' : 'already_used' }); + } + if (path.endsWith('/api/oauth/usage')) return success({ + five_hour: { utilization: used ? 0 : 24, resets_at: future(3) }, + seven_day: { utilization: used ? 0 : 41, resets_at: future(72) }, + cedar_ember: { eligible: true, at_limit: false, next_grant_id: remaining ? 'mock-grant' : null, + grants: [{ id: 'mock-grant', label: 'Preview quota resets', resets_total: 2, resets_left: remaining, ends_at: future(72), clears: ['five_hour', 'seven_day'], usable_now: remaining > 0, use_requires_limit: false }] }, + }); + if (path.endsWith(':loadcodeassist')) return success({ currentTier: { id: 'g1-pro-tier', name: 'Pro' } }); + if (path.endsWith(':retrieveuserquotasummary')) return success({ groups: auth.endsWith('-empty-quota') ? [] : ['Gemini Models', 'Claude Models'].map((displayName, index) => ({ + displayName, description: 'Models within this group share quota. The limit fully resets at the time shown above.', + buckets: ['Five Hour Limit Remaining', 'Weekly Limit Remaining'].map((window, bucket) => ({ + window, remainingFraction: index ? 0.06 : bucket ? 0.84 : 0.75, + resetTime: future(bucket ? 72 : 3), + })), + })) }); + if (url.hostname === 'api.kimi.com' && path.endsWith('/usages')) return success({ + usage: { used: 64, limit: 100 }, + limits: [{ window: { duration: 5, timeUnit: 'TIME_UNIT_HOUR' }, detail: { limit: 100, remaining: 80, reset_in: 3600 } }], + }); + if (path.endsWith('/getuserstatus')) return success({ userStatus: { planStatus: { + planInfo: { planName: 'Devin Pro' }, dailyQuotaRemainingPercent: 63, weeklyQuotaRemainingPercent: 42, + dailyQuotaResetAtUnix: Math.floor(Date.now() / 1000) + 3600, weeklyQuotaResetAtUnix: Math.floor(Date.now() / 1000) + 259200, + planEnd: future(24 * 30), + } } }); + if (path.endsWith('/billing')) return success({ currentPeriod: { type: 'weekly', start: future(-120), end: future(48) }, creditUsagePercent: 36, productUsage: [{ product: 'Grok Code', usagePercent: 31 }] }); + return undefined; + }; +} diff --git a/tests/browser-mock-ui.cjs b/tests/browser-mock-ui.cjs new file mode 100644 index 0000000..a0cd9a6 --- /dev/null +++ b/tests/browser-mock-ui.cjs @@ -0,0 +1,50 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +(async () => { + const { createServer } = await import('vite'); + const react = (await import('@vitejs/plugin-react')).default; + const server = await createServer({ configFile: false, root: path.resolve(__dirname, '..'), plugins: [react()], + logLevel: 'error', server: { host: '127.0.0.1', port: 0, watch: null } }); + let browser; + try { + await server.listen(); + const base = `http://127.0.0.1:${server.httpServer.address().port}`; + browser = await chromium.launch({ channel: process.platform === 'win32' ? 'msedge' : undefined, headless: true, args: ['--no-proxy-server'] }); + const page = await browser.newPage({ viewport: { width: 1505, height: 1000 } }); + page.setDefaultTimeout(10000); + const errors = [], externalRequests = []; + page.on('pageerror', error => errors.push(String(error))); + await page.route('**/*', route => { + if (route.request().url().startsWith(`${base}/`)) return route.continue(); + externalRequests.push(route.request().url()); + return route.abort(); + }); + await page.addInitScript(() => localStorage.setItem('easy-cli-proxy-api.locale', 'en')); + await page.goto(`${base}/?mock=running&mockDelay=5`); + await page.locator('.app-shell').waitFor(); + await page.locator('.nav-section button').nth(2).click(); + await page.getByRole('tab', { name: 'Authentication Files', exact: true }).click(); + await page.locator('.auth-file-card').first().waitFor(); + assert.equal(await page.locator('.auth-file-card').count(), 10); + await page.locator('.auth-list-pagination select').selectOption('20'); + await page.waitForFunction(() => document.querySelectorAll('.auth-file-card').length > 10); + await page.locator('.auth-files-heading-actions').getByRole('button', { name: 'Refresh quota', exact: true }).click(); + const card = name => page.locator('.auth-file-card').filter({ has: page.locator(`.auth-card-filename[title="${name}"]`) }); + for (const name of ['codex-personal.json', 'claude-team.json', 'kimi-personal.json', 'devin-team.json', 'xai-subscription.json', 'antigravity-team-with-a-long-account-name.json']) { + await card(name).locator('[role="progressbar"]').first().waitFor(); + } + assert.equal(await card('codex-disabled.json').getByRole('switch').getAttribute('aria-checked'), 'false'); + await card('codex-disabled.json').getByRole('switch').click(); + await page.waitForFunction(() => document.querySelector('.auth-card-filename[title="codex-disabled.json"]')?.closest('.auth-file-card')?.querySelector('[role="switch"]')?.getAttribute('aria-checked') === 'true'); + const cooling = card('codex-exhausted.json'); + await cooling.getByRole('button', { name: 'Clear cooldown', exact: true }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: 'Clear cooldown', exact: true }).click(); + await cooling.locator('.auth-file-health-compact').waitFor({ state: 'hidden' }); + await page.screenshot({ path: 'bin-work/browser-mock-credentials.png', fullPage: true }); + assert.deepEqual(errors, []); + assert.deepEqual(externalRequests, []); + console.log('PASS: real app browser mock, six provider quotas, pagination, credential toggle and cooldown reset without external requests.'); + } finally { await browser?.close(); await server.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/browserMockQuota.test.ts b/tests/browserMockQuota.test.ts new file mode 100644 index 0000000..2813b55 --- /dev/null +++ b/tests/browserMockQuota.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'; +import { createBrowserMockRuntime } from '../src/mocks/browserMockRuntime'; +import { managementApi } from '../src/services/managementApi'; +import { consumeClaudeResetCredit, consumeCodexResetCredit, loadQuota, type AuthFile } from '../src/services/quotaService'; +import { resetAuthFileCooldown } from '../src/services/authFiles'; + +let runtime: ReturnType; +let get: ReturnType; +let post: ReturnType; +const request = (method: string, path: string, body?: unknown, query?: unknown) => + runtime.invoke('management_request', { request: { method, path, body, query } }); +const files = async () => (await request('GET', '/credentials') as { files: AuthFile[] }).files; + +beforeEach(() => { + runtime = createBrowserMockRuntime('running'); + get = spyOn(managementApi, 'get').mockImplementation(async (path, query) => await request('GET', path, undefined, query) as never); + post = spyOn(managementApi, 'post').mockImplementation(async (path, body) => await request('POST', path, body) as never); +}); +afterEach(() => { get.mockRestore(); post.mockRestore(); }); + +test('all supported providers parse through the actual quota service', async () => { + const accounts = await files(); + expect(accounts.filter(file => file.source === 'file').length).toBeGreaterThan(10); + for (const provider of ['codex', 'claude', 'antigravity', 'kimi', 'xai', 'devin']) { + const file = accounts.find(file => file.provider === provider)!; + const quota = await loadQuota(file); + expect(quota.status).toBe('success'); + expect(quota.rows.length).toBeGreaterThanOrEqual(2); + expect(quota.resetCreditsError).toBeUndefined(); + expect(quota.rows.every(row => row.remainingPercent !== null), JSON.stringify({ provider, rows: quota.rows })).toBe(true); + } +}); + +test('edge accounts expose exhausted, unknown, disabled and upstream-error states', async () => { + const accounts = await files(); + const quota = (name: string) => loadQuota(accounts.find(file => file.name === name)!); + expect((await quota('codex-exhausted.json')).rows[0].remainingPercent).toBe(0); + expect((await quota('codex-unknown-quota.json')).rows[0].remainingPercent).toBeNull(); + expect((await quota('antigravity-empty-quota.json')).status).toBe('error'); + expect((await quota('xai-paid-api.json')).rows[0].remainingPercent).toBeNull(); + expect((await quota('codex-disabled.json')).status).toBe('error'); + expect(await quota('claude-expired.json')).toMatchObject({ status: 'error', error: 'Mock OAuth token expired' }); +}); + +test('Codex and Claude resets consume credits and refresh only that account', async () => { + const accounts = await files(); + for (const provider of ['codex', 'claude']) { + const account = accounts.find(file => file.provider === provider)!; + const reset = provider === 'codex' ? consumeCodexResetCredit : consumeClaudeResetCredit; + expect((await loadQuota(account)).resetCredits).toBe(2); + const result = await reset(account); + expect(result.status).toBe('success'); + expect(result.resetCredits).toBe(1); + expect(result.rows[0].remainingPercent).toBe(100); + expect((await reset(account)).resetCredits).toBe(0); + } + expect((await loadQuota(accounts.find(file => file.name === 'codex-idle.json')!)).resetCredits).toBe(2); +}); + +test('cooldown reset acknowledges the correct credential and preserves disabled state', async () => { + const account = (await files()).find(file => file.name === 'codex-exhausted.json')!; + await request('PATCH', '/credentials/status', { name: account.name, disabled: true }); + expect(await resetAuthFileCooldown(String(account.auth_index))).toMatchObject({ status: 'ok', auth_index: account.auth_index, models: ['gpt-5.2-codex'] }); + expect((await files()).find(file => file.name === account.name)).toMatchObject({ disabled: true, cooldowns: [] }); +}); + +test('imports JSON contents, replaces the same filename, and deletes without mutating other accounts', async () => { + const originalCount = (await files()).length; + const upload = (content: string) => runtime.invoke('upload_auth_file', { name: 'imported.json', data: Array.from(new TextEncoder().encode(content)) }); + await upload(JSON.stringify({ type: 'kimi', note: 'Imported example', priority: 9 })); + const first = (await files()).find(file => file.name === 'imported.json')!; + expect(first).toMatchObject({ provider: 'kimi', note: 'Imported example', priority: 9 }); + await upload(JSON.stringify({ type: 'claude', note: 'Replaced' })); + expect((await files()).length).toBe(originalCount + 1); + expect((await files()).find(file => file.name === 'imported.json')?.priority).toBeUndefined(); + expect(await request('GET', '/credentials/download', undefined, { name: 'imported.json' })).toMatchObject({ provider: 'claude', note: 'Replaced', auth_index: first.auth_index }); + await expect(upload('{broken')).rejects.toThrow(); + await expect(upload('[]')).rejects.toThrow('JSON object'); + await request('DELETE', '/credentials', undefined, { name: 'imported.json' }); + expect((await files()).length).toBe(originalCount); + await expect(request('GET', '/credentials/download', undefined, { name: 'imported.json' })).rejects.toThrow('not found'); +}); diff --git a/tests/browserMockRuntime.test.ts b/tests/browserMockRuntime.test.ts index 3ddc918..693d507 100644 --- a/tests/browserMockRuntime.test.ts +++ b/tests/browserMockRuntime.test.ts @@ -19,6 +19,52 @@ describe('browser mock options', () => { }); describe('browser mock runtime', () => { + test('empty scenario exposes empty credentials, providers and usage after installing the core', async () => { + const runtime = createBrowserMockRuntime('empty'); + await runtime.invoke('install_bundled_core'); + await runtime.invoke('start_core_process'); + expect(await runtime.invoke('management_request', { request: { method: 'GET', path: '/credentials' } })).toMatchObject({ files: [] }); + const providers = await runtime.invoke('management_request', { request: { method: 'GET', path: '/config/api-keys' } }) as Record; + expect(Object.values(providers).every(groups => groups.length === 0)).toBe(true); + expect(await runtime.invoke('get_usage_overview')).toMatchObject({ totalRequests: 0, totalTokens: 0, timeline: [] }); + expect(await runtime.invoke('get_usage_events')).toMatchObject({ items: [], total: 0 }); + expect(await runtime.invoke('get_usage_analysis')).toEqual({ models: [], providers: [], sources: [], apiKeys: [] }); + expect(await runtime.invoke('get_usage_pricing')).toMatchObject({ rows: [], totalCost: 0 }); + }); + + test('filters usage before pagination, including boolean and date filters', async () => { + const runtime = createBrowserMockRuntime('running'); + const first = await runtime.invoke('get_usage_events', { query: { failed: true, page_size: 1 } }) as { items: Array>; total: number; totalPages: number }; + expect(first).toMatchObject({ total: 2, totalPages: 2 }); + expect(first.items).toHaveLength(1); + expect(first.items[0].failed).toBe(true); + const next = await runtime.invoke('get_usage_events', { query: { failed: true, page_size: 1, page: 2 } }) as typeof first; + expect(next.items[0].id).not.toBe(first.items[0].id); + const exact = await runtime.invoke('get_usage_events', { query: { + start: first.items[0].timestamp, end: first.items[0].timestamp, + provider: first.items[0].provider, model: first.items[0].model, source: first.items[0].source, + api_key_hash: first.items[0].api_key_hash, failed: true, canceled: false, + } }) as typeof first; + expect(exact.items).toEqual(first.items); + expect(await runtime.invoke('get_usage_events', { query: { model: 'absent-model' } })).toMatchObject({ items: [], total: 0 }); + expect(await runtime.invoke('get_usage_events', { query: { canceled: true } })).toMatchObject({ total: 1 }); + }); + + test('unknown routes fail explicitly and canceled OAuth sessions cannot create credentials', async () => { + const runtime = createBrowserMockRuntime('running'); + await expect(runtime.invoke('management_request', { request: { method: 'GET', path: '/missing-route' } })).rejects.toThrow('not implemented'); + await expect(runtime.invoke('management_request', { request: { method: 'POST', path: '/requests/api-call', body: { url: 'https://example.test/missing', method: 'GET' } } })).rejects.toThrow('not implemented'); + const session = await runtime.invoke('start_oauth_login', { provider: 'kimi' }) as { state: string }; + await runtime.invoke('management_request', { request: { method: 'DELETE', path: '/oauth/session', query: { state: session.state } } }); + expect(await runtime.invoke('get_oauth_status', { state: session.state })).toMatchObject({ status: 'error' }); + const credentials = await runtime.invoke('management_request', { request: { method: 'GET', path: '/credentials' } }) as { files: Array<{ name: string }> }; + expect(credentials.files.some(file => file.name === 'kimi-mock-login.json')).toBe(false); + }); + + test('latency simulation also works outside a browser', async () => { + expect(await createBrowserMockRuntime('running', undefined, 1).invoke('get_core_status')).toMatchObject({ ready: true }); + }); + for (const scenario of ['running', 'empty'] as const) { test(`installs the bundled version independently of the latest release (${scenario})`, async () => { const runtime = createBrowserMockRuntime(scenario);