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:api-access-layout": "node tests/api-access-layout-ui.cjs",
|
||||||
"test:ui:responsive": "node tests/responsive-layout-ui.cjs",
|
"test:ui:responsive": "node tests/responsive-layout-ui.cjs",
|
||||||
"test:ui:settings": "node tests/config-settings-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",
|
"screenshots": "node scripts/capture-readme-screenshots.cjs",
|
||||||
"tauri": "tauri"
|
"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 { applyTemplateChanges, readTemplatePath, sameTemplateValue, type TemplateConfigChange } from '../services/templateConfig';
|
||||||
import { createPluginMock } from './pluginMock';
|
import { createPluginMock } from './pluginMock';
|
||||||
|
import { createQuotaMock, createQuotaMockFiles } from './quotaMock';
|
||||||
|
|
||||||
export type BrowserMockScenario = 'running' | 'stopped' | 'empty' | 'error';
|
export type BrowserMockScenario = 'running' | 'stopped' | 'empty' | 'error';
|
||||||
export type BrowserMockMode = BrowserMockScenario | 'off';
|
export type BrowserMockMode = BrowserMockScenario | 'off';
|
||||||
@@ -47,7 +48,7 @@ const SHARED_PROVIDER_FIELDS = new Set([
|
|||||||
|
|
||||||
const clone = <Value,>(value: Value): Value => structuredClone(value);
|
const clone = <Value,>(value: Value): Value => structuredClone(value);
|
||||||
const sleep = (delayMs: number) => delayMs > 0
|
const sleep = (delayMs: number) => delayMs > 0
|
||||||
? new Promise<void>((resolve) => window.setTimeout(resolve, delayMs))
|
? new Promise<void>((resolve) => globalThis.setTimeout(resolve, delayMs))
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
const asObject = (value: unknown): JsonObject => (
|
const asObject = (value: unknown): JsonObject => (
|
||||||
@@ -169,10 +170,10 @@ function createUsageEvents() {
|
|||||||
source: index % 2 === 0 ? 'codex' : 'claude-code',
|
source: index % 2 === 0 ? 'codex' : 'claude-code',
|
||||||
source_display: index % 2 === 0 ? 'Codex' : 'Claude Code',
|
source_display: index % 2 === 0 ? 'Codex' : 'Claude Code',
|
||||||
failed,
|
failed,
|
||||||
canceled: false,
|
canceled: index === 9,
|
||||||
failure_status: failed ? 429 : 0,
|
failure_status: failed ? 429 : 0,
|
||||||
failure_body: failed ? 'Mock rate limit exceeded' : '',
|
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],
|
model: models[index % models.length],
|
||||||
alias: '',
|
alias: '',
|
||||||
reasoning_effort: index % 2 === 0 ? 'high' : 'medium',
|
reasoning_effort: index % 2 === 0 ? 'high' : 'medium',
|
||||||
@@ -303,6 +304,7 @@ function createState(scenario: BrowserMockScenario) {
|
|||||||
modtime: Date.now() - 45 * 60_000,
|
modtime: Date.now() - 45 * 60_000,
|
||||||
excluded_models: ['claude-legacy-*'],
|
excluded_models: ['claude-legacy-*'],
|
||||||
},
|
},
|
||||||
|
...createQuotaMockFiles(),
|
||||||
{
|
{
|
||||||
name: 'gemini-runtime',
|
name: 'gemini-runtime',
|
||||||
provider: 'gemini',
|
provider: 'gemini',
|
||||||
@@ -498,9 +500,10 @@ function createState(scenario: BrowserMockScenario) {
|
|||||||
versionSource: { source: 'github', gitcodeAvailable: true, customMirrors: ['https://gh-proxy.example.com/'] },
|
versionSource: { source: 'github', gitcodeAvailable: true, customMirrors: ['https://gh-proxy.example.com/'] },
|
||||||
// Native groups are authoritative; legacy routes only adapt their own view.
|
// Native groups are authoritative; legacy routes only adapt their own view.
|
||||||
providerConfig: Object.fromEntries(PROVIDER_SECTIONS.map((section) => [
|
providerConfig: Object.fromEntries(PROVIDER_SECTIONS.map((section) => [
|
||||||
section, groupProviderRecords(section, legacyProviderConfig[section]),
|
section, scenario === 'empty' ? [] : groupProviderRecords(section, legacyProviderConfig[section]),
|
||||||
])) as Record<ProviderSection, JsonObject[]>,
|
])) as Record<ProviderSection, JsonObject[]>,
|
||||||
authFiles,
|
authFiles: scenario === 'empty' ? [] : authFiles,
|
||||||
|
quotaApiCall: createQuotaMock(),
|
||||||
oauthExcludedModels: { codex: [], claude: ['claude-legacy-*'], gemini: [] } as Record<string, string[]>,
|
oauthExcludedModels: { codex: [], claude: ['claude-legacy-*'], gemini: [] } as Record<string, string[]>,
|
||||||
oauthSessions: new Map<string, string>(),
|
oauthSessions: new Map<string, string>(),
|
||||||
analysis,
|
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();
|
const url = readString(body.url).toLowerCase();
|
||||||
if (url.includes('/models')) {
|
if (url.includes('/models')) {
|
||||||
return {
|
return {
|
||||||
@@ -645,47 +650,8 @@ function mockApiCall(body: JsonObject) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (url.includes('/api/oauth/profile')) {
|
if (!/\/(chat\/completions|responses|messages)(\?|$)/.test(url)) {
|
||||||
return { status_code: 200, body: { account: { has_claude_pro: true, has_claude_max: false } } };
|
throw new Error(`Browser Mock upstream request not implemented: ${readString(body.method)} ${readString(body.url)}`);
|
||||||
}
|
|
||||||
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 }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status_code: 200,
|
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) {
|
if (v8Provider) {
|
||||||
const section = v8Provider[0] as ProviderSection;
|
const section = v8Provider[0] as ProviderSection;
|
||||||
if (method === 'GET') return clone(state.providerConfig[section]);
|
if (method === 'GET') return clone(state.providerConfig[section]);
|
||||||
@@ -730,7 +708,8 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) {
|
|||||||
}
|
}
|
||||||
if (method === 'GET' && path === '/credentials/download') {
|
if (method === 'GET' && path === '/credentials/download') {
|
||||||
const file = findAuthFile(state, readString(query.name));
|
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 === '/credentials/models') return mockModelDefinitions();
|
||||||
if (method === 'GET' && path.startsWith('/routing/model-definitions/')) 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')) {
|
if (method === 'PATCH' && (path === '/credentials/fields' || path === '/credentials/status')) {
|
||||||
const patch = asObject(body);
|
const patch = asObject(body);
|
||||||
const file = findAuthFile(state, readString(patch.name));
|
const file = findAuthFile(state, readString(patch.name));
|
||||||
if (file) Object.assign(file, patch);
|
if (!file) throw new Error(`Browser Mock credential not found: ${readString(patch.name)}`);
|
||||||
return clone(file ?? null);
|
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') {
|
if (method === 'DELETE' && path === '/credentials') {
|
||||||
const index = state.authFiles.findIndex((file) => file.name === query.name);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
if (method === 'POST' && path === '/requests/api-call') return mockApiCall(asObject(body));
|
if (method === 'POST' && path === '/requests/api-call') return mockApiCall(state, asObject(body));
|
||||||
if (method === 'DELETE' && path === '/oauth/session') return null;
|
if (method === 'DELETE' && path === '/oauth/session') {
|
||||||
return {};
|
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> = {
|
const CORE_CONFIG_PATHS: Record<string, string> = {
|
||||||
@@ -810,6 +802,17 @@ export function createBrowserMockRuntime(
|
|||||||
delayMs = 0,
|
delayMs = 0,
|
||||||
): BrowserMockRuntime {
|
): BrowserMockRuntime {
|
||||||
const state = createState(scenario);
|
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 pluginMock = createPluginMock(state);
|
||||||
const emit = (event: string, payload: unknown) => emitEvent(event, clone(payload));
|
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 'management_request': return clone(managementResponse(state, payload));
|
||||||
case 'upload_auth_file': {
|
case 'upload_auth_file': {
|
||||||
const name = readString(payload.name) || 'uploaded-mock.json';
|
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 };
|
return { ok: true, name };
|
||||||
}
|
}
|
||||||
case 'resolve_api_access_remarks': {
|
case 'resolve_api_access_remarks': {
|
||||||
@@ -1049,6 +1058,7 @@ export function createBrowserMockRuntime(
|
|||||||
case 'get_oauth_status': {
|
case 'get_oauth_status': {
|
||||||
const session = readString(payload.state);
|
const session = readString(payload.state);
|
||||||
const provider = state.oauthSessions.get(session);
|
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`)) {
|
if (provider && !state.authFiles.some((file) => file.name === `${provider}-mock-login.json`)) {
|
||||||
state.authFiles.push({
|
state.authFiles.push({
|
||||||
name: `${provider}-mock-login.json`, provider, type: provider, source: 'file',
|
name: `${provider}-mock-login.json`, provider, type: provider, source: 'file',
|
||||||
@@ -1071,13 +1081,24 @@ export function createBrowserMockRuntime(
|
|||||||
const query = asObject(payload.query);
|
const query = asObject(payload.query);
|
||||||
const page = Math.max(1, readNumber(query.page, 1));
|
const page = Math.max(1, readNumber(query.page, 1));
|
||||||
const pageSize = Math.max(1, readNumber(query.page_size, 50));
|
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;
|
const start = (page - 1) * pageSize;
|
||||||
return {
|
return {
|
||||||
items: clone(state.usageEvents.slice(start, start + pageSize)),
|
items: clone(events.slice(start, start + pageSize)),
|
||||||
total: state.usageEvents.length,
|
total: events.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalPages: Math.max(1, Math.ceil(state.usageEvents.length / pageSize)),
|
totalPages: Math.max(1, Math.ceil(events.length / pageSize)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'get_usage_pricing': {
|
case 'get_usage_pricing': {
|
||||||
@@ -1092,7 +1113,7 @@ export function createBrowserMockRuntime(
|
|||||||
estimatedCost: index === 0 ? 1.92 : 1.71,
|
estimatedCost: index === 0 ? 1.92 : 1.71,
|
||||||
price: clone(price),
|
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 'get_usage_storage_settings': return clone(state.usageStorage);
|
||||||
case 'save_usage_storage_settings': {
|
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', () => {
|
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) {
|
for (const scenario of ['running', 'empty'] as const) {
|
||||||
test(`installs the bundled version independently of the latest release (${scenario})`, async () => {
|
test(`installs the bundled version independently of the latest release (${scenario})`, async () => {
|
||||||
const runtime = createBrowserMockRuntime(scenario);
|
const runtime = createBrowserMockRuntime(scenario);
|
||||||
|
|||||||
Reference in new issue
Block a user