feat: add plugin management and redesign dashboard and usage analytics

- Add plugin store, version selection, configuration, and OAuth login
- Support Muse (Meta) OAuth device authorization
- Redesign home dashboard and add batch model health checks
- Support per-credential provider editing, removal, and reordering
- Improve usage charts and track actual response models
- Preserve installed plugins and runtime data during portable rebuilds
- Update translations, documentation, and related tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 12ad2a8e97
commit d29a1af69b
109 files changed
+9363 -1129

No files matched your search

+18 -20
View File
@@ -2,7 +2,7 @@ const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const widths = [1440, 980, 854, 640];
const widths = [1440, 1266, 980, 854, 640];
const locales = ['zh-CN', 'en'];
(async () => {
@@ -31,8 +31,8 @@ const locales = ['zh-CN', 'en'];
page.on('pageerror', error => errors.push(`${label}: ${error}`));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
await page.goto(`${base}/tests/fixtures/provider-groups.html?layout=dense&locale=${locale}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelectorAll('.real-provider-row').length === 4
&& document.querySelectorAll('.provider-row-title > span:not(.state-pill)').length === 4);
await page.waitForFunction(() => document.querySelectorAll('.real-provider-row').length === 8
&& document.querySelectorAll('.provider-row-title > span:not(.state-pill)').length === 8);
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -62,8 +62,8 @@ const locales = ['zh-CN', 'en'];
listTop: document.querySelector('.provider-resource-panel').getBoundingClientRect().top,
};
});
assert.equal(navigation.count, 5, `${label}: every provider category remains available`);
assert.equal(navigation.rows, 1, `${label}: categories retain a single top row`);
assert.equal(navigation.count, 9, `${label}: every provider category remains available`);
assert.equal(navigation.rows, 2, `${label}: categories fit into two rows above the resource list`);
assert.equal(navigation.allVisible, true, `${label}: all category controls fit without hidden columns`);
assert.equal(navigation.labelsFit, true, `${label}: full category names stay readable`);
assert.ok(navigation.bottom <= navigation.listTop + 1, `${label}: category navigation stays above the resource list`);
@@ -76,21 +76,21 @@ const locales = ['zh-CN', 'en'];
const groups = await page.evaluate(() => window.groupFixture.groups);
const rows = page.locator('.real-provider-row');
for (const group of groups) {
const row = rows.filter({ has: page.locator('.provider-row-title strong', { hasText: group.name }) });
let rowIndex = 0;
for (const group of groups) for (const key of group.keys) {
const effective = { ...group, ...Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)) };
const row = rows.nth(rowIndex++);
const meta = row.locator('.provider-row-meta');
assert.equal(await row.count(), 1, `${label}: each group has exactly one row`);
assert.equal(await row.count(), 1, `${label}: each API key has its own row`);
assert.equal(await meta.isVisible(), true, `${label} ${group.name}: metadata stays visible`);
const metaText = await meta.innerText();
const expectedModels = locale === 'en' ? `${group.models.length} models` : `模型 ${group.models.length} 个`;
if (group.models.length) assert.ok(metaText.includes(expectedModels), `${label} ${group.name}: model count is preserved`);
const expectedModels = locale === 'en' ? `${effective.models.length} models` : `模型 ${effective.models.length} 个`;
if (effective.models.length) assert.ok(metaText.includes(expectedModels), `${label} ${group.name}: effective model count is preserved`);
const priorityLabel = locale === 'en' ? 'Priority' : '优先级';
if (group.priority === null) assert.ok(!metaText.includes(priorityLabel), `${label}: inherited priority is not displayed as zero`);
else assert.ok(metaText.includes(`${priorityLabel} ${group.priority}`), `${label} ${group.name}: priority, including zero, is preserved`);
if (group.keys.length > 1) {
const keyCount = locale === 'en' ? `${group.keys.length} keys total` : `共 ${group.keys.length} 个密钥`;
assert.ok(metaText.includes(keyCount), `${label} ${group.name}: multiple keys stay grouped and counted`);
}
if (effective.priority === null) assert.ok(!metaText.includes(priorityLabel), `${label}: inherited priority is not displayed as zero`);
else assert.ok(metaText.includes(`${priorityLabel} ${effective.priority}`), `${label} ${group.name}: effective priority, including zero, is preserved`);
assert.equal(await row.getByRole('checkbox').isChecked(), !effective['excluded-models']?.includes('*'), `${label}: each key shows its own effective enabled state`);
assert.ok(!metaText.includes(locale === 'en' ? 'keys total' : '个密钥'), `${label}: individual rows do not show group key counts`);
assert.equal(await row.locator('.provider-row-url').getAttribute('title'), group['base-url'], `${label}: full URL remains available`);
const remark = row.locator('.provider-row-title > span:not(.state-pill)');
assert.equal(await remark.getAttribute('title'), await remark.textContent(), `${label}: the full remark remains available when shortened`);
@@ -123,14 +123,12 @@ const locales = ['zh-CN', 'en'];
}
}
const partial = rows.filter({ hasText: 'Mock Borealis partial' });
assert.equal(await partial.locator('.state-pill').textContent(), locale === 'en' ? 'Some keys disabled' : '部分密钥已停用');
assert.equal(await rows.filter({ hasText: 'Mock Dusk disabled' }).getByRole('checkbox').isChecked(), false);
assert.equal(await rows.locator('.state-pill').count(), 0, 'Individual keys do not display a partial group state');
assert.deepEqual(await page.evaluate(() => window.groupFixture.writes), [], 'Layout checks must not change provider records');
await page.close();
}
assert.deepEqual(errors, [], 'Dense API layouts must not produce runtime errors');
console.log('PASS: dense API groups retain navigation, metadata and reachable actions at four widths in Chinese and English.');
console.log('PASS: dense API key rows retain navigation, effective metadata, existing remarks and reachable actions at five widths in Chinese and English.');
} finally {
if (browser) await browser.close();
await server.close();
+16 -15
View File
@@ -33,8 +33,13 @@ const path = require('node:path');
});
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
const form = page.getByRole('dialog', { name: 'Edit Group', exact: true });
const form = page.getByRole('dialog', { name: 'Edit API Key', exact: true });
assert.equal(await form.locator('.model-config-entry').count(), 30);
assert.equal(await form.getByLabel('API Key', { exact: true }).count(), 1);
assert.equal(await form.locator('.provider-group-key').count(), 0);
assert.equal(await form.getByText(/shared settings|inherit shared|One group connects/i).count(), 0);
assert.equal(await form.getByLabel('Group Name', { exact: true }).count(), 0);
assert.equal(await form.getByLabel('Key for Model Discovery', { exact: true }).count(), 0);
// Older WebKit makes an inline-size query container a fixed-position containing block.
// Explicit layout containment reproduces that behavior on current browser engines.
@@ -71,18 +76,15 @@ const path = require('node:path');
await assertViewportOverlay('.config-dialog-backdrop');
await assertDialogControls(form);
await form.getByLabel('Remark', { exact: true }).fill('Small-window edit');
await form.locator('.provider-advanced-settings summary').click();
await form.locator('.provider-advanced-settings > summary').click();
const headers = form.getByRole('textbox', { name: /Custom Headers/ });
await headers.fill('X-Layout-Test: reachable');
await form.getByRole('combobox', { name: 'Disable Cooldown', exact: true }).selectOption('false');
const keySettings = form.locator('.provider-group-key').first();
await keySettings.locator('summary').click();
await keySettings.getByRole('combobox', { name: 'Cache User ID', exact: true }).selectOption('false');
await keySettings.locator('summary').click();
await form.getByRole('combobox', { name: 'Cache User ID', exact: true }).selectOption('false');
await assertDialogControls(form);
const scroll = await form.evaluate((element) => ({ top: element.scrollTop, max: element.scrollHeight - element.clientHeight }));
assert.ok(scroll.top > 0 && scroll.max > 0, 'Advanced settings must be reachable by scrolling inside the dialog');
await form.locator('.provider-advanced-settings summary').click();
await form.locator('.provider-advanced-settings > summary').click();
}
await form.getByRole('button', { name: 'Fetch Models', exact: true }).click();
@@ -100,15 +102,14 @@ const path = require('node:path');
await form.getByRole('button', { name: 'Save', exact: true }).click();
await form.waitFor({ state: 'detached' });
const record = await page.evaluate(() => window.providerFixture.records[0]);
assert.equal(record.headers['X-Layout-Test'], 'reachable');
assert.equal(record.keys[0].headers['X-Layout-Test'], 'reachable');
assert.equal(record.models.length, 30);
assert.equal(record['disable-cooling'], false);
assert.equal(record.keys[0]['disable-cooling'], false);
assert.equal(record.keys[0].cloak['cache-user-id'], false);
assert.equal(await page.evaluate(() => document.body.style.overflow), '');
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
await form.locator('.provider-advanced-settings summary').click();
await form.locator('.provider-group-key').first().locator('summary').click();
await form.locator('.provider-advanced-settings > summary').click();
for (const name of ['Disable Cooldown', 'Cache User ID']) {
const control = form.getByRole('combobox', { name, exact: true });
assert.equal(await control.inputValue(), 'false');
@@ -117,15 +118,15 @@ const path = require('node:path');
await form.getByRole('button', { name: 'Save', exact: true }).click();
await form.waitFor({ state: 'detached' });
const inherited = await page.evaluate(() => window.providerFixture.records[0]);
assert.equal(inherited['disable-cooling'], undefined);
assert.equal(inherited.keys[0]['disable-cooling'], undefined);
assert.equal(inherited.keys[0].cloak?.['cache-user-id'], undefined);
await page.locator('.provider-category-panel button').filter({ hasText: 'OpenAI' }).click();
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
const add = page.getByRole('dialog', { name: 'Add Group', exact: true });
await page.getByRole('button', { name: 'Add Service', exact: true }).click();
const add = page.getByRole('dialog', { name: 'Add Service', exact: true });
await assertViewportOverlay('.config-dialog-backdrop');
await assertDialogControls(add);
await add.locator('.provider-advanced-settings summary').click();
await add.locator('.provider-advanced-settings > summary').click();
assert.equal(await add.getByLabel('Test Model', { exact: true }).count(), 0);
await add.getByRole('button', { name: 'Cancel', exact: true }).click();
await add.waitFor({ state: 'detached' });
+20 -1
View File
@@ -4,6 +4,7 @@ import {
dedupeAuthFiles,
isOAuthCredentialFile,
normalizeAuthFilePriorityInput,
normalizeOAuthProvider,
oauthModelProvidersFromAuthFiles,
parseAuthFilePriority,
setOAuthCredentialFileDisabled,
@@ -77,9 +78,10 @@ describe('OAuth credential file boundaries', () => {
{ name: 'devin.json', type: 'cognition' },
{ name: 'antigravity.json', type: 'anti-gravity' },
{ name: 'openai.json', type: 'openai' },
{ name: 'muse.json', type: 'muse' },
{ name: 'plugin.json', provider: 'custom-oauth', source: 'file' },
{ name: 'unknown.json' },
])).toEqual(['antigravity', 'claude', 'codex', 'custom-oauth', 'devin']);
])).toEqual(['antigravity', 'claude', 'codex', 'custom-oauth', 'devin', 'meta']);
expect(oauthModelProvidersFromAuthFiles(nonOAuthFiles)).toEqual([]);
});
@@ -104,6 +106,12 @@ describe('OAuth credential file boundaries', () => {
}
expect(writes).toEqual([]);
});
it('normalizes Meta OAuth aliases used by the core and Management Center', () => {
expect(normalizeOAuthProvider(' Muse ')).toBe('meta');
expect(normalizeOAuthProvider('meta_ai')).toBe('meta_ai');
expect(normalizeOAuthProvider('anthropic')).toBe('claude');
});
});
describe('authentication file priority', () => {
@@ -135,4 +143,15 @@ describe('authentication file priority', () => {
{ name: 'claude-old.json', provider: 'claude', modtime: 2 },
], 'codex')).toEqual(['codex-old.json', 'codex-new.json']);
});
it('applies the default priority to a newly created Muse credential through the muse alias', () => {
const before = snapshotAuthFiles([
{ name: 'muse-existing.json', provider: 'muse', modtime: 1 },
]);
expect(changedOAuthAuthFileNames(before, [
{ name: 'muse-existing.json', provider: 'muse', modtime: 2 },
{ name: 'muse-new.json', provider: 'meta', modtime: 2 },
{ name: 'muse-priority.json', provider: 'muse', modtime: 2, priority: 4 },
], 'muse')).toEqual(['muse-existing.json', 'muse-new.json']);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import { checkCoreModelHealth, checkCoreModelsHealth } from '../src/services/coreHealthCheck';
describe('CPA 内核模型健康检测', () => {
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
});
afterEach(() => {
clearMocks();
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
it('只传入暴露的模型 ID 和超时,由原生命令选择内核地址和凭据', async () => {
mockIPC((command, args) => {
expect(command).toBe('core_health_probe');
expect(args).toEqual({ model: 'team/GPT-Alias', timeoutMs: 15_000 });
return { firstTokenLatencyMs: 120.5, responseLatencyMs: 180.1 };
});
expect(await checkCoreModelHealth('team/GPT-Alias')).toEqual({
model: 'team/GPT-Alias', status: 'healthy', success: true,
firstTokenLatencyMs: 121, responseLatencyMs: 180,
});
});
it('无首 Token 的非流式结果只显示响应时间', async () => {
mockIPC(() => ({ responseLatencyMs: 0 }));
expect(await checkCoreModelHealth('chat')).toMatchObject({
success: true, firstTokenLatencyMs: undefined, responseLatencyMs: 1,
});
});
it('保留失败原因并识别超时,失败模型不打断其他模型', async () => {
mockIPC((_command, args) => {
if (args?.model === 'timeout') throw new Error('request timed out');
if (args?.model === 'auth') throw 'HTTP 401: unauthorized';
return { firstTokenLatencyMs: 20, responseLatencyMs: 30 };
});
const results = await checkCoreModelsHealth([{ name: 'timeout' }, { name: 'auth' }, { name: 'working' }]);
expect(results[0]).toMatchObject({ model: 'timeout', success: false, status: 'failed', timedOut: true, error: 'request timed out' });
expect(results[1]).toMatchObject({ model: 'auth', success: false, timedOut: false, error: 'HTTP 401: unauthorized' });
expect(results[2]).toMatchObject({ model: 'working', success: true });
});
it('去除重复 ID,保留大小写及别名,并限制同时请求数为四个', async () => {
const requested: string[] = [];
let active = 0;
let maxActive = 0;
mockIPC(async (_command, args) => {
requested.push(String(args?.model));
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
return { responseLatencyMs: 5 };
});
const models = ['alias', 'ALIAS', 'team/model', 'one', 'two', 'three', 'four', 'alias', ''];
const results = await checkCoreModelsHealth(models.map((name) => ({ name })));
expect(maxActive).toBe(4);
expect(requested).toEqual(['alias', 'ALIAS', 'team/model', 'one', 'two', 'three', 'four']);
expect(results.map((result) => result.model)).toEqual(requested);
});
it('停止或离开页面后不再启动排队请求,也不交付迟到的结果', async () => {
const controller = new AbortController();
const started: string[] = [];
const delivered: string[] = [];
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
mockIPC(async () => { await gate; return { responseLatencyMs: 10 }; });
const checking = checkCoreModelsHealth(
Array.from({ length: 20 }, (_, index) => ({ name: `model-${index}` })),
(result) => delivered.push(result.model), controller.signal,
(model) => started.push(model.name),
);
expect(started).toHaveLength(4);
controller.abort();
release();
expect(await checking).toEqual([]);
expect(started).toHaveLength(4);
expect(delivered).toEqual([]);
});
});
+2 -2
View File
@@ -16,7 +16,7 @@ const base = process.env.DEVIN_TEST_BASE_URL || 'http://127.0.0.1:1421';
const calls = () => page.evaluate(() => window.devinFixture.calls);
await open();
await card().waitFor();
assert.equal(await page.locator('.oauth-card').count(), 6);
assert.equal(await page.locator('.oauth-card').count(), 7);
await click('Start Sign-In');
await card().getByRole('textbox').waitFor();
assert.ok((await calls()).some(call => call.cmd === 'start_oauth_login' && call.args.provider === 'devin' && call.args.browser === 'none'));
@@ -40,7 +40,7 @@ const base = process.env.DEVIN_TEST_BASE_URL || 'http://127.0.0.1:1421';
await page.waitForTimeout(150);
assert.equal(await card().getByText('Completed', { exact: true }).count(), 0);
assert.ok(await card().getByRole('textbox').isVisible());
assert.ok((await calls()).some(call => call.cmd === 'management_request' && call.args.request.path === '/oauth-session' && call.args.request.query.state === 'attempt-1'));
assert.ok((await calls()).some(call => call.cmd === 'management_request' && call.args.request.path === '/oauth/session' && call.args.request.query.state === 'attempt-1'));
await page.evaluate(() => { window.devinFixture.cancelError = true; });
await click('Refresh Link');
await page.getByText('Cancellation failed', { exact: false }).waitFor();
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Home dashboard regression</title></head><body><div id="root"></div><script type="module" src="./home-dashboard.tsx"></script></body></html>
+99
View File
@@ -0,0 +1,99 @@
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { mockIPC } from '@tauri-apps/api/mocks';
import { emit } from '@tauri-apps/api/event';
import { I18nProvider } from '../../src/i18n';
import { CoreRuntimeProvider } from '../../src/coreRuntime';
import { AppUpdateProvider } from '../../src/appUpdate';
import { KernelPage } from '../../src/pages/Kernel';
import { CoreHealthPanel } from '../../src/pages/CoreHealthPanel';
import { createBrowserMockRuntime } from '../../src/mocks/browserMockRuntime';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'en');
document.documentElement.dataset.theme = params.get('theme') || 'light';
const runtime = createBrowserMockRuntime(params.has('offline') ? 'stopped' : 'running', (event, payload) => { void emit(event, payload); });
const initialModels = ['gpt-5.2-codex', 'claude-sonnet-4-6', 'gemini-3-pro', 'deepseek-chat', 'custom/team-alias', 'long-provider/llama-4-scout-instruct-with-a-long-model-name']
.map((name) => ({ name }));
const fixture = {
calls: [] as Array<{ cmd: string; args: any; at: number }>,
copied: [] as string[],
models: initialModels,
modelError: params.has('modelError') ? 'Fixture model list unavailable' : '',
holdModels: params.has('loading'),
modelReleases: [] as Array<() => void>,
holdProbes: false,
probeReleases: [] as Array<() => void>,
probeFailures: {} as Record<string, string>,
usageError: params.has('usageError'),
keyError: params.has('keyError'),
key: params.has('noKey') ? '' : 'test-client-access-key-never-real',
port: 8317,
refreshPanel: () => {},
resolvePanel: () => {},
changeConfig: () => { fixture.port += 1; return emit('config-files-changed', {}); },
releaseModels: () => { fixture.holdModels = false; fixture.modelReleases.splice(0).forEach((release) => release()); },
releaseProbes: () => { fixture.holdProbes = false; fixture.probeReleases.splice(0).forEach((release) => release()); },
};
Object.assign(window, { homeFixture: fixture });
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: async (value: string) => { fixture.copied.push(value); } },
});
mockIPC(async (cmd, args: any = {}) => {
fixture.calls.push({ cmd, args, at: Date.now() });
if (cmd === 'get_gui_settings') return { host: '127.0.0.1', port: fixture.port, runOnStartup: false };
if (cmd === 'get_core_config_settings') {
if (fixture.keyError) throw new Error('Fixture key unavailable');
return { apiKeys: fixture.key ? [{ apiKey: fixture.key }] : [] };
}
if (cmd === 'get_core_tls_settings') return { enabled: params.has('tls'), cert: '', key: '' };
if (cmd === 'get_usage_overview') {
if (fixture.usageError) throw new Error('Fixture usage unavailable');
if (params.has('noRequests')) return { totalRequests: 0, successCount: 0, failureCount: 0, canceledCount: 0, successRate: 0 };
return { totalRequests: 503, successCount: 492, failureCount: 11, canceledCount: 0, successRate: 0 };
}
if (cmd === 'management_request' && args.request.path === '/credentials') {
if (params.has('noCredentials')) return { files: [] };
if (params.has('unknownCredentials')) return { files: Array.from({ length: 11 }, (_, index) => ({
name: `oauth-${index}.json`, provider: 'codex', source: 'file', status: 'refreshing',
})) };
return { files: Array.from({ length: 11 }, (_, index) => ({
name: `oauth-${index}.json`, provider: 'codex', source: 'file', status: 'active', disabled: index >= 5,
})) };
}
if (cmd === 'management_request' && args.request.path === '/config/api-keys') {
return { openai: [{ keys: [{ 'api-key': 'fixture-provider-key-a' }, { 'api-key': '' }] }], claude: [{ keys: [{ 'api-key': 'fixture-provider-key-b' }] }] };
}
if (cmd === 'get_core_models') {
if (fixture.holdModels) await new Promise<void>((resolve) => { fixture.modelReleases.push(resolve); });
if (fixture.modelError) throw new Error(fixture.modelError);
return structuredClone(fixture.models);
}
if (cmd === 'core_health_probe') {
const failure = fixture.probeFailures[args.model];
if (fixture.holdProbes) await new Promise<void>((resolve) => { fixture.probeReleases.push(resolve); });
if (failure) throw new Error(failure);
return { firstTokenLatencyMs: 184, responseLatencyMs: 642 };
}
return runtime.invoke(cmd, args);
}, { shouldMockEvents: true });
function PanelHarness() {
const [models, setModels] = useState(initialModels);
const [loading, setLoading] = useState(false);
fixture.refreshPanel = () => { setModels((current) => current.map((model) => ({ ...model }))); setLoading(true); };
fixture.resolvePanel = () => { setModels((current) => current.map((model) => ({ ...model }))); setLoading(false); };
return <CoreHealthPanel coreReady models={models} modelsLoading={loading} modelsError="" contextKey="stable-fixture" onRefreshModels={fixture.refreshPanel} />;
}
createRoot(document.getElementById('root')!).render(
<I18nProvider><AppUpdateProvider><CoreRuntimeProvider>
<div className="app-shell">
<aside className="sidebar" aria-hidden="true"><strong>EasyCLIProxyAPI</strong><span>Desktop console</span></aside>
<div className="workspace"><main className="content">{params.get('view') === 'panel' ? <PanelHarness /> : <KernelPage />}</main></div>
</div>
</CoreRuntimeProvider></AppUpdateProvider></I18nProvider>,
);
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Meta OAuth integration</title></head><body><div id="root"></div><script type="module" src="./meta-oauth.tsx"></script></body></html>
+133
View File
@@ -0,0 +1,133 @@
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { OAuthLoginPage } from '../../src/pages/ManagementPages';
import { EasyModePage } from '../../src/pages/EasyModePage';
import '../../src/styles/index.css';
type MetaStatus = 'wait' | 'ok' | 'error';
type MetaFixture = {
calls: Array<{ cmd: string; args: any }>;
attempts: number;
status: MetaStatus;
statusCalls: number;
holdStatus: boolean;
releaseStatus: null | (() => void);
cancelledStates: string[];
priorityPatches: Array<{ name: string; priority: number }>;
files: Array<Record<string, unknown>>;
currentState: string;
copied: string[];
clockOffsetMs: number;
};
const params = new URLSearchParams(location.search);
const view = params.get('view');
const locale = params.get('locale') ?? 'en';
localStorage.setItem('easy-cli-proxy-api.locale', locale);
localStorage.setItem('easy-cli-proxy-api.oauth-browser.v3', 'none');
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
// Keep OAuth polling quick in this fixture while preserving the production timer behavior.
const nativeSetInterval = window.setInterval.bind(window);
const nativeSetTimeout = window.setTimeout.bind(window);
(window as any).setInterval = (handler: TimerHandler, timeout?: number, ...args: any[]) =>
nativeSetInterval(handler, timeout === 3000 ? 25 : timeout, ...args);
(window as any).setTimeout = (handler: TimerHandler, timeout?: number, ...args: any[]) =>
nativeSetTimeout(handler, timeout === 1500 ? 25 : timeout, ...args);
const fixture: MetaFixture = {
calls: [],
attempts: 0,
status: 'wait',
statusCalls: 0,
holdStatus: false,
releaseStatus: null,
cancelledStates: [],
priorityPatches: [],
files: params.has('kimiAi') ? [{ name: 'kimi-ai-existing.json', provider: 'kimi-ai', status: 'active' }] : [],
currentState: '',
copied: [],
clockOffsetMs: 0,
};
Object.assign(window, { metaFixture: fixture });
const nativeDateNow = Date.now.bind(Date);
Date.now = () => nativeDateNow() + fixture.clockOffsetMs;
const credentialForAttempt = (attempt: number) => ({
name: `meta-oauth-${attempt}.json`,
provider: 'meta',
auth_index: `meta-${attempt}`,
status: 'active',
});
mockIPC(async (cmd, args: any = {}) => {
fixture.calls.push({ cmd, args });
if (cmd === 'set_app_locale') return null;
if (cmd === 'list_oauth_browsers') {
return [{ id: 'default', label: 'System Default' }];
}
if (cmd === 'start_oauth_login') {
fixture.attempts += 1;
fixture.status = 'wait';
fixture.statusCalls = 0;
fixture.currentState = `meta-attempt-${fixture.attempts}`;
return {
url: `https://auth.meta.ai/device?state=${fixture.currentState}`,
state: fixture.currentState,
opened: !params.has('openError'),
openError: params.has('openError') ? 'Fixture browser open failed' : null,
userCode: `META-CODE-${fixture.attempts}`,
flow: 'device',
expiresIn: 900,
};
}
if (cmd === 'get_oauth_status') {
fixture.statusCalls += 1;
const state = String(args.state ?? '');
if (fixture.holdStatus && state === 'meta-attempt-1') {
fixture.holdStatus = false;
return new Promise((resolve) => {
fixture.releaseStatus = () => resolve({ status: 'ok' });
});
}
if (fixture.status === 'ok') {
const generated = credentialForAttempt(Number(state.split('-').pop()) || fixture.attempts);
if (!fixture.files.some((file) => file.name === generated.name)) fixture.files.push(generated);
return { status: 'ok' };
}
if (fixture.status === 'error') return { status: 'error', error: 'Meta authorization failed' };
return { status: 'wait' };
}
if (cmd === 'open_oauth_url') return null;
if (cmd === 'management_request') {
const request = args.request ?? {};
if (request.path === '/credentials') return { files: fixture.files };
if (request.path === '/credentials/fields') {
const body = request.body ?? {};
const patch = { name: String(body.name ?? ''), priority: Number(body.priority) };
fixture.priorityPatches.push(patch);
const file = fixture.files.find((item) => item.name === patch.name);
if (file) file.priority = patch.priority;
return {};
}
if (request.path === '/oauth/session') {
const state = String(request.query?.state ?? '');
fixture.cancelledStates.push(state);
return { status: 'ok', cancelled: true };
}
if (request.path === '/config') return {};
if (request.path.startsWith('/config/api-keys/')) return [];
throw new Error(`Unexpected management request: ${request.method} ${request.path}`);
}
throw new Error('Unexpected command: ' + cmd + ' ' + JSON.stringify(args));
}, { shouldMockEvents: true });
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<main style={{ padding: 24 }}>
{view === 'easy' ? <EasyModePage /> : <OAuthLoginPage />}
</main>
</I18nProvider>,
);
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Plugin configuration fixture</title></head><body><div id="root"></div><script type="module" src="./plugin-config.tsx"></script></body></html>
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { PluginConfigDialog } from '../../src/pages/PluginConfigDialog';
import { pluginsApi, type PluginConfigObject, type PluginListEntry } from '../../src/services/plugins';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en');
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
mockIPC(() => null);
const fixture = {
loads: 0,
saved: 0,
patches: [] as PluginConfigObject[],
options: [] as unknown[],
holdSave: false,
releaseSave: null as null | (() => void),
};
Object.assign(window, { pluginConfigFixture: fixture });
const config: PluginConfigObject = {
enabled: true, priority: 4, token: ' keep spaces ', retries: 2, timeout: 0.5,
active: false, modes: ['chat'], headers: { old: 'value' }, algorithm: 'fast',
undeclared: { preserved: true },
};
pluginsApi.getConfig = async () => {
fixture.loads += 1;
if (params.has('loadError') && fixture.loads === 1) throw new Error('Fixture load failure');
return { ...config };
};
pluginsApi.patchConfig = async (_id, patch, options) => {
fixture.patches.push(patch);
fixture.options.push(options);
if (fixture.holdSave) await new Promise<void>(resolve => { fixture.releaseSave = resolve; });
return { ...config, ...patch };
};
const plugin: PluginListEntry = {
id: 'test-plugin', path: '/plugins/test', configured: true, registered: true,
enabled: true, effectiveEnabled: true, supportsOAuth: false, supportsQuota: false, logo: '', menus: [], metadata: null,
configFields: params.has('raw') ? [] : [
['token', 'string'], ['retries', 'integer'], ['timeout', 'number'], ['active', 'boolean'],
['modes', 'array'], ['headers', 'object'], ['algorithm', 'enum'], ['inherited', 'boolean'],
].map(([name, type]) => ({ name, type, description: `${name} configuration`, enumValues: type === 'enum' ? ['fast', 'accurate'] : [] })),
};
function Fixture() {
const [open, setOpen] = useState(false);
return <I18nProvider><button type="button" onClick={() => setOpen(true)}>Open configuration</button>
{open ? <PluginConfigDialog plugin={plugin} onClose={() => setOpen(false)} onSaved={() => { fixture.saved += 1; }} /> : null}
</I18nProvider>;
}
createRoot(document.getElementById('root')!).render(<Fixture />);
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Plugin OAuth fixture</title></head><body><div id="root"></div><script type="module" src="./plugin-oauth.tsx"></script></body></html>
+69
View File
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { PluginOAuthDialog } from '../../src/pages/PluginOAuthDialog';
import type { PluginListEntry } from '../../src/services/plugins';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en');
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
const nativeSetTimeout = window.setTimeout.bind(window);
window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => nativeSetTimeout(handler, timeout === 3000 ? 25 : timeout, ...args)) as typeof window.setTimeout;
const fixture = {
calls: [] as { cmd: string; args: Record<string, unknown> }[],
starts: 0, completed: 0, polls: 0,
status: 'wait',
cancelled: [] as string[],
copied: [] as string[],
releaseStart: null as (() => void) | null,
releaseStatus: null as (() => void) | null,
unmount: () => {},
};
Object.assign(window, { pluginOAuthFixture: fixture });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { fixture.copied.push(value); } } });
mockIPC(async (cmd, unknownArgs) => {
const args = (unknownArgs ?? {}) as Record<string, unknown>;
fixture.calls.push({ cmd, args });
if (cmd === 'start_oauth_login') {
const attempt = ++fixture.starts;
if (params.has('startError') && attempt === 1) throw new Error('Fixture start error');
const result = {
url: params.has('invalidUrl') ? 'javascript:alert(1)' : `https://login.example.test/authorize?state=plugin-${attempt}`,
state: params.has('missingState') ? null : `plugin-${attempt}`,
userCode: params.has('device') ? 'ABCD-EFGH' : null,
flow: params.has('device') ? 'device' : 'authorization_code',
expiresIn: params.has('expiry') ? 1 : 900,
opened: true, openError: null,
};
if (params.has('holdStart')) await new Promise<void>(resolve => { fixture.releaseStart = resolve; });
return result;
}
if (cmd === 'get_oauth_status') {
fixture.polls += 1;
if (params.has('holdStatus')) await new Promise<void>(resolve => { fixture.releaseStatus = resolve; });
if (params.has('pollError')) throw new Error('Fixture poll error');
return { status: fixture.status, error: fixture.status === 'error' ? 'Fixture authorization denied' : null };
}
if (cmd === 'management_request') {
const request = args.request as { method: string; path: string; query: { state: string } };
if (request.method === 'DELETE' && request.path === '/oauth/session') fixture.cancelled.push(request.query.state);
return { status: 'ok', cancelled: true };
}
if (cmd === 'open_oauth_url' && params.has('openError')) throw new Error('Fixture browser error');
return null;
});
const plugin: PluginListEntry = {
id: 'test-plugin', path: '/plugins/test', configured: true, registered: true,
enabled: true, effectiveEnabled: !params.has('disabled'), supportsOAuth: true,
oauthProvider: 'grok', supportsQuota: false, logo: '', menus: [], metadata: null, configFields: [],
};
function Fixture() {
const [open, setOpen] = useState(false);
fixture.unmount = () => setOpen(false);
return <I18nProvider><button type="button" onClick={() => setOpen(true)}>Open plugin login</button>
{open ? <PluginOAuthDialog plugin={plugin} onClose={() => setOpen(false)} onCompleted={() => { fixture.completed += 1; }} /> : null}
</I18nProvider>;
}
createRoot(document.getElementById('root')!).render(<Fixture />);
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Plugins fixture</title></head><body><div id="root"></div><script type="module" src="./plugins.tsx"></script></body></html>
+148
View File
@@ -0,0 +1,148 @@
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { PluginsPage } from '../../src/pages/PluginsPage';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en');
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
type Request = { method: string; path: string; query?: Record<string, string>; body?: unknown };
const state = {
supported: !params.has('unsupported'),
commands: [] as { command: string; args: unknown }[],
requests: [] as Request[],
installs: [] as Request[],
resourceRequests: [] as unknown[],
failNextList: false,
failNextInstall: false,
holdInstall: false,
releaseInstall: null as null | (() => void),
settings: {
enabled: true, dir: 'plugins', 'store-sources': ['https://registry.example.test/plugins.json'],
'store-auth': [{ host: 'registry.example.test', 'token-env': 'PLUGIN_FIXTURE_TOKEN' }],
'auth-revision': 9,
configs: {
'sample..plugin': { enabled: true, priority: 3, endpoint: 'https://example.test/api', untouched: { retained: true } },
dormant: { enabled: false },
kiro: { enabled: true },
unloaded: { enabled: true },
} as Record<string, Record<string, unknown>>,
},
};
Object.assign(window, { pluginsFixture: state });
const pluginEntries: Record<string, unknown>[] = [
{ id: 'sample..plugin', path: '/plugins/sample..plugin.dll', configured: true, registered: true,
supports_oauth: true, oauth_provider: 'sample-sso',
metadata: { name: 'Sample provider', version: '1.0.0', author: 'Fixture author' },
config_fields: [{ name: 'endpoint', type: 'string', description: 'Endpoint URL' }],
menus: [null, { path: '', menu: 'Empty' }, { path: '/v0/resource/plugins/sample..plugin/usage', menu: 'Usage dashboard' }],
},
{ id: 'dormant', path: '/plugins/dormant.dll', configured: true, registered: true,
supports_oauth: true, oauth_provider: 'dormant-sso',
metadata: { name: 'Dormant provider', version: '1.0.0' }, menus: [], config_fields: [],
},
{ id: 'kiro', path: '', configured: true, registered: false,
supports_oauth: false, metadata: null, menus: [], config_fields: [],
},
{ id: 'unloaded', path: '/plugins/unloaded.dll', configured: true, registered: false,
supports_oauth: false, metadata: { name: 'Unloaded provider', version: '1.0.0' }, menus: [], config_fields: [],
},
];
const storeEntries: Record<string, unknown>[] = [
{ id: 'sample..plugin', name: 'Sample provider', source_id: 'official', source_name: 'Official',
source_url: 'https://official.example.test/plugins.json', repository: 'router-for-me/sample',
version: '2.0.0', install_type: 'github-release', installed: true, installed_version: '1.0.0', update_available: true,
description: 'Official fixture provider', platforms: [{ goos: 'windows', goarch: 'amd64' }], tags: ['chat'],
},
{ id: 'analytics', name: 'Community analytics', source_id: 'community', source_name: 'Community',
source_url: 'https://registry.example.test/plugins.json', repository: 'router-for-me/analytics',
version: '1.0.0', install_type: 'github-release', description: 'Third-party registry with official-looking metadata',
},
{ id: 'direct', name: 'Direct artifact', source_id: 'official', source_name: 'Official',
repository: 'router-for-me/direct', version: '1.0.0', install_type: 'direct',
},
{ id: 'private', name: 'Private provider', source_id: 'private', source_name: 'Private',
repository: 'private/provider', auth_required: true, auth_configured: false, install_type: 'github-release',
},
{ id: 'kiro', name: 'Kiro', source_id: 'official', source_name: 'Official',
repository: 'router-for-me/kiro', version: '0.1.1', install_type: 'github-release', installed: false,
description: 'A configured provider whose plugin file has not been installed.',
},
];
mockIPC(async (command, args) => {
state.commands.push({ command, args });
if (command === 'start_oauth_login') return { url: 'https://login.example.test/authorize?state=sample-state', state: 'sample-state' };
if (command === 'get_oauth_status') return { status: 'wait' };
if (command === 'get_plugin_support') return state.supported;
if (command === 'get_plugin_resource_url') {
state.resourceRequests.push(args);
if (args?.pluginId !== 'sample..plugin' || args?.menuIndex !== 2) throw new Error('Wrong original menu index');
return `${location.origin}/tests/fixtures/plugins.html?resource=1`;
}
if (command === 'open_external_url' || command === 'restart_core_process') return null;
if (command !== 'management_request') return null;
const request = args?.request as Request;
state.requests.push(structuredClone(request));
if (request.path === '/oauth/session' && request.method === 'DELETE') return { status: 'ok', cancelled: true };
if (request.path === '/plugins' && request.method === 'GET') {
if (state.failNextList) { state.failNextList = false; throw new Error('Fixture list temporarily unavailable'); }
return {
plugins_enabled: state.settings.enabled, plugins_dir: state.settings.dir,
plugins: pluginEntries.map(entry => ({ ...entry,
enabled: state.settings.configs[String(entry.id)]?.enabled === true,
effective_enabled: entry.registered === true && state.settings.enabled && state.settings.configs[String(entry.id)]?.enabled === true,
})),
};
}
if (request.path === '/plugins/store' && request.method === 'GET') return {
plugins_enabled: state.settings.enabled, plugins_dir: state.settings.dir, plugins: storeEntries,
source_errors: [{ source_id: 'offline', source_name: 'Offline source', message: 'Fixture registry unavailable' }],
};
if (request.path === '/config/plugins') {
if (request.method === 'GET') return structuredClone(state.settings);
if (request.method === 'PATCH') { Object.assign(state.settings, request.body); return { status: 'ok' }; }
}
const configMatch = /^\/config\/plugins\/configs\/([^/]+)(\/enabled)?$/.exec(request.path);
if (configMatch) {
const id = decodeURIComponent(configMatch[1]);
if (request.method === 'GET') return structuredClone(state.settings.configs[id] ?? {});
if (request.method === 'PUT') {
state.settings.configs[id] = configMatch[2]
? { ...state.settings.configs[id], enabled: request.body }
: request.body as Record<string, unknown>;
return { status: 'ok' };
}
}
const installMatch = /^\/plugins\/store\/([^/]+)\/install$/.exec(request.path);
if (installMatch && request.method === 'POST') {
state.installs.push(structuredClone(request));
if (state.holdInstall) await new Promise<void>(resolve => { state.releaseInstall = resolve; });
if (state.failNextInstall) { state.failNextInstall = false; throw new Error('Fixture installation failed'); }
const id = decodeURIComponent(installMatch[1]);
const entry = storeEntries.find(candidate => candidate.id === id && candidate.source_id === request.query?.source);
if (!entry) throw new Error('Incorrect plugin store source');
entry.installed = true;
entry.installed_version = request.query?.version || entry.version;
entry.update_available = false;
state.settings.configs[id] ??= { enabled: false };
return { status: 'ok', id, source_id: entry.source_id, version: entry.installed_version,
install_type: entry.install_type, restart_required: true, plugins_enabled: state.settings.enabled };
}
const deleteMatch = /^\/plugins\/([^/]+)$/.exec(request.path);
if (deleteMatch && request.method === 'DELETE') {
const id = decodeURIComponent(deleteMatch[1]);
const index = pluginEntries.findIndex(entry => entry.id === id);
const removed = pluginEntries[index];
if (index >= 0) pluginEntries.splice(index, 1);
delete state.settings.configs[id];
return { status: 'ok', id, file_deleted: Boolean(removed?.path), configured_removed: true,
restart_required: removed?.registered === true };
}
throw new Error(`Unhandled fixture request: ${request.method} ${request.path}`);
});
createRoot(document.getElementById('root')!).render(params.has('resource')
? <h1>Sample resource preview</h1>
: <I18nProvider><main style={{ padding: '24px', maxWidth: '1280px', margin: '0 auto' }}><PluginsPage /></main></I18nProvider>);
+52 -10
View File
@@ -13,8 +13,10 @@ import '../../src/styles/index.css';
const query = new URLSearchParams(location.search);
const denseLayout = query.get('layout') === 'dense';
const remarkScenario = query.get('scenario') === 'remarks';
const remarkStorageKey = 'provider-groups-fixture-remarks';
localStorage.setItem('easy-cli-proxy-api.locale', query.get('locale') ?? 'en');
const fixture = window as typeof window & { groupFixture: { groups: Record<string, unknown>[], writes: unknown[], probes: unknown[] } };
const fixture = window as typeof window & { groupFixture: { groups: Record<string, unknown>[], openaiGroups: Record<string, unknown>[], writes: unknown[], probes: unknown[] } };
fixture.groupFixture = {
groups: denseLayout ? [
{
@@ -43,7 +45,7 @@ fixture.groupFixture = {
keys: [{ 'api-key': 'key-inherited', weight: 2, priority: null },
{ 'api-key': 'key-overridden', weight: 5, priority: 0, 'proxy-url': 'direct', headers: { 'X-Key': 'second' }, models: [{ name: 'private-model' }], 'excluded-models': [], 'disable-cooling': false }],
}, { name: 'Backup gateway', 'base-url': 'https://backup.example.test/v1', models: [{ name: 'backup-model' }], keys: [{ 'api-key': 'backup-key' }] }],
writes: [], probes: [],
openaiGroups: [], writes: [], probes: [],
};
const denseRemarks = [
'Fictional layout fixture · 三个测试密钥',
@@ -55,16 +57,50 @@ const remarks = new Map<string, string>();
if (denseLayout) fixture.groupFixture.groups.forEach((group, index) => {
remarks.set(providerRemarkIdentity('codex-api-key', apiAccessRemarkLocatorFromRecord('codex-api-key', group)), denseRemarks[index]);
});
if (remarkScenario) {
const persisted = sessionStorage.getItem(remarkStorageKey);
if (persisted) {
const state = JSON.parse(persisted) as { groups: Record<string, unknown>[]; remarks: [string, string][] };
fixture.groupFixture.groups = state.groups;
state.remarks.forEach(([identity, remark]) => remarks.set(identity, remark));
} else {
remarks.set(providerRemarkIdentity('codex-api-key', apiAccessRemarkLocatorFromRecord('codex-api-key', fixture.groupFixture.groups[0])), 'Existing group note');
}
}
const persistRemarkScenario = () => {
if (remarkScenario) sessionStorage.setItem(remarkStorageKey, JSON.stringify({ groups: fixture.groupFixture.groups, remarks: [...remarks] }));
};
const resolveRemark = (section: ProviderSection, locator: ApiAccessRemarkLocator) => {
const candidates = [locator];
if (['gemini-api-key', 'codex-api-key', 'claude-api-key'].includes(section) && locator.providerName) {
candidates.push({ ...locator, providerName: '' });
}
for (const candidate of candidates) {
for (const record of [candidate, { ...candidate, configIdentity: '' }]) {
const identity = providerRemarkIdentity(section, record);
if (remarks.has(identity)) return remarks.get(identity)!;
}
}
return '';
};
mockIPC((cmd, args: any) => {
if (cmd === 'set_app_locale') return null;
if (cmd === 'resolve_api_access_remarks') return args.queries.map((item: ApiAccessRemarkLocator & { providerSection: ProviderSection }) =>
denseLayout ? remarks.get(providerRemarkIdentity(item.providerSection, item)) ?? '' : '');
resolveRemark(item.providerSection, item));
if (cmd === 'save_api_access_remark') {
if (denseLayout) {
const update = args.update as { providerSection: ProviderSection; previousRecords: ApiAccessRemarkLocator[]; records: ApiAccessRemarkLocator[]; remark: string };
update.previousRecords.forEach(record => remarks.delete(providerRemarkIdentity(update.providerSection, record)));
update.records.forEach(record => remarks.set(providerRemarkIdentity(update.providerSection, record), update.remark));
const update = args.update as { providerSection: ProviderSection; previousRecords: ApiAccessRemarkLocator[]; records: ApiAccessRemarkLocator[]; allRecords: ApiAccessRemarkLocator[]; remark: string };
const identity = (record: ApiAccessRemarkLocator) => providerRemarkIdentity(update.providerSection, record);
const allRecords = new Set(update.allRecords.map(identity));
const replacing = new Set([...update.previousRecords, ...update.records].map(identity));
const migrations = update.allRecords.map(record => [identity(record), resolveRemark(update.providerSection, record)] as const);
// Match the backend: discard orphaned/group identities, replace explicit updates,
// then migrate surviving records, including intentionally empty remarks.
for (const stored of remarks.keys()) {
if (JSON.parse(stored)[0] === update.providerSection && (!allRecords.has(stored) || replacing.has(stored))) remarks.delete(stored);
}
update.records.forEach(record => remarks.set(identity(record), update.remark.trim()));
migrations.forEach(([stored, remark]) => { if (!remarks.has(stored)) remarks.set(stored, remark); });
persistRemarkScenario();
return null;
}
if (cmd !== 'management_request') throw new Error(`Unexpected ${cmd}`);
@@ -73,10 +109,16 @@ mockIPC((cmd, args: any) => {
fixture.groupFixture.probes.push(request.body);
return { status_code: 200, body: { data: [{ id: 'gpt-original' }, { id: 'new-discovered' }] } };
}
if (request.method === 'GET') return request.path === '/config/api-keys/codex' ? structuredClone(fixture.groupFixture.groups) : [];
if (request.path !== '/config/api-keys/codex' || request.method !== 'PUT') throw new Error('Unexpected mutation');
if (request.method === 'GET') {
if (request.path === '/config/api-keys/codex') return structuredClone(fixture.groupFixture.groups);
if (request.path === '/config/api-keys/openai-compatibility') return structuredClone(fixture.groupFixture.openaiGroups);
return [];
}
if (!['/config/api-keys/codex', '/config/api-keys/openai-compatibility'].includes(request.path) || request.method !== 'PUT') throw new Error('Unexpected mutation');
fixture.groupFixture.writes.push(structuredClone(request.body));
fixture.groupFixture.groups = structuredClone(request.body);
if (request.path === '/config/api-keys/codex') fixture.groupFixture.groups = structuredClone(request.body);
else fixture.groupFixture.openaiGroups = structuredClone(request.body);
persistRemarkScenario();
return { status: 'ok' };
});
createRoot(document.getElementById('root')!).render(<I18nProvider><div className="app-shell"><aside className="sidebar" /><div className="workspace"><main className="content"><ApiAccessPage /></main></div></div></I18nProvider>);
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/usage-analysis.tsx"></script></body></html>
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { UsageRecordsPage } from '../../src/pages/UsageRecordsPage';
import type { UsageCategory } from '../../src/pages/UsageAnalysisView';
import type { UsageTimelinePoint } from '../../src/services/usageTrend';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
const scenario = params.get('scenario') || 'mixed';
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'en');
localStorage.setItem('cpa-gui.usage-records-tab.v1', 'analysis');
// All 36 sample hours must remain in range independently of the machine clock.
localStorage.setItem('cpa-gui.usage-records-range.v1', 'all');
document.documentElement.dataset.theme = params.get('theme') || 'light';
const specifications = [
['model-a', 'gpt-6-astra', 10, 1000],
['model-b', 'claude-sonnet-4-5', 8, 500],
['model-c', 'gemini-3.1-pro', 12, 300],
['model-d', 'gpt-6-sol', 6, 200],
['model-e', 'deepseek-v3.2', 4, 100],
['model-f', 'qwen3-coder-plus', 9, 60],
['model-g', 'grok-code-fast', 5, 40],
['model-h', 'llama-4-scout-instruct-with-a-long-model-name', 6, 10],
] as const;
// Derive every displayed dimension from these same 60 requests. The mixed
// sample has 44 successes, eight failures, eight cancellations, and 20K tokens.
const records = scenario === 'empty' ? [] : specifications.flatMap(([model, label, requests, tokens]) =>
Array.from({ length: requests }, (_, index) => ({
model,
label,
tokens: scenario === 'zero-tokens' || scenario === 'canceled' ? 0 : tokens,
status: scenario === 'canceled' ? 'canceled' : index === 0 ? 'failed' : index === 1 ? 'canceled' : 'success',
})),
);
const sumTokens = records.reduce((total, record) => total + record.tokens, 0);
const count = (status: string) => records.filter(record => record.status === status).length;
const groups = (identify: (record: typeof records[number], index: number) => [string, string]): UsageCategory[] => {
const items = new Map<string, UsageCategory>();
records.forEach((record, index) => {
const [key, label] = identify(record, index);
const item = items.get(key) || { key, label, requests: 0, failures: 0, tokens: 0 };
item.requests += 1;
item.failures += Number(record.status === 'failed');
item.tokens += record.tokens;
items.set(key, item);
});
return [...items.values()].reverse();
};
const analysis = {
models: groups(record => [record.model, record.label]),
providers: groups((_, index) => [`provider-${index % 2}`, ['OpenAI', 'Anthropic'][index % 2]]),
sources: groups((_, index) => [`source-${index % 3}`, ['CLI workspace', 'Desktop assistant', 'long-source-name-for-development@example.com'][index % 3]]),
apiKeys: groups((_, index) => [`key-${index % 4}`, ['Development', 'Personal', 'Team workspace', 'Long API key description for layout verification'][index % 4]]),
};
const timeline: UsageTimelinePoint[] = Array.from({ length: records.length ? 36 : 0 }, (_, hour) => {
const date = new Date(2026, 9, 1, hour);
const items = records.filter((_, index) => index % 36 === hour);
return {
hour: [date.getFullYear(), date.getMonth() + 1, date.getDate(), date.getHours()].map((part, index) => index ? String(part).padStart(2, '0') : part).join('-'),
requests: items.length,
success: items.filter(item => item.status === 'success').length,
failure: items.filter(item => item.status === 'failed').length,
canceled: items.filter(item => item.status === 'canceled').length,
tokens: items.reduce((total, item) => total + item.tokens, 0),
};
}).reverse(); // The UI must sort timestamps, not trust the backend array order.
mockIPC(async command => {
if (command === 'plugin:event|listen') return 1;
if (command === 'plugin:event|unlisten' || command === 'set_app_locale') return null;
if (command === 'get_usage_collector_status') return { state: 'collecting', message: '', lastCollectedAt: null, totalRecords: records.length };
if (command === 'get_usage_analysis') return analysis;
if (command === 'get_usage_overview') return {
totalRequests: records.length, successCount: count('success'), failureCount: count('failed'), canceledCount: count('canceled'),
successRate: count('success') + count('failed') > 0 ? count('success') / (count('success') + count('failed')) * 100 : 0,
totalTokens: sumTokens, inputTokens: sumTokens, outputTokens: 0,
reasoningTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
rpm: records.length / (36 * 60), tpm: sumTokens / (36 * 60), tps: 0, tpsSampleCount: 0,
averageLatencyMs: 0, cacheHitRate: 0, estimatedCost: 0, pricedRequests: 0, timeline,
};
throw new Error(`Unhandled analysis fixture command: ${command}`);
});
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<div className="app-shell">
<aside className="sidebar" aria-hidden="true" />
<div className="workspace"><main className="content"><UsageRecordsPage /></main></div>
</div>
</I18nProvider>,
);
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html>
<head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head>
<body><div id="root"></div><script type="module" src="/tests/fixtures/usage-response-model.tsx"></script></body>
</html>
+84
View File
@@ -0,0 +1,84 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { EventsView, type UsageRecord } from '../../src/pages/UsageEventsView';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
// Keep the model column visible even if a previous fixture changed the user's layout.
localStorage.setItem('cpa-gui.usage-events-visible-cols.v3', JSON.stringify([
'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider',
]));
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v2');
localStorage.removeItem('cpa-gui.usage-events-col-widths.v2');
document.documentElement.dataset.theme = params.get('theme') || 'light';
const tokens = {
input_tokens: 1000,
output_tokens: 200,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_tokens: 1200,
};
const record = (id: string, model: string, alias: string, response_model?: string): UsageRecord => ({
id,
row_id: id,
timestamp: '2026-10-03T08:00:00.000Z',
latency_ms: 2000,
ttft_ms: 200,
source: 'ui-regression',
source_display: 'UI regression',
failed: false,
canceled: false,
failure_status: 0,
failure_body: '',
provider: 'test-provider',
model,
alias,
...(response_model === undefined ? {} : { response_model }),
reasoning_effort: 'high',
endpoint: '/v1/responses',
api_key_hash: 'ui-key',
api_key_display: 'sk-ui',
api_key_remark: 'UI fixture',
tokens,
});
// Keep these cases in a stable order; the browser test uses it to inspect each model cell.
const records: UsageRecord[] = [
record('differing', 'gpt-6-astra', 'astra', 'gpt-5.6-luna'),
record('matching', 'gpt-6-sol', 'same-alias', 'gpt-6-sol'),
record('snapshot-prefix', 'openai/gpt-4o-latest', 'gpt4o', 'gpt-4o-2024-08-06'),
record('missing', 'gpt-4o', '', undefined),
record('empty', 'empty-response-model', '', ''),
record('whitespace', 'whitespace-response-model', '', ' '),
record('long', `model-${'x'.repeat(120)}`, `alias-${'a'.repeat(120)}`, `response-${'r'.repeat(120)}`),
record('quoted', 'quoted-model', 'quoted-alias', 'model,"quoted"'),
record('formula', 'formula-model', 'formula-alias', '=SUM(A1)'),
];
mockIPC(async () => null);
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<div className="app-shell">
<aside className="sidebar" aria-hidden="true" />
<div className="workspace">
<main className="content">
<div className="usage-records-page">
<EventsView
events={{ items: records, total: records.length, page: 1, pageSize: 20, totalPages: 1 }}
pageSize={20}
onPage={() => {}}
onPageSizeChange={() => {}}
/>
</div>
</main>
</div>
</div>
</I18nProvider>,
);
+325
View File
@@ -0,0 +1,325 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = process.env.HOME_DASHBOARD_TEST_BASE_URL || 'http://127.0.0.1:1421';
(async () => {
let server;
let browser;
try {
try { await fetch(base, { signal: AbortSignal.timeout(5000) }); } catch {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-home-dashboard', plugins: [react()], logLevel: 'error', optimizeDeps: { noDiscovery: true, include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@tauri-apps/api/core', '@tauri-apps/api/mocks', '@tauri-apps/api/event', 'lucide-react', 'react-markdown', '@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'] }, server: { host: '127.0.0.1', port: Number(new URL(base).port), strictPort: true, watch: null } });
await server.listen();
}
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
const errors = [];
const makePage = async (viewport = { width: 1280, height: 1050 }) => {
const page = await browser.newPage({ viewport });
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(60000);
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
return page;
};
const page = await makePage();
const showHealth = async (target = page, query = '') => {
await target.locator('.core-health-open').click();
await target.getByRole('dialog').waitFor();
if (!query.includes('loading') && !query.includes('modelError') && !query.includes('offline')) {
await target.locator('.core-health-table tbody tr').first().waitFor();
}
};
const closeHealth = async (target = page) => {
await target.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
await target.getByRole('dialog').waitFor({ state: 'detached' });
assert.equal(await target.locator('.core-health-open').evaluate(node => node === document.activeElement), true);
};
const open = async (query = '', target = page, showDialog = true) => {
const options = new URLSearchParams(query);
if (!options.has('locale')) options.set('locale', 'en');
await target.goto(`${base}/tests/fixtures/home-dashboard.html?${options}`, { waitUntil: 'domcontentloaded' });
await target.locator('.core-health-entry').waitFor();
if (showDialog) await showHealth(target, query);
};
const probes = target => target.evaluate(() => window.homeFixture.calls.filter(call => call.cmd === 'core_health_probe'));
const checkAll = target => target.locator('.core-health-actions').getByRole('button', { name: 'Check All', exact: true });
const awaitIdle = target => target.waitForFunction(() => !document.querySelector('.core-health-progress'));
const value = id => page.locator(`[data-stat="${id}"] .home-stat-value`);
const runtimeValue = label => page.locator('.control-panel .panel-detail-row').filter({ has: page.getByText(label, { exact: true }) }).locator('dd');
const meter = id => page.locator(`[data-stat="${id}"] .home-stat-track[role="meter"]`);
await open('', page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
fs.mkdirSync('misc', { recursive: true });
await page.screenshot({ path: 'misc/home-dashboard-initial.png', fullPage: true, animations: 'disabled' });
console.log('Initial homepage rendered: misc/home-dashboard-initial.png');
assert.equal(await page.locator('.home-stat-card').count(), 4);
assert.equal(await runtimeValue('Installation Status').innerText(), 'Installed');
assert.equal(await runtimeValue('Runtime Status').innerText(), 'Running');
assert.equal(await page.locator('.home-runtime-details .panel-detail-row').count(), 6, 'runtime details retain both statuses, both versions, PID, and port');
for (const [id, expected] of [['usage', '97.8%'], ['credentials', '11'], ['providerKeys', '2'], ['models', '6']]) {
assert.equal(await value(id).innerText(), expected, `incorrect ${id} aggregate`);
}
assert.match(await page.locator('[data-stat="credentials"] p').innerText(), /5 available · 6 not in rotation/);
assert.match(await page.locator('[data-stat="usage"] p').innerText(), /503 requests/);
assert.ok(Math.abs(Number(await meter('usage').getAttribute('aria-valuenow')) - 492 / 503 * 100) < 0.000001, 'success meter must use the measured success rate');
assert.ok(Math.abs(Number(await meter('credentials').getAttribute('aria-valuenow')) - 5 / 11 * 100) < 0.000001, 'credential meter must use the available share');
assert.equal(await page.getByRole('meter', { name: 'Available credential share', exact: true }).count(), 1);
assert.equal(await page.locator('[data-stat="providerKeys"] [role="meter"], [data-stat="models"] [role="meter"]').count(), 0, 'counts without a denominator must not display a ratio');
const usageQuery = await page.evaluate(() => window.homeFixture.calls.find(call => call.cmd === 'get_usage_overview').args.query);
assert.equal(Date.parse(usageQuery.end) - Date.parse(usageQuery.start), 86_400_000);
assert.equal((await probes(page)).length, 0, 'opening the dashboard must not perform inference');
assert.equal(await page.locator('.core-health-panel, .core-health-table, .core-health-dialog').count(), 0, 'the dashboard must show only the compact entry');
assert.ok((await page.locator('.core-health-entry').boundingBox()).height < 150, 'the collapsed entry must not take up a full panel');
assert.equal(await page.locator('.home-overview-heading .core-health-entry').count(), 1, 'health monitoring belongs in the overview header');
assert.equal(await page.locator('.home-dashboard > .core-health-entry').count(), 0, 'health monitoring must not leave an orphan entry below the overview');
const healthSummaryBounds = await page.locator('.core-health-entry-status').boundingBox();
assert.ok(healthSummaryBounds.width > 1 && healthSummaryBounds.height > 1, 'the health summary must stay visible beside its entry');
const access = page.locator('.home-access-panel');
assert.equal(await access.locator('.client-api-card').count(), 1, 'only the selected connection details should be shown');
assert.equal(await access.locator('.home-key-value').innerText(), '••••••••••••••••••••');
assert.equal(await access.getByText('test-client-access-key-never-real', { exact: true }).count(), 0);
await access.getByRole('button', { name: 'Show key', exact: true }).click();
assert.equal(await access.locator('.home-key-value').innerText(), 'test-client-access-key-never-real');
await access.getByRole('button', { name: 'Hide key', exact: true }).click();
await access.getByRole('button', { name: 'Copy authentication key', exact: true }).click();
await page.waitForFunction(() => window.homeFixture.copied.includes('test-client-access-key-never-real'));
const openaiTab = page.locator('#home-protocol-openai');
await openaiTab.focus();
await page.keyboard.press('ArrowRight');
assert.equal(await page.locator('#home-protocol-claude').getAttribute('aria-selected'), 'true');
assert.equal(await page.locator('#home-protocol-claude').evaluate(node => node === document.activeElement), true);
assert.equal(await page.getByRole('tabpanel').locator('code').innerText(), 'http://127.0.0.1:8317');
await page.getByRole('tabpanel').getByRole('button').click();
await page.waitForFunction(() => window.homeFixture.copied.includes('http://127.0.0.1:8317'));
await page.locator('#home-protocol-claude').focus();
await page.keyboard.press('End');
assert.equal(await page.locator('#home-protocol-gemini').getAttribute('aria-selected'), 'true');
await page.keyboard.press('Home');
assert.equal(await openaiTab.getAttribute('aria-selected'), 'true');
assert.equal(await page.getByRole('tabpanel').locator('code').innerText(), 'http://127.0.0.1:8317/v1');
await showHealth();
const dialog = page.getByRole('dialog', { name: 'Model Health', exact: true });
assert.equal(await dialog.getAttribute('aria-modal'), 'true');
assert.equal(await dialog.evaluate(node => node.contains(document.activeElement)), true, 'opening the modal must focus a control inside it');
assert.equal(await dialog.getByRole('checkbox').count(), 0, 'health checks must not offer automatic mode');
assert.equal(await dialog.getByText(/automatically|automatic check/i).count(), 0);
assert.equal((await probes(page)).length, 0, 'opening the modal must not perform inference');
const focusable = dialog.locator('button:not(:disabled), input:not(:disabled), summary, [tabindex="0"]');
await focusable.last().focus();
await page.keyboard.press('Tab');
assert.equal(await focusable.first().evaluate(node => node === document.activeElement), true, 'Tab must wrap inside the modal');
await page.keyboard.press('Shift+Tab');
assert.equal(await focusable.last().evaluate(node => node === document.activeElement), true, 'Shift+Tab must wrap inside the modal');
await page.getByRole('button', { name: 'Check gpt-5.2-codex', exact: true }).click();
await page.locator('.core-health-status.healthy').waitFor();
assert.deepEqual((await probes(page)).map(call => call.args), [{ model: 'gpt-5.2-codex', timeoutMs: 15000 }]);
assert.match(await page.locator('.core-health-table tbody tr').first().locator('.core-health-latency').innerText(), /184 ms.*642 ms/);
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'detached' });
assert.equal(await page.locator('.core-health-open').evaluate(node => node === document.activeElement), true, 'Escape must restore focus to the entry');
assert.notEqual(await page.evaluate(() => document.body.style.overflow), 'hidden', 'closing must release the page scroll lock');
await showHealth();
assert.equal(await page.locator('.core-health-status.healthy').count(), 1, 'completed results must survive closing and reopening');
assert.equal((await probes(page)).length, 1, 'reopening must not launch a check');
await page.locator('.core-health-backdrop').click({ position: { x: 2, y: 2 } });
await dialog.waitFor({ state: 'detached' });
assert.equal(await page.locator('.core-health-open').evaluate(node => node === document.activeElement), true, 'backdrop dismissal must restore focus');
await showHealth();
await page.locator('.core-health-search input').fill('custom/team');
assert.equal(await page.locator('.core-health-table tbody tr').count(), 1);
await page.locator('.core-health-search input').fill('not-a-real-model');
assert.equal(await page.locator('.core-health-table tbody tr').count(), 0);
await page.locator('.core-health-search input').clear();
await page.evaluate(() => {
window.homeFixture.probeFailures['gemini-3-pro'] = 'HTTP 503: fixture upstream unavailable';
window.homeFixture.probeFailures['deepseek-chat'] = 'deadline has elapsed';
});
await checkAll(page).click();
await page.waitForFunction(() => document.querySelectorAll('.core-health-status.failed').length === 2);
assert.equal((await probes(page)).length, 7);
assert.equal(await page.locator('.core-health-status.healthy').count(), 4);
const gemini = page.locator('.core-health-table tbody tr').filter({ hasText: 'gemini-3-pro' });
await gemini.locator('summary').click();
assert.match(await gemini.locator('details p').innerText(), /HTTP 503/);
console.log('Home data, connection tabs, clipboard, single/batch/error checks passed.');
await open();
await page.evaluate(() => { window.homeFixture.holdProbes = true; });
await checkAll(page).click();
await page.waitForFunction(() => window.homeFixture.probeReleases.length === 4);
assert.equal(await page.locator('.core-health-status.queued').count(), 2);
await page.getByRole('button', { name: 'Stop Checking', exact: true }).click();
await page.evaluate(() => window.homeFixture.releaseProbes());
await awaitIdle(page);
assert.equal((await probes(page)).length, 4, 'stop must not launch queued model requests');
assert.equal(await page.locator('.core-health-status.healthy').count(), 0, 'late probe results must not populate stopped checks');
await open();
await page.getByRole('button', { name: 'Check gpt-5.2-codex', exact: true }).click();
await page.locator('.core-health-status.healthy').waitFor();
await page.evaluate(() => { window.homeFixture.holdProbes = true; });
await checkAll(page).click();
await page.waitForFunction(() => window.homeFixture.probeReleases.length === 4);
assert.equal(await page.locator('.core-health-status.queued').count(), 2);
await closeHealth();
await page.evaluate(() => window.homeFixture.releaseProbes());
await showHealth();
await awaitIdle(page);
assert.equal((await probes(page)).length, 5, 'closing must not launch queued model requests');
assert.equal(await page.locator('.core-health-status.healthy').count(), 1, 'closing must keep completed results and discard late results');
await open();
await page.evaluate(() => { window.homeFixture.holdProbes = true; });
await checkAll(page).click();
await page.waitForFunction(() => window.homeFixture.probeReleases.length === 4);
await page.evaluate(() => window.homeFixture.changeConfig());
await page.waitForFunction(() => document.querySelector('#home-protocol-panel code')?.textContent.includes(':8318/'));
await page.evaluate(() => window.homeFixture.releaseProbes());
await awaitIdle(page);
assert.equal(await page.locator('.core-health-status.healthy').count(), 0, 'config replacement must discard pending results');
assert.equal((await probes(page)).length, 4);
await open('loading=1');
await page.waitForFunction(() => window.homeFixture.modelReleases.length > 0);
assert.equal(await checkAll(page).isDisabled(), true);
assert.equal(await page.locator('.core-health-empty .spin').count(), 1);
await page.evaluate(() => window.homeFixture.releaseModels());
await page.locator('.core-health-table tbody tr').first().waitFor();
await open('modelError=1');
await page.locator('.core-health-error').waitFor();
assert.equal(await checkAll(page).isDisabled(), true);
assert.equal(await value('models').innerText(), '—');
await page.evaluate(() => { window.homeFixture.modelError = ''; });
await page.getByRole('button', { name: 'Refresh Models', exact: true }).click();
await page.locator('.core-health-table tbody tr').first().waitFor();
await open('offline=1');
await page.getByRole('dialog').getByText('Start the core to check model health', { exact: true }).waitFor();
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await value('usage').innerText(), '97.8%');
assert.equal(await value('models').innerText(), '—');
assert.equal(await runtimeValue('Installation Status').innerText(), 'Installed');
assert.equal(await runtimeValue('Runtime Status').innerText(), 'Stopped');
assert.equal(await meter('usage').count(), 1, 'local usage stays measured while the core is stopped');
assert.equal(await meter('credentials').count(), 0, 'unavailable credential data must not display a ratio');
assert.equal(await checkAll(page).isDisabled(), true);
assert.equal(await page.evaluate(() => window.homeFixture.calls.filter(call => call.cmd === 'get_core_models').length), 0);
await open('noKey=1', page, false);
assert.equal(await access.locator('.home-key-value').count(), 0);
await open('keyError=1', page, false);
assert.equal(await access.locator('.home-field-error').count(), 1);
await open('tls=1', page, false);
assert.equal(await page.getByRole('tabpanel').locator('code').innerText(), 'https://127.0.0.1:8317/v1');
await open('noRequests=1&unknownCredentials=1', page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await value('usage').innerText(), '—', 'an empty request window has no measured success rate');
assert.equal(await page.locator('[data-stat="usage"] p').innerText(), 'No completed requests yet');
assert.equal(await value('credentials').innerText(), '11');
assert.match(await page.locator('[data-stat="credentials"] p').innerText(), /0 available · 0 not in rotation · 11 with unknown status/);
for (const id of ['usage', 'credentials']) {
assert.equal(await meter(id).count(), 0, `unmeasured ${id} must not display a zero-percent meter`);
assert.equal(await page.locator(`[data-stat="${id}"] .home-stat-track[aria-hidden="true"]`).count(), 1);
}
await open('noCredentials=1&usageError=1', page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await value('usage').innerText(), '—');
assert.equal(await value('credentials').innerText(), '0');
assert.equal(await meter('usage').count(), 0, 'a failed usage read must not display a ratio');
assert.equal(await meter('credentials').count(), 0, 'an empty credential list has no denominator');
console.log('Modal dismissal/focus/retention/cancellation, stop, stale configuration, loading/offline/retry/key/TLS checks passed.');
// Overview refreshes and elapsed five-minute periods must never trigger inference.
const timed = await makePage();
await timed.clock.install({ time: new Date('2026-10-03T04:00:00Z') });
await timed.clock.pauseAt(new Date('2026-10-03T04:00:01Z'));
await open('', timed);
assert.equal(await timed.getByRole('dialog').getByRole('checkbox').count(), 0);
for (let cycle = 1; cycle <= 2; cycle += 1) {
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 0, `the open dialog must stay idle after ${cycle} five-minute periods`);
}
assert.ok(await timed.evaluate(() => window.homeFixture.calls.filter(call => call.cmd === 'get_core_models').length) >= 6);
await checkAll(timed).click();
await timed.waitForFunction(() => document.querySelectorAll('.core-health-status.healthy').length === 6);
assert.equal((await probes(timed)).length, 6, 'manual Check All must probe every model once');
for (let cycle = 1; cycle <= 2; cycle += 1) {
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, `manual checks must not repeat after ${cycle} five-minute periods`);
}
await closeHealth(timed);
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'the closed dialog must not start another check');
await showHealth(timed);
assert.equal(await timed.locator('.core-health-status.healthy').count(), 6);
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'reopening must not schedule another check');
// Also stress loading=true and fresh-but-equal arrays on every refresh.
await open('view=panel', timed);
await checkAll(timed).click();
await timed.waitForFunction(() => document.querySelectorAll('.core-health-status.healthy').length === 6);
for (let cycle = 1; cycle <= 2; cycle += 1) {
await timed.evaluate(() => window.homeFixture.refreshPanel());
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'loading model lists must not schedule checks');
await timed.evaluate(() => window.homeFixture.resolvePanel());
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'refreshed model arrays must not schedule checks');
}
await checkAll(timed).click();
await timed.waitForFunction(() => window.homeFixture.calls.filter(call => call.cmd === 'core_health_probe').length === 12);
await awaitIdle(timed);
await timed.close();
console.log('Fake-clock manual-only checks passed: no inference while idle or across repeated five-minute periods, close/reopen, and model refreshes.');
fs.mkdirSync('misc', { recursive: true });
for (const [theme, width] of [['light', 1280], ['dark', 1280], ['light', 640], ['dark', 640], ['light', 390], ['dark', 390]]) {
const height = width < 640 ? 844 : 1050;
await page.setViewportSize({ width, height });
await open(`locale=zh-CN&theme=${theme}`, page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await page.locator('.core-health-table').count(), 0);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${theme}/${width} collapsed homepage overflows horizontally`);
await page.screenshot({ path: `misc/home-dashboard-${theme}-${width}.png`, fullPage: true, animations: 'disabled' });
await showHealth();
await page.locator('.core-health-actions .primary-button').click();
await page.waitForFunction(() => document.querySelectorAll('.core-health-status.healthy').length === 6);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${theme}/${width} overflows horizontally`);
const bounds = await page.locator('.core-health-dialog').boundingBox();
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= width + 1 && bounds.y + bounds.height <= height + 1, `${theme}/${width} modal leaves the viewport`);
await page.screenshot({ path: `misc/home-health-dialog-${theme}-${width}.png`, animations: 'disabled' });
}
for (const [theme, width, height] of [['light', 1280, 900], ['dark', 1280, 900], ['light', 640, 600], ['dark', 640, 600]]) {
await page.setViewportSize({ width, height });
await page.evaluate(theme => {
localStorage.setItem('easy-cli-proxy-api.locale', 'zh-CN');
localStorage.setItem('easy-cli-proxy-api.theme', theme);
}, theme);
await page.goto(`${base}/?mock=running`, { waitUntil: 'domcontentloaded' });
await page.locator('.home-overview[aria-busy="false"]').waitFor();
const toolbar = page.locator('#browser-mock-toolbar');
if (await toolbar.count()) await toolbar.evaluate(node => { node.style.display = 'none'; });
assert.equal(await page.locator('.home-stat-card').count(), 4);
assert.equal(await page.locator('.home-overview-heading .core-health-open').count(), 1);
assert.equal(await page.locator('.home-dashboard > .core-health-entry').count(), 0);
assert.equal(await page.locator('.core-health-table').count(), 0);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${theme}/${width} app homepage overflows horizontally`);
await page.screenshot({ path: `misc/home-dashboard-app-${theme}-${width}.png`, animations: 'disabled' });
if (theme === 'light' && width === 1280) await page.screenshot({ path: 'misc/home-dashboard-app.png', animations: 'disabled' });
await showHealth();
const bounds = await page.locator('.core-health-dialog').boundingBox();
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= width + 1 && bounds.y + bounds.height <= height + 1, `${theme}/${width} app modal leaves the viewport`);
await page.screenshot({ path: `misc/home-health-dialog-app-${theme}-${width}.png`, animations: 'disabled' });
if (theme === 'light' && width === 1280) await page.screenshot({ path: 'misc/home-health-dialog-app.png', animations: 'disabled' });
}
assert.deepEqual(errors, []);
console.log('Home dashboard browser checks passed. Screenshots: misc/home-{dashboard,health-dialog}-{light,dark}-{1280,640,390}.png and app.png');
} finally {
if (browser) await browser.close();
if (server) await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+167
View File
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import {
countHomeProviderKeys,
loadHomeOverview,
normalizeHomeModels,
summarizeHomeCredentials,
summarizeHomeUsage,
} from '../src/services/homeOverview';
describe('home overview aggregation', () => {
it('measures success over completed requests and leaves empty/canceled-only windows unmeasured', () => {
expect(summarizeHomeUsage({ totalRequests: 12, successCount: 9, failureCount: 1, canceledCount: 2 }).successRate).toBe(90);
expect(summarizeHomeUsage({ totalRequests: 0, successCount: 0, failureCount: 0, canceledCount: 0 }).successRate).toBeNull();
expect(summarizeHomeUsage({ totalRequests: 5, successCount: 0, failureCount: 0, canceledCount: 5 }).successRate).toBeNull();
expect(summarizeHomeUsage({ totalRequests: 2, successCount: 0, failureCount: 2, canceledCount: 0 }).successRate).toBe(0);
expect(() => summarizeHomeUsage({ totalRequests: 0 })).toThrow();
expect(() => summarizeHomeUsage({ totalRequests: 1, successCount: 2, failureCount: 0, canceledCount: 0 })).toThrow();
});
it('deduplicates OAuth files and excludes runtime/API key records', () => {
expect(summarizeHomeCredentials({ files: [
{ name: 'meta-user.json', source: 'file', status: 'active', cooldowns: [] },
{ name: 'meta-user.json', runtime_only: true, status: 'active' },
{ name: 'codex-user.json', source: 'file', status: 'ready' },
{ name: 'disabled.json', disabled: true },
{ name: 'runtime.json', runtime_only: true, status: 'active' },
{ name: 'key.json', account_type: 'api_key', status: 'active' },
{ name: 'plain-key', status: 'active' },
] })).toEqual({ total: 3, available: 2, unavailable: 1, unknown: 0 });
});
it('does not call missing, pending, model-only error, or malformed cooldown states available', () => {
const cooldown = { scope: 'credential', remaining_seconds: 60, retry_at: '2026-10-03T12:00:00Z' };
expect(summarizeHomeCredentials({ files: [
{ name: 'quota.json', status: 'active', cooldowns: [cooldown] },
{ name: 'expired.json', status: 'error', unavailable: true },
{ name: 'disabled.json', status: 'disabled' },
{ name: 'missing.json' },
{ name: 'pending.json', status: 'pending' },
{ name: 'refreshing.json', status: 'refreshing' },
{ name: 'model-only.json', status: 'error', unavailable: false,
cooldowns: [{ ...cooldown, scope: 'model', model_key: 'model-a' }] },
{ name: 'unknown-cooldown.json', status: 'active', cooldowns: null },
{ name: 'broken-cooldown.json', status: 'ready', cooldowns: [{}] },
] })).toEqual({ total: 9, available: 0, unavailable: 3, unknown: 6 });
expect(() => summarizeHomeCredentials({})).toThrow();
expect(() => summarizeHomeCredentials({ files: [null] })).toThrow();
expect(summarizeHomeCredentials({ files: [] })).toEqual({ total: 0, available: 0, unavailable: 0, unknown: 0 });
});
it('counts actual provider keys across groups, including disabled keys and future providers', () => {
expect(countHomeProviderKeys({
gemini: [{ name: 'one', keys: [{ 'api-key': 'gemini-1' }, { 'api-key': 'gemini-2', disabled: true }] }],
'openai-compatibility': [{ name: 'compat', keys: [{ 'api-key': 'compat-1' }, { 'api-key': 'compat-2' }] }],
meta: [{ keys: [{ 'api-key': 'meta-1' }] }],
'future-provider': [{ keys: [{ 'api-key': 'next-key' }, { 'api-key': ' ' }, {}] }],
claude: [{ keys: [] }, { name: 'keyless' }],
})).toBe(6);
expect(countHomeProviderKeys({})).toBe(0);
expect(() => countHomeProviderKeys(['client-key'])).toThrow();
expect(() => countHomeProviderKeys({ codex: [{ keys: 'invalid' }] })).toThrow();
});
it('preserves exposed aliases and case-sensitive model IDs while removing duplicate IDs', () => {
expect(normalizeHomeModels([
{ name: ' prefix/Model-A ', displayName: 'Model A', isAlias: true },
{ name: 'prefix/Model-A' },
{ name: 'prefix/model-a' },
{ name: 'custom-alias', displayName: '' },
])).toEqual([
{ name: 'prefix/Model-A', displayName: 'Model A' },
{ name: 'prefix/model-a' },
{ name: 'custom-alias' },
]);
expect(normalizeHomeModels([])).toEqual([]);
expect(() => normalizeHomeModels(null)).toThrow();
expect(() => normalizeHomeModels([{ name: '' }])).toThrow();
});
});
describe('home overview loading', () => {
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
});
afterEach(() => {
clearMocks();
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
const usage = { totalRequests: 5, successCount: 4, failureCount: 1, canceledCount: 0 };
it('loads a rolling 24 hour window locally without contacting a stopped core', async () => {
const calls: string[] = [];
mockIPC((command, args) => {
calls.push(command);
expect(command).toBe('get_usage_overview');
expect(args?.query).toEqual({ start: '2026-10-02T08:30:00.000Z', end: '2026-10-03T08:30:00.000Z' });
return usage;
});
expect(await loadHomeOverview(false, Date.parse('2026-10-03T08:30:00Z'))).toEqual({
usage: { ...usage, successRate: 80 }, credentials: null, providerKeys: null, models: null, errors: {},
});
expect(calls).toEqual(['get_usage_overview']);
});
it('uses the credential and raw provider config endpoints plus the dedicated core model command', async () => {
const requests: string[] = [];
mockIPC((command, args) => {
if (command === 'get_usage_overview') return usage;
if (command === 'get_core_models') return [{ name: 'exposed-alias' }];
expect(command).toBe('management_request');
const request = args?.request as { method: string; path: string };
expect(request.method).toBe('GET');
requests.push(request.path);
if (request.path === '/credentials') return { files: [{ name: 'muse.json', status: 'ready' }] };
if (request.path === '/config/api-keys') return { meta: [{ keys: [{ 'api-key': 'provider-key' }] }] };
throw new Error(`Unexpected path ${request.path}`);
});
expect(await loadHomeOverview(true)).toEqual({
usage: { ...usage, successRate: 80 }, credentials: { total: 1, available: 1, unavailable: 0, unknown: 0 },
providerKeys: 1, models: [{ name: 'exposed-alias' }], errors: {},
});
expect(requests.sort()).toEqual(['/config/api-keys', '/credentials']);
});
it('keeps successful metrics when independent requests fail and reports failed values as unknown', async () => {
mockIPC((command, args) => {
if (command === 'get_usage_overview') return usage;
if (command === 'get_core_models') throw 'Model list unavailable';
const request = args?.request as { path: string };
if (request.path === '/credentials') return { files: [] };
throw new Error('Management API error (401): unauthorized');
});
expect(await loadHomeOverview(true)).toEqual({
usage: { ...usage, successRate: 80 }, credentials: { total: 0, available: 0, unavailable: 0, unknown: 0 },
providerKeys: null, models: null,
errors: { providerKeys: 'Management API error (401): unauthorized', models: 'Model list unavailable' },
});
});
it('treats only an explicitly absent provider config node as an empty configuration', async () => {
for (const message of ['Management API error (404): not_found', 'Management API error (404): 404 page not found']) {
mockIPC((command, args) => {
if (command === 'get_usage_overview') throw new Error('Usage database unavailable');
if (command === 'get_core_models') return [];
const request = args?.request as { path: string };
if (request.path === '/credentials') return { files: [] };
throw message;
});
const overview = await loadHomeOverview(true);
expect(overview.usage).toBeNull();
expect(overview.errors.usage).toBe('Usage database unavailable');
expect(overview.models).toEqual([]);
if (message.endsWith(': not_found')) {
expect(overview.providerKeys).toBe(0);
expect(overview.errors.providerKeys).toBeUndefined();
} else {
expect(overview.providerKeys).toBeNull();
expect(overview.errors.providerKeys).toBe(message);
}
}
});
});
+172
View File
@@ -0,0 +1,172 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = process.env.META_OAUTH_TEST_BASE_URL || process.env.DEVIN_TEST_BASE_URL || 'http://127.0.0.1:1421';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
const open = async (query = '') => {
await page.goto(base + '/tests/fixtures/meta-oauth.html?' + query, { waitUntil: 'domcontentloaded' });
await page.locator('.oauth-card, .simple-mode-provider-card').first().waitFor();
};
const managementCard = () => page.locator('.oauth-card').filter({ hasText: /Muse \(Meta\) OAuth|Muse OAuth|Meta OAuth/ });
const easyCard = () => page.locator('.simple-mode-provider-card').filter({ hasText: /Muse \(Meta\) OAuth|Muse OAuth|Meta OAuth/ });
const calls = () => page.evaluate(() => window.metaFixture.calls);
const fixture = () => page.evaluate(() => window.metaFixture);
const installClipboard = () => page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: async (value) => window.metaFixture.copied.push(String(value)) },
});
});
const waitForStatusCall = () => page.waitForFunction(() => window.metaFixture.statusCalls > 0);
const startManagement = async () => {
const card = managementCard();
await card.getByRole('button', { name: /Start Sign-In|开始登录/, exact: true }).click();
await card.getByText('META-CODE-1', { exact: true }).waitFor();
};
// Management view: Meta is a seventh OAuth provider, with device code and no callback input.
await open('locale=en');
const card = managementCard();
assert.equal(await page.locator('.oauth-card').count(), 7, 'Meta should add a seventh OAuth card');
assert.equal(await card.count(), 1, 'the Muse (Meta) card should be unique');
assert.equal(await card.getByRole('textbox').count(), 0, 'Meta must not render a callback textbox');
await installClipboard();
await startManagement();
assert.ok((await calls()).some(call => call.cmd === 'start_oauth_login' && call.args.provider === 'meta' && call.args.browser === 'none'));
assert.equal(await card.getByText('Device Code', { exact: true }).count(), 1);
assert.equal(await card.getByRole('button', { name: 'Copy Device Code', exact: true }).count(), 1);
await card.getByRole('button', { name: 'Copy Device Code', exact: true }).click();
await page.waitForFunction(() => window.metaFixture.copied.includes('META-CODE-1'));
await card.getByRole('button', { name: 'Open Link', exact: true }).click();
assert.ok((await calls()).some(call => call.cmd === 'open_oauth_url' && String(call.args.url).includes('meta-attempt-1')));
// Complete the flow and confirm the newly written credential is assigned priority zero.
await waitForStatusCall();
await page.evaluate(() => { window.metaFixture.status = 'ok'; });
await card.getByText('Completed', { exact: true }).waitFor();
assert.ok((await fixture()).priorityPatches.some(patch => patch.name === 'meta-oauth-1.json' && patch.priority === 0));
assert.equal(await card.getByText('META-CODE-1', { exact: true }).count(), 0, 'success clears the device code');
// A refresh cancels the old session and stale completion cannot win over attempt two.
await open('locale=en');
const refreshed = managementCard();
await page.evaluate(() => { window.metaFixture.holdStatus = true; });
await refreshed.getByRole('button', { name: /Start Sign-In/, exact: true }).click();
await refreshed.getByText('META-CODE-1', { exact: true }).waitFor();
await page.waitForFunction(() => window.metaFixture.releaseStatus !== null);
await refreshed.getByRole('button', { name: 'Refresh Link', exact: true }).click();
await page.waitForFunction(() => window.metaFixture.attempts === 2);
await refreshed.getByText('META-CODE-2', { exact: true }).waitFor();
const refreshedFixture = await fixture();
assert.ok(refreshedFixture.cancelledStates.includes('meta-attempt-1'));
assert.equal(await refreshed.getByText('META-CODE-1', { exact: true }).count(), 0);
await page.evaluate(() => { if (window.metaFixture.releaseStatus) window.metaFixture.releaseStatus(); });
await page.waitForTimeout(100);
assert.equal(await refreshed.getByText('Completed', { exact: true }).count(), 0, 'stale attempt must not complete refreshed login');
assert.ok(await refreshed.getByText('META-CODE-2', { exact: true }).isVisible());
// Error state can be retried and a later success still clears its device code.
await open('locale=en');
const retry = managementCard();
await retry.getByRole('button', { name: /Start Sign-In/, exact: true }).click();
await retry.getByText('META-CODE-1', { exact: true }).waitFor();
await waitForStatusCall();
await page.evaluate(() => { window.metaFixture.status = 'error'; });
await page.getByText(/sign-in failed/i).waitFor();
await retry.getByRole('button', { name: /Start Sign-In/, exact: true }).click();
await page.waitForFunction(() => window.metaFixture.attempts === 2);
await retry.getByText('META-CODE-2', { exact: true }).waitFor();
await page.waitForFunction(() => window.metaFixture.statusCalls > 1);
await page.evaluate(() => { window.metaFixture.status = 'ok'; });
await retry.getByText('Completed', { exact: true }).waitFor();
// Easy mode uses the same Meta flow and keeps the browser selection explicit.
await open('view=easy&locale=en&openError=1');
const easy = easyCard();
assert.equal(await page.locator('.simple-mode-provider-card').count(), 7, 'Easy mode should expose Meta OAuth');
await installClipboard();
await easy.getByRole('button', { name: 'Start Sign-In', exact: true }).click();
await easy.getByText('META-CODE-1', { exact: true }).waitFor();
assert.equal(await easy.getByText('Device Code', { exact: true }).count(), 1);
assert.equal(await easy.getByRole('textbox').count(), 0);
await easy.getByRole('button', { name: 'Copy Device Code', exact: true }).click();
await page.waitForFunction(() => window.metaFixture.copied.includes('META-CODE-1'));
await easy.getByRole('button', { name: 'Copy Link', exact: true }).click();
await page.waitForFunction(() => window.metaFixture.copied.includes('https://auth.meta.ai/device?state=meta-attempt-1'));
await easy.getByRole('button', { name: 'Open Link', exact: true }).click();
assert.ok((await calls()).some(call => call.cmd === 'open_oauth_url'
&& call.args.url === 'https://auth.meta.ai/device?state=meta-attempt-1'
&& call.args.browser === 'default'), 'Easy mode retains a usable fallback link when automatic browser launch fails');
await page.waitForFunction(() => window.metaFixture.calls.some(call => call.cmd === 'start_oauth_login' && call.args.provider === 'meta'));
const easyStart = (await calls()).find(call => call.cmd === 'start_oauth_login' && call.args.provider === 'meta');
assert.equal(easyStart.args.browser, 'default');
await waitForStatusCall();
await page.evaluate(() => { window.metaFixture.status = 'ok'; });
await easy.locator('.state-pill.success').waitFor();
assert.equal(await easy.getByText('META-CODE-1', { exact: true }).count(), 0, 'Easy mode success clears the device code');
assert.equal(await easy.locator('.simple-mode-oauth-authorization').count(), 0, 'Easy mode success clears its authorization link');
// Meta's 900-second expiry outlives the old ten-minute fallback, then clears stale controls.
await open('view=easy&locale=en');
const expiring = easyCard();
await expiring.getByRole('button', { name: 'Start Sign-In', exact: true }).click();
await expiring.getByText('META-CODE-1', { exact: true }).waitFor();
const pollsBeforeClockAdvance = await page.evaluate(() => {
window.metaFixture.clockOffsetMs = 601_000;
return window.metaFixture.statusCalls;
});
await page.waitForFunction((previous) => window.metaFixture.statusCalls > previous, pollsBeforeClockAdvance);
assert.ok(await expiring.getByText('META-CODE-1', { exact: true }).isVisible(), 'device code is still valid after 601 seconds');
assert.ok(await expiring.getByRole('button', { name: 'Waiting for browser authorization...', exact: true }).isDisabled());
await page.evaluate(() => { window.metaFixture.clockOffsetMs = 901_000; });
await page.getByText('Authorization timed out. Please initiate login again.', { exact: true }).waitFor();
assert.equal(await expiring.getByText('META-CODE-1', { exact: true }).count(), 0, 'expiry clears the device code');
assert.equal(await expiring.locator('.simple-mode-oauth-authorization').count(), 0, 'expiry clears the authorization link');
assert.ok(await expiring.getByRole('button', { name: 'Start Sign-In', exact: true }).isEnabled());
// Existing kimi-ai credentials continue to map to Kimi in Easy mode.
await open('view=easy&locale=en&kimiAi=1');
const kimi = page.locator('.simple-mode-provider-card').filter({ hasText: 'Kimi OAuth' });
await kimi.locator('.state-pill.success').waitFor();
assert.ok((await kimi.getAttribute('class')).split(' ').includes('connected'));
assert.equal((await calls()).filter(call => call.cmd === 'start_oauth_login').length, 0);
// Check the rendered labels and width constraints in both themes and viewport classes.
fs.mkdirSync('misc', { recursive: true });
for (const [theme, width] of [['light', 1280], ['dark', 390]]) {
await page.setViewportSize({ width, height: 900 });
await open(`locale=zh-CN&theme=${theme}`);
const zhCard = managementCard();
await zhCard.getByRole('button', { name: /开始登录/ }).click();
await zhCard.getByText('META-CODE-1', { exact: true }).waitFor();
assert.equal(await zhCard.getByText('设备码', { exact: true }).count(), 1);
assert.equal(await zhCard.getByRole('button', { name: '复制设备码', exact: true }).count(), 1);
assert.equal(await zhCard.getByRole('textbox').count(), 0);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${theme} viewport overflows horizontally`);
await page.screenshot({ path: `misc/meta-oauth-${theme}.png`, fullPage: true });
await open(`view=easy&locale=zh-CN&theme=${theme}`);
const easyResponsive = easyCard();
await easyResponsive.getByRole('button', { name: /开始登录/ }).click();
await easyResponsive.getByText('META-CODE-1', { exact: true }).waitFor();
assert.equal(await easyResponsive.getByText('设备码', { exact: true }).count(), 1);
assert.equal(await easyResponsive.getByRole('button', { name: '复制设备码', exact: true }).count(), 1);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Easy mode ${theme} viewport overflows horizontally`);
await easyResponsive.screenshot({ path: `misc/meta-oauth-easy-${theme}.png` });
}
assert.deepEqual(errors, []);
console.log('Meta OAuth browser checks passed: provider card, device code copy/open, refresh cancellation, stale poll isolation, success priority, retry, Easy mode expiry, Kimi credential compatibility and responsive labels.');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+110
View File
@@ -0,0 +1,110 @@
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, '..'), cacheDir: 'node_modules/.vite-plugin-config-test', plugins: [react()], logLevel: 'error', optimizeDeps: { entries: ['tests/fixtures/plugin-config.html'] }, server: { host: '127.0.0.1', port: 1433, strictPort: false, watch: null } });
let browser;
try {
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1024, height: 800 } });
page.setDefaultTimeout(30000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
const open = async query => {
await page.goto(`${base}/tests/fixtures/plugin-config.html?${query ?? ''}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Open configuration' }).click();
await page.getByRole('dialog').waitFor();
await page.waitForFunction(() => document.querySelector('[role="dialog"]')?.getAttribute('aria-busy') === 'false');
};
const field = name => page.getByLabel(new RegExp(`^${name}`));
const save = () => page.getByRole('button', { name: 'Save', exact: true });
await open();
assert.equal(await save().isDisabled(), true);
assert.equal(await field('token').inputValue(), ' keep spaces ');
assert.equal(await field('inherited').inputValue(), '');
for (let index = 0; index < 18; index += 1) {
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => Boolean(document.activeElement?.closest('[role="dialog"]'))), true);
}
await field('retries').fill('1.5');
await save().click();
assert.equal(await field('retries').getAttribute('aria-invalid'), 'true');
assert.equal(await page.evaluate(() => window.pluginConfigFixture.patches.length), 0);
await field('retries').fill('0');
await field('modes').fill('{}');
await save().click();
assert.equal(await field('modes').getAttribute('aria-invalid'), 'true');
await field('modes').fill('[]');
await field('headers').fill('{"replacement":true}');
await field('token').fill(' new secret ');
await page.evaluate(() => { window.pluginConfigFixture.holdSave = true; });
await save().click();
await page.waitForFunction(() => window.pluginConfigFixture.patches.length === 1);
await page.keyboard.press('Escape');
assert.equal(await page.getByRole('dialog').count(), 1, 'Escape must not close while saving');
assert.equal(await field('token').isDisabled(), true);
assert.deepEqual(await page.evaluate(() => window.pluginConfigFixture.patches[0]), {
retries: 0, modes: [], headers: { replacement: true }, token: ' new secret ',
});
await page.evaluate(() => window.pluginConfigFixture.releaseSave());
await page.getByRole('dialog').waitFor({ state: 'detached' });
assert.equal(await page.evaluate(() => window.pluginConfigFixture.saved), 1);
assert.equal(await page.getByRole('button', { name: 'Open configuration' }).evaluate(element => element === document.activeElement), true);
await open('raw');
const raw = field('Custom configuration');
const baseline = JSON.parse(await raw.inputValue());
assert.equal(Object.hasOwn(baseline, 'enabled'), false);
await raw.fill('[]');
await save().click();
assert.equal(await raw.getAttribute('aria-invalid'), 'true');
await raw.fill('{"priority":2}');
await save().click();
await page.getByText('Use the controls above to edit enabled and priority.', { exact: true }).waitFor();
delete baseline.token;
baseline.headers = { updated: true };
baseline.nullable = null;
await raw.fill(JSON.stringify(baseline));
await save().click();
await page.getByRole('dialog').waitFor({ state: 'detached' });
assert.deepEqual(await page.evaluate(() => window.pluginConfigFixture.patches[0]), { headers: { updated: true }, nullable: null });
assert.deepEqual(await page.evaluate(() => window.pluginConfigFixture.options[0]), { nullMeansDelete: false, removeKeys: ['token'] });
await open('loadError');
await page.getByText(/Fixture load failure/).waitFor();
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await field('token').waitFor();
await field('token').fill('edited');
await page.keyboard.press('Escape');
await page.getByText('There are unsaved changes.', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Keep editing', exact: true }).click();
assert.equal(await field('token').inputValue(), 'edited');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Discard changes', exact: true }).click();
await page.getByRole('dialog').waitFor({ state: 'detached' });
await page.setViewportSize({ width: 390, height: 680 });
await open('locale=zh-CN&theme=dark');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
const box = await page.getByRole('dialog').boundingBox();
assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 390 && box.y + box.height <= 680);
assert.equal(await page.getByRole('button', { name: '保存', exact: true }).isVisible(), true);
if (process.env.PLUGIN_CONFIG_SCREENSHOT) await page.screenshot({ path: process.env.PLUGIN_CONFIG_SCREENSHOT });
assert.deepEqual(errors, []);
console.log('Plugin configuration UI passed: typed validation, touched-only patches, JSON fallback, retry, discard, pending state, keyboard focus, mobile layout.');
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await browser?.close();
await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+115
View File
@@ -0,0 +1,115 @@
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, '..'), cacheDir: 'node_modules/.vite-plugin-oauth-test', plugins: [react()], logLevel: 'error', optimizeDeps: { entries: ['tests/fixtures/plugin-oauth.html'] }, server: { host: '127.0.0.1', port: 1435, strictPort: false, watch: null } });
let browser;
try {
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1024, height: 800 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
const open = async query => {
await page.goto(`${base}/tests/fixtures/plugin-oauth.html?${query ?? ''}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Open plugin login' }).click();
await page.getByRole('dialog').waitFor();
};
const calls = cmd => page.evaluate(command => window.pluginOAuthFixture.calls.filter(call => call.cmd === command), cmd);
const waitSession = () => page.getByLabel('Sign-in URL', { exact: true }).waitFor();
await open(); await waitSession();
assert.deepEqual((await calls('start_oauth_login'))[0].args, { provider: 'grok', browser: 'none', pluginProvider: true });
assert.equal((await calls('open_oauth_url')).length, 0, 'Opening the dialog must not launch a browser automatically');
await page.getByRole('button', { name: 'Open in browser', exact: true }).click();
assert.deepEqual((await calls('open_oauth_url'))[0].args, { url: 'https://login.example.test/authorize?state=plugin-1', browser: 'default' });
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
assert.deepEqual(await page.evaluate(() => window.pluginOAuthFixture.copied), ['https://login.example.test/authorize?state=plugin-1']);
const callback = page.getByLabel('Callback URL', { exact: true });
await callback.fill('http://localhost/callback?code=secret&state=wrong');
await page.getByRole('button', { name: 'Submit callback', exact: true }).click();
assert.equal(await callback.getAttribute('aria-invalid'), 'true');
assert.equal((await calls('submit_oauth_callback')).length, 0);
await callback.fill('http://localhost/callback?code=secret&state=plugin-1');
await page.getByRole('button', { name: 'Submit callback', exact: true }).click();
assert.deepEqual((await calls('submit_oauth_callback'))[0].args, { provider: 'grok', redirectUrl: 'http://localhost/callback?code=secret&state=plugin-1', pluginProvider: true });
await page.evaluate(() => { window.pluginOAuthFixture.status = 'ok'; });
await page.getByText('Authorization completed and credentials saved.', { exact: true }).waitFor();
assert.equal(await page.evaluate(() => window.pluginOAuthFixture.completed), 1);
await page.keyboard.press('Escape');
await page.getByRole('dialog').waitFor({ state: 'detached' });
assert.deepEqual(await page.evaluate(() => window.pluginOAuthFixture.cancelled), []);
assert.equal(await page.getByRole('button', { name: 'Open plugin login' }).evaluate(element => element === document.activeElement), true);
await open('device'); await waitSession();
assert.equal(await page.getByLabel('Callback URL', { exact: true }).count(), 0);
await page.getByText('ABCD-EFGH', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Copy code', exact: true }).click();
assert.deepEqual(await page.evaluate(() => window.pluginOAuthFixture.copied), ['ABCD-EFGH']);
for (let index = 0; index < 10; index += 1) {
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => Boolean(document.activeElement?.closest('[role="dialog"]'))), true);
}
await page.keyboard.press('Escape');
await page.waitForFunction(() => window.pluginOAuthFixture.cancelled.includes('plugin-1'));
await open('holdStart');
await page.waitForFunction(() => window.pluginOAuthFixture.releaseStart !== null);
await page.keyboard.press('Escape');
await page.evaluate(() => window.pluginOAuthFixture.releaseStart());
await page.waitForFunction(() => window.pluginOAuthFixture.cancelled.includes('plugin-1'));
assert.equal(await page.evaluate(() => window.pluginOAuthFixture.completed), 0);
await open('holdStatus'); await waitSession();
await page.waitForFunction(() => window.pluginOAuthFixture.releaseStatus !== null);
await page.evaluate(() => { window.pluginOAuthFixture.unmount(); });
await page.getByRole('dialog').waitFor({ state: 'detached' });
await page.evaluate(() => { window.pluginOAuthFixture.status = 'ok'; window.pluginOAuthFixture.releaseStatus(); });
await page.waitForFunction(() => window.pluginOAuthFixture.cancelled.includes('plugin-1'));
assert.equal(await page.evaluate(() => window.pluginOAuthFixture.completed), 0, 'Stale successful poll must not complete a dismissed session');
await open('expiry'); await waitSession();
await page.getByText('Sign-in expired. Request a new link.', { exact: true }).waitFor();
assert.deepEqual(await page.evaluate(() => window.pluginOAuthFixture.cancelled), ['plugin-1']);
await page.getByRole('button', { name: 'Retry sign-in', exact: true }).click();
await page.waitForFunction(() => window.pluginOAuthFixture.starts === 2);
await waitSession();
assert.equal(await page.getByLabel('Sign-in URL', { exact: true }).inputValue(), 'https://login.example.test/authorize?state=plugin-2');
await open('startError');
await page.getByText('Fixture start error', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Retry sign-in', exact: true }).click();
await waitSession();
assert.equal(await page.evaluate(() => window.pluginOAuthFixture.starts), 2);
await open('pollError');
await page.getByText('Fixture poll error', { exact: true }).waitFor();
await page.waitForFunction(() => window.pluginOAuthFixture.cancelled.includes('plugin-1'));
await open('missingState');
await page.getByText('The kernel returned no sign-in session to check.', { exact: true }).waitFor();
assert.equal(await page.evaluate(() => window.pluginOAuthFixture.polls), 0);
await open('invalidUrl');
await page.getByText('Invalid sign-in URL.', { exact: true }).waitFor();
await page.waitForFunction(() => window.pluginOAuthFixture.cancelled.includes('plugin-1'));
await open('disabled');
await page.getByText('Sign-in is unavailable. Check that the plugin is enabled.', { exact: true }).waitFor();
assert.equal(await page.evaluate(() => window.pluginOAuthFixture.starts), 0);
await page.setViewportSize({ width: 390, height: 680 });
await open('device&locale=zh-CN&theme=dark');
await page.getByText('ABCD-EFGH', { exact: true }).waitFor();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
const box = await page.getByRole('dialog').boundingBox();
assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 390 && box.y + box.height <= 680);
if (process.env.PLUGIN_OAUTH_SCREENSHOT) await page.screenshot({ path: process.env.PLUGIN_OAUTH_SCREENSHOT });
assert.deepEqual(errors, []);
console.log('Plugin OAuth UI passed: native contract, callback validation, device code, polling, expiry/retry, cancellation/stale responses, focus and mobile layout.');
} catch (error) { console.error(error); process.exitCode = 1; }
finally { await browser?.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test';
import { createBrowserMockRuntime } from '../src/mocks/browserMockRuntime';
const request = (runtime: ReturnType<typeof createBrowserMockRuntime>, method: string, path: string, body?: unknown, query?: unknown) =>
runtime.invoke('management_request', { request: { method, path, body, query } });
describe('browser plugin mock', () => {
test('exposes installed plugins, update catalog and a non-network resource page', async () => {
const runtime = createBrowserMockRuntime('running');
expect(await runtime.invoke('get_plugin_support')).toBe(true);
expect(await request(runtime, 'GET', '/plugins')).toMatchObject({
plugins_enabled: true,
plugins: [{ id: 'request-inspector', effective_enabled: true, menus: [{ menu: 'Inspector status' }] }],
});
expect(await request(runtime, 'GET', '/plugins/store')).toMatchObject({ plugins: [
{ id: 'request-inspector', installed: true, update_available: true },
{ id: 'response-tags', source_id: 'community-demo', installed: false },
] });
const url = await runtime.invoke('get_plugin_resource_url', { pluginId: 'request-inspector', menuIndex: 0 });
expect(url).toStartWith('data:text/html;charset=utf-8,');
await expect(runtime.invoke('get_plugin_resource_url', { pluginId: 'request-inspector', menuIndex: 1 })).rejects.toThrow('Plugin page is unavailable');
});
test('keeps configuration, global state and resource availability synchronized', async () => {
const runtime = createBrowserMockRuntime('running');
const path = '/config/plugins/configs/request-inspector';
await request(runtime, 'PUT', path, { enabled: true, priority: 5, options: { first: 1 }, literal: null });
await request(runtime, 'PATCH', path, { options: { second: false }, nullable: null });
expect(await request(runtime, 'GET', path)).toEqual({ enabled: true, priority: 5, options: { first: 1, second: false }, literal: null, nullable: null });
await request(runtime, 'DELETE', `${path}/literal`);
await request(runtime, 'PUT', `${path}/enabled`, false);
await expect(runtime.invoke('get_plugin_resource_url', { pluginId: 'request-inspector', menuIndex: 0 })).rejects.toThrow('Plugin page is unavailable');
await request(runtime, 'PATCH', '/config/plugins', { enabled: false, 'store-sources': [], 'store-auth': [] });
expect(await runtime.invoke('get_core_config_settings')).toMatchObject({ pluginsEnabled: false });
expect(await runtime.invoke('get_extended_core_config')).toMatchObject({ plugins: { enabled: false, configs: { 'request-inspector': { priority: 5 } } } });
expect(await request(runtime, 'GET', '/plugins/store')).toMatchObject({ plugins: [{ id: 'request-inspector' }] });
const config = await request(runtime, 'GET', path) as Record<string, unknown>;
expect(config).not.toHaveProperty('literal');
config.priority = 99;
expect(await request(runtime, 'GET', path)).toHaveProperty('priority', 5);
});
test('persists installs and deletes and honors the selected source and version', async () => {
const runtime = createBrowserMockRuntime('running');
await expect(request(runtime, 'POST', '/plugins/store/response-tags/install', {}, { source: 'official' })).rejects.toThrow('not_found');
expect(await request(runtime, 'POST', '/plugins/store/response-tags/install', {}, { source: 'community-demo', version: 'v0.4.0' })).toMatchObject({
status: 'installed', id: 'response-tags', source_id: 'community-demo', version: '0.4.0', restart_required: false,
});
expect(await request(runtime, 'GET', '/config/plugins/configs/response-tags')).toEqual({ enabled: true });
expect(await request(runtime, 'DELETE', '/plugins/response-tags')).toMatchObject({ file_deleted: true, configured_removed: true });
await expect(request(runtime, 'GET', '/config/plugins/configs/response-tags')).rejects.toThrow('Management API error (404): not_found');
expect(await request(runtime, 'GET', '/plugins')).toMatchObject({ plugins: [{ id: 'request-inspector' }] });
});
test('respects core lifecycle and explicit error scenarios', async () => {
const stopped = createBrowserMockRuntime('stopped');
await expect(stopped.invoke('get_plugin_support')).rejects.toThrow('core is not ready');
await stopped.invoke('start_core_process');
expect(await stopped.invoke('get_plugin_support')).toBe(true);
await stopped.invoke('stop_core_process');
await expect(request(stopped, 'GET', '/plugins')).rejects.toThrow('core is not ready');
const empty = createBrowserMockRuntime('empty');
await empty.invoke('install_bundled_core');
await empty.invoke('start_core_process');
expect(await request(empty, 'GET', '/plugins')).toMatchObject({ plugins_enabled: false, plugins: [] });
const error = createBrowserMockRuntime('error');
await expect(error.invoke('get_plugin_support')).rejects.toThrow('Browser Mock error scenario');
await expect(error.invoke('get_plugin_resource_url')).rejects.toThrow('Browser Mock error scenario');
await expect(request(error, 'GET', '/plugins')).rejects.toThrow('Browser Mock error scenario');
});
});
+201
View File
@@ -0,0 +1,201 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
(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',
cacheDir: 'node_modules/.vite-plugins-ui',
optimizeDeps: { noDiscovery: true, include: ['react', 'react-dom/client', 'react/jsx-runtime', 'lucide-react'] },
server: { host: '127.0.0.1', port: 1434, strictPort: false, watch: null },
});
let browser;
try {
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.setDefaultTimeout(12000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
const open = async (query = '') => {
await page.goto(`${base}/tests/fixtures/plugins.html?${query}`, { waitUntil: 'domcontentloaded' });
await page.locator('.plugins-heading h1').waitFor();
};
const card = title => page.locator('.plugin-card').filter({ has: page.getByRole('heading', { name: title, exact: true }) });
const tab = name => page.locator('.plugin-tabs').getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
const installs = () => page.evaluate(() => window.pluginsFixture.installs);
const noOverflow = async () => assert.equal(await page.evaluate(() =>
document.documentElement.scrollWidth <= innerWidth + 1), true, 'Plugin page must fit the viewport');
await open();
await card('Sample provider').getByRole('button', { name: 'Configure', exact: true }).waitFor();
assert.equal(await page.locator('.plugin-card').count(), 4);
await tab('Local plugins').waitFor();
assert.match(await page.locator('.plugin-summary').innerText(), /Installed\s+3/);
await card('kiro').getByText('Plugin file not found', { exact: true }).waitFor();
await card('Unloaded provider').getByText('Not loaded', { exact: true }).waitFor();
assert.equal(await page.getByText('Waiting to load', { exact: true }).count(), 0);
assert.equal(await card('kiro').getByRole('button', { name: 'Sign in', exact: true }).count(), 0);
assert.equal(await card('kiro').getByRole('button', { name: 'Uninstall kiro', exact: true }).count(), 0);
assert.equal(await card('kiro').getByRole('button', { name: 'Disable', exact: true }).count(), 0);
assert.equal(await card('Unloaded provider').getByRole('button', { name: 'Find in store', exact: true }).count(), 0);
await card('Unloaded provider').getByRole('button', { name: 'Open logs', exact: true }).click();
assert.equal(await page.evaluate(() => window.pluginsFixture.commands.some(call => call.command === 'open_core_logs_directory')), true);
await noOverflow();
await card('kiro').getByRole('button', { name: 'Find in store', exact: true }).click();
await card('Kiro').getByRole('button', { name: 'Install', exact: true }).waitFor();
assert.equal(await tab('Plugin store').getAttribute('aria-current'), 'page');
assert.equal(await page.getByRole('searchbox').inputValue(), 'kiro');
assert.equal(await page.locator('.plugin-card').count(), 1);
await tab('Local plugins').click();
await card('kiro').getByRole('button', { name: 'Remove configuration kiro', exact: true }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove configuration', exact: true }).click();
await card('kiro').waitFor({ state: 'detached' });
assert.equal(await page.evaluate(() => Object.hasOwn(window.pluginsFixture.settings.configs, 'kiro')), false);
assert.equal(await page.evaluate(() => window.pluginsFixture.requests.some(request =>
request.method === 'DELETE' && request.path === '/plugins/kiro')), true);
assert.match(await page.locator('.plugin-summary').innerText(), /Installed\s+3/);
assert.equal(await page.getByText('Restart the core to fully apply this change.', { exact: true }).count(), 0);
assert.equal(await card('Dormant provider').getByRole('button', { name: 'Sign in', exact: true }).count(), 0);
await card('Sample provider').getByRole('button', { name: 'Sign in', exact: true }).click();
await dialog().getByLabel('Sign-in URL', { exact: true }).waitFor();
assert.equal(await page.evaluate(() => window.pluginsFixture.commands.some(call => call.command === 'start_oauth_login' && call.args.provider === 'sample-sso' && call.args.pluginProvider === true)), true);
await page.keyboard.press('Escape');
await dialog().waitFor({ state: 'detached' });
await card('Sample provider').getByRole('button', { name: 'Usage dashboard' }).click();
await page.frameLocator('.plugin-resource iframe').getByRole('heading', { name: 'Sample resource preview' }).waitFor();
assert.deepEqual(await page.evaluate(() => window.pluginsFixture.resourceRequests), [{ pluginId: 'sample..plugin', menuIndex: 2 }]);
assert.equal(await page.locator('.plugin-resource iframe').getAttribute('referrerpolicy'), 'no-referrer');
assert.equal((await page.locator('.plugin-resource iframe').getAttribute('src')).includes('token'), false);
await page.getByRole('button', { name: 'Back to plugins' }).click();
const globalSwitch = page.getByRole('checkbox', { name: 'Enable plugins', exact: true });
await globalSwitch.uncheck();
await page.getByText('Plugins are globally disabled.', { exact: false }).waitFor();
await card('Sample provider').getByText('Globally disabled', { exact: true }).waitFor();
assert.equal(await card('Sample provider').getByRole('button', { name: 'Usage dashboard' }).count(), 0);
assert.equal(await card('Sample provider').getByRole('button', { name: 'Sign in', exact: true }).count(), 0);
assert.equal(await page.evaluate(() => window.pluginsFixture.settings.configs['sample..plugin'].untouched.retained), true);
await globalSwitch.check();
await card('Sample provider').getByRole('button', { name: 'Disable', exact: true }).click();
await card('Sample provider').getByRole('button', { name: 'Enable', exact: true }).waitFor();
assert.equal(await page.evaluate(() => window.pluginsFixture.requests.some(request =>
request.method === 'PUT' && request.path === '/config/plugins/configs/sample%2E%2Eplugin/enabled' && request.body === false)), true);
await card('Sample provider').getByRole('button', { name: 'Enable', exact: true }).click();
const search = page.getByRole('searchbox');
await search.fill('does-not-exist');
await page.getByRole('heading', { name: 'No matching plugins' }).waitFor();
await search.fill('');
await tab('Plugin store').click();
await card('Community analytics').getByRole('button', { name: 'Install', exact: true }).waitFor();
await page.getByText('Offline source: Fixture registry unavailable').waitFor();
assert.equal(await card('Private provider').getByRole('button', { name: 'Store authentication required' }).isDisabled(), true);
await page.getByRole('combobox', { name: 'Plugin store' }).selectOption('updates');
assert.equal(await page.locator('.plugin-card').count(), 1);
await page.getByRole('combobox', { name: 'Plugin store' }).selectOption('all');
await card('Community analytics').getByRole('button', { name: 'Install', exact: true }).click();
await dialog().getByLabel('Version tag (blank for latest)').fill('../unsafe');
assert.equal(await dialog().getByRole('button', { name: 'Install', exact: true }).isDisabled(), true);
await dialog().getByLabel('Version tag (blank for latest)').fill('v1.2.3');
await dialog().getByRole('button', { name: 'Install', exact: true }).click();
assert.equal((await installs()).length, 0, 'First third-party confirmation cannot install');
await dialog().getByText(/has not been reviewed by the official project/).waitFor();
await dialog().getByRole('button', { name: 'Understood, review source' }).click();
const token = dialog().getByLabel(/Type the following/);
await token.fill('wrong/source');
assert.equal(await dialog().getByRole('button', { name: 'Install', exact: true }).isDisabled(), true);
await token.fill('router-for-me/analytics');
await page.evaluate(() => { window.pluginsFixture.holdInstall = true; });
await dialog().getByRole('button', { name: 'Install', exact: true }).click();
await page.waitForFunction(() => window.pluginsFixture.installs.length === 1);
await page.keyboard.press('Escape');
assert.equal(await dialog().count(), 1, 'In-flight installation keeps the dialog open');
assert.equal(await dialog().getByRole('button', { name: 'Close', exact: true }).isDisabled(), true);
assert.deepEqual((await installs())[0].query, { source: 'community', version: 'v1.2.3' });
await page.evaluate(() => { window.pluginsFixture.holdInstall = false; window.pluginsFixture.releaseInstall(); });
await dialog().waitFor({ state: 'detached' });
await page.getByText('Restart the core to fully apply this change.', { exact: true }).waitFor();
await card('Sample provider').getByRole('button', { name: 'Update', exact: true }).click();
await dialog().getByLabel('Version tag (blank for latest)').fill('v2.0.0');
await page.evaluate(() => { window.pluginsFixture.failNextInstall = true; });
await dialog().getByRole('button', { name: 'Update', exact: true }).click();
await dialog().getByRole('alert').filter({ hasText: 'Fixture installation failed' }).waitFor();
await dialog().getByRole('button', { name: 'Update', exact: true }).click();
await dialog().waitFor({ state: 'detached' });
assert.equal((await installs()).length, 3, 'Official install retries without a third-party gate');
await card('Direct artifact').getByRole('button', { name: 'Install', exact: true }).click();
assert.equal(await dialog().getByLabel('Version tag (blank for latest)').count(), 0, 'Direct artifacts do not accept GitHub tags');
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await tab('Plugin settings').click();
const directory = page.getByLabel('Plugin directory', { exact: true });
const sources = page.getByLabel(/Additional store sources/);
const auth = page.getByLabel(/Store authentication rules/);
await directory.fill('custom-plugins');
await sources.fill('javascript:alert(1)');
await page.locator('.plugin-settings').getByRole('button', { name: 'Save', exact: true }).click();
await page.locator('.plugin-settings').getByRole('alert').waitFor();
assert.equal(await page.evaluate(() => window.pluginsFixture.settings.dir), 'plugins');
await sources.fill('https://another.example.test/registry.json');
await auth.fill('[{"host":"another.example.test","token-env":"PLUGIN_TEST_TOKEN"}]');
await page.locator('.plugin-settings').getByRole('button', { name: 'Save', exact: true }).click();
await page.waitForFunction(() => window.pluginsFixture.settings.dir === 'custom-plugins');
assert.equal(await page.evaluate(() => window.pluginsFixture.settings['auth-revision']), 9);
assert.equal(await page.evaluate(() => window.pluginsFixture.settings.configs['sample..plugin'].untouched.retained), true);
await tab('Local plugins').click();
await card('Dormant provider').getByRole('button', { name: 'Uninstall Dormant provider' }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Uninstall', exact: true }).click();
await card('Dormant provider').waitFor({ state: 'detached' });
assert.equal(await page.evaluate(() => Object.hasOwn(window.pluginsFixture.settings.configs, 'dormant')), false);
await page.evaluate(() => { window.pluginsFixture.failNextList = true; });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await page.getByRole('alert').filter({ hasText: 'Fixture list temporarily unavailable' }).waitFor();
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await page.getByRole('alert').filter({ hasText: 'Fixture list temporarily unavailable' }).waitFor({ state: 'detached' });
for (const locale of ['en', 'zh-CN']) {
for (const width of [640, 1024]) {
await page.setViewportSize({ width, height: 800 });
await open(`locale=${locale}&theme=${locale === 'en' ? 'light' : 'dark'}`);
await page.locator('.plugin-card').first().waitFor();
await tab(locale === 'en' ? 'Local plugins' : '本地插件').waitFor();
await card('kiro').getByText(locale === 'en' ? 'Plugin file not found' : '未找到插件文件', { exact: true }).waitFor();
await card('Unloaded provider').getByText(locale === 'en' ? 'Not loaded' : '未加载', { exact: true }).waitFor();
assert.match(await page.locator('.plugin-summary').innerText(), locale === 'en' ? /Installed\s+3/ : /已安装\s+3/);
await noOverflow();
if (process.env.PLUGIN_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.PLUGIN_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.PLUGIN_SCREENSHOT_DIR, `plugins-local-${locale}-${width}.png`), fullPage: true });
}
await page.locator('.plugin-tabs button').nth(1).click();
await card('Community analytics').waitFor();
await noOverflow();
if (process.env.PLUGIN_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.PLUGIN_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.PLUGIN_SCREENSHOT_DIR, `plugins-${locale}-${width}.png`), fullPage: true });
}
}
}
await open('unsupported');
await page.getByText('This core does not support plugins.', { exact: false }).waitFor();
assert.equal(await page.locator('.plugin-tabs').count(), 0);
assert.equal(await page.evaluate(() => window.pluginsFixture.requests.length), 0, 'Unsupported cores do not receive plugin mutations or list requests');
assert.deepEqual(errors, []);
console.log('Plugin UI passed: configured-only and unloaded states, installed counts, store lookup, configuration removal, resources, global/instance toggle, store filters, source trust gate, versions, retry, settings, uninstall, unsupported cores, responsive English/Chinese.');
} finally {
await browser?.close();
await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+272
View File
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import {
mergePluginConfigPatch, normalizePluginList, normalizePluginStore, pluginsApi, pluginStoreApi,
} from '../src/services/plugins';
import {
buildRepositoryURL, collectPluginResourceEntries, getPluginConfirmToken, isOfficialPlugin,
isOfficialRepository, resolvePluginAssetURL,
} from '../src/services/pluginResources';
import {
buildGitHubReleasesPageURL, isValidManualReleaseTag, supportsPluginVersionSelection,
} from '../src/services/pluginReleaseVersions';
import { buildPluginConfigDraft, buildPluginConfigPatch } from '../src/services/pluginConfigDraft';
import { getPluginStatus, isPluginInstalled } from '../src/services/pluginStatus';
describe('plugin Management API', () => {
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
});
afterEach(() => {
clearMocks();
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
it('preserves the latest unknown config fields and removes or replaces only edited fields', async () => {
const requests: unknown[] = [];
mockIPC((command, args) => {
expect(command).toBe('management_request');
const request = args?.request as { method: string; path: string; body?: unknown };
requests.push(request);
if (request.method === 'GET') return {
enabled: true, priority: 7, unknown: { concurrent: 'kept' },
options: { removed: 1, keep: 2 }, old: 'delete', list: [1, 2],
};
return { status: 'ok' };
});
await pluginsApi.patchConfig('sample..plugin', { options: { keep: 3 }, old: null, list: [], flag: false, zero: 0 });
expect(requests).toMatchObject([
{ method: 'GET', path: '/config/plugins/configs/sample%2E%2Eplugin' },
{ method: 'PUT', path: '/config/plugins/configs/sample%2E%2Eplugin', body: {
enabled: true, priority: 7, unknown: { concurrent: 'kept' },
options: { keep: 3 }, list: [], flag: false, zero: 0,
} },
]);
expect((requests[1] as { body: unknown }).body).not.toHaveProperty('old');
});
it('defaults only a missing configuration node and propagates transport, auth and endpoint errors', async () => {
mockIPC(() => { throw 'Management API error (404): not_found'; });
expect(await pluginsApi.getConfig('sample')).toEqual({});
for (const message of [
'Management API error (404): 404 page not found',
'Management API error (401): unauthorized',
'Management API request failed: connection refused',
]) {
mockIPC(() => { throw new Error(message); });
await expect(pluginsApi.patchConfig('sample', { label: 'changed' })).rejects.toThrow(message);
}
mockIPC(() => []);
await expect(pluginsApi.getConfig('sample')).rejects.toThrow('Invalid plugin configuration response');
});
it('updates global settings with PATCH so other plugin configs and auth revision survive', async () => {
let request: unknown;
mockIPC((_command, args) => { request = args?.request; return {}; });
await pluginsApi.updateSettings({ enabled: true, storeSources: [], storeAuth: [] });
expect(request).toMatchObject({ method: 'PATCH', path: '/config/plugins', body: {
enabled: true, 'store-sources': [], 'store-auth': [],
} });
expect((request as { body: unknown }).body).not.toHaveProperty('configs');
expect((request as { body: unknown }).body).not.toHaveProperty('dir');
});
it('reads global source/auth configuration without losing unknown auth fields', async () => {
mockIPC(() => ({ enabled: false, dir: 'custom/plugins', 'store-sources': ['https://store.test/index.json'],
'store-auth': [{ host: 'store.test', token: 'fixture-only', custom: true }], configs: {} }));
expect(await pluginsApi.getSettings()).toEqual({ enabled: false, dir: 'custom/plugins',
storeSources: ['https://store.test/index.json'],
storeAuth: [{ host: 'store.test', token: 'fixture-only', custom: true }],
});
});
it('toggles one instance, keeps source/version selection, and returns restart flags', async () => {
const requests: unknown[] = [];
mockIPC((_command, args) => {
requests.push(args?.request);
return { status: 'ok', id: 'sample', source_id: 'private/source', version: 'v1.2.3+test',
restart_required: true, file_deleted: true, configured_removed: true };
});
await pluginsApi.updateEnabled('sample', false);
const installed = await pluginStoreApi.install('sample', { sourceId: ' private/source ', version: ' v1.2.3+test ' });
const deleted = await pluginsApi.deletePlugin('sample');
expect(requests).toMatchObject([
{ method: 'PUT', path: '/config/plugins/configs/sample/enabled', body: false },
{ method: 'POST', path: '/plugins/store/sample/install', query: { source: 'private/source', version: 'v1.2.3+test' },
body: { version: 'v1.2.3+test' }, timeoutMs: 120_000 },
{ method: 'DELETE', path: '/plugins/sample' },
]);
expect(installed).toMatchObject({ sourceId: 'private/source', version: 'v1.2.3+test', restartRequired: true });
expect(deleted).toMatchObject({ fileDeleted: true, configuredRemoved: true, restartRequired: true });
});
it('rejects path traversal and separators before requests', async () => {
const requests: unknown[] = [];
mockIPC((_command, args) => { requests.push(args); return {}; });
for (const id of ['', '.', '..', '../config', 'a/b', 'a\\b', '\u0000', 'a b', 'a'.repeat(129)]) {
expect(() => pluginsApi.updateEnabled(id, true)).toThrow('Invalid plugin ID');
await expect(pluginStoreApi.install(id)).rejects.toThrow('Invalid plugin ID');
}
expect(requests).toEqual([]);
});
});
describe('plugin normalization and resources', () => {
it('does not treat the core configuration-only response as installed or loading', () => {
const response = normalizePluginList({ plugins_enabled: true, plugins_dir: 'plugins', plugins:
['example', 'kiro', 'model-fallback-router'].map(id => ({
id, path: '', configured: true, registered: false, enabled: true,
effective_enabled: false, metadata: null, config_fields: [], menus: [],
})),
});
expect(response.plugins.filter(isPluginInstalled)).toHaveLength(0);
expect(response.plugins.map(plugin => getPluginStatus(plugin, response.pluginsEnabled)))
.toEqual(['missingFile', 'missingFile', 'missingFile']);
expect(collectPluginResourceEntries(response.plugins)).toEqual([]);
const [configured] = response.plugins;
const discovered = { ...configured, path: 'plugins/windows/amd64/kiro-v0.1.1.dll' };
expect(getPluginStatus(discovered, true)).toBe('notLoaded');
expect(getPluginStatus(discovered, false)).toBe('globalDisabled');
expect(getPluginStatus({ ...discovered, enabled: false }, true)).toBe('inactive');
// A loaded library can stay registered after its on-disk file disappears.
// Keep it available for unloading instead of offering configuration removal.
const loaded = { ...configured, registered: true, effectiveEnabled: true };
expect(isPluginInstalled(loaded)).toBe(true);
expect(getPluginStatus(loaded, true)).toBe('active');
expect(getPluginStatus(loaded, false)).toBe('globalDisabled');
});
it('retains runtime capabilities, schema fallback and backend-declared resource paths', () => {
const result = normalizePluginList({ plugins_enabled: true, plugins_dir: '/plugins', plugins: [null, {}, {
id: 'sample', enabled: false, effective_enabled: true, registered: true,
supports_oauth: true, oauth_provider: 'custom-oauth', supports_quota: true, quota_provider: 'custom',
metadata: { name: 'Sample', config_fields: [{ name: 'count', type: 'integer' }] },
menus: [null, { path: '', menu: 'Unavailable' }, { path: '/v0/resource/plugins/sample/ui?view=usage', menu: 'Usage' }],
}] });
expect(result.plugins).toHaveLength(1);
expect(result.plugins[0]).toMatchObject({ enabled: false, supportsOAuth: true, oauthProvider: 'custom-oauth',
supportsQuota: true, quotaProvider: 'custom', configFields: [{ name: 'count', type: 'integer' }],
});
const [resource] = collectPluginResourceEntries(result.plugins);
expect(resource).toMatchObject({ pluginID: 'sample', label: 'Usage', menuIndex: 2 });
expect(resolvePluginAssetURL(resource.menu.path, 'http://127.0.0.1:8317')).toBe(
'http://127.0.0.1:8317/v0/resource/plugins/sample/ui?view=usage',
);
expect(collectPluginResourceEntries([{ ...result.plugins[0], effectiveEnabled: false }])).toEqual([]);
for (const path of ['javascript:alert(1)', 'data:text/html,<script>', '//evil.test/ui', '/\\evil.test/ui']) {
expect(resolvePluginAssetURL(path, 'http://127.0.0.1:8317')).toBe('');
}
});
it('keeps registry identity and failures when duplicate IDs exist across sources', () => {
const result = normalizePluginStore({ plugins: [
{ id: 'sample', source_id: 'official', installed: true, installed_source_id: 'official', install_source_status: 'match' },
{ id: 'sample', source_id: 'third-party', installed: false },
], source_errors: [{ source_id: 'offline', source_url: 'https://offline.test', message: 'Unavailable' }] });
expect(result.plugins.map((entry) => entry.storeId)).toEqual(['official/sample', 'third-party/sample']);
expect(result.plugins[0]).toMatchObject({ installedSourceId: 'official', installSourceStatus: 'match' });
expect(result.sourceErrors[0]).toMatchObject({ sourceId: 'offline', message: 'Unavailable' });
expect(() => normalizePluginStore({ error: 'unavailable' })).toThrow();
expect(() => normalizePluginList('not JSON')).toThrow();
});
});
describe('plugin installation trust and versions', () => {
it('requires the official source and a canonical official GitHub repository', () => {
for (const repository of ['router-for-me/sample', 'https://github.com/router-for-me/sample.git']) {
expect(isOfficialPlugin({ sourceId: 'official', repository })).toBe(true);
expect(isOfficialPlugin({ sourceId: 'untrusted', repository })).toBe(false);
}
for (const repository of [
'other/sample', 'https://github.com.evil.test/router-for-me/sample',
'https://github.com@evil.test/router-for-me/sample', 'https://evil.test/router-for-me/sample',
'http://github.com/router-for-me/sample', 'https://github.com/router-for-me/../other/sample',
'https://github.com/router-for-me/sample?redirect=evil', 'javascript:alert(1)',
]) expect(isOfficialRepository(repository)).toBe(false);
expect(buildRepositoryURL('javascript:alert(1)')).toBe('');
expect(getPluginConfirmToken({ repository: 'other/sample', id: 'plugin' })).toBe('other/sample');
expect(getPluginConfirmToken({ repository: '', id: 'plugin' })).toBe('plugin');
});
it('limits manual selection to GitHub releases and rejects unsafe tags', () => {
expect(supportsPluginVersionSelection(' GitHub-Release ')).toBe(true);
expect(supportsPluginVersionSelection('direct')).toBe(false);
for (const version of ['v1.2.3', '1.0.0-rc.1+build']) expect(isValidManualReleaseTag(version)).toBe(true);
for (const version of ['', '../v1', 'v1/next', 'x'.repeat(129), 'v1?foo=bar']) expect(isValidManualReleaseTag(version)).toBe(false);
expect(buildGitHubReleasesPageURL('router-for-me/sample.git')).toBe('https://github.com/router-for-me/sample/releases');
expect(buildGitHubReleasesPageURL('https://evil.test/router-for-me/sample')).toBe('');
});
});
describe('schema-driven plugin configuration', () => {
const fields = [
{ name: 'prompt', type: 'string', enumValues: [], description: '' },
{ name: 'items', type: 'array', enumValues: [], description: '' },
{ name: 'count', type: 'integer', enumValues: [], description: '' },
{ name: 'optional', type: 'boolean', enumValues: [], description: '' },
];
const t = (key: string) => key;
it('does not persist untouched defaults and preserves meaningful string whitespace', () => {
const draft = buildPluginConfigDraft({ enabled: false, configFields: fields }, {});
expect(buildPluginConfigPatch(draft, fields, t).patch).toEqual({});
draft.values.prompt = ' secret or prompt\n';
draft.touchedFields.prompt = true;
draft.touchedFields.optional = true;
expect(buildPluginConfigPatch(draft, fields, t).patch).toEqual({ prompt: ' secret or prompt\n', optional: false });
draft.values.prompt = '';
expect(buildPluginConfigPatch(draft, fields, t).patch.prompt).toBeNull();
});
it('validates array structure and safe integers, preserving mixed JSON data', () => {
const draft = buildPluginConfigDraft({ enabled: true, configFields: fields }, {});
draft.touchedFields.items = true;
draft.values.items = '{"not":"array"}';
draft.touchedFields.count = true;
draft.values.count = '9007199254740993';
draft.priorityTouched = true;
draft.priority = '1.5';
expect(buildPluginConfigPatch(draft, fields, t).errors).toEqual({
priority: 'plugin_management.invalid_priority', items: 'plugin_management.expected_array',
count: 'plugin_management.invalid_integer',
});
draft.values.items = '[0,false,{"nested":[1]}]';
draft.values.count = '0';
draft.priority = '-2';
expect(buildPluginConfigPatch(draft, fields, t)).toEqual({
patch: { priority: -2, items: [0, false, { nested: [1] }], count: 0 }, errors: {},
});
});
it('treats prototype-looking config keys as data', () => {
const current = JSON.parse('{"__proto__":{"keep":true},"constructor":"original","safe":true}');
const changes = JSON.parse('{"__proto__":{"next":true},"constructor":null}');
const merged = mergePluginConfigPatch(current, changes);
expect(Object.hasOwn(merged, '__proto__')).toBe(true);
expect(merged.__proto__).toEqual({ next: true });
expect(Object.hasOwn(merged, 'constructor')).toBe(false);
expect(Object.getPrototypeOf(merged)).toBe(Object.prototype);
const prototypeField = [{ name: '__proto__', type: 'string', enumValues: [], description: '' }];
const draft = buildPluginConfigDraft({ enabled: false, configFields: prototypeField }, current);
draft.values.__proto__ = 'literal';
draft.touchedFields.__proto__ = true;
expect(Object.hasOwn(buildPluginConfigPatch(draft, prototypeField, t).patch, '__proto__')).toBe(true);
const inheritedField = [{ name: 'constructor', type: 'string', enumValues: [], description: '' }];
const untouched = buildPluginConfigDraft({ enabled: false, configFields: inheritedField }, {});
untouched.touchedFields = { ...untouched.touchedFields };
expect(untouched.values.constructor).toBe('');
expect(Object.keys(buildPluginConfigPatch(untouched, inheritedField, t).patch)).toEqual([]);
});
it('keeps literal nulls in raw JSON while removing only explicitly deleted keys', () => {
expect(mergePluginConfigPatch({ latest: { preserved: true }, remove: 'old', nullable: 1 },
{ nullable: null, newNull: null }, { nullMeansDelete: false, removeKeys: ['remove'] })).toEqual({
latest: { preserved: true }, nullable: null, newNull: null,
});
expect(mergePluginConfigPatch({ nullable: 1 }, { nullable: null })).toEqual({});
});
});
+106
View File
@@ -0,0 +1,106 @@
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from 'bun:test';
const archiveName = 'CLIProxyAPI_8.0.10_windows_amd64.zip';
const archiveContents = 'verified bundled core archive';
async function withPortableFixture(run: (root: string, coreOutput: string) => Promise<void>) {
// Run the real CLI in a disposable project so packaging cannot touch bin-work.
const root = await mkdtemp(join(tmpdir(), 'easycli-portable-preparation-'));
try {
for (const directory of ['scripts', 'src-tauri', 'cpa-core', 'bin-work/cpa-core']) {
await mkdir(join(root, directory), { recursive: true });
}
for (const script of ['portable.mjs', 'version.mjs']) {
await copyFile(fileURLToPath(new URL(`../scripts/${script}`, import.meta.url)), join(root, 'scripts', script));
}
await writeFile(join(root, 'src-tauri', 'Cargo.toml'), '[package]\nname = "cpa-gui"\nversion = "0.3.0"\n');
await writeFile(join(root, 'core-version.txt'), '8.0.10\n');
await writeFile(join(root, 'cpa-gui.exe'), 'new GUI executable');
await writeFile(join(root, 'cpa-core', archiveName), archiveContents);
const digest = createHash('sha256').update(archiveContents).digest('hex');
await writeFile(join(root, 'cpa-core', 'checksums.txt'), `${digest} ${archiveName}\n`);
await run(root, join(root, 'bin-work', 'cpa-core'));
} finally {
await rm(root, { recursive: true, force: true });
}
}
function prepare(root: string, preserve = false) {
const result = spawnSync('node', [
join(root, 'scripts', 'portable.mjs'),
'--binary', join(root, 'cpa-gui.exe'),
'--output', join(root, 'bin-work'),
'--os', 'windows',
'--arch', 'amd64',
'--download', 'false',
'--preserve-runtime-config', String(preserve),
], { encoding: 'utf8', cwd: root });
expect(result.stderr).toBe('');
expect(result.status).toBe(0);
return result.stdout;
}
test('local portable rebuilds preserve installed plugins and runtime data while replacing bundled archives', async () => {
await withPortableFixture(async (root, coreOutput) => {
const preserved = new Map([
['config.yaml', 'plugins:\n configs:\n kiro: {enabled: true}\n'],
['plugins/windows/amd64/kiro-v1.2.3.dll', 'installed kiro binary'],
['plugins/model-fallback-router-v0.2.0.dll', 'installed fallback binary'],
['custom-plugins/local-router.dll', 'custom plugin binary'],
['oauth/account.json', '{"fixture":"credential"}'],
['logs/request.log', 'runtime log'],
['user-data.json', '{"keep":true}'],
['user-backup.zip', 'custom archive'],
['cli-proxy-api.exe', 'existing installed core'],
['cpa-gui-meta.json', '{"version":"v8.0.11"}'],
['cpa-gui-bundled-core-handled.txt', 'v8.0.9'],
['CLIProxyAPI_7.0.0_windows_amd64.zip/keep.txt', 'same-named directory is user data'],
]);
for (const [relative, contents] of preserved) {
const target = join(coreOutput, relative);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, contents);
}
for (const oldArchive of [
'CLIProxyAPI_8.0.9_windows_amd64.zip',
'CLIProxyAPI_8.0.10_windows_aarch64.zip',
'CLIProxyAPI_8.0.9_linux_amd64.tar.gz',
'CLIProxyAPI_8.0.9_darwin_aarch64.tar.gz',
]) {
await writeFile(join(coreOutput, oldArchive), 'old bundled archive');
}
// Repeat the same copy to exercise the ordinary rebuild/update workflow.
for (let attempt = 0; attempt < 2; attempt++) {
expect(prepare(root, true)).toContain('preserved installed plugins and runtime data');
for (const [relative, contents] of preserved) {
expect(await readFile(join(coreOutput, relative), 'utf8')).toBe(contents);
}
const archives = (await readdir(coreOutput, { withFileTypes: true }))
.filter(entry => entry.isFile() && entry.name.startsWith('CLIProxyAPI_'))
.map(entry => entry.name);
expect(archives).toEqual([archiveName]);
expect(await readFile(join(coreOutput, archiveName), 'utf8')).toBe(archiveContents);
}
});
});
test('release portable preparation still creates an archive-only core directory', async () => {
await withPortableFixture(async (root, coreOutput) => {
await mkdir(join(coreOutput, 'plugins'), { recursive: true });
await writeFile(join(coreOutput, 'plugins', 'sample.dll'), 'stale release staging file');
await writeFile(join(coreOutput, 'config.yaml'), 'stale release configuration');
await writeFile(join(coreOutput, 'CLIProxyAPI_8.0.9_windows_amd64.zip'), 'old archive');
expect(prepare(root)).toContain('(archive only)');
expect(await readdir(coreOutput)).toEqual([archiveName]);
expect(await readFile(join(coreOutput, archiveName), 'utf8')).toBe(archiveContents);
expect(await readFile(join(root, 'bin-work', 'EasyCLIProxyAPI.exe'), 'utf8')).toBe('new GUI executable');
});
});
+22 -10
View File
@@ -1,10 +1,19 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const base = 'http://127.0.0.1:1421';
const path = require('node:path');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
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()],
optimizeDeps: { entries: ['tests/fixtures/provider-duplicates.html'] }, 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}`;
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [];
@@ -18,10 +27,11 @@ const base = 'http://127.0.0.1:1421';
const form = page.locator('.api-provider-dialog');
const waitForSave = () => form.waitFor({ state: 'detached' });
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
await form.getByLabel('Group Name', { exact: true }).fill('Second route');
await form.getByLabel('Key 1', { exact: true }).fill('shared-test-key');
await page.getByRole('button', { name: 'Add API Key', exact: true }).click();
assert.equal(await form.getByLabel('Group Name', { exact: true }).count(), 0);
await form.getByLabel('API Key', { exact: true }).fill('shared-test-key');
await form.getByLabel('Base URL', { exact: true }).fill('https://claude.example.test');
await form.locator('.provider-advanced-settings > summary').click();
await form.getByLabel('Priority', { exact: true }).fill('1');
await form.getByLabel('Remark', { exact: true }).fill('Second route');
await form.getByRole('button', { name: 'Add Custom Model', exact: true }).click();
@@ -34,19 +44,20 @@ const base = 'http://127.0.0.1:1421';
assert.equal(await rows.count(), 2);
await rows.filter({ hasText: 'Second route' }).getByRole('button', { name: 'Edit', exact: true }).click();
await form.locator('.provider-advanced-settings > summary').click();
await form.getByLabel('Priority', { exact: true }).fill('2');
await form.locator('.model-config-entry input').nth(1).fill('claude-b-edited');
await page.evaluate(() => window.providerFixture.records.reverse());
await form.getByRole('button', { name: 'Save', exact: true }).click();
await waitForSave();
let saved = await records();
assert.equal(saved[0].priority, 2);
assert.equal(saved[0].models[0].alias, 'claude-b-edited');
assert.equal(saved[0].keys[0].priority, 2);
assert.equal(saved[0].keys[0].models[0].alias, 'claude-b-edited');
assert.equal(saved[1].priority, 10);
assert.equal(saved[1].models[0].alias, 'claude-a');
await rows.filter({ hasText: 'Second route' }).getByRole('checkbox').uncheck();
await page.waitForFunction(() => window.providerFixture.records[0]['excluded-models']?.includes('*'));
await page.waitForFunction(() => window.providerFixture.records[0].keys[0]['excluded-models']?.includes('*'));
assert.equal((await records())[1]['excluded-models'], undefined);
await rows.filter({ hasText: 'Second route' }).waitFor();
@@ -58,7 +69,7 @@ const base = 'http://127.0.0.1:1421';
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(250);
await page.keyboard.press('Space');
await page.waitForFunction(() => window.providerFixture.records[1].priority === 2);
await page.waitForFunction(() => window.providerFixture.records[1].keys[0].priority === 2);
assert.equal((await records())[0].priority, 10);
await rows.filter({ hasText: 'Second route' }).getByRole('button', { name: 'Delete', exact: true }).click();
@@ -70,6 +81,7 @@ const base = 'http://127.0.0.1:1421';
assert.deepEqual(errors, []);
console.log('PASS: #276 create, edit after external reorder, disable, drag, and delete preserve shared-credential sibling routes.');
} finally {
await browser.close();
await browser?.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+123 -28
View File
@@ -20,35 +20,41 @@ const fs = require('node:fs/promises');
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await page.goto(`${base}/tests/fixtures/provider-groups.html`);
const rows = page.locator('.real-provider-row');
const primary = () => rows.filter({ hasText: 'Primary gateway' });
const primary = () => rows.nth(0);
const groups = () => page.evaluate(() => window.groupFixture.groups);
const dialog = page.getByRole('dialog', { name: 'Edit Group', exact: true });
await primary().waitFor();
assert.equal(await rows.count(), 2, 'One row per group, not per key');
const dialog = page.getByRole('dialog', { name: 'Edit API Key', exact: true });
await rows.nth(2).waitFor();
assert.equal(await rows.count(), 3, 'Multi-key groups expand into individual API key rows');
const initial = await groups();
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
assert.equal(await dialog.locator('.provider-group-key').count(), 2);
assert.equal(await dialog.getByLabel('API Key', { exact: true }).inputValue(), 'key-inherited');
assert.equal(await dialog.getByLabel('Priority', { exact: true }).inputValue(), '2', 'Effective inherited values are shown');
assert.equal(await dialog.locator('.provider-group-key').count(), 0);
assert.equal(await dialog.getByText(/shared settings|inherit shared|One group connects/i).count(), 0);
assert.equal(await dialog.getByLabel('Group Name', { exact: true }).count(), 0);
assert.equal(await dialog.getByLabel('Key for Model Discovery', { exact: true }).count(), 0);
await dialog.locator('.provider-advanced-settings > summary').click();
await dialog.getByLabel('Priority', { exact: true }).last().fill('7');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
let saved = await groups();
assert.deepEqual(saved, [{ ...initial[0], priority: 7 }, initial[1]]);
assert.equal(saved[0].keys[0].priority, 7);
assert.equal(saved[0].priority, initial[0].priority, 'Editing a key preserves shared defaults');
assert.deepEqual(saved[0].keys[1], initial[0].keys[1], 'Editing one key preserves its sibling');
assert.deepEqual(saved[1], initial[1]);
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
const second = dialog.locator('.provider-group-key').nth(1);
await second.locator('summary').click();
await second.getByLabel('Override: Priority', { exact: true }).uncheck();
await second.getByLabel('Key 2', { exact: true }).fill('rotated-second-key');
await rows.nth(1).getByRole('button', { name: 'Edit', exact: true }).click();
assert.equal(await dialog.getByLabel('Priority', { exact: true }).inputValue(), '0');
await dialog.getByLabel('API Key', { exact: true }).fill('rotated-second-key');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
saved = await groups();
assert.equal(saved[0].keys[1]['api-key'], 'rotated-second-key');
assert.ok(!('priority' in saved[0].keys[1]), 'Unchecking restores inheritance');
assert.equal(saved[0].keys[1].priority, 0);
assert.deepEqual(saved[0].keys[1].models, initial[0].keys[1].models);
assert.deepEqual(saved[0].keys[0], initial[0].keys[0]);
assert.equal(saved[0].keys[0].priority, 7);
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
await dialog.getByLabel('Key for Model Discovery').selectOption({ index: 1 });
await rows.nth(1).getByRole('button', { name: 'Edit', exact: true }).click();
await dialog.getByRole('button', { name: /Fetch Models|Get Models|Select Models/ }).click();
await page.waitForFunction(() => window.groupFixture.probes.length > 0);
const probe = await page.evaluate(() => window.groupFixture.probes.at(-1));
@@ -58,30 +64,119 @@ const fs = require('node:fs/promises');
await page.locator('.model-discovery-dialog').getByRole('button', { name: 'Cancel', exact: true }).click();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
const add = page.getByRole('dialog', { name: 'Add Group', exact: true });
await add.getByLabel('Group Name', { exact: true }).fill('New multi-key group');
await page.getByRole('button', { name: 'Add API Key', exact: true }).click();
const add = page.getByRole('dialog', { name: 'Add API Key', exact: true });
assert.equal(await add.getByLabel('Group Name', { exact: true }).count(), 0);
await add.getByLabel('Base URL', { exact: true }).fill('https://new.example.test/v1');
await add.getByLabel('Key 1', { exact: true }).fill('new-key-a');
await add.getByRole('button', { name: 'Add Key', exact: true }).click();
await add.getByLabel('Key 2', { exact: true }).fill('new-key-b');
await add.getByLabel('API Key', { exact: true }).fill('new-key-a');
await add.getByRole('button', { name: 'Save', exact: true }).click();
await add.waitFor({ state: 'detached' });
saved = await groups();
assert.equal(saved.length, 3);
assert.deepEqual(saved[2].keys, [{ 'api-key': 'new-key-a' }, { 'api-key': 'new-key-b' }]);
assert.ok(saved[2].name, 'An ordinary addition automatically creates a named native group');
assert.equal(saved[2]['base-url'], 'https://new.example.test/v1');
assert.equal(saved[2].keys.length, 1);
assert.equal(saved[2].keys[0]['api-key'], 'new-key-a');
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
await page.evaluate(() => { window.groupFixture.groups[0].keys[1].weight = 9; });
const writes = await page.evaluate(() => window.groupFixture.writes.length);
await dialog.locator('.provider-advanced-settings > summary').click();
await dialog.getByLabel('Priority', { exact: true }).last().fill('9');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.locator('.action-feedback-message').waitFor();
assert.equal(await page.evaluate(() => window.groupFixture.writes.length), writes, 'Stale save must not write');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await rows.nth(3).waitFor();
const beforeToggle = await groups();
await rows.nth(0).getByRole('checkbox').uncheck();
await page.waitForFunction(() => window.groupFixture.groups[0].keys[0]['excluded-models']?.includes('*'));
saved = await groups();
assert.deepEqual(saved[0].keys[1], beforeToggle[0].keys[1], 'Disabling one key preserves its sibling');
assert.equal(saved[0]['excluded-models'], undefined, 'Disabling a key leaves the group enabled');
assert.equal(await rows.nth(1).getByRole('checkbox').isChecked(), true);
await rows.nth(0).getByRole('button', { name: 'Delete', exact: true }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click();
await page.waitForFunction(() => window.groupFixture.groups[0].keys.length === 1);
saved = await groups();
assert.deepEqual(saved[0].keys[0], beforeToggle[0].keys[1], 'Deleting one key preserves its sibling');
assert.deepEqual(saved[1], initial[1]);
await rows.nth(0).getByRole('button', { name: 'Delete', exact: true }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click();
await page.waitForFunction(() => window.groupFixture.groups.length === 2);
assert.deepEqual((await groups())[0], initial[1], 'Deleting the final key removes only its empty group');
await page.locator('.provider-category-panel button').filter({ hasText: 'OpenAI' }).click();
await page.getByRole('button', { name: 'Add Service', exact: true }).click();
const service = page.getByRole('dialog', { name: 'Add Service', exact: true });
await service.getByLabel('Service Name', { exact: true }).fill('New compatible service');
await service.getByLabel('Base URL', { exact: true }).fill('https://compatible.example.test/v1');
await service.getByLabel('Key 1', { exact: true }).fill('compatible-key-a');
assert.equal(await service.getByLabel('Key for Model Discovery', { exact: true }).count(), 0, 'One key does not need a discovery selector');
await service.getByRole('button', { name: 'Add API Key', exact: true }).click();
await service.getByLabel('Key 2', { exact: true }).fill('compatible-key-b');
assert.equal(await service.getByLabel('Key for Model Discovery').count(), 1);
await service.getByRole('button', { name: 'Save', exact: true }).click();
await service.waitFor({ state: 'detached' });
const compatible = await page.evaluate(() => window.groupFixture.openaiGroups);
assert.equal(compatible.length, 1);
assert.deepEqual(compatible[0].keys.map(key => key['api-key']), ['compatible-key-a', 'compatible-key-b']);
assert.equal(await rows.count(), 1, 'OpenAI-compatible services retain multiple keys in one service');
// A legacy group remark must migrate to every individual key before the
// backend removes group identities that are absent from allRecords.
await page.goto(`${base}/tests/fixtures/provider-groups.html?scenario=remarks`);
const rowRemark = (index) => rows.nth(index).locator('.provider-row-title > span:not(.state-pill)');
await rowRemark(1).getByText('Existing group note', { exact: true }).waitFor();
await rows.nth(0).getByRole('button', { name: 'Edit', exact: true }).click();
assert.equal(await dialog.getByLabel('Remark', { exact: true }).inputValue(), 'Existing group note');
await dialog.locator('.provider-advanced-settings > summary').click();
await dialog.getByLabel('Priority', { exact: true }).fill('8');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
await rowRemark(0).getByText('Existing group note', { exact: true }).waitFor();
assert.equal(await rowRemark(1).textContent(), 'Existing group note', 'Editing one key preserves the legacy group remark on its sibling');
await rows.nth(0).getByRole('button', { name: 'Edit', exact: true }).click();
await dialog.getByLabel('Remark', { exact: true }).fill('');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
await page.reload();
await rowRemark(1).getByText('Existing group note', { exact: true }).waitFor();
assert.equal(await rowRemark(0).count(), 0, 'An explicitly cleared remark stays empty after reload');
await rows.nth(1).getByRole('button', { name: 'Edit', exact: true }).click();
await dialog.getByLabel('Base URL', { exact: true }).fill('https://moved.example.test/v1');
await dialog.getByLabel('Remark', { exact: true }).fill('Moved key note');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
const detached = await groups();
assert.equal(detached.length, 3, 'A URL change detaches only the selected key into a new native group');
assert.equal(detached[0].keys.length, 1);
assert.equal(detached[0]['base-url'], 'https://gateway.example.test/v1');
assert.equal(detached[1]['base-url'], 'https://moved.example.test/v1');
await page.reload();
await rowRemark(1).getByText('Moved key note', { exact: true }).waitFor();
assert.equal(await rowRemark(0).count(), 0, 'Splitting a sibling into another group preserves an explicitly empty remark');
assert.equal(await rows.nth(1).locator('.provider-row-url').getAttribute('title'), 'https://moved.example.test/v1');
await page.evaluate(() => {
window.groupFixture.groups = [{ name: 'DeepSeek-via-relay', 'base-url': 'https://relay.example.test/v1',
models: [{ name: 'deepseek-chat' }], keys: [{ 'api-key': 'deepseek-relay-test' }] }];
});
const writesBeforeCategory = await page.evaluate(() => window.groupFixture.writes.length);
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await rows.waitFor({ state: 'detached' });
await page.locator('.provider-category-panel button').filter({ hasText: 'DeepSeek' }).click();
await rows.waitFor();
assert.equal(await rows.count(), 1, 'Legacy DeepSeek group names retain their category with a relay URL');
assert.equal(await rows.first().locator('.provider-row-url').getAttribute('title'), 'https://relay.example.test/v1');
assert.equal(await page.evaluate(() => window.groupFixture.writes.length), writesBeforeCategory, 'Categorization leaves stored groups unchanged');
await fs.mkdir(path.resolve('bin-work/provider-groups-qa'), { recursive: true });
for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) {
for (const viewport of [{ width: 1280, height: 800 }, { width: 640, height: 600 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport);
await page.goto(`${base}/tests/fixtures/provider-groups.html?locale=zh-CN`);
await rows.first().getByRole('button', { name: '编辑', exact: true }).click();
@@ -90,13 +185,13 @@ const fs = require('node:fs/promises');
const dimensions = await form.evaluate(el => ({width: el.clientWidth, scroll: el.scrollWidth, offenders: [...el.querySelectorAll('*')].filter(child=>child.getBoundingClientRect().right > el.getBoundingClientRect().right).map(child=>({tag:child.tagName,cls:child.className,width:child.getBoundingClientRect().width})).slice(0,12)}));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify({viewport,dimensions}));
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/edit-${viewport.width}.png`) });
await form.locator('.provider-group-key').nth(1).locator('summary').click();
await form.locator('.provider-group-key').nth(1).scrollIntoViewIfNeeded();
assert.ok(await form.evaluate(el => el.scrollWidth <= el.clientWidth + 1), 'Expanded overrides fit');
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/overrides-${viewport.width}.png`) });
await form.locator('.provider-advanced-settings > summary').click();
await form.locator('.provider-advanced-settings').scrollIntoViewIfNeeded();
assert.ok(await form.evaluate(el => el.scrollWidth <= el.clientWidth + 1), 'Advanced settings fit');
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/advanced-${viewport.width}.png`) });
await form.getByRole('button', { name: '取消', exact: true }).click();
}
assert.deepEqual(errors, []);
console.log('PASS: native group editing, key rotation and inheritance, discovery credentials, multi-key creation, stale-save protection and responsive layout.');
console.log('PASS: individual key editing, key rotation, discovery, disable/delete isolation, automatic groups, legacy remark migration and clearing, URL splits, compatible multi-key services, stale-save protection and responsive layout.');
} finally { await browser?.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+264
View File
@@ -0,0 +1,264 @@
import { describe, expect, test } from 'bun:test';
import {
appendProviderEntry, locateProviderEntry, providerEntries, removeProviderEntry,
reorderProviderEntries, updateProviderEntry,
} from '../src/services/providerEntries';
type RecordValue = Record<string, unknown>;
const groups = (): RecordValue[] => [{
name: 'gateway', 'base-url': 'https://gateway.test/v1', priority: 3,
headers: { 'X-Shared': 'value', 'auth-index': 'a valid header' },
models: [{ name: 'shared', custom: { retained: true } }],
'excluded-models': ['old-*'], 'disable-cooling': true,
'request-retry': 2, 'request-scoped-errors': [{ status: 429, action: 'continue' }],
prefix: 'team', 'proxy-url': 'http://proxy.test:80',
keys: [
{ 'api-key': 'key-a', priority: null, models: null, 'auth-index': 'runtime-a', vendor: { token: 'extension' } },
{ 'api-key': 'key-b', priority: 0, headers: {}, models: [], 'disable-cooling': false, 'excluded-models': [] },
],
}];
const keysOf = (group: RecordValue) => group.keys as RecordValue[];
describe('provider entries over native groups', () => {
test('flattens effective keys while retaining immutable source snapshots', () => {
const original = groups();
const entries = providerEntries('codex-api-key', original);
expect(entries).toHaveLength(2);
expect(entries[0].record).toMatchObject({ priority: 3, models: original[0].models, vendor: { token: 'extension' } });
expect(entries[0].record).not.toHaveProperty('name');
expect(entries[0].record).not.toHaveProperty('keys');
expect(entries[1].record).toMatchObject({ priority: 0, headers: {}, models: [], 'disable-cooling': false });
keysOf(original[0])[0]['api-key'] = 'externally-changed';
expect(keysOf(entries[0].source.group)[0]['api-key']).toBe('key-a');
entries[0].record.headers = {};
expect(entries[0].source.group.headers).toHaveProperty('X-Shared');
});
test('retains fields owned by a key even when they share a group field name', () => {
const entries = providerEntries('codex-api-key', [{ name: 'group-label', keys: [{ 'api-key': 'key', name: 'key-extension' }] }]);
expect(entries[0].record.name).toBe('key-extension');
});
test('keeps compatibility providers as single raw groups', () => {
const original = groups();
const entries = providerEntries('openai-compatibility', original);
expect(entries).toHaveLength(1);
expect(entries[0].record).toEqual(original[0]);
expect(entries[0].source.keyIndex).toBeUndefined();
});
test('generates unique family names and stores a new endpoint at group level', () => {
const result = appendProviderEntry([{ name: 'codex-1', keys: [] }, { name: 'codex-3', keys: [] }], 'codex-api-key', {
'api-key': 'new-key', 'base-url': 'https://new.test', priority: 0,
vendor: { retained: true }, 'auth-index': 'runtime', headers: { 'auth-index': 'valid' },
});
expect(result[2]).toEqual({ name: 'codex-2', 'base-url': 'https://new.test', keys: [{
'api-key': 'new-key', priority: 0, vendor: { retained: true }, headers: { 'auth-index': 'valid' },
}] });
});
test('single-key edits preserve inherited nulls, unknown fields and sibling policy', () => {
const original = groups();
const first = providerEntries('codex-api-key', original)[0];
const result = updateProviderEntry(original, first.source, first.record, { ...first.record, 'api-key': 'rotated', priority: 4 });
expect(result[0].priority).toBe(3);
expect(keysOf(result[0])[0]).toEqual({ 'api-key': 'rotated', priority: 4, models: null, vendor: { token: 'extension' } });
expect(keysOf(result[0])[1]).toEqual(keysOf(original[0])[1]);
expect(keysOf(original[0])[0]['api-key']).toBe('key-a');
expect((result[0].headers as RecordValue)['auth-index']).toBe('a valid header');
});
test('unchanged effective settings do not materialize inherited fields', () => {
const original = groups();
const first = providerEntries('codex-api-key', original)[0];
const result = updateProviderEntry(original, first.source, first.record, structuredClone(first.record));
expect(keysOf(result[0])[0]).toEqual({ 'api-key': 'key-a', priority: null, models: null, vendor: { token: 'extension' } });
expect(keysOf(result[0])[0]).not.toHaveProperty('headers');
});
test('cleared shared fields become empty overrides only for the selected key', () => {
const original = groups();
const first = providerEntries('codex-api-key', original)[0];
const after = { ...first.record };
for (const field of ['headers', 'models', 'excluded-models', 'prefix', 'proxy-url', 'priority', 'request-retry', 'request-scoped-errors']) delete after[field];
const result = updateProviderEntry(original, first.source, first.record, after);
expect(keysOf(result[0])[0]).toMatchObject({
headers: {}, models: [], 'excluded-models': [], prefix: '', 'proxy-url': '', priority: 0,
'request-retry': -1, 'request-scoped-errors': [],
});
expect(result[0].models).toEqual(original[0].models);
expect(keysOf(result[0])[1]).toEqual(keysOf(original[0])[1]);
});
test('removing a cooling override restores inheritance and explicit null remains explicit', () => {
const original = groups();
const second = providerEntries('codex-api-key', original)[1];
const after = { ...second.record, priority: null };
delete after['disable-cooling'];
const result = updateProviderEntry(original, second.source, second.record, after);
expect(keysOf(result[0])[1]).not.toHaveProperty('disable-cooling');
expect(keysOf(result[0])[1].priority).toBeNull();
expect(providerEntries('codex-api-key', result)[1].record).toMatchObject({ priority: 3, 'disable-cooling': true });
});
test('changing an endpoint isolates the selected key without changing shared policy', () => {
const original = groups();
original.push({ name: 'gateway-2', keys: [] });
const first = providerEntries('codex-api-key', original)[0];
const result = updateProviderEntry(original, first.source, first.record, { ...first.record, 'base-url': 'https://other.test', priority: 8 });
expect(result).toHaveLength(3);
expect(result[0]['base-url']).toBe('https://gateway.test/v1');
expect(keysOf(result[0])).toEqual([keysOf(original[0])[1]]);
expect(result[1]).toMatchObject({ name: 'gateway-3', 'base-url': 'https://other.test', priority: 3, headers: original[0].headers });
expect(keysOf(result[1])).toEqual([{ 'api-key': 'key-a', priority: 8, models: null, vendor: { token: 'extension' } }]);
expect(keysOf(result[1])[0]).not.toHaveProperty('base-url');
});
test('a single-key endpoint edit preserves the existing group identity', () => {
const original = [{ name: 'one', 'base-url': 'https://old.test', keys: [{ 'api-key': 'key' }] }];
const entry = providerEntries('codex-api-key', original)[0];
const result = updateProviderEntry(original, entry.source, entry.record, { ...entry.record, 'base-url': 'https://new.test' });
expect(result).toEqual([{ ...original[0], 'base-url': 'https://new.test' }]);
});
test('removing one key preserves its siblings and the final key removes its group', () => {
const original = groups();
const result = removeProviderEntry(original, providerEntries('codex-api-key', original)[0].source);
expect(keysOf(result[0])).toEqual([keysOf(original[0])[1]]);
expect(removeProviderEntry(result, providerEntries('codex-api-key', result)[0].source)).toEqual([]);
});
test('identical keys retain their positional identity within an unchanged group', () => {
const original = [{ name: 'duplicates', keys: [{ 'api-key': 'same' }, { 'api-key': 'same' }] }];
const second = providerEntries('codex-api-key', original)[1];
const result = updateProviderEntry(original, second.source, second.record, { ...second.record, weight: 2 });
expect(keysOf(result[0])).toEqual([{ 'api-key': 'same' }, { 'api-key': 'same', weight: 2 }]);
});
test('rejects stale edits when a sibling or a shared setting changed', () => {
const original = groups();
const first = providerEntries('codex-api-key', original)[0];
for (const change of [(value: RecordValue[]) => { keysOf(value[0])[1].priority = 9; },
(value: RecordValue[]) => { value[0].priority = 9; }]) {
const changed = structuredClone(original);
change(changed);
expect(() => updateProviderEntry(changed, first.source, first.record, first.record)).toThrow('configuration changed');
expect(() => removeProviderEntry(changed, first.source)).toThrow();
}
});
test('can locate a uniquely unchanged group after unrelated groups were reordered', () => {
const original = [...groups(), { name: 'other', keys: [{ 'api-key': 'other' }] }];
const first = providerEntries('codex-api-key', original)[0];
expect(locateProviderEntry([original[1], original[0]], first.source)).toEqual({ groupIndex: 1, keyIndex: 0 });
});
test('rejects ambiguous duplicate-group relocation even when only one old match remains', () => {
const original = [groups()[0], groups()[0]];
const entry = providerEntries('codex-api-key', original)[2];
expect(locateProviderEntry(original, entry.source)).toEqual({ groupIndex: 1, keyIndex: 0 });
expect(() => locateProviderEntry([original[0]], entry.source)).toThrow();
const changed = [...original, { name: 'third', keys: [] }];
expect(() => locateProviderEntry(changed, entry.source)).toThrow();
});
test('ignores transient response metadata but never header contents during stale checks', () => {
const original = groups();
const first = providerEntries('codex-api-key', original)[0];
const changed = structuredClone(original);
keysOf(changed[0])[0]['auth-index'] = 'different-runtime-index';
expect(locateProviderEntry(changed, first.source)).toEqual({ groupIndex: 0, keyIndex: 0 });
(changed[0].headers as RecordValue)['auth-index'] = 'different-header';
expect(() => locateProviderEntry(changed, first.source)).toThrow();
});
test('reordering keys rebuilds group fragments without losing inheritance or unknown fields', () => {
const original = [...groups(), { name: 'other', 'base-url': 'https://other.test', custom: { kept: true },
keys: [{ 'api-key': 'key-c', weight: 5, vendor: { retained: true } }] }, { name: 'empty', keys: [] }];
const entries = providerEntries('codex-api-key', original);
const result = reorderProviderEntries(original, 'codex-api-key', entries[2].source, entries[1].source);
expect(providerEntries('codex-api-key', result).map((entry) => entry.record['api-key'])).toEqual(['key-a', 'key-c', 'key-b']);
expect(result.map((group) => group.name)).toEqual(['gateway', 'other', 'gateway-2', 'empty']);
expect(result[0].headers).toEqual(original[0].headers);
expect(result[2].headers).toEqual(original[0].headers);
expect(keysOf(result[0])[0]).toMatchObject({ priority: null, models: null, vendor: { token: 'extension' } });
expect(keysOf(result[2])[0]).toEqual(keysOf(original[0])[1]);
expect(result[1]).toEqual(original[1]);
expect(original).toHaveLength(3);
});
test('reorders within a shared group without changing group settings', () => {
const original = groups();
const entries = providerEntries('codex-api-key', original);
const result = reorderProviderEntries(original, 'codex-api-key', entries[0].source, entries[1].source);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('gateway');
expect(keysOf(result[0]).map((key) => key['api-key'])).toEqual(['key-b', 'key-a']);
expect(result[0].models).toEqual(original[0].models);
});
test('filtered key reordering leaves hidden credentials in their complete-list slots', () => {
const original = [{ name: 'all', 'base-url': 'https://gateway.test', keys: [
{ 'api-key': 'a' }, { 'api-key': 'hidden-a' }, { 'api-key': 'b' }, { 'api-key': 'hidden-b' }, { 'api-key': 'c' },
] }];
const entries = providerEntries('codex-api-key', original);
const visible = [entries[0].source, entries[2].source, entries[4].source];
const result = reorderProviderEntries(original, 'codex-api-key', entries[0].source, entries[4].source, visible);
expect(keysOf(result[0]).map((key) => key['api-key'])).toEqual(['b', 'hidden-a', 'c', 'hidden-b', 'a']);
expect(result[0].name).toBe('all');
expect(keysOf(original[0])[0]['api-key']).toBe('a');
});
test('filtered cross-group reordering preserves each fragment policy and hidden group position', () => {
const original = [...groups(), { name: 'hidden-deepseek', 'base-url': 'https://api.deepseek.com',
custom: { hidden: true }, keys: [{ 'api-key': 'hidden', weight: 7 }] },
{ name: 'other', 'base-url': 'https://other.test', headers: { 'X-Other': 'retained' },
keys: [{ 'api-key': 'key-c', weight: 5, vendor: { retained: true } }] }, { name: 'empty', keys: [] }];
const entries = providerEntries('codex-api-key', original);
const visible = [entries[0].source, entries[1].source, entries[3].source];
const result = reorderProviderEntries(original, 'codex-api-key', entries[3].source, entries[1].source, visible);
const effective = providerEntries('codex-api-key', result);
expect(effective.map((entry) => entry.record['api-key'])).toEqual(['key-a', 'key-c', 'hidden', 'key-b']);
expect(result.map((group) => group.name)).toEqual(['gateway', 'other', 'hidden-deepseek', 'gateway-2', 'empty']);
expect(result[2]).toEqual(original[1]);
expect(result[0].headers).toEqual(original[0].headers);
expect(result[3].headers).toEqual(original[0].headers);
expect(result[3].models).toEqual(original[0].models);
expect(keysOf(result[0])[0]).toMatchObject({ models: null, priority: null, vendor: { token: 'extension' } });
expect(keysOf(result[3])[0]).toEqual(keysOf(original[0])[1]);
expect(result[1]).toEqual(original[2]);
});
test('validates every visible snapshot before reordering even when source and target are unchanged', () => {
const original = ['a', 'b', 'c'].map((key) => ({ name: key, keys: [{ 'api-key': key }] }));
const entries = providerEntries('codex-api-key', original);
const changed = structuredClone(original);
keysOf(changed[1])[0].weight = 9;
expect(() => reorderProviderEntries(changed, 'codex-api-key', entries[0].source, entries[2].source,
entries.map((entry) => entry.source))).toThrow('configuration changed');
expect(() => reorderProviderEntries(changed, 'codex-api-key', entries[0].source, entries[0].source,
entries.map((entry) => entry.source))).toThrow('configuration changed');
expect(() => reorderProviderEntries(original, 'codex-api-key', entries[0].source, entries[2].source,
[entries[0].source, entries[1].source])).toThrow();
});
test('filtered compatibility reordering leaves hidden providers at their existing positions', () => {
const original = ['a', 'hidden', 'b'].map((name) => ({ name, keys: [{ 'api-key': name }] }));
const entries = providerEntries('openai-compatibility', original);
const result = reorderProviderEntries(original, 'openai-compatibility', entries[0].source, entries[2].source,
[entries[0].source, entries[2].source]);
expect(result).toEqual([original[2], original[1], original[0]]);
});
test('compatibility operations preserve provider policies and move whole providers', () => {
const original = [...groups(), { name: 'other', keys: [{ 'api-key': 'other' }], disabled: true }];
const entries = providerEntries('openai-compatibility', original);
const updated = updateProviderEntry(original, entries[0].source, entries[0].record, { ...entries[0].record, disabled: true });
expect(updated[0].disabled).toBe(true);
expect(keysOf(updated[0])[0]).not.toHaveProperty('auth-index');
expect(updated[0].headers).toEqual(original[0].headers);
expect(reorderProviderEntries(original, 'openai-compatibility', entries[0].source, entries[1].source)[0]).toEqual(original[1]);
expect(removeProviderEntry(original, entries[0].source)).toEqual([original[1]]);
expect(appendProviderEntry([], 'openai-compatibility', original[1])).toEqual([original[1]]);
});
});
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test';
import {
buildProviderKeyRecord, providerDraftFromRecord, type ProviderDraft, type ProviderSection,
} from '../src/pages/ApiAccessPage';
import { providerEntries, updateProviderEntry } from '../src/services/providerEntries';
import { cleanProviderGroup } from '../src/services/providerGroups';
type Value = Record<string, unknown>;
const keys = (group: Value) => group.keys as Value[];
const fixture = (): Value[] => [{
name: 'shared', 'base-url': 'https://gateway.test/v1', priority: 3,
headers: { 'X-Shared': 'group', 'auth-index': 'literal header' }, 'excluded-models': ['old-*'],
models: [
{ name: 'same-upstream', alias: 'regular', vendor: { index: 1 }, thinking: { levels: ['low'], 'zero-allowed': false } },
{ name: 'same-upstream', alias: 'fast', vendor: { index: 2 }, thinking: { levels: ['high'] } },
{ name: 'same-upstream', alias: 'careful', vendor: { index: 3 }, 'max-context-length': 128000 },
],
'request-retry': 2, 'request-scoped-errors': [{ status: 429, action: 'continue', match: ['quota'] }],
keys: [
{ 'api-key': 'key-a', priority: null, models: null, 'request-retry': null, 'disable-cooling': null,
weight: null, 'auth-index': 'runtime-a', custom: { list: [1, 2], 'test-model': 'extension' } },
{ 'api-key': 'key-b', headers: {}, 'excluded-models': [], weight: 0, websockets: false },
],
}];
const edit = (groups: Value[], change: (draft: ProviderDraft) => void, section: ProviderSection = 'codex-api-key', index = 0) => {
const entry = providerEntries(section, groups)[index];
const draft = providerDraftFromRecord(section, entry.record);
change(draft);
const after = buildProviderKeyRecord(section, draft, entry.record);
return { after, draft, result: updateProviderEntry(groups, entry.source, entry.record, after) };
};
describe('single-provider form edits over native groups', () => {
for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key', 'interactions-api-key', 'vertex-api-key', 'xai-api-key', 'meta-api-key'] as const) {
test(`${section} untouched form preserves inheritance, aliases, unknown configuration and sibling keys`, () => {
const original = fixture();
const { draft, result } = edit(original, () => {}, section);
expect(draft.models.map((model) => model.alias)).toEqual(['regular', 'fast', 'careful']);
expect(result).toEqual(original.map(cleanProviderGroup));
expect(keys(result[0])[0]).toMatchObject({ priority: null, models: null, 'request-retry': null, 'disable-cooling': null, weight: null });
expect(keys(result[0])[0]).not.toHaveProperty('headers');
expect(keys(result[0])[0]).not.toHaveProperty('websockets');
expect(keys(result[0])[0].custom).toEqual({ list: [1, 2], 'test-model': 'extension' });
});
}
test('untouched absent, null and explicit empty form values retain their stored representations', () => {
for (const fields of [
{},
{ models: null, headers: null, priority: null, 'proxy-url': null, 'excluded-models': null, 'disable-cooling': null, websockets: null },
{ models: [], headers: {}, priority: 0, 'proxy-url': '', 'excluded-models': [], 'disable-cooling': false, websockets: false },
]) {
const original = [{ name: 'untouched', keys: [{ 'api-key': 'key', ...fields }] }];
expect(edit(original, () => {}).result).toEqual(original);
}
});
test('changing only a local remark does not modify provider configuration', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.remark = 'My display note'; });
expect(result).toEqual(original.map(cleanProviderGroup));
expect(keys(result[0])[0]).not.toHaveProperty('remark');
});
test('preserves an existing key-owned name extension on unrelated edits', () => {
const original = fixture();
keys(original[0])[0].name = 'retained key metadata';
expect(edit(original, (draft) => { draft.remark = 'Updated note'; }).result).toEqual(original.map(cleanProviderGroup));
});
test('removes exactly one repeated-source alias and keeps other model metadata', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.models = draft.models.filter((_, index) => index !== 1); });
const existing = original[0].models as Value[];
expect(keys(result[0])[0].models).toEqual([existing[0], existing[2]]);
expect(result[0].models).toEqual(existing);
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
expect(providerEntries('codex-api-key', result)[1].record.models).toEqual(existing);
});
test('renames one alias without duplicating or merging its repeated-source siblings', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.models[1].alias = 'fast-renamed'; });
const existing = original[0].models as Value[];
expect(keys(result[0])[0].models).toEqual([existing[0], { ...existing[1], alias: 'fast-renamed' }, existing[2]]);
});
test('clearing every selected model writes an explicit empty list for that credential', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.models = []; });
expect(keys(result[0])[0].models).toEqual([]);
expect(result[0].models).toEqual(original[0].models);
});
test('clearing shared headers and exclusions writes empty overrides while preserving siblings', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.headersText = ''; draft.excludedModelsText = ''; });
expect(keys(result[0])[0]).toMatchObject({ headers: {}, 'excluded-models': [] });
expect(result[0].headers).toEqual(original[0].headers);
expect(result[0]['excluded-models']).toEqual(original[0]['excluded-models']);
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
});
test('editing header values does not strip fields named like response metadata', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.headersText = 'auth-index: literal header\ntest-model: allowed header\nX-New: added'; });
expect(keys(result[0])[0].headers).toEqual({ 'auth-index': 'literal header', 'test-model': 'allowed header', 'X-New': 'added' });
});
test('changing a shared endpoint splits only the edited credential and retains inherited policies', () => {
const original = fixture();
const { result } = edit(original, (draft) => { draft.baseUrl = 'https://new.test/v1'; });
expect(result).toHaveLength(2);
expect(result[0]['base-url']).toBe(original[0]['base-url']);
expect(keys(result[0])).toEqual([keys(original[0])[1]]);
expect(result[1]['base-url']).toBe('https://new.test/v1');
expect(result[1].models).toEqual(original[0].models);
expect(result[1].headers).toEqual(original[0].headers);
expect(keys(result[1])).toEqual([cleanProviderGroup(keys(original[0])[0])]);
});
test('weight changes affect only the selected key and clearing it restores the default', () => {
const original = fixture();
const changed = edit(original, (draft) => { draft.weight = '5'; }).result;
expect(keys(changed[0])[0].weight).toBe(5);
expect(keys(changed[0])[1].weight).toBe(0);
const cleared = edit(changed, (draft) => { draft.weight = ''; }).result;
expect(keys(cleared[0])[0]).not.toHaveProperty('weight');
for (const weight of ['1.5', 'nope', '1000001']) {
expect(() => edit(original, (draft) => { draft.weight = weight; })).toThrow();
}
});
test('template edits retain untouched policies and apply explicit false and zero', () => {
const original = fixture();
keys(original[0])[0]['disable-codex-cloaking'] = true;
keys(original[0])[0]['alpha-search'] = null;
const { result } = edit(original, (draft) => {
draft.templateFields = { ...draft.templateFields, 'request-retry': 0, 'disable-codex-cloaking': false, 'alpha-search': true };
});
expect(keys(result[0])[0]).toMatchObject({ 'request-retry': 0, 'disable-codex-cloaking': false, 'alpha-search': true });
expect(keys(result[0])[0]).not.toHaveProperty('request-scoped-errors');
expect(result[0]['request-scoped-errors']).toEqual(original[0]['request-scoped-errors']);
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
});
test('edited template rules retain unedited extension fields and clearing inherited rules is explicit', () => {
const original = fixture();
const rules = [{ status: 503, action: 'continue-and-cooldown', match: ['overloaded'], custom: { retained: true } }];
const changed = edit(original, (draft) => { draft.templateFields = { ...draft.templateFields, 'request-scoped-errors': rules }; }).result;
expect(keys(changed[0])[0]['request-scoped-errors']).toEqual(rules);
const cleared = edit(original, (draft) => { delete draft.templateFields!['request-scoped-errors']; }).result;
expect(keys(cleared[0])[0]['request-scoped-errors']).toEqual([]);
});
test('untouched Claude cloak metadata survives and an edited cache choice preserves unrelated fields', () => {
const original = fixture();
keys(original[0])[0].cloak = { mode: 'always', 'strict-mode': true, 'cache-user-id': true, 'sensitive-words': ['private'], custom: { retained: true } };
expect(edit(original, () => {}, 'claude-api-key').result).toEqual(original.map(cleanProviderGroup));
const changed = edit(original, (draft) => { draft.cloakCacheUserId = false; }, 'claude-api-key').result;
expect(keys(changed[0])[0].cloak).toEqual({ ...(keys(original[0])[0].cloak as Value), 'cache-user-id': false });
});
test('editing one Claude cloak option keeps explicit false and absent sibling options distinct', () => {
for (const cloak of [
{ 'strict-mode': false, 'cache-user-id': false, custom: { retained: true } },
{ 'cache-user-id': false, custom: { retained: true } },
]) {
const original = fixture();
keys(original[0])[0].cloak = cloak;
const changed = edit(original, (draft) => { draft.cloakCacheUserId = true; }, 'claude-api-key').result;
expect(keys(changed[0])[0].cloak).toEqual({ ...cloak, 'cache-user-id': true });
}
});
});
+1 -1
View File
@@ -183,7 +183,7 @@ const viewports = [
if (viewport.width === 964) {
const rows = await page.locator('.provider-category-panel button').evaluateAll(buttons =>
new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size);
assert.equal(rows, 1, `${label}: provider navigation must not consume several rows in a short window`);
assert.equal(rows, 2, `${label}: nine provider categories stay within two rows in a short window`);
await assertReachable(page.locator('.provider-category-panel button').last(), `${label} final provider category`);
}
await assertReachable(page.locator('.provider-row-actions button').first(), `${label} provider action`);
+170
View File
@@ -0,0 +1,170 @@
// Run Vite on port 1421, then node tests/usage-analysis-ui.cjs.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const base = 'http://127.0.0.1:1421';
const screenshots = path.resolve('.codex/request-log-ui');
fs.mkdirSync(screenshots, { recursive: true });
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true,
args: ['--no-proxy-server'], ignoreDefaultArgs: ['--hide-scrollbars'] });
try {
const page = await browser.newPage({ viewport: { width: 1266, height: 900 }, timezoneId: 'Asia/Shanghai' });
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
const open = async (params = {}) => {
await page.goto(`${base}/tests/fixtures/usage-analysis.html?${new URLSearchParams(params)}`, { waitUntil: 'domcontentloaded' });
await page.locator('.usage-refresh-btn:not(:disabled)').waitFor();
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
};
const modelRows = () => page.locator('.usage-model-bar-row').evaluateAll(rows => rows.map(row => ({
key: row.dataset.modelKey || 'other',
value: Number(row.dataset.metricValue),
share: Number(row.querySelector('.usage-model-share').textContent.replace('%', '')),
})));
const chartBars = () => page.locator('.usage-activity-bar').evaluateAll(bars => bars.map(bar => ({
requests: Number(bar.dataset.requests), success: Number(bar.dataset.success),
failed: Number(bar.dataset.failure), canceled: Number(bar.dataset.canceled),
})));
const assertChartTotals = async (expected = { requests: 60, success: 44, failed: 8, canceled: 8 }) => {
const bars = await chartBars();
const totals = { requests: 0, success: 0, failed: 0, canceled: 0 };
for (const bar of bars) {
assert.equal(bar.success + bar.failed + bar.canceled, bar.requests, 'Every time interval preserves all three request outcomes');
for (const key of Object.keys(totals)) totals[key] += bar[key];
}
assert.deepEqual(totals, expected, 'Responsive interval grouping preserves request and outcome totals');
return bars;
};
const assertNoOverflow = async (label) => {
const overflow = await page.evaluate(() => [document.documentElement, document.body,
...document.querySelectorAll('.content, .usage-records-page, .usage-filter-panel, .usage-analysis-view, .usage-analysis-card, .usage-analysis-context, .usage-activity-card, .usage-activity-plot')]
.filter(element => element.scrollWidth > element.clientWidth + 1)
.map(element => ({ element: element.className || element.tagName, width: element.clientWidth, scrollWidth: element.scrollWidth })));
assert.deepEqual(overflow, [], `${label}: analysis fits without horizontal overflow`);
};
const assertShares = (rows, total) => {
assert.equal(rows.reduce((sum, row) => sum + row.value, 0), total, 'Visible rows retain the full metric total');
for (const row of rows) assert.ok(Math.abs(row.share - (total ? row.value / total * 100 : 0)) <= 0.051,
`${row.key}: percentage uses the selected metric total`);
};
await open();
assert.equal(await page.locator('#usage-tab-analysis').getAttribute('aria-selected'), 'true');
assert.equal(await page.locator('.usage-filter-panel select').count(), 6, 'Analysis keeps the shared filter controls');
assert.equal(await page.locator('.usage-filter-panel button').count(), 0, 'Analysis does not restore the removed reset button');
let rows = await modelRows();
assert.deepEqual(rows.map(row => row.key), ['model-a', 'model-b', 'model-c', 'model-d', 'model-f', 'other']);
assert.deepEqual(rows.map(row => row.value), [10000, 4000, 3600, 1200, 540, 660], 'Top five plus other includes all eight models');
assertShares(rows, 20000);
await page.getByRole('button', { name: 'View all 8 models' }).click();
rows = await modelRows();
assert.equal(rows.length, 8);
assert.equal(rows.some(row => row.key === 'other'), false);
assertShares(rows, 20000);
await page.getByRole('button', { name: 'Show less', exact: true }).click();
await page.getByRole('button', { name: 'Requests', exact: true }).click();
rows = await modelRows();
assert.deepEqual(rows.map(row => row.key), ['model-c', 'model-a', 'model-f', 'model-b', 'model-d', 'other']);
assert.deepEqual(rows.map(row => row.value), [12, 10, 9, 8, 6, 15]);
assertShares(rows, 60);
assert.equal(await page.getByRole('button', { name: 'Requests', exact: true }).getAttribute('aria-pressed'), 'true');
await page.getByRole('button', { name: 'Token', exact: true }).click();
assertShares(await modelRows(), 20000);
assert.equal(await page.locator('.usage-results-center strong').textContent(), '84.6%', 'Success rate excludes the eight canceled requests from its denominator');
assert.equal(await page.locator('.usage-results-ring svg').getAttribute('aria-label'), '44 successful, 8 failed, 8 canceled');
const ringLengths = await page.locator('.usage-results-ring circle[stroke-dasharray]').evaluateAll(circles => circles.map(circle => Number(circle.getAttribute('stroke-dasharray').split(' ')[0])));
assert.equal(ringLengths.length, 3, 'The ring draws all three nonzero outcomes');
for (let index = 0; index < ringLengths.length; index += 1) {
assert.ok(Math.abs(ringLengths[index] / (2 * Math.PI * 46) - [44, 8, 8][index] / 60) < 0.00001, 'Ring segments include cancellations in total outcome share');
}
assert.deepEqual(await page.locator('.usage-results-legend dd').evaluateAll(items => items.map(item => Number(item.title))), [44, 8, 8]);
assert.deepEqual(await page.locator('.usage-results-legend dd > span').allTextContents(), ['73.3%', '13.3%', '13.3%']);
assert.equal(await page.locator('.usage-failure-model').count(), 3, 'Failure ranking shows its three leading models');
const desktopBars = await assertChartTotals();
assert.equal(desktopBars.length, 36, 'Desktop view retains all hourly sample intervals');
assert.deepEqual(desktopBars.map(bar => bar.requests), [...Array(24).fill(2), ...Array(12).fill(1)], 'Reversed backend timestamps render in chronological order');
const svg = page.locator('.usage-activity-svg');
const tooltip = page.locator('.usage-activity-tooltip');
await svg.focus();
await page.keyboard.press('Home');
assert.match(await tooltip.locator('strong').textContent(), /^10\/1 12:00 AM-01:00 AM$/);
assert.deepEqual(await tooltip.locator('div > b').allTextContents(), ['0', '2', '0']);
const firstHighlight = Number(await page.locator('.usage-activity-highlight').getAttribute('x'));
await page.keyboard.press('ArrowRight');
assert.ok(Number(await page.locator('.usage-activity-highlight').getAttribute('x')) > firstHighlight);
assert.deepEqual(await tooltip.locator('div > b').allTextContents(), ['0', '0', '2']);
await page.keyboard.press('End');
assert.match(await tooltip.locator('strong').textContent(), /^10\/2 11:00 AM-11:30 AM$/);
const accessibleDetail = await svg.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')).textContent);
assert.match(accessibleDetail, /1 requests/);
assert.match(accessibleDetail, /Success 1/);
await page.keyboard.press('Escape');
assert.equal(await tooltip.count(), 0);
await svg.evaluate(element => element.blur());
const firstBar = await page.locator('.usage-activity-bar').first().locator('rect').first().boundingBox();
await page.mouse.move(firstBar.x + firstBar.width / 2, firstBar.y + firstBar.height / 2);
await tooltip.waitFor();
assert.deepEqual(await tooltip.locator('div > b').allTextContents(), ['0', '2', '0'], 'Hover reveals the interval under the pointer');
await page.mouse.move(0, 0);
assert.equal(await tooltip.count(), 0, 'Hover details clear when the pointer leaves the chart');
const details = page.locator('.usage-context-detail');
assert.equal(await details.count(), 3);
for (let index = 0; index < 3; index += 1) {
const detail = details.nth(index);
assert.equal(await detail.evaluate(element => element.open), false);
await detail.locator('summary').click();
assert.equal(await detail.evaluate(element => element.open), true, 'Provider, source, and key breakdowns expand independently');
assert.equal(await detail.locator('li').count(), [2, 3, 4][index]);
assert.equal(await detail.locator('li > strong').evaluateAll(items => items.reduce((total, item) => total + Number(item.title), 0)), 60);
await detail.locator('summary').click();
}
await open({ scenario: 'zero-tokens' });
assertShares(await modelRows(), 0);
assert.equal(await page.locator('.usage-analysis-empty').count(), 0, 'Recorded requests remain available even if they used zero tokens');
await page.getByRole('button', { name: 'Requests', exact: true }).click();
assertShares(await modelRows(), 60);
await assertChartTotals();
await open({ scenario: 'canceled' });
assert.equal(await page.locator('.usage-results-center strong').textContent(), '—', 'All-canceled requests have no completed-request success rate');
assert.deepEqual(await page.locator('.usage-results-legend dd').evaluateAll(items => items.map(item => Number(item.title))), [0, 0, 60]);
await assertChartTotals({ requests: 60, success: 0, failed: 0, canceled: 60 });
assert.equal(await page.locator('.usage-analysis-no-failures').count(), 1);
await open({ scenario: 'empty' });
assert.equal(await page.locator('.usage-analysis-empty').count(), 1);
assert.equal(await page.locator('.usage-analysis-primary, .usage-activity-card').count(), 0, 'Empty data does not show misleading charts or percentages');
for (const [width, height, theme] of [[1266, 900, 'light'], [1167, 895, 'light'], [640, 900, 'light'], [390, 900, 'light'], [1266, 900, 'dark'], [390, 900, 'dark']]) {
await page.setViewportSize({ width, height });
await open({ theme, locale: 'zh-CN' });
await assertNoOverflow(`${width}x${height} ${theme}`);
if (width >= 1167) {
assert.ok(await page.locator('.usage-analysis-view').evaluate(element => element.scrollHeight <= element.clientHeight + 1),
'The default dashboard, including source summaries, fits the reference desktop window');
}
const bars = await assertChartTotals();
if (width === 390) assert.ok(bars.length < desktopBars.length, 'Narrow charts merge adjacent intervals');
await page.screenshot({ path: path.join(screenshots, `analysis-${width}-${theme}.png`), fullPage: true });
await page.locator('.usage-analysis-expand').click();
await assertNoOverflow(`${width}x${height} ${theme} all model names`);
await page.locator('.usage-analysis-expand').click();
for (const detail of await page.locator('.usage-context-detail').all()) await detail.locator('summary').click();
await assertNoOverflow(`${width}x${height} ${theme} expanded details`);
}
assert.deepEqual(errors, [], 'Analysis interactions do not produce runtime errors');
console.log('PASS: model metric rankings/shares and aggregation, request outcomes, zero-token/canceled/empty states, chronological chart totals and keyboard/hover, category details, responsive light/dark layouts.');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+169
View File
@@ -0,0 +1,169 @@
// Run Vite on port 1421, then node tests/usage-response-model-ui.cjs.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const base = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:1421';
const locales = {
'zh-CN': { request: '请求模型', upstream: '上游模型', response: '上游响应' },
en: { request: 'Requested model', upstream: 'Upstream model', response: 'Upstream response' },
ja: { request: 'リクエストモデル', upstream: '上流モデル', response: '上流レスポンス' },
'zh-TW': { request: '請求模型', upstream: '上游模型', response: '上游響應' },
};
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let quoted = false;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (quoted) {
if (character === '"' && text[index + 1] === '"') {
cell += '"';
index += 1;
} else if (character === '"') {
quoted = false;
} else {
cell += character;
}
} else if (character === '"' && cell === '') {
quoted = true;
} else if (character === ',') {
row.push(cell);
cell = '';
} else if (character === '\r' || character === '\n') {
if (character === '\r' && text[index + 1] === '\n') index += 1;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else {
cell += character;
}
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
return rows;
}
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, timezoneId: 'Asia/Shanghai' });
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
const open = async (locale = 'zh-CN', theme = 'light') => {
await page.goto(`${base}/tests/fixtures/usage-response-model.html?locale=${encodeURIComponent(locale)}&theme=${theme}`, { waitUntil: 'domcontentloaded' });
await page.locator('.usage-td-model').first().waitFor();
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
};
const modelCells = () => page.locator('.usage-td-model');
const modelDetails = () => modelCells().evaluateAll(cells => cells.map(cell => ({
main: cell.querySelector(':scope > strong')?.textContent || '',
sublines: Array.from(cell.querySelectorAll(':scope > small')).map(item => item.textContent || ''),
response: cell.querySelector(':scope > .usage-response-model')?.textContent || '',
title: cell.getAttribute('title'),
})));
const assertNoHorizontalPageOverflow = async (label) => {
const dimensions = await page.evaluate(() => ({
htmlWidth: document.documentElement.clientWidth,
htmlScrollWidth: document.documentElement.scrollWidth,
bodyWidth: document.body.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
tableViewport: document.querySelector('.usage-table-wrap')?.clientWidth || 0,
tableContent: document.querySelector('.usage-table-wrap')?.scrollWidth || 0,
}));
assert.ok(dimensions.htmlScrollWidth <= dimensions.htmlWidth + 1, `${label}: document does not overflow horizontally`);
assert.ok(dimensions.bodyScrollWidth <= dimensions.bodyWidth + 1, `${label}: body does not overflow horizontally`);
return dimensions;
};
await open('zh-CN');
assert.equal(await modelCells().count(), 9, 'Every fixture record renders a model cell');
const zhDetails = await modelDetails();
assert.deepEqual(zhDetails[0], {
main: 'astra',
sublines: ['gpt-6-astra', '↳ 上游响应: gpt-5.6-luna'],
response: '↳ 上游响应: gpt-5.6-luna',
title: '请求模型: astra\n上游模型: gpt-6-astra\n上游响应: gpt-5.6-luna',
}, 'Differing alias, upstream model, and upstream response remain distinguishable');
assert.deepEqual(zhDetails[1], {
main: 'same-alias',
sublines: ['gpt-6-sol', '↳ 上游响应: gpt-6-sol'],
response: '↳ 上游响应: gpt-6-sol',
title: '请求模型: same-alias\n上游模型: gpt-6-sol\n上游响应: gpt-6-sol',
}, 'A response equal to the upstream model still renders');
assert.deepEqual(zhDetails[2].sublines, ['openai/gpt-4o-latest', '↳ 上游响应: gpt-4o-2024-08-06'], 'Snapshot and prefix model names preserve the existing upstream subline');
assert.equal(zhDetails[2].title, '请求模型: gpt4o\n上游模型: openai/gpt-4o-latest\n上游响应: gpt-4o-2024-08-06');
assert.deepEqual(zhDetails.slice(3, 6).map(item => item.sublines), [[], [], []], 'Missing, empty, and whitespace response_model values stay omitted');
assert.deepEqual(zhDetails.slice(3, 6).map(item => item.title), [null, null, null], 'Legacy rows keep the existing native-title behavior');
assert.equal(await page.locator('[class*="mismatch"], [class*="warning"]').count(), 0, 'Model strings do not produce an inequality warning');
await assertNoHorizontalPageOverflow('desktop');
for (const child of await modelCells().first().locator(':scope > strong, :scope > small').all()) {
assert.equal(await child.getAttribute('title'), zhDetails[0].title, 'Hovering each line exposes the full three-model title');
}
const longLine = await modelCells().nth(6).locator('.usage-response-model').evaluate(element => ({
truncated: element.scrollWidth > element.clientWidth,
overflow: getComputedStyle(element).textOverflow,
title: element.title,
}));
assert.equal(longLine.truncated, true, 'Long response names stay within the model column');
assert.equal(longLine.overflow, 'ellipsis', 'Long response names are visibly ellipsized');
assert.ok(longLine.title.includes(`response-${'r'.repeat(120)}`), 'The complete long response remains available in the title');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '导出本页 CSV', exact: true }).click();
const download = await downloadPromise;
const csvPath = await download.path();
assert.ok(csvPath, 'CSV export creates a download');
const csv = await fs.promises.readFile(csvPath, 'utf8');
const csvRows = parseCsv(csv.replace(/^\uFEFF/, ''));
const header = csvRows[0];
const aliasIndex = header.indexOf('alias');
const responseIndex = header.indexOf('response_model');
assert.equal(responseIndex, aliasIndex + 1, 'response_model is immediately after alias in CSV');
const csvById = new Map(csvRows.slice(1).map(row => [row[0], row]));
assert.equal(csvById.get('differing')[responseIndex], 'gpt-5.6-luna', 'CSV preserves a regular response model');
assert.equal(csvById.get('matching')[responseIndex], 'gpt-6-sol', 'CSV preserves a response equal to model');
assert.equal(csvById.get('missing')[responseIndex], '', 'CSV preserves an empty legacy response field');
assert.equal(csvById.get('empty')[responseIndex], '', 'CSV preserves an explicitly empty response field');
assert.equal(csvById.get('whitespace')[responseIndex], ' ', 'CSV preserves nonempty whitespace exactly');
assert.equal(csvById.get('quoted')[responseIndex], 'model,"quoted"', 'CSV escapes quotes and commas in response_model');
assert.equal(csvById.get('formula')[responseIndex], "'=SUM(A1)", 'CSV formula mitigation protects response_model values');
for (const [locale, labels] of Object.entries(locales)) {
await open(locale);
const details = await modelDetails();
assert.equal(details[0].response, `↳ ${labels.response}: gpt-5.6-luna`, `${locale}: response line is localized`);
assert.equal(details[0].title, `${labels.request}: astra\n${labels.upstream}: gpt-6-astra\n${labels.response}: gpt-5.6-luna`, `${locale}: model title localizes all three labels`);
}
for (const [theme, color] of [['light', 'rgb(194, 65, 12)'], ['dark', 'rgb(251, 146, 60)']]) {
await open('zh-CN', theme);
assert.equal(await modelCells().first().locator('.usage-response-model').evaluate(element => getComputedStyle(element).color), color, `${theme}: response line uses the theme's readable orange`);
if (process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.USAGE_RESPONSE_MODEL_SCREENSHOT_DIR, `response-model-${theme}.png`) });
}
}
await page.setViewportSize({ width: 390, height: 850 });
await open('zh-CN');
const narrow = await assertNoHorizontalPageOverflow('narrow');
assert.ok(narrow.tableContent > narrow.tableViewport, 'Narrow viewport keeps wide usage columns inside a scrollable table');
assert.equal(await page.locator('.usage-table-top-scrollbar').count(), 1, 'Narrow viewport renders the synchronized top scrollbar');
assert.ok(await page.locator('.usage-td-model').nth(6).boundingBox(), 'Long model names remain visible in the narrow model cell');
assert.deepEqual(errors, [], 'Response model interactions produce no runtime errors');
console.log('PASS: response-model rendering, titles/locales, CSV ordering/escaping/formula mitigation, legacy omission, and desktop/narrow geometry.');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+137 -5
View File
@@ -29,10 +29,136 @@ function parseCsv(text) {
ignoreDefaultArgs: ['--hide-scrollbars'],
});
try {
const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
const page = await browser.newPage({ viewport: { width: 1375, height: 897 } });
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
await page.addInitScript(() => {
localStorage.setItem('easy-cli-proxy-api.locale', 'zh-CN');
localStorage.setItem('easy-cli-proxy-api.theme', 'light');
localStorage.setItem('cpa-gui.usage-records-tab.v1', 'overview');
});
// A delayed real-app mock exposes layout differences before and after data arrives.
await page.goto(`${base}/?mock=running&mockDelay=400`, { waitUntil: 'domcontentloaded' });
await page.locator('.nav-section').getByRole('button', { name: '使用记录', exact: true }).click();
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
const assertSharedFilterLayout = async (label, expectSingleRow) => {
const panel = page.locator('.usage-records-page > .usage-tab-panel > .usage-filter-panel');
await panel.waitFor();
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
assert.equal(await panel.evaluate(element => element === element.parentElement.firstElementChild), true,
`${label}: shared filters stay above the tab content`);
assert.equal(await page.locator('.usage-events-panel .usage-filter-panel').count(), 0,
`${label}: request details do not embed a second filter toolbar`);
const geometry = await panel.evaluate(element => {
const row = element.querySelector('.usage-filter-row');
const selects = [...row.querySelectorAll('select')];
return {
selectCount: selects.length,
tops: selects.map(control => control.getBoundingClientRect().top),
overflow: [document.documentElement, document.body, document.querySelector('.content'),
document.querySelector('.usage-records-page'), element, row]
.filter(node => node.scrollWidth > node.clientWidth + 1)
.map(node => node.className || node.tagName),
};
});
assert.equal(geometry.selectCount, 6, `${label}: all six filters are present`);
if (expectSingleRow) {
assert.ok(Math.max(...geometry.tops) - Math.min(...geometry.tops) <= 1,
`${label}: all six selectors fit on one desktop row`);
}
assert.deepEqual(geometry.overflow, [], `${label}: responsive filters do not overflow horizontally`);
assert.equal(await panel.locator('button').count(), 0, `${label}: filters have no reset button`);
for (const control of await panel.locator('select').all()) {
assert.equal(await control.isEnabled(), true, `${label}: every filter is usable`);
await control.scrollIntoViewIfNeeded();
assert.equal(await control.evaluate(element => {
const bounds = element.getBoundingClientRect();
const row = element.closest('.usage-filter-row').getBoundingClientRect();
return bounds.left >= Math.max(0, row.left) - 1
&& bounds.right <= Math.min(innerWidth, row.right) + 1;
}), true, `${label}: every filter remains fully visible within the row`);
}
};
const readSharedGeometry = () => page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const layoutBox = (element) => {
const bounds = element.getBoundingClientRect();
let x = bounds.x + scrollX;
let y = bounds.y + scrollY;
// Compare layout positions, including when Playwright brings a tab into
// view by scrolling a narrow viewport or an inner scrolling container.
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
if (ancestor !== document.scrollingElement) {
x += ancestor.scrollLeft;
y += ancestor.scrollTop;
}
}
return { x, y, width: bounds.width, height: bounds.height };
};
const selectors = ['.usage-topbar', '.usage-page-navigation', '.usage-tabs'];
const shared = Object.fromEntries(selectors.map(selector => [selector, layoutBox(document.querySelector(selector))]));
const filters = document.querySelector('.usage-filter-panel');
return {
shared,
filters: filters ? layoutBox(filters) : null,
};
});
const assertBoxesMatch = (actual, expected, label) => {
for (const property of ['x', 'y', 'width', 'height']) {
assert.ok(Math.abs(actual[property] - expected[property]) <= 1,
`${label}: ${property} must stay stable (${expected[property]} -> ${actual[property]})`);
}
};
const assertSharedGeometry = (actual, expected, label, hasFilters = true) => {
for (const selector of Object.keys(expected.shared)) {
assertBoxesMatch(actual.shared[selector], expected.shared[selector], `${label} ${selector}`);
}
if (hasFilters) {
assert.ok(actual.filters, `${label}: shared filters are present`);
assertBoxesMatch(actual.filters, expected.filters, `${label} shared filters`);
} else {
assert.equal(actual.filters, null, `${label}: data management has no shared filters`);
}
};
const initialGeometry = await readSharedGeometry();
await page.locator('.usage-refresh-btn:not(:disabled)').waitFor();
assertSharedGeometry(await readSharedGeometry(), initialGeometry, 'Initial overview data load');
for (const viewport of [
{ width: 1167, height: 895 },
{ width: 1375, height: 897 },
{ width: 1024, height: 700 },
{ width: 800, height: 700 },
{ width: 640, height: 700 },
]) {
await page.setViewportSize(viewport);
await page.locator('#usage-tab-overview').click();
await page.locator('.usage-refresh-btn:not(:disabled)').waitFor();
const baseline = await readSharedGeometry();
const size = `${viewport.width}x${viewport.height}`;
for (const tab of ['overview', 'analysis', 'events', 'pricing', 'data-management']) {
await page.locator(`#usage-tab-${tab}`).click();
const hasFilters = tab !== 'data-management';
assertSharedGeometry(await readSharedGeometry(), baseline, `${size} ${tab} loading`, hasFilters);
await page.locator('.usage-refresh-btn:not(:disabled)').waitFor();
assertSharedGeometry(await readSharedGeometry(), baseline, `${size} ${tab} loaded`, hasFilters);
if (hasFilters) await assertSharedFilterLayout(`${size} ${tab}`, viewport.width >= 800);
await page.locator('.usage-refresh-btn').click();
assertSharedGeometry(await readSharedGeometry(), baseline, `${size} ${tab} refreshing`, hasFilters);
await page.locator('.usage-refresh-btn:not(:disabled)').waitFor();
assertSharedGeometry(await readSharedGeometry(), baseline, `${size} ${tab} refreshed`, hasFilters);
}
}
await page.setViewportSize({ width: 1100, height: 700 });
await page.goto(`${base}/tests/fixtures/usage-layout.html?tab=events&locale=en`, { waitUntil: 'domcontentloaded' });
await page.locator('.usage-table-top-scrollbar:not(.is-hidden)').waitFor();
await page.locator('.usage-page-size-select').selectOption('200');
@@ -208,8 +334,7 @@ function parseCsv(text) {
assert.notEqual(await firstRow.locator('.usage-td-time strong').textContent(), originalTime, 'Bottom pagination loads a different request page');
assert.equal(await page.locator('.usage-pagination-summary').textContent(), 'Showing 21 - 40 of 400');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).count(), 1, 'The request log keeps a clear filters control visible');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).isDisabled(), true, 'The clear filters control is disabled without active filters');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).count(), 0, 'The request log has no reset filters control');
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('test-model');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 266');
assert.equal(await page.locator('.usage-events-table tbody strong[title="test-model"]').count(), 20, 'Changing a filter resets pagination and shows matching models');
@@ -219,7 +344,14 @@ function parseCsv(text) {
await page.getByRole('combobox', { name: 'Request result', exact: true }).selectOption('failed');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 66');
assert.equal(await page.locator('.usage-events-table tbody .usage-result.failed').count(), 20);
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).isDisabled(), false, 'Active filters enable the clear filters control');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).count(), 0, 'Active filters do not show a reset filters control');
await page.locator('.usage-filter-panel select').first().selectOption('custom');
const customInputs = page.locator('.usage-custom-range input');
assert.equal(await customInputs.count(), 2, 'Custom time range inputs remain available');
await customInputs.first().fill('2026-10-01T00:00');
await customInputs.last().fill('2026-10-03T23:59');
assert.equal(await page.locator('.usage-filter-panel button').count(), 0, 'Custom time ranges do not show a reset filters control');
await page.locator('.usage-filter-panel select').first().selectOption('4h');
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('');
await page.getByRole('combobox', { name: 'Request result', exact: true }).selectOption('all');
@@ -229,7 +361,7 @@ function parseCsv(text) {
assert.equal(await page.getByRole('combobox', { name: 'Request result', exact: true }).inputValue(), 'all');
assert.deepEqual(errors, [], 'Request log interactions do not produce runtime errors');
console.log('PASS: request values, CSV export, filters, bottom pagination, column visibility/resizing, and synchronized horizontal scrolling with 200 rows.');
console.log('PASS: stable navigation/filter geometry across all five tabs, loading, and refresh at 1167/1375/1024/800/640px; responsive filters without reset controls, custom ranges, request values, CSV export, pagination, column visibility/resizing, and synchronized horizontal scrolling with 200 rows.');
} finally {
await browser.close();
}