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.
This commit is contained in:
1 parent
9f451f8299
commit
46a9d3d449
7 files changed
+416
-59
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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/`.
|
||||
@@ -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): Value => structuredClone(value);
|
||||
const sleep = (delayMs: number) => delayMs > 0
|
||||
? new Promise<void>((resolve) => window.setTimeout(resolve, delayMs))
|
||||
? new Promise<void>((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<ProviderSection, JsonObject[]>,
|
||||
authFiles,
|
||||
authFiles: scenario === 'empty' ? [] : authFiles,
|
||||
quotaApiCall: createQuotaMock(),
|
||||
oauthExcludedModels: { codex: [], claude: ['claude-legacy-*'], gemini: [] } as Record<string, string[]>,
|
||||
oauthSessions: new Map<string, string>(),
|
||||
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<string, string> = {
|
||||
@@ -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': {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
type RecordValue = Record<string, unknown>;
|
||||
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<string, number>();
|
||||
const redeemed = new Set<string>();
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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; });
|
||||
@@ -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<typeof createBrowserMockRuntime>;
|
||||
let get: ReturnType<typeof spyOn>;
|
||||
let post: ReturnType<typeof spyOn>;
|
||||
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');
|
||||
});
|
||||
@@ -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<string, unknown[]>;
|
||||
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<Record<string, unknown>>; 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);
|
||||
|
||||
Reference in new issue
Block a user