feat: refine management UI and enhance credential and plugin OAuth support

- Adopt a compact seven-column credential layout with visible quotas, request stats, and actions
- Display cooldown scope, countdowns, and model details with accessible reset controls
- Fix cooldown misassignment when merging same-named credentials from different accounts
- Add client visibility management with search, auto-detection, and persisted preferences
- Integrate plugin OAuth providers with deduplication and change synchronization
- Refine dashboard typography and spacing, and streamline usage history controls
- Add translations, unit tests, and responsive interaction coverage
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent cc24f8419f
commit 3b11361f1b
46 files changed
+2301 -418

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
// Run Vite on port 1421, then node tests/agent-client-list-ui.cjs.
// PLAYWRIGHT_MODULE and AGENT_CLIENT_TEST_URL can target an existing local setup.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const base = process.env.AGENT_CLIENT_TEST_URL || 'http://127.0.0.1:1421';
const storageKey = 'cpa-gui.agent-visible-clients.v1';
const automaticClients = ['Claude Code', 'Claude Desktop', 'Codex', 'OpenCode', 'Pi'];
const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1460, height: 1060 } });
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());
fs.mkdirSync(screenshots, { recursive: true });
const clients = () => page.locator('.agent-list-items button strong');
const visibleClients = () => clients().allTextContents();
const activeClient = () => page.locator('.agent-list-items button.active strong').textContent();
const stored = () => page.evaluate(key => localStorage.getItem(key), storageKey);
const manage = () => page.getByRole('button', { name: '管理客户端', exact: true });
const dialog = () => page.getByRole('dialog', { name: '管理客户端', exact: true });
const checkbox = name => dialog().getByRole('checkbox', { name, exact: true });
const save = () => dialog().getByRole('button', { name: '保存', exact: true });
const cancel = () => dialog().getByRole('button', { name: '取消', exact: true });
const search = () => dialog().getByRole('searchbox', { name: '搜索客户端', exact: true });
const automatic = () => dialog().getByRole('button', { name: '恢复自动显示', exact: true });
const ready = () => page.waitForFunction(() => window.fixtureCalls?.some(call => call.cmd === 'get_agent_config_statuses')
&& document.querySelector('.agent-client-list-heading button')
&& !document.querySelector('.agent-client-list-heading button').disabled);
const waitClients = async names => {
await page.waitForFunction(expected => {
const actual = Array.from(document.querySelectorAll('.agent-list-items button strong'), node => node.textContent);
return JSON.stringify(actual) === JSON.stringify(expected);
}, names);
assert.deepEqual(await visibleClients(), names);
};
const open = async (extra = 'mixed-clients', saved = null) => {
if (page.url() !== 'about:blank') {
await page.evaluate(({ key, saved }) => {
if (saved === null) localStorage.removeItem(key);
else localStorage.setItem(key, saved);
}, { key: storageKey, saved });
}
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=codex&${extra}`, { waitUntil: 'domcontentloaded' });
await ready();
await page.evaluate(() => document.fonts.ready);
};
const refresh = async override => {
await page.evaluate(value => { window.fixtureClientStatusesOverride = value; }, override);
await page.locator('.agent-client-list-heading button').click();
await ready();
};
const closeAndFocus = async close => {
await close();
await dialog().waitFor({ state: 'hidden' });
assert.equal(await manage().evaluate(node => node === document.activeElement), true,
'closing the client manager returns keyboard focus to its trigger');
};
const assertNoHorizontalOverflow = async label => {
const metrics = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
assert.ok(metrics.scrollWidth <= metrics.width + 1, `${label}: the page must fit the viewport`);
const shown = page.getByRole('dialog');
if (await shown.count()) {
const bounds = await shown.evaluate(node => {
const rect = node.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: innerWidth, scrollWidth: node.scrollWidth, clientWidth: node.clientWidth };
});
assert.ok(bounds.left >= -1 && bounds.right <= bounds.width + 1, `${label}: the dialog must fit the viewport`);
assert.ok(bounds.scrollWidth <= bounds.clientWidth + 1, `${label}: the dialog must not scroll horizontally`);
}
};
// Installation, config-only and Pi plugin-only detection all keep clients accessible.
await open();
await waitClients(automaticClients);
assert.equal(await activeClient(), 'Codex');
assert.equal(await page.locator('.agent-list-items button.active').getAttribute('aria-pressed'), 'true');
assert.equal(await stored(), null, 'automatic defaults do not become a custom preference');
// Search reaches uninstalled clients; a cancelled draft never affects navigation or storage.
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 13);
await search().fill('herMES');
assert.equal(await dialog().getByRole('checkbox').count(), 1);
await checkbox('Hermes Agent').check();
await search().fill('no-such-client');
assert.equal(await dialog().getByRole('checkbox').count(), 0);
await search().fill('');
assert.equal(await checkbox('Hermes Agent').isChecked(), true, 'search preserves pending choices');
await closeAndFocus(() => cancel().click());
await waitClients(automaticClients);
assert.equal(await stored(), null);
assert.equal(await activeClient(), 'Codex');
await manage().click();
assert.equal(await checkbox('Hermes Agent').isChecked(), false, 'cancel discards the draft');
await checkbox('Hermes Agent').check();
await closeAndFocus(() => page.keyboard.press('Escape'));
assert.equal(await stored(), null, 'Escape also discards changes');
// A keyboard user cannot escape the dialog, and cannot save an empty client list.
await manage().click();
for (let index = 0; index < 20; index++) {
await page.keyboard.press('Tab');
assert.equal(await dialog().evaluate(node => node.contains(document.activeElement)), true, 'Tab stays in the modal');
}
for (const control of await dialog().getByRole('checkbox').all()) {
if (await control.isChecked() && await control.isEnabled()) await control.uncheck();
}
const remaining = await dialog().getByRole('checkbox', { checked: true }).count();
assert.ok(remaining <= 1);
if (remaining === 0) assert.equal(await save().isDisabled(), true, 'empty selection must disable Save');
else assert.equal(await dialog().getByRole('checkbox', { checked: true }).isDisabled(), true,
'if the last client cannot be removed, its checkbox must communicate that state');
await checkbox('Hermes Agent').check();
for (const name of automaticClients) if (await checkbox(name).isChecked()) await checkbox(name).uncheck();
assert.equal(await checkbox('Hermes Agent').isChecked(), true);
await closeAndFocus(() => save().click());
await waitClients(['Hermes Agent']);
assert.equal(await activeClient(), 'Hermes Agent', 'removing the active client selects the first remaining client');
assert.deepEqual(JSON.parse(await stored()), ['hermes']);
await page.reload();
await ready();
await waitClients(['Hermes Agent']);
assert.equal(await activeClient(), 'Hermes Agent', 'saved visibility is restored on reload');
// Detection updates automatic mode but never overrides a manually curated list.
const override = { 'kimi-code': { installed: true, configExists: true, version: '2.0' } };
await refresh(override);
await waitClients(['Hermes Agent']);
await manage().click();
await automatic().click();
assert.equal(await checkbox('Kimi Code').isChecked(), true);
assert.equal(await checkbox('Claude Desktop').isChecked(), true);
await closeAndFocus(() => cancel().click());
assert.deepEqual(JSON.parse(await stored()), ['hermes'], 'restoring automatic visibility is still a cancellable draft');
await manage().click();
await automatic().click();
await closeAndFocus(() => save().click());
assert.equal(await stored(), null, 'saving automatic mode clears the custom preference');
await waitClients([...automaticClients, 'Kimi Code', 'Hermes Agent']);
await refresh({ ...override,
'claude-code': { installed: false, configured: false, configExists: false },
openclaw: { installed: true },
});
await waitClients(['Claude Desktop', 'Codex', 'OpenCode', 'Pi', 'Kimi Code', 'OpenClaw', 'Hermes Agent']);
// Detection failure and a clean machine still offer a usable selected client and the full catalog.
await open('not-installed&no-plugin&fresh');
await waitClients(['Codex']);
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 13);
await closeAndFocus(() => cancel().click());
await open('mixed-clients&fail-detection');
await page.getByText(/模拟客户端检测失败/).waitFor();
await waitClients(['Codex']);
await refresh({});
await waitClients(automaticClients);
await open('mixed-clients&shell');
await waitClients(automaticClients);
await page.screenshot({ path: path.join(screenshots, 'automatic-desktop.png'), fullPage: true });
await manage().click();
await dialog().waitFor();
await page.screenshot({ path: path.join(screenshots, 'manager-desktop.png'), fullPage: true });
await closeAndFocus(() => cancel().click());
// Both entry points remain usable with narrow windows and English text in dark mode.
for (const embedded of [false, true]) {
for (const english of [false, true]) {
await page.setViewportSize({ width: 360, height: 800 });
await open(`mixed-clients&shell&${embedded ? 'embedded&' : ''}${english ? 'locale=en&theme=dark' : ''}`);
await waitClients(automaticClients);
const label = `${embedded ? 'embedded' : 'full'}-${english ? 'dark-en' : 'light-zh'}`;
await assertNoHorizontalOverflow(label);
const trigger = page.getByRole('button', { name: english ? 'Manage Clients' : '管理客户端', exact: true });
await trigger.click();
const modal = page.getByRole('dialog', { name: english ? 'Manage Clients' : '管理客户端', exact: true });
await modal.waitFor();
await assertNoHorizontalOverflow(`${label} manager`);
const hermes = modal.getByRole('checkbox', { name: 'Hermes Agent', exact: true });
await hermes.check();
assert.equal(await hermes.isChecked(), true, `${label}: the last catalog entry is reachable`);
await page.screenshot({ path: path.join(screenshots, `${label}-manager.png`), fullPage: true });
await page.keyboard.press('Escape');
await modal.waitFor({ state: 'hidden' });
assert.equal(await trigger.evaluate(node => node === document.activeElement), true);
}
}
assert.deepEqual(errors, []);
console.log('PASS: automatic detection, custom visibility, persistence, active selection, search, cancellation, empty selection, modal focus, retry, narrow layouts and dark English');
console.log(`Screenshots: ${screenshots}`);
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exit(1); });
+52 -28
View File
@@ -30,7 +30,7 @@ const desktopViewports = [
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'get_agent_config_statuses')
await page.waitForFunction(() => window.fixtureCalls?.some(call => call.cmd === 'get_agent_config_statuses')
&& document.querySelector('.agent-config-panel')?.getBoundingClientRect().height > 0
&& !document.querySelector('.agent-client-list-heading button')?.disabled);
await page.evaluate(() => document.fonts.ready);
@@ -119,13 +119,18 @@ const desktopViewports = [
assert.deepEqual(metrics.controlsOutside, [], `${label}: every visible control must remain inside the panel`);
const tabs = await page.locator('.agent-subpage-tabs').evaluateAll(nodes => nodes
.filter(node => getComputedStyle(node).display !== 'none')
.map(node => node.getBoundingClientRect().height));
assert.ok(tabs.every(height => height >= 40), `${label}: the tab bar must retain its full height`);
.map(node => ({
height: node.getBoundingClientRect().height,
buttonHeights: Array.from(node.querySelectorAll('[role="tab"]'))
.map(button => button.getBoundingClientRect().height),
})));
assert.ok(tabs.every(({ height, buttonHeights }) => height >= 39 && buttonHeights.every(value => value >= 38)),
`${label}: the tab bar must retain its 38px buttons and 1px bottom border`);
}
return metrics;
};
const assertClientListScroll = async (label, { requireOverflow = true } = {}) => {
const assertClientListScroll = async (label, { requireOverflow = false } = {}) => {
const metrics = await page.locator('.agent-list-items').evaluate(list => {
const style = getComputedStyle(list);
const overflowX = list.scrollWidth > list.clientWidth + 1;
@@ -166,7 +171,7 @@ const desktopViewports = [
assert.deepEqual(metrics.unreachable, [], `${label}: scrolling must reveal every client option`);
};
const assertDesktopLayout = async (label, { requireClientOverflow = true } = {}) => {
const assertDesktopLayout = async (label, { requireClientOverflow = false } = {}) => {
const [clientList, configuration] = await page.locator('.agent-client-list, .agent-config-panel')
.evaluateAll(nodes => nodes.map(node => {
const rect = node.getBoundingClientRect();
@@ -287,7 +292,7 @@ const desktopViewports = [
if (metrics.tabRect) {
assert.equal(metrics.tabInsideRegion, false,
`${label}: tabs must stay fixed above, not inside, the scrolling content region`);
assert.ok(metrics.tabRect.height >= 40 && metrics.tabHeights.every(height => height >= 34),
assert.ok(metrics.tabRect.height >= 39 && metrics.tabHeights.every(height => height >= 38),
`${label}: the tab bar and tab buttons must retain their full height`);
assert.ok(metrics.tabRect.top >= metrics.panelRect.top - 1
&& metrics.tabRect.bottom <= metrics.panelRect.bottom + 1,
@@ -330,7 +335,7 @@ const desktopViewports = [
};
});
const assertFixedShellDesktop = async (label, { requireRightOverflow = false } = {}) => {
const assertFixedShellDesktop = async (label, { requireRightOverflow = false, requireClientOverflow = false } = {}) => {
const metrics = await shellLayoutMetrics();
assert.ok(Math.abs(metrics.client.height - metrics.configuration.height) <= 1,
`${label}: fixed-height desktop panels must remain equal in height`);
@@ -344,7 +349,7 @@ const desktopViewports = [
`${label}: fixed-height desktop layout must not create a far-right page scrollbar`);
assert.ok(metrics.documentScrollWidth <= metrics.documentClientWidth + 1,
`${label}: fixed-height desktop layout must not create horizontal page scrolling`);
await assertClientListScroll(label, { requireOverflow: requireRightOverflow });
await assertClientListScroll(label, { requireOverflow: requireClientOverflow });
await assertShellScrollRegion(label, { requireOverflow: requireRightOverflow });
return metrics;
};
@@ -355,7 +360,6 @@ const desktopViewports = [
};
const textFlow = { full: {}, embedded: {} };
const naturalHeight = { full: {}, embedded: {} };
for (const viewport of desktopViewports) {
await page.setViewportSize(viewport);
for (const embedded of [false, true]) {
@@ -363,25 +367,39 @@ const desktopViewports = [
await open({ client, embedded });
await openSubpage('基础配置');
const label = `${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/core`;
const core = await assertDesktopLayout(label);
await assertDesktopLayout(label);
if (client === 'zcode' && viewport.height === 941) {
naturalHeight[embedded ? 'embedded' : 'full'][viewport.width] = core.configuration.height;
const status = page.locator('.agent-list-items button').filter({ hasText: 'Antigravity CLI' }).locator('small');
await status.evaluate(node => {
node.textContent = '当前设备已检测到本地智能体客户端,可以直接从此页面启动';
});
const longModel = 'gemini-3.8-flash-high-with-a-long-client-status-for-layout-verification';
await page.evaluate(model => {
window.fixtureClientStatusesOverride = { 'antigravity-cli': { appliedModel: model } };
}, longModel);
await page.locator('.agent-client-list-heading button').click();
await page.waitForFunction(model => Array.from(document.querySelectorAll('.agent-list-items small'))
.some(node => node.textContent.includes(model)), longModel);
const flow = await status.evaluate(node => ({
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
fits: node.scrollWidth <= node.clientWidth + 1 && node.scrollHeight <= node.clientHeight + 1,
rowHeight: node.closest('button').getBoundingClientRect().height,
overflows: node.scrollWidth > node.clientWidth + 1,
fitsVertically: node.scrollHeight <= node.clientHeight + 1,
whiteSpace: getComputedStyle(node).whiteSpace,
overflow: getComputedStyle(node).overflow,
textOverflow: getComputedStyle(node).textOverflow,
title: node.title,
text: node.textContent,
}));
assert.equal(flow.fits, true, `${label}: client status text must wrap without clipping`);
assert.equal(flow.whiteSpace, 'normal', `${label}: client status text must use responsive wrapping`);
const afterWrap = await page.locator('.agent-client-list, .agent-config-panel')
assert.equal(flow.overflows, true, `${label}: the long status fixture must exercise text truncation`);
assert.equal(flow.fitsVertically, true, `${label}: client status text must fit on one line`);
assert.equal(flow.whiteSpace, 'nowrap', `${label}: client status text must remain on one line`);
assert.equal(flow.overflow, 'hidden', `${label}: long client status text must stay within its row`);
assert.equal(flow.textOverflow, 'ellipsis', `${label}: truncated client status text must show an ellipsis`);
assert.equal(flow.title, flow.text, `${label}: the title must retain the full client status text`);
assert.ok(flow.title.includes(longModel), `${label}: the title must include the full long model name`);
const afterTruncation = await page.locator('.agent-client-list, .agent-config-panel')
.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
assert.ok(Math.abs(afterWrap[0] - afterWrap[1]) <= 1,
`${label}: wrapped client text must not break equal panel heights`);
assert.ok(Math.abs(afterTruncation[0] - afterTruncation[1]) <= 1,
`${label}: truncated client text must not break equal panel heights`);
textFlow[embedded ? 'embedded' : 'full'][viewport.width] = flow;
}
@@ -394,10 +412,10 @@ const desktopViewports = [
for (const mode of ['full', 'embedded']) {
assert.ok(textFlow[mode][900].width < textFlow[mode][1280].width,
`${mode}: client text width must follow the proportional sidebar`);
assert.ok(textFlow[mode][900].height > textFlow[mode][1280].height,
`${mode}: client text must reflow as the proportional sidebar narrows`);
assert.ok(naturalHeight[mode][900] > naturalHeight[mode][1280],
`${mode}: the natural panel height must grow when its content reflows at a narrower width`);
assert.ok(Math.abs(textFlow[mode][900].height - textFlow[mode][1280].height) <= 1,
`${mode}: client status must retain a single line as the proportional sidebar narrows`);
assert.ok(Math.abs(textFlow[mode][900].rowHeight - textFlow[mode][1280].rowHeight) <= 1,
`${mode}: compact client rows must retain their height as the proportional sidebar narrows`);
}
// In the real application shell, the desktop workbench fills the same bounded content area as Usage records.
@@ -409,7 +427,10 @@ const desktopViewports = [
await open({ client: 'claude-desktop', shell: true });
await openSubpage('基础配置');
const label = `${viewport.width}x${viewport.height}/shell/claude-desktop/core`;
const metrics = await assertFixedShellDesktop(label, { requireRightOverflow: height === 700 });
const metrics = await assertFixedShellDesktop(label, {
requireRightOverflow: height === 700,
requireClientOverflow: height === 700,
});
fixedShellByHeight.set(height, metrics);
}
const fixedShellEntries = [...fixedShellByHeight.entries()];
@@ -425,11 +446,14 @@ const desktopViewports = [
`${viewportHeight}px shell: vertical resizing must not move the workbench top edge`);
}
// A 900px window still uses the bounded shell, but its usable workspace crosses the one-column breakpoint.
await page.setViewportSize({ width: 900, height: 941 });
// A 790px window keeps the bounded shell while its padded workspace crosses the 760px container breakpoint.
await page.setViewportSize({ width: 790, height: 941 });
await open({ client: 'claude-desktop', shell: true });
await openSubpage('基础配置');
const shellNarrowLabel = '900x941/shell/claude-desktop/core';
const shellNarrowLabel = '790x941/shell/claude-desktop/core';
const shellWorkspaceWidth = await page.locator('.agents-page').evaluate(node => node.clientWidth);
assert.ok(shellWorkspaceWidth <= 760,
`${shellNarrowLabel}: the usable workspace must cross the 760px container breakpoint`);
const shellNarrow = await shellLayoutMetrics();
assert.ok(Math.abs(shellNarrow.client.x - shellNarrow.configuration.x) <= 1
&& Math.abs(shellNarrow.client.width - shellNarrow.configuration.width) <= 1,
+3 -2
View File
@@ -3,9 +3,10 @@ const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1421/') ? route.continue() : route.abort());
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
@@ -22,7 +23,7 @@ const assert = require('node:assert/strict');
return refresh && !refresh.disabled && window.fixtureCalls.some(call => call.cmd === 'get_agent_models');
});
const open = async query => {
await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?reset-selections&' + query);
await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?reset-selections&' + query, { waitUntil: 'domcontentloaded' });
await ready();
};
const remount = async embedded => {
+72 -9
View File
@@ -27,6 +27,18 @@ const fs = require('node:fs/promises');
const clear = target => target.getByRole('button', { name: '清除冷却', exact: true });
const confirmation = () => page.getByRole('alertdialog', { name: '清除凭证冷却?', exact: true });
const requests = () => page.evaluate(() => window.cooldownFixture.resetRequests);
const expandMetadata = async target => {
const button = target.locator('.auth-list-details-button');
if (await button.getAttribute('aria-expanded') !== 'true') await button.click();
};
const expandCooldown = async target => {
const details = target.locator('.auth-file-health-compact > details');
if (await details.getAttribute('open') === null) await details.locator('.auth-health-summary').click();
};
const assertMainRowHeight = async label => {
const heights = await page.locator('.auth-credential-row').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
assert.ok(heights.every(height => height >= 88 && height <= 120), `${label}: cooldown content does not stretch the seven-column main row: ${JSON.stringify(heights)}`);
};
const open = async query => {
await page.goto(`${base}/tests/fixtures/auth-file-cooldown.html?${query || ''}`);
await primary().waitFor();
@@ -41,12 +53,35 @@ const fs = require('node:fs/promises');
await fs.mkdir(screenshotDir, { recursive: true });
await open();
assert.equal(await primary().locator('details').getAttribute('open'), null);
assert.equal(await clear(primary()).isVisible(), true, 'Reset is discoverable while details are collapsed');
assert.equal(await primary().locator('.auth-file-health details').getAttribute('open'), null);
assert.equal(await primary().locator('.auth-list-details').isVisible(), false, 'File metadata starts independently collapsed');
assert.equal(await primary().locator('.auth-file-health-compact').isVisible(), true, 'Cooldown summary is visible before opening any details');
assert.equal(await primary().locator('.auth-health-body').isVisible(), false, 'Individual cooldown records start collapsed');
assert.ok((await primary().locator('.auth-health-heading').innerText()).includes('凭证及 2 个模型冷却'));
assert.equal(await primary().locator('.auth-credential-row > .auth-list-cell').count(), 7, 'Cooldown rows retain the complete seven-column overview');
assert.equal(await primary().locator('.auth-list-recent').isVisible(), true, 'Recent requests stay visible while diagnostics are collapsed');
assert.equal(await primary().locator('.auth-list-usage').isVisible(), true, 'Runtime counters stay visible while diagnostics are collapsed');
assert.equal(await primary().locator('.auth-list-icon-actions > button:visible').count(), 5, 'Credential actions stay directly available');
assert.equal(await clear(primary()).isVisible(), true, 'Reset is directly available beside the collapsed cooldown summary');
await assertMainRowHeight('Collapsed cooldown');
await primary().screenshot({ path: path.join(screenshotDir, 'cooldown-collapsed-light-desktop.png') });
for (const name of ['05-empty.json', '06-null.json', '07-absent.json', '08-no-index.json']) {
assert.equal(await clear(card(name)).count(), 0, `${name} must not offer an unsafe or inapplicable reset`);
}
await primary().locator('.auth-health-summary').click();
assert.equal(await card('05-empty.json').locator('.auth-file-health-compact').count(), 0, 'An empty snapshot does not display a cooldown strip');
assert.equal(await card('06-null.json').locator('.auth-file-health-compact').isVisible(), true);
assert.ok((await card('06-null.json').locator('.auth-file-health-compact').innerText()).includes('冷却状态未知'), 'Null remains an explicit unknown state');
assert.equal(await card('07-absent.json').locator('.auth-file-health-compact').count(), 0, 'A missing snapshot never claims to know cooldown state');
await expandMetadata(card('07-absent.json'));
assert.ok((await card('07-absent.json').locator('.auth-list-details').innerText()).includes('当前内核未提供冷却详情'));
await primary().locator('.auth-health-summary').focus();
await page.keyboard.press('Enter');
assert.equal(await primary().locator('.auth-health-body').isVisible(), true, 'Enter opens cooldown records without opening metadata');
await page.keyboard.press('Space');
assert.equal(await primary().locator('.auth-health-body').isVisible(), false, 'Space closes native cooldown disclosure');
await expandCooldown(primary());
assert.equal(await primary().locator('.auth-list-details').isVisible(), false, 'Cooldown and metadata disclosures are independent');
await assertMainRowHeight('Expanded cooldown');
const details = primary().locator('.auth-health-body');
for (const text of ['整个凭证', 'gpt-fictional', 'claude-fictional', '额度或速率限制', '上游服务暂时异常', 'HTTP 429', 'HTTP 503', '退避等级 0']) {
assert.ok((await details.innerText()).includes(text), `Cooldown detail contains ${text}`);
@@ -58,6 +93,7 @@ const fs = require('node:fs/promises');
assert.equal(await page.evaluate(() => window.cooldownFixture.reads), 1);
assert.ok((await card('04-expired-soon.json').innerText()).includes('冷却计时已到期'));
assert.equal(await clear(card('04-expired-soon.json')).isEnabled(), true, 'Elapsed records remain explicitly resettable');
await expandCooldown(card('03-disabled-runtime.json'));
assert.ok((await card('03-disabled-runtime.json').innerText()).includes('运行时'));
assert.ok((await card('03-disabled-runtime.json').locator('.auth-file-health').innerText()).includes('已停用'));
assert.equal(await clear(card('03-disabled-runtime.json')).isEnabled(), true, 'Runtime and disabled credentials can clear existing cooldown records');
@@ -71,6 +107,7 @@ const fs = require('node:fs/promises');
assert.deepEqual(await requests(), [], 'Cancel leaves the backend untouched');
assert.equal(await primary().locator('.auth-health-records li').count(), 3);
await expandCooldown(other());
const otherDeadline = await other().locator('.auth-health-records time').getAttribute('datetime');
const otherBefore = await other().locator('.auth-health-countdown').innerText();
await confirmReset(primary());
@@ -93,7 +130,7 @@ const fs = require('node:fs/promises');
assert.ok((await page.locator('.app-notice-stack').innerText()).includes('01-primary.json'), 'Success identifies the cleared credential');
await open('resetError=1');
await primary().locator('.auth-health-summary').click();
await expandCooldown(primary());
await confirmReset(primary());
await releaseReset();
await page.getByText(/Fixture cooldown reset failed/).waitFor();
@@ -108,7 +145,8 @@ const fs = require('node:fs/promises');
await page.waitForFunction(() => window.cooldownFixture.reads === 2);
await clear(card('03-disabled-runtime.json')).waitFor({ state: 'detached' });
assert.ok((await card('03-disabled-runtime.json').innerText()).includes('运行时'));
assert.ok((await card('03-disabled-runtime.json').locator('.auth-file-health').innerText()).includes('已停用'), 'Clearing cooldown preserves disabled state');
assert.ok((await card('03-disabled-runtime.json').locator('.auth-list-status').innerText()).includes('已停用'), 'Clearing cooldown preserves disabled state in the main row');
assert.equal(await card('03-disabled-runtime.json').locator('.auth-file-health-compact').count(), 0, 'Successful reset removes only the cooldown strip');
await open();
await confirmReset(primary());
@@ -128,15 +166,40 @@ const fs = require('node:fs/promises');
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 });
await open(`theme=${theme}`);
await primary().locator('.auth-health-summary').click();
await other().locator('.auth-health-summary').click();
const sizes = await page.locator('.auth-file-card, .auth-file-health').evaluateAll(nodes => nodes.map(node => ({
assert.equal(await clear(primary()).isVisible(), true);
assert.equal(await primary().locator('.auth-list-details').isVisible(), false);
if (width >= 1280) await assertMainRowHeight(`${theme} ${width} collapsed`);
await primary().screenshot({ path: path.join(screenshotDir, `cooldown-collapsed-${theme}-${width}.png`) });
await expandCooldown(primary());
await expandCooldown(other());
const sizes = await page.locator('.auth-file-card, .auth-file-health:visible, .auth-file-table-scroll').evaluateAll(nodes => nodes.map(node => ({
className: node.className, width: node.clientWidth, scroll: node.scrollWidth,
})));
assert.ok(sizes.every(size => size.scroll <= size.width + 1), `${theme} ${width}: ${JSON.stringify(sizes)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `No page overflow at ${theme} ${width}`);
await page.screenshot({ path: path.join(screenshotDir, `details-${theme}-${width}.png`), fullPage: true });
await primary().screenshot({ path: path.join(screenshotDir, `card-${theme}-${width}.png`) });
await expandMetadata(primary());
const summary = primary().locator('.auth-file-requests-summary');
assert.equal(await summary.isVisible(), true, 'File details expose the separate request summary');
assert.equal(await summary.locator('.auth-file-request-block:visible').count(), 20);
assert.equal(await primary().locator('.auth-list-recent .auth-file-request-block:visible').count(), 20, 'The main request timeline remains intact');
assert.ok((await summary.locator('.auth-file-requests-heading').innerText()).includes('成功 20'));
assert.ok((await summary.locator('.auth-file-requests-heading').innerText()).includes('失败 2'));
assert.equal(await summary.locator('.auth-file-requests-rate').innerText(), '95.2%', 'Summary rate uses the recent window while counts retain the runtime totals');
const block = summary.locator('.auth-file-request-block').nth(18);
await block.focus();
assert.ok((await summary.getByRole('tooltip').innerText()).includes('失败 1'));
await page.keyboard.press('ArrowRight');
assert.equal(await summary.locator('.auth-file-request-block').nth(19).evaluate(node => node === document.activeElement), true);
await page.keyboard.press('ArrowLeft');
assert.equal(await block.evaluate(node => node === document.activeElement), true);
await page.keyboard.press('Escape');
assert.equal(await summary.getByRole('tooltip').count(), 0);
await block.blur();
assert.equal(await summary.evaluate(node => node.scrollWidth <= node.clientWidth + 1), true, 'Request summary fits desktop and mobile');
if (width >= 1280) await assertMainRowHeight(`${theme} ${width} expanded`);
await primary().screenshot({ path: path.join(screenshotDir, `cooldown-and-requests-${theme}-${width}.png`) });
await clear(primary()).click();
await confirmation().waitFor();
assert.equal(await confirmation().evaluate(node => node.scrollWidth <= node.clientWidth + 1), true, 'Confirmation fits viewport');
@@ -146,7 +209,7 @@ const fs = require('node:fs/promises');
}
}
assert.deepEqual(errors, []);
console.log('PASS: cooldown models/reasons/countdowns, confirmation and cancel, single auth_index POST, pending duplicate protection, isolated success refresh, failure preservation, stale response protection, unknown/empty/missing-index guards, elapsed records, runtime/disabled state, light/dark desktop/mobile layout.');
console.log('PASS: directly visible cooldown summary/reset, independent keyboard disclosure, seven-column main-row height, cooldown models/reasons/countdowns, request summary counts/rate and keyboard tooltips, confirmation and cancel, single auth_index POST, pending duplicate protection, isolated success refresh, failure preservation, stale response protection, unknown/empty/missing-index guards, elapsed records, runtime/disabled state, light/dark desktop/mobile layout.');
console.log(`Screenshots: ${screenshotDir}`);
} finally { await browser?.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+98 -19
View File
@@ -15,7 +15,7 @@ const fs = require('node:fs/promises');
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } });
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 }, timezoneId: 'Asia/Shanghai' });
page.setDefaultTimeout(10000);
const errors = [], externalRequests = [];
page.on('pageerror', error => errors.push(String(error)));
@@ -30,27 +30,68 @@ const fs = require('node:fs/promises');
const pagination = () => page.locator('.auth-list-pagination');
const next = () => pagination().getByRole('button', { name: '下一页', exact: true });
const previous = () => pagination().getByRole('button', { name: '上一页', exact: true });
const expand = async target => {
const button = target.locator('.auth-list-details-button');
if (await button.getAttribute('aria-expanded') !== 'true') await button.click();
};
const open = async (query = '') => {
await page.goto(`${base}/tests/fixtures/auth-file-list.html?${query}`);
await card(1).waitFor();
await page.waitForFunction(() => window.authFileListFixture.reads === 1);
};
const assertFits = async label => {
const dimensions = await cards().evaluateAll(nodes => nodes.map(node => ({ width: node.clientWidth, scroll: node.scrollWidth })));
assert.ok(dimensions.every(size => size.scroll <= size.width + 1), `${label}: cards overflow ${JSON.stringify(dimensions)}`);
const dimensions = await page.locator('.auth-file-card, .auth-file-table-scroll').evaluateAll(nodes => nodes.map(node => ({
className: node.className, width: node.clientWidth, scroll: node.scrollWidth, overflowX: getComputedStyle(node).overflowX,
})));
assert.ok(dimensions.every(size => size.scroll <= size.width + 1 || (
size.className === 'auth-file-table-scroll' && size.width > 700 && size.width < 906 && size.overflowX === 'auto'
)), `${label}: only intermediate widths may scroll inside the table; cards must fit ${JSON.stringify(dimensions)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `${label}: page overflow`);
};
const assertMainInformation = async (label, width) => {
for (const selector of ['.auth-card-filename', '.auth-list-plan', '.auth-list-status', '.auth-list-recent', '.auth-list-usage', '.auth-list-quota', '.auth-card-actions']) {
assert.equal(await card(1).locator(selector).isVisible(), true, `${label}: ${selector} is visible without opening details`);
}
assert.equal(await card(1).locator('.auth-list-recent .auth-file-request-block:visible').count(), 20, `${label}: the request timeline remains visible`);
assert.equal(await card(1).locator('.auth-file-usage-metric:visible').count(), 4, `${label}: all four runtime counters remain visible`);
assert.equal(await card(1).locator('.auth-list-icon-actions > button:visible').count(), 5, `${label}: all five actions are directly available`);
assert.equal(await card(1).getByRole('switch').isVisible(), true);
assert.equal(await card(1).locator('.auth-list-details-button').isVisible(), true);
assert.equal(await card(1).locator('.auth-list-details').isVisible(), false, `${label}: file metadata and the expanded request summary start collapsed`);
if (width >= 1280) {
const heights = await page.locator('.auth-credential-row').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
assert.ok(heights.every(height => height >= 88 && height <= 120), `${label}: dense desktop rows stay near 104px, including quota footnotes: ${JSON.stringify(heights)}`);
const cells = await card(1).locator('.auth-credential-row > .auth-list-cell').evaluateAll(nodes => nodes.map(node => {
const bounds = node.getBoundingClientRect(); return { left: bounds.left, right: bounds.right };
}));
assert.equal(cells.length, 7, `${label}: desktop has seven information columns`);
assert.ok(cells.slice(1).every((cell, index) => cell.left >= cells[index].right - 1), `${label}: all seven columns remain in one row`);
}
};
await fs.mkdir(screenshotDir, { recursive: true });
await open();
assert.equal(await cards().count(), 10, 'Default page contains ten of twelve credentials');
assert.equal(await previous().isDisabled(), true);
assert.ok((await pagination().innerText()).includes('1 / 2'));
assert.equal(await page.locator('.auth-file-list-head > span').count(), 7);
const cells = await card(1).locator('.auth-credential-row > .auth-list-cell').evaluateAll(nodes => nodes.map(node => {
const bounds = node.getBoundingClientRect(); return { left: bounds.left, right: bounds.right };
}));
assert.equal(cells.length, 7, 'Desktop row includes all seven columns');
assert.ok(cells.slice(1).every((cell, index) => cell.left >= cells[index].right - 1), 'Desktop columns stay in one horizontal row');
await assertMainInformation('initial desktop', 1800);
assert.equal(await card(2).locator('.auth-file-health-compact').isVisible(), true, 'Cooldown summary is directly visible below the main row');
assert.equal(await card(2).locator('.auth-health-body').isVisible(), false, 'Individual cooldown records start collapsed');
assert.equal(await card(2).getByRole('button', { name: '清除冷却', exact: true }).isVisible(), true, 'Clear cooldown is available without opening file details');
assert.equal(await card(1).getByRole('button', { name: '删除', exact: true }).isVisible(), true, 'Delete is directly available alongside the other actions');
assert.equal(await card(4).locator('.credential-quota-row:visible').count(), 2, 'The first two quota windows are always shown');
assert.equal(await card(2).locator('.credential-quota-row.low .credential-quota-label strong').first().innerText(), '0%', 'Exhausted quota stays explicitly zero');
assert.equal(await card(2).getByRole('progressbar').first().getAttribute('aria-valuenow'), '0');
assert.equal(await card(1).getByRole('progressbar').first().getAttribute('aria-valuenow'), '72');
assert.equal(await card(1).getByRole('progressbar').nth(1).getAttribute('aria-valuenow'), '83');
assert.equal(await card(5).locator('.credential-quota-row.unknown [role="progressbar"]').count(), 0, 'Unknown quota is not reported as a zero-percent progress bar');
assert.equal(await card(5).locator('.credential-quota-label strong').innerText(), '—', 'Unknown quota retains its separate presentation');
const resetTimes = card(1).locator('.credential-quota-reset-time');
assert.equal(await resetTimes.count(), 2, 'Both quota reset times stay visible');
assert.ok((await resetTimes.first().innerText()).includes('10/03 15:00'), 'Five-hour window preserves its exact reset timestamp');
assert.ok((await resetTimes.nth(1).innerText()).includes('10/08 12:00'), 'Weekly window preserves its exact reset timestamp');
const resetTitle = await resetTimes.first().getAttribute('title');
assert.ok(resetTitle.startsWith(await resetTimes.first().innerText()) && resetTitle.includes('小时后'), 'Compact timestamps retain their exact time and relative countdown on hover');
await next().click();
await card(11).waitFor();
assert.equal(await cards().count(), 2);
@@ -83,23 +124,35 @@ const fs = require('node:fs/promises');
assert.equal(await detailsButton.getAttribute('aria-expanded'), 'true');
assert.equal(await card(1).locator('.auth-list-details').isVisible(), true);
assert.ok((await card(1).locator('.auth-list-details').innerText()).includes('Fictional account note'));
const requestSummary = card(1).locator('.auth-file-requests-summary');
assert.equal(await requestSummary.isVisible(), true, 'File details include the additional compact request summary');
assert.equal(await requestSummary.locator('.auth-file-request-block:visible').count(), 20);
assert.ok((await requestSummary.locator('.auth-file-requests-heading').innerText()).includes('成功 1,200'));
assert.ok((await requestSummary.locator('.auth-file-requests-heading').innerText()).includes('失败 3'));
await requestSummary.locator('.auth-file-request-block').nth(18).focus();
assert.equal(await requestSummary.getByRole('tooltip').isVisible(), true, 'Expanded request summary retains keyboard tooltips');
await page.keyboard.press('Escape');
await requestSummary.locator('.auth-file-request-block').nth(18).blur();
await detailsButton.click();
assert.equal(await card(1).locator('.auth-list-details').isVisible(), false);
const extraQuota = card(4).locator('.credential-quota-more');
assert.equal(await extraQuota.getAttribute('open'), null);
const extraQuota = card(4).locator('.auth-list-quota .credential-quota');
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), false);
await extraQuota.locator('summary').click();
await extraQuota.locator('.credential-quota-more > summary').click();
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), true, 'All additional quotas remain accessible');
assert.ok((await extraQuota.innerText()).includes('No quota limit supplied'));
assert.equal(await card(1).locator('.auth-file-request-block').count(), 20);
await card(1).locator('.auth-file-request-block').nth(18).focus();
assert.equal(await card(1).getByRole('tooltip').isVisible(), true, 'Recent request details are keyboard accessible');
assert.equal(await extraQuota.locator('.credential-quota-row:visible').count(), 4, 'Expanding quota details shows all four rows');
assert.equal(await card(4).locator('.auth-list-details').isVisible(), false, 'Additional quota rows open independently from health and metadata');
await extraQuota.locator('.credential-quota-more > summary').click();
assert.equal(await card(1).locator('.auth-list-recent .auth-file-request-block').count(), 20);
await card(1).locator('.auth-list-recent .auth-file-request-block').nth(18).focus();
assert.equal(await card(1).locator('.auth-list-recent').getByRole('tooltip').isVisible(), true, 'Recent request details are keyboard accessible');
await page.keyboard.press('Escape');
await card(1).locator('.auth-file-request-block').nth(18).blur();
await card(1).locator('.auth-list-recent .auth-file-request-block').nth(18).blur();
assert.equal(await card(2).getByRole('button', { name: '清除冷却', exact: true }).isVisible(), true);
await card(2).locator('.auth-health-summary').click();
assert.ok((await card(2).locator('.auth-health-body').innerText()).includes('gpt-fictional-layout'), 'Model cooldown details remain available');
assert.equal(await card(2).locator('.auth-list-details').isVisible(), false, 'Cooldown expansion is independent of file metadata');
await card(1).getByRole('switch').click();
await page.waitForFunction(() => window.authFileListFixture.reads === 2);
@@ -118,29 +171,55 @@ const fs = require('node:fs/promises');
await card(12).getByRole('button', { name: '删除', exact: true }).click();
const deleteDialog = page.getByRole('alertdialog', { name: '删除', exact: true });
await deleteDialog.waitFor();
assert.equal(await page.evaluate(() => window.authFileListFixture.requests.filter(request => request.method === 'DELETE').length), 0, 'The direct delete action still requires confirmation');
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click();
assert.equal(await card(12).count(), 1, 'Cancel preserves the credential');
await card(12).getByRole('button', { name: '删除', exact: true }).click();
await deleteDialog.waitFor();
await deleteDialog.getByRole('button', { name: '删除', exact: true }).click();
await card(12).waitFor({ state: 'detached' });
assert.equal(await cards().count(), 11);
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.requests.find(request => request.method === 'DELETE')?.query), { name: '12-account.json' });
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.unhandled), []);
const cases = ['light', 'dark'].flatMap(theme => [1800, 1280, 390].map(width => ({ theme, width, locale: 'zh-CN' })));
cases.push({ theme: 'light', width: 1800, locale: 'en' }, { theme: 'light', width: 390, locale: 'en' });
const cases = ['light', 'dark'].flatMap(theme => [1800, 1280, 1024, 390].flatMap(width => ['zh-CN', 'en'].map(locale => ({ theme, width, locale }))));
for (const { theme, width, locale } of cases) {
await page.setViewportSize({ width, height: 1000 });
await open(`theme=${theme}&locale=${locale}`);
await assertMainInformation(`${locale} ${theme} ${width}`, width);
await assertFits(`${locale} ${theme} ${width}`);
await page.screenshot({ path: path.join(screenshotDir, `list-${locale}-${theme}-${width}.png`), fullPage: true });
await card(1).locator('.auth-list-details-button').click();
await expand(card(1));
await card(2).locator('.auth-health-summary').click();
await card(4).locator('.credential-quota-more > summary').click();
await assertFits(`${locale} ${theme} ${width} expanded`);
await card(1).screenshot({ path: path.join(screenshotDir, `request-summary-${locale}-${theme}-${width}.png`) });
await card(2).screenshot({ path: path.join(screenshotDir, `cooldown-${locale}-${theme}-${width}.png`) });
await card(4).screenshot({ path: path.join(screenshotDir, `expanded-quota-${locale}-${theme}-${width}.png`) });
await open(`theme=${theme}&locale=${locale}&small=1`);
assert.equal(await cards().count(), 3, 'Screenshot-like idle state shows three accounts');
assert.equal(await pagination().count(), 0, 'Small lists omit unnecessary pagination');
assert.equal(await page.locator('.auth-list-details:visible, .auth-file-health:visible').count(), 0, 'Small idle list starts with diagnostics collapsed');
await assertMainInformation(`${locale} ${theme} ${width} small idle`, width);
assert.equal(await page.locator('.auth-file-usage-metric:visible').count(), 12, 'Idle counters stay visible on all three rows');
assert.equal(await page.locator('.auth-list-quota:visible').count(), 3, 'Disabled and idle quota states remain visible');
await assertFits(`${locale} ${theme} ${width} small idle`);
await page.screenshot({ path: path.join(screenshotDir, `small-idle-${locale}-${theme}-${width}.png`), fullPage: true });
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.unhandled), []);
}
await page.setViewportSize({ width: 1800, height: 1200 });
await open('theme=light&locale=zh-CN');
const tableBounds = await page.locator('.auth-file-table-scroll').boundingBox();
const seventhBounds = await card(7).boundingBox();
await page.screenshot({ path: path.join(screenshotDir, 'reference-dense-preview.png'), clip: {
x: tableBounds.x, y: tableBounds.y, width: tableBounds.width,
height: seventhBounds.y + seventhBounds.height - tableBounds.y,
} });
await open('theme=light&locale=zh-CN&small=1');
await page.locator('.auth-file-table-scroll').screenshot({ path: path.join(screenshotDir, 'reference-small-idle-preview.png') });
assert.deepEqual(errors, []);
assert.deepEqual(externalRequests, []);
console.log('PASS: offline credential list, seven desktop columns, ten/two pagination, search/provider/status reset, page size twenty, details, all quota rows, request keyboard tooltip, cooldown controls, exact status PATCH, deletion, light/dark 1800/1280/390 and English desktop/mobile without page or card overflow.');
console.log('PASS: offline seven-column dense credential list with visible request/runtime/quota/action data, compact row height, exact quota percentages/reset timestamps, unknown quota, ten/two pagination, search/provider/status reset, page size twenty, additional quota disclosure, main-row and detail-summary request keyboard tooltips, directly visible cooldown controls, exact status PATCH, direct delete confirmation/cancel, small idle accounts without pagination, light/dark Chinese/English 1800/1280/1024/390 without page or card overflow, contained table scrolling only at intermediate widths.');
console.log(`Screenshots: ${screenshotDir}`);
} finally { await browser?.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+24 -1
View File
@@ -9,7 +9,7 @@ const cooldown = {
retry_at: '2026-01-01T10:00:32Z', remaining_seconds: 32, http_status: 429,
};
const render = (file: Record<string, unknown>, options: {
receivedAtMs?: number; resetting?: boolean; resetDisabled?: boolean; onReset?: () => void;
receivedAtMs?: number; resetting?: boolean; resetDisabled?: boolean; onReset?: () => void; compact?: boolean;
} = {}) => renderToStaticMarkup(<I18nProvider>
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} onReset={() => {}} {...options} />
</I18nProvider>);
@@ -46,4 +46,27 @@ describe('credential cooldown actions', () => {
expect(pending).toMatch(/<button[^>]*disabled=""[^>]*aria-busy="true"/);
expect(pending).toContain('Clearing…</button>');
});
it('keeps cooldown scope visible for disabled credentials in the compact strip', () => {
const html = render({ disabled: true, cooldowns: [
{ ...cooldown, scope: 'credential', model_key: undefined, reason: 'credential_quota' },
cooldown,
{ ...cooldown, model_key: 'model-b' },
] }, { compact: true });
const summary = html.slice(0, html.indexOf('</summary>'));
expect(summary).toContain('auth-file-health-compact');
expect(summary).toContain('Credential and 2 models cooling down');
expect(summary).not.toContain('<strong>Disabled</strong>');
expect(html).toContain('HTTP 429');
expect(html.slice(html.indexOf('</details>'))).toContain('Clear cooldown</button>');
});
it('does not report unknown snapshots as a known clear cooldown state', () => {
const unknown = render({ status: 'active', cooldowns: null }, { compact: true });
expect(unknown).toContain('auth-file-health-compact');
expect(unknown).toContain('Cooldown state is unknown');
expect(unknown).not.toContain('Clear cooldown</button>');
expect(render({ status: 'active', cooldowns: [] }, { compact: true })).not.toContain('auth-file-health');
expect(render({ status: 'active' }, { compact: true })).not.toContain('auth-file-health');
});
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
import { I18nProvider } from '../src/i18n';
import type { AuthFile, QuotaState } from '../src/services/quotaService';
const renderDense = (quota: QuotaState, file: AuthFile = { name: 'test.json', provider: 'codex' }, busy = false) =>
renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={file} quota={quota}
compact dense disabled={busy || file.disabled === true} onRefresh={() => {}} onReset={() => {}} /></I18nProvider>);
it('keeps window percentages, reset times and additional windows available in dense mode', () => {
const html = renderDense({ status: 'success', rows: [
{ label: '5h', remainingPercent: 140, reset: '10:30', detail: 'Window detail' },
{ label: 'weekly', remainingPercent: null, reset: 'Friday' },
{ label: 'extra', remainingPercent: 0, reset: 'Monday' },
] });
expect(html).toContain('credential-quota-dense');
expect(html).toContain('aria-valuenow="100"');
expect(html).not.toContain('140%');
expect(html).toContain('credential-quota-row unknown');
expect(html).toContain('title="10:30">10:30</small>');
expect(html).toContain('Window detail');
expect(html).toContain('<details class="credential-quota-more">');
expect(html).toContain('extra');
});
it('shows reset credits in the button while preserving applicability and expiry', () => {
const html = renderDense({ status: 'success', rows: [{ label: '5h', remainingPercent: 0 }],
resetCredits: 2, resetCreditsApplicable: 0, subscriptionActiveUntil: '2030-01-01T00:00:00Z',
});
const resetButton = html.match(/<button[^>]*credential-quota-reset[^>]*>[\s\S]*?<\/button>/)?.[0];
expect(resetButton).toContain('2');
expect(resetButton).not.toContain('disabled=""');
expect(html).toContain('Currently applicable: 0');
expect(html).toContain('Subscription expires');
});
it('does not confuse a busy list with a disabled credential or discard cached quota', () => {
const idle = renderDense({ status: 'idle', rows: [] }, undefined, true);
expect(idle).toContain('Quota has not been fetched');
expect(idle).not.toContain('Disabled');
const disabled = renderDense({ status: 'idle', rows: [] }, { name: 'test.json', disabled: true });
expect(disabled).toContain('Disabled');
const cached = renderDense({ status: 'success', rows: [{ label: '5h', remainingPercent: 75 }] },
{ name: 'test.json', disabled: true }, true);
expect(cached).toContain('aria-valuenow="75"');
});
it('keeps raw failures accessible without manufacturing progress in dense error and loading states', () => {
const failed = renderDense({ status: 'error', rows: [], error: 'upstream diagnostic details' });
expect(failed).toContain('<details class="credential-quota-error-details">');
expect(failed).toContain('title="upstream diagnostic details"');
expect(failed).toContain('<small>upstream diagnostic details</small>');
expect(failed).not.toContain('role="progressbar"');
const pending = renderDense({ status: 'loading', rows: [], pendingAction: 'reset' });
expect(pending).toContain('aria-busy="true"');
expect(pending).toContain('indeterminate');
expect(pending).not.toContain('aria-valuenow');
});
@@ -0,0 +1,56 @@
import { expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AuthFileRequestStatus } from '../src/components/AuthFileRequestStatus';
import { I18nProvider } from '../src/i18n';
const render = (file: Record<string, unknown>, summary = true, compact = false) =>
renderToStaticMarkup(<I18nProvider><AuthFileRequestStatus file={file} summary={summary} compact={compact} /></I18nProvider>);
it('keeps cumulative counters separate from the recent rate and preserves interval labels', () => {
const html = render({ success: 900, failed: 100, recent_requests: [
{ time: '23:50-00:00', success: 3, failed: 1 },
{ time: '00:00-00:10', success: 0, failed: 2 },
] });
expect(html).toContain('auth-file-requests-summary');
expect(html).toContain('>Success 900</span>');
expect(html).toContain('>Failed 100</span>');
expect(html).toContain('aria-label="Success rate 50%"');
expect(html.match(/<button /g)).toHaveLength(20);
expect(html).toContain('aria-label="23:50-00:00 · Success 3 · Failed 1 · Success rate 75%"');
expect(html).toContain('aria-label="00:00-00:10 · Success 0 · Failed 2 · Success rate 0%"');
expect(html).not.toContain('class="auth-file-requests-window"');
});
it('distinguishes missing statistics, known zero counters, and an actual zero percent success rate', () => {
const unknown = render({});
expect(unknown).toContain('>Success —</span>');
expect(unknown).toContain('>Failed —</span>');
expect(unknown).toContain('The core did not provide recent request statistics');
expect(unknown).toContain('aria-label="Success rate —"');
expect(unknown).not.toContain('<button ');
const empty = render({ success: 0, failed: 0, recent_requests: [] });
expect(empty).toContain('>Success 0</span>');
expect(empty).toContain('>Failed 0</span>');
expect(empty).toContain('aria-label="Success rate —"');
expect(empty.match(/<button /g)).toHaveLength(20);
const failed = render({ success: 0, failed: 2, recent_requests: [{ time: '10:00-10:10', success: 0, failed: 2 }] });
expect(failed).toContain('aria-label="Success rate 0%"');
expect(failed).toContain('background-color:rgb(239, 68, 68)');
});
it('preserves existing full and compact layouts while making summary take precedence', () => {
const file = { success: 2, failed: 0, recent_requests: [{ time: '10:00-10:10', success: 2, failed: 0 }] };
const full = render(file, false);
expect(full).toContain('class="auth-file-requests-window"');
expect(full).toContain('auth-file-requests-rate success');
const compact = render(file, false, true);
expect(compact).toContain('auth-file-requests-compact');
expect(compact).toContain('auth-file-requests-latest');
expect(compact).not.toContain('auth-file-requests-rate');
const summary = render(file, true, true);
expect(summary).toContain('auth-file-requests-summary');
expect(summary).not.toContain('auth-file-requests-compact');
expect(summary).toContain('aria-label="Success rate 100%"');
});
+52
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import {
authFileCooldownResetIndex,
changedOAuthAuthFileNames,
dedupeAuthFiles,
isOAuthCredentialFile,
@@ -41,6 +42,57 @@ describe('认证文件列表规范化', () => {
expect(files[0].account_type).toBeUndefined();
expect(isOAuthCredentialFile(files[0])).toBe(true);
});
const cooldown = {
scope: 'model', model_key: 'model-b', reason: 'quota',
retry_at: '2040-01-01T00:00:00Z', remaining_seconds: 30,
};
it('keeps different auth indexes separate when filenames match', () => {
const disk = { name: 'same.json', auth_index: 'A', source: 'file', path: '/synthetic/a', disabled: false };
const runtime = { name: 'same.json', auth_index: 'B', runtime_only: true, cooldowns: [cooldown] };
for (const entries of [[disk, runtime], [runtime, disk]]) {
const files = dedupeAuthFiles(entries);
expect(files).toHaveLength(2);
expect(files.find((file) => authFileCooldownResetIndex(file) === 'A')).toEqual(disk);
expect(files.find((file) => authFileCooldownResetIndex(file) === 'B')).toEqual(runtime);
}
});
it('merges the same normalized index and preserves atomic empty or unknown cooldowns', () => {
for (const cooldowns of [[], null]) {
const files = dedupeAuthFiles([
{ name: 'same.json', auth_index: 0, source: 'file', path: '/synthetic/a', cooldowns },
{ name: 'same.json', authIndex: ' 0 ', runtime_only: true, email: 'account@example.test', cooldowns: [cooldown] },
{ name: 'same.json', auth_index: 'B', runtime_only: true, cooldowns: [cooldown] },
]);
expect(files).toHaveLength(2);
const sameIdentity = files.find((file) => authFileCooldownResetIndex(file) === '0')!;
expect(sameIdentity.path).toBe('/synthetic/a');
expect(sameIdentity.email).toBe('account@example.test');
expect(sameIdentity.runtime_only).toBeUndefined();
expect(sameIdentity.cooldowns).toEqual(cooldowns);
expect(files.find((file) => authFileCooldownResetIndex(file) === 'B')?.cooldowns).toEqual([cooldown]);
}
});
it('keeps unindexed records separate when the filename has conflicting identities', () => {
const files = dedupeAuthFiles([
{ name: 'same.json', source: 'file', path: '/synthetic/unknown', cooldowns: [cooldown] },
{ name: 'same.json', auth_index: 'A', runtime_only: true, cooldowns: [] },
{ name: 'same.json', authIndex: 'B', runtime_only: true, cooldowns: null },
]);
expect(files).toHaveLength(3);
const unknown = files.find((file) => authFileCooldownResetIndex(file) === undefined)!;
expect(unknown.path).toBe('/synthetic/unknown');
expect(unknown.cooldowns).toEqual([cooldown]);
for (const index of ['A', 'B']) {
const file = files.find((entry) => authFileCooldownResetIndex(entry) === index)!;
expect(file.path).toBeUndefined();
expect(file.runtime_only).toBe(true);
expect(file.cooldowns).toEqual(index === 'A' ? [] : null);
}
});
});
const oauthFile = { name: 'codex-user.json', provider: 'codex', source: 'file', account_type: 'oauth' };
+12 -1
View File
@@ -27,6 +27,7 @@ const harnessModels=[
const harnessSnapshot=()=>({revision:String(harnessRevision),provider:harnessProvider,baseUrl:'http://127.0.0.1:8317/v1',defaultModel:currentModel,configured:!!currentModel,models:harnessModels.map(m=>({...m,configuration:harnessConfigurations[m.id]??{}}))});
let harnessStatus={running:params.has('running'),pid:params.has('running')?100:null as number|null,mode:params.has('running')?params.get('harness-mode')||'web':null as string|null};
let appliedCount=0;
let detectionCount=0;
(window as any).fixtureOauthLoggedIn=!params.has('no-oauth-login');
const calls:any[]=[];(window as any).fixtureCalls=calls;
let embedded=params.has('embedded');
@@ -35,7 +36,17 @@ mockIPC(async (cmd,args:any) => {
calls.push({cmd,args});
if(cmd==='plugin:event|listen') return 1;
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed')&&params.get('config-only')!==id,pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')||params.get('config-only')===id?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configExists:!params.has('not-installed')||params.get('config-only')===id,configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&&currentOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status);
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') {
if(params.has('fail-detection')&&++detectionCount===1)throw new Error('模拟客户端检测失败');
return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed')&&params.get('config-only')!==id,pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')||params.get('config-only')===id?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configExists:!params.has('not-installed')||params.get('config-only')===id,configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&&currentOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null}))
.map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status)
.map(status=>{
if(!params.has('mixed-clients'))return status;
const installed=['claude-code','codex','opencode'].includes(status.id);
return {...status,installed,pluginInstalled:status.id==='pi',configExists:installed||status.id==='claude-desktop',configured:installed&&!!currentModel,configurationSynchronized:installed&&!!currentModel,connectionState:installed&&currentModel?'configured':'not-configured',currentModel:installed?currentModel:null,appliedModel:installed?currentModel:null,modificationEnabled:installed&&!!currentModel,modificationState:installed&&currentModel?'applied':'unconfigured',launchTargets:installed?status.launchTargets:[],version:installed?status.version:null,cliVersion:installed?status.cliVersion:null,pluginVersion:status.id==='pi'?'1.0':null};
})
.map(status=>({...status,...((window as any).fixtureClientStatusesOverride?.[status.id]??{})}));
}
if(cmd==='close_codex_config_modification') {
closeCount++;
if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚');
+5
View File
@@ -53,6 +53,11 @@ mockIPC(async (cmd, args) => {
observed_at: new Date().toISOString(),
files: state.files.map(file => ({
provider: 'codex', source: 'file', size: 1024, updated_at: '2026-10-01T00:00:00Z',
recent_requests: Array.from({ length: 20 }, (_, index) => ({
time: `${String(8 + Math.floor(index / 6)).padStart(2, '0')}:${String(index % 6 * 10).padStart(2, '0')}`,
success: index === 19 ? 0 : index === 18 ? 2 : 1,
failed: index === 18 ? 1 : 0,
})),
success: 20, failed: 2, ...file,
...(Array.isArray(file.cooldowns) ? { cooldowns: file.cooldowns.map(record => ({
...record, remaining_seconds: Math.max(1, Number(record.remaining_seconds) - elapsed),
+10 -7
View File
@@ -12,26 +12,29 @@ const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
document.documentElement.dataset.theme = params.get('theme') || 'light';
const startedAt = Date.now();
const smallList = params.has('small');
type Request = { path: string; method: string; body?: Record<string, unknown>; query?: Record<string, string> };
const files: Record<string, unknown>[] = Array.from({ length: 12 }, (_, index) => ({
const files: Record<string, unknown>[] = Array.from({ length: smallList ? 3 : 12 }, (_, index) => ({
name: `${String(index + 1).padStart(2, '0')}-account.json`,
email: index === 0 ? 'long-fictional-account-for-layout@example.test' : `account-${index + 1}@example.test`,
auth_index: `fixture-account-${index + 1}`, provider: index === 3 ? 'claude' : index === 4 ? 'xai' : 'codex',
auth_index: `fixture-account-${index + 1}`, provider: smallList ? ['codex', 'devin', 'xai'][index] : index === 3 ? 'claude' : index === 4 ? 'xai' : 'codex',
source: 'file', size: 1800 + index * 350, updated_at: '2026-10-01T00:00:00Z',
disabled: index === 2 || index === 6, status: index === 2 || index === 6 ? 'disabled' : 'active',
priority: index % 3, plan_type: index % 3 === 0 ? 'Pro 20x' : 'Team',
disabled: smallList ? index !== 1 : index === 2 || index === 6,
status: (smallList ? index !== 1 : index === 2 || index === 6) ? 'disabled' : 'active',
priority: smallList ? 0 : index % 3, plan_type: smallList ? index === 0 ? 'Team' : '' : index % 3 === 0 ? 'Pro 20x' : 'Team',
note: index === 0 ? 'Fictional account note, visible after opening credential details.' : '',
success: 1200 + index * 2450, failed: 3 + index * 97,
recent_requests: Array.from({ length: 20 }, (_, bucket) => ({
success: smallList ? 0 : 1200 + index * 2450, failed: smallList ? 0 : 3 + index * 97,
recent_requests: Array.from({ length: smallList ? 0 : 20 }, (_, bucket) => ({
time: `${String(8 + Math.floor(bucket / 6)).padStart(2, '0')}:${String(bucket % 6 * 10).padStart(2, '0')}`,
success: bucket === 19 ? 0 : (bucket + index) % 7 + 1,
failed: bucket === 19 || (bucket + index) % 5 ? 0 : 2,
})),
cooldowns: index === 1 ? [{ scope: 'model', model_key: 'gpt-fictional-layout', reason: 'quota',
cooldowns: !smallList && index === 1 ? [{ scope: 'model', model_key: 'gpt-fictional-layout', reason: 'quota',
retry_at: new Date(startedAt + 180000).toISOString(), remaining_seconds: 180, http_status: 429, backoff_level: 0 }] : [],
}));
updateQuotaCache(Object.fromEntries(files.map((file, index) => {
if (smallList) return [quotaKey(file), { status: 'idle', rows: [] } satisfies QuotaState];
const quota: QuotaState = {
status: 'success', plan: String(file.plan_type),
subscriptionActiveUntil: index === 0 ? '2026-11-01T00:00:00Z' : undefined,
+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 providers fixture</title></head><body><div id="root"></div><script type="module" src="./plugin-oauth-providers.tsx"></script></body></html>
+99
View File
@@ -0,0 +1,99 @@
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 { notifyPluginResourcesChanged } from '../../src/services/pluginResources';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en');
localStorage.setItem('easy-cli-proxy-api.oauth-browser.v3', 'none');
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
const plugin = (id: string, name: string, extra: Record<string, unknown> = {}) => ({
id, path: `/plugins/${id}`, configured: true, registered: true,
enabled: true, effective_enabled: true, supports_oauth: true, oauth_provider: id,
supports_quota: false, logo: '', menus: [], config_fields: [],
metadata: { name, version: '1.0.0', author: 'Fixture', logo: '', config_fields: [] },
...extra,
});
const installedPlugins = [plugin('zcode', 'ZCode'), plugin('codebuddy', 'CodeBuddy')];
const filteredPlugins = [
plugin('disabled', 'Disabled provider', { enabled: false, effective_enabled: false }),
plugin('unregistered', 'Unregistered provider', { registered: false, effective_enabled: false }),
plugin('non-oauth', 'Non OAuth provider', { supports_oauth: false }),
plugin('invalid', 'Invalid provider', { oauth_provider: '../unsafe' }),
plugin('missing', 'Missing provider', { oauth_provider: '' }),
plugin('zcode-duplicate', 'Duplicate ZCode', { oauth_provider: 'zcode' }),
plugin('builtin-duplicate', 'Duplicate Codex', { oauth_provider: 'codex' }),
];
const fixture = {
calls: [] as { cmd: string; args: Record<string, unknown> }[],
plugins: params.has('empty') ? [] : params.has('pendingRegistration')
? [plugin('zcode', 'ZCode', { registered: false, effective_enabled: false })]
: [...installedPlugins, ...filteredPlugins],
installedPlugins,
listCalls: 0,
holdNextList: false,
releaseList: null as (() => void) | null,
releasedLists: 0,
starts: 0,
status: 'wait',
cancelled: [] as string[],
refresh: notifyPluginResourcesChanged,
};
Object.assign(window, { pluginOAuthProvidersFixture: fixture });
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;
mockIPC(async (cmd, unknownArgs) => {
const args = (unknownArgs ?? {}) as Record<string, unknown>;
fixture.calls.push({ cmd, args });
if (cmd === 'set_app_locale') return null;
if (cmd === 'list_oauth_browsers') return [
{ id: 'default', label: 'System Default' }, { id: 'edge', label: 'Microsoft Edge' },
];
if (cmd === 'get_plugin_support') return !params.has('unsupported');
if (cmd === 'start_oauth_login') {
const state = `plugin-session-${++fixture.starts}`;
const device = args.provider === 'codebuddy';
return {
url: `https://login.example.test/authorize?state=${state}`,
state, userCode: device ? 'BUDDY-CODE' : null,
flow: device ? 'device' : 'authorization_code', expiresIn: 900, opened: false,
};
}
if (cmd === 'get_oauth_status') return { status: fixture.status };
if (cmd === 'submit_oauth_callback' || cmd === 'open_oauth_url') return null;
if (cmd === 'management_request') {
const request = args.request as { method: string; path: string; query?: Record<string, string> };
if (request.path === '/plugins') {
fixture.listCalls += 1;
if (params.has('listError')) throw new Error('Fixture plugin list unavailable');
const response = {
plugins_enabled: true, plugins_dir: '/plugins', plugins: structuredClone(fixture.plugins),
};
if (fixture.holdNextList) {
fixture.holdNextList = false;
await new Promise<void>(resolve => {
fixture.releaseList = () => { fixture.releaseList = null; fixture.releasedLists += 1; resolve(); };
});
}
return response;
}
if (request.method === 'DELETE' && request.path === '/oauth/session') {
fixture.cancelled.push(request.query?.state ?? '');
return { status: 'ok', cancelled: true };
}
if (request.path === '/credentials') return { files: [] };
throw new Error(`Unexpected management request: ${request.method} ${request.path}`);
}
throw new Error(`Unexpected command: ${cmd}`);
}, { shouldMockEvents: true });
createRoot(document.getElementById('root')!).render(
<I18nProvider><main style={{ padding: 20 }}><OAuthLoginPage /></main></I18nProvider>,
);
+46
View File
@@ -316,6 +316,52 @@ const base = process.env.HOME_DASHBOARD_TEST_BASE_URL || 'http://127.0.0.1:1421'
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' });
}
// The normal desktop dashboard must fit the usable viewport, including the
// shorter CSS viewport produced by desktop display scaling.
const desktopViewports = [
{ width: 1464, height: 845 },
{ width: 1280, height: 800 },
{ width: 1440, height: 900 },
{ width: 1280, height: 720 },
];
for (const locale of ['zh-CN', 'en']) {
for (const theme of ['light', 'dark']) {
await page.setViewportSize(desktopViewports[0]);
await page.evaluate(({ locale, theme }) => {
localStorage.setItem('easy-cli-proxy-api.locale', locale);
localStorage.setItem('easy-cli-proxy-api.theme', theme);
}, { locale, 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('html').getAttribute('lang'), locale);
assert.equal(await page.locator('html').getAttribute('data-theme'), theme);
for (const viewport of desktopViewports) {
await page.setViewportSize(viewport);
const layout = await page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
window.scrollTo(0, 0);
const root = document.scrollingElement;
const cards = [...document.querySelectorAll('.home-top-grid > .panel, .home-stat-card')].map(node => {
const bounds = node.getBoundingClientRect();
return { name: node.getAttribute('data-stat') || node.className, top: bounds.top, bottom: bounds.bottom, height: bounds.height };
});
return { scrollHeight: root.scrollHeight, clientHeight: root.clientHeight, viewportHeight: innerHeight, cards };
});
const context = `${locale}/${theme}/${viewport.width}x${viewport.height}`;
assert.equal(layout.cards.length, 6, `${context} must show both top panels and all four overview cards`);
assert.ok(layout.scrollHeight <= layout.clientHeight + 1,
`${context} homepage causes a vertical page scrollbar: ${layout.scrollHeight}px content in ${layout.clientHeight}px viewport`);
for (const card of layout.cards) {
assert.ok(card.height > 0 && card.top >= -1 && card.bottom <= layout.viewportHeight + 1,
`${context} ${card.name} must be fully visible without scrolling: ${JSON.stringify(card)}`);
}
}
}
}
console.log('Desktop homepage fits all four viewport sizes in Chinese/English and light/dark themes without vertical page overflow.');
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 {
+174
View File
@@ -0,0 +1,174 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
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-providers-test', plugins: [react()],
logLevel: 'error', optimizeDeps: { entries: ['tests/fixtures/plugin-oauth-providers.html'] },
server: { host: '127.0.0.1', port: 1436, 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: 1366, height: 1000 } });
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-providers.html?${query}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('heading', { name: 'Codex OAuth', exact: true }).waitFor();
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.calls.some(call => call.cmd === 'get_plugin_support'));
await page.waitForFunction(() => document.querySelector('.oauth-browser-picker select')?.disabled === false);
};
const card = name => page.locator('.oauth-card').filter({ has: page.getByRole('heading', { name, exact: true }) });
const calls = cmd => page.evaluate(command => window.pluginOAuthProvidersFixture.calls.filter(call => call.cmd === command), cmd);
const settle = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const screenshot = async (name, fullPage = true) => {
const directory = process.env.PLUGIN_OAUTH_PROVIDERS_SCREENSHOT_DIR;
if (!directory) return;
fs.mkdirSync(directory, { recursive: true });
await page.screenshot({ path: path.join(directory, name), fullPage });
};
await open();
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
assert.equal(await page.locator('.oauth-card').count(), 9, 'seven built-ins plus two eligible plugin providers');
for (const name of ['Disabled provider', 'Unregistered provider', 'Non OAuth provider', 'Invalid provider', 'Missing provider', 'Duplicate ZCode', 'Duplicate Codex']) {
assert.equal(await page.getByRole('heading', { name: `${name} OAuth`, exact: true }).count(), 0, `${name} must not become a provider card`);
}
await screenshot('plugin-oauth-providers-light-desktop.png');
await page.getByLabel('Choose Browser', { exact: true }).selectOption('edge');
const zcodeLogin = card('ZCode OAuth').getByRole('button', { name: 'Start Sign-In', exact: true });
await zcodeLogin.focus();
await page.keyboard.press('Enter');
await page.getByRole('dialog', { name: 'Plugin sign-in', exact: true }).waitFor();
await page.getByLabel('Sign-in URL', { exact: true }).waitFor();
assert.deepEqual((await calls('start_oauth_login'))[0].args, { provider: 'zcode', browser: 'none', pluginProvider: true });
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-session-1', browser: 'edge',
});
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.getByRole('dialog').waitFor({ state: 'detached' });
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.cancelled.includes('plugin-session-1'));
assert.equal(await zcodeLogin.evaluate(element => element === document.activeElement), true);
await card('CodeBuddy OAuth').getByRole('button', { name: 'Start Sign-In', exact: true }).click();
await page.getByText('BUDDY-CODE', { exact: true }).waitFor();
assert.deepEqual((await calls('start_oauth_login'))[1].args, { provider: 'codebuddy', browser: 'none', pluginProvider: true });
assert.equal(await page.getByLabel('Callback URL', { exact: true }).count(), 0, 'device flow must not show callback input');
await page.keyboard.press('Escape');
await page.getByRole('dialog').waitFor({ state: 'detached' });
// Reloading provider metadata must expose newly installed plugins and remove disabled ones.
await open('empty');
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.listCalls === 1);
assert.equal(await page.locator('.oauth-card').count(), 7);
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = structuredClone(fixture.installedPlugins);
fixture.refresh();
});
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = fixture.plugins.map(plugin => plugin.id === 'zcode' ? { ...plugin, enabled: false, effective_enabled: false } : plugin);
fixture.refresh();
});
await card('ZCode OAuth').waitFor({ state: 'detached' });
assert.equal(await card('CodeBuddy OAuth').count(), 1);
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = structuredClone(fixture.installedPlugins);
});
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await card('ZCode OAuth').waitFor();
// A previous in-flight list response cannot restore an already disabled provider.
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.holdNextList = true;
fixture.refresh();
});
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.releaseList !== null);
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = [];
fixture.refresh();
});
await card('CodeBuddy OAuth').waitFor({ state: 'detached' });
await page.evaluate(() => window.pluginOAuthProvidersFixture.releaseList());
await settle();
assert.equal(await page.locator('.oauth-card').count(), 7, 'stale provider response must be ignored');
// Refocusing the window discovers changes made outside this page.
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = structuredClone(fixture.installedPlugins);
window.dispatchEvent(new Event('focus'));
});
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
// Plugin registration can finish after navigating away from the installer.
// The provider should appear from the bounded retry without another event.
await open('pendingRegistration');
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.listCalls === 1);
await settle();
assert.equal(await card('ZCode OAuth').count(), 0, 'pending plugin is not ready for login');
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = [structuredClone(fixture.installedPlugins[0])];
});
await card('ZCode OAuth').waitFor();
assert.ok(await page.evaluate(() => window.pluginOAuthProvidersFixture.listCalls > 1), 'registration must be refreshed automatically');
assert.equal(await page.locator('.oauth-card').count(), 8);
await open('unsupported');
await settle();
assert.equal(await page.locator('.oauth-card').count(), 7);
assert.equal(await page.evaluate(() => window.pluginOAuthProvidersFixture.listCalls), 0, 'unsupported kernel must not request plugins');
await open('listError');
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.listCalls > 0);
await settle();
assert.equal(await page.locator('.oauth-card').count(), 7, 'plugin failure must leave built-in login available');
assert.equal(await card('Codex OAuth').getByRole('button', { name: 'Start Sign-In', exact: true }).isEnabled(), true);
await page.setViewportSize({ width: 390, height: 844 });
await open('theme=dark&locale=zh-CN');
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await screenshot('plugin-oauth-providers-dark-mobile.png');
await card('CodeBuddy OAuth').getByRole('button', { name: '开始登录', exact: true }).click();
await page.getByText('BUDDY-CODE', { exact: true }).waitFor();
const box = await page.getByRole('dialog').boundingBox();
assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844);
await screenshot('plugin-oauth-providers-dark-mobile-dialog.png', false);
assert.deepEqual(errors, []);
console.log('Plugin OAuth provider UI passed: dynamic visibility, eligibility/deduplication, refresh/install/disable, stale responses, native plugin login, focus trap/cancellation, unsupported kernels and responsive layout.');
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await browser?.close();
await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'bun:test';
import { collectPluginOAuthProviders } from '../src/services/pluginOAuthProviders';
import { getPluginTitle } from '../src/services/pluginResources';
import { normalizePluginList, type PluginListEntry } from '../src/services/plugins';
function rawPlugin(id: string, provider: string, overrides: Record<string, unknown> = {}) {
return {
id,
path: `plugins/windows/amd64/${id}.dll`,
configured: true,
registered: true,
enabled: true,
effective_enabled: true,
supports_oauth: true,
oauth_provider: provider,
supports_quota: false,
quota_provider: '',
logo: '',
config_fields: [],
menus: [],
metadata: null,
...overrides,
};
}
function normalize(plugins: unknown[]): PluginListEntry[] {
return normalizePluginList({
plugins_enabled: true,
plugins_dir: 'plugins',
plugins,
}).plugins;
}
describe('plugin OAuth provider discovery', () => {
it('shows ZCode and CodeBuddy from the backend capabilities without menus or quota support', () => {
const plugins = normalize([
rawPlugin('zcode', 'zcode', { metadata: { name: 'ZCode', version: '1.0.0' } }),
rawPlugin('codebuddy', 'codebuddy', { metadata: { name: 'CodeBuddy', version: '1.0.0' } }),
]);
const providers = collectPluginOAuthProviders(plugins);
expect(providers.map(plugin => plugin.oauthProvider)).toEqual(['zcode', 'codebuddy']);
expect(providers.map(getPluginTitle)).toEqual(['ZCode', 'CodeBuddy']);
expect(providers.every(plugin => plugin.menus.length === 0 && !plugin.supportsQuota)).toBe(true);
expect(providers[0]).toBe(plugins[0]);
expect(providers[1]).toBe(plugins[1]);
});
it('only includes effective plugins that declare OAuth and a valid provider key', () => {
const plugins = normalize([
rawPlugin('active', 'zcode'),
rawPlugin('disabled', 'disabled', { enabled: false, effective_enabled: false }),
rawPlugin('not-loaded', 'not-loaded', { registered: false, effective_enabled: false }),
rawPlugin('no-oauth', 'no-oauth', { supports_oauth: false }),
rawPlugin('missing-provider', ''),
rawPlugin('invalid-provider', '../config'),
]);
expect(collectPluginOAuthProviders(plugins).map(plugin => plugin.id)).toEqual(['active']);
});
it('rejects invalid provider keys even if an entry reaches the helper without normalization', () => {
const [plugin] = normalize([rawPlugin('active', 'zcode')]);
const invalidProviders = [undefined, '', ' ', '../config', 'a/b', 'a?b', 'https://provider.test'];
expect(collectPluginOAuthProviders(invalidProviders.map(oauthProvider => ({
...plugin, oauthProvider,
})))).toEqual([]);
});
it('deduplicates by OAuth provider and excludes built-ins rather than using plugin registration IDs', () => {
const plugins = normalize([
rawPlugin('disabled-copy', 'zcode', { effective_enabled: false }),
rawPlugin('zcode-auth-extension', 'zcode'),
rawPlugin('another-zcode-extension', 'zcode'),
rawPlugin('codex-auth-extension', 'codex'),
rawPlugin('claude-auth-extension', 'claude'),
rawPlugin('codebuddy-auth-extension', 'codebuddy'),
]);
expect(collectPluginOAuthProviders(plugins, ['codex', 'claude']).map(plugin => ({
id: plugin.id, provider: plugin.oauthProvider,
}))).toEqual([
{ id: 'zcode-auth-extension', provider: 'zcode' },
{ id: 'codebuddy-auth-extension', provider: 'codebuddy' },
]);
});
it('accepts future backend-declared providers without a provider name allowlist', () => {
const plugins = normalize([
rawPlugin('custom-auth-extension', ' Future-Provider-42 ', {
metadata: { name: 'Future Provider' },
}),
]);
const providers = collectPluginOAuthProviders(plugins, ['codex', 'claude']);
expect(providers).toHaveLength(1);
expect(providers[0].oauthProvider).toBe('future-provider-42');
expect(getPluginTitle(providers[0])).toBe('Future Provider');
});
it('keeps a usable plugin title when metadata is absent or its name is blank', () => {
const providers = collectPluginOAuthProviders(normalize([
rawPlugin('zcode', 'zcode'),
rawPlugin('codebuddy', 'codebuddy', { metadata: { name: ' ' } }),
]));
expect(providers.map(getPluginTitle)).toEqual(['zcode', 'codebuddy']);
});
});
+9 -12
View File
@@ -71,19 +71,16 @@ const viewports = [
};
const assertRequestToolbar = async label => {
const metrics = await page.locator('.usage-events-panel').evaluate(panel => {
const summary = panel.querySelector('.usage-events-summary');
const table = panel.querySelector('.usage-table-wrap');
const scrollbar = panel.querySelector('.usage-table-top-scrollbar');
const footer = panel.querySelector('.usage-events-footer');
const pagination = panel.querySelector('.usage-pagination-controls');
const summaryRect = summary.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
const scrollbarRect = scrollbar.getBoundingClientRect();
const footerRect = footer.getBoundingClientRect();
const paginationRect = pagination.getBoundingClientRect();
const controls = [...pagination.querySelectorAll('button, select, .usage-pagination-info')];
const controls = [...footer.querySelectorAll('button, select, .usage-pagination-info')];
return {
summaryBottom: summaryRect.bottom,
panelTop: panelRect.top,
tableTop: tableRect.top,
tableBottom: tableRect.bottom,
scrollbarTop: scrollbarRect.top,
@@ -92,18 +89,18 @@ const viewports = [
controlsContained: controls.every(control => {
const rect = control.getBoundingClientRect();
return rect.width > 0 && rect.height > 0
&& rect.left >= paginationRect.left - 1 && rect.right <= paginationRect.right + 1
&& rect.top >= paginationRect.top - 1 && rect.bottom <= paginationRect.bottom + 1
&& rect.left >= footerRect.left - 1 && rect.right <= footerRect.right + 1
&& rect.top >= footerRect.top - 1
&& rect.bottom <= footerRect.bottom + 1;
}),
};
});
assert.ok(metrics.summaryBottom <= metrics.tableTop + 1, `${label}: request heading and filters must remain above the table`);
assert.ok(Math.abs(metrics.tableTop - metrics.panelTop) <= 1, `${label}: the request table starts directly at the top of the panel`);
assert.ok(metrics.tableBottom <= metrics.scrollbarTop + 1, `${label}: the horizontal scrollbar must remain below the table`);
assert.ok(metrics.scrollbarBottom <= metrics.footerTop + 1, `${label}: the scrollbar must not overlap the pagination footer`);
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their footer`);
for (const control of await page.locator('.usage-pagination-controls button, .usage-page-size-select').all()) {
await assertReachable(control, `${label} pagination`);
assert.equal(metrics.controlsContained, true, `${label}: table actions and pagination controls must fit completely inside their footer`);
for (const control of await page.locator('.usage-events-footer button, .usage-page-size-select').all()) {
await assertReachable(control, `${label} footer controls`);
}
const scrolled = await page.locator('.usage-events-panel .usage-table-wrap').evaluate(table => {
table.scrollLeft = table.scrollWidth;
+2 -2
View File
@@ -164,7 +164,7 @@ function parseCsv(text) {
await page.locator('.usage-page-size-select').selectOption('200');
await page.waitForFunction(() => document.querySelectorAll('.usage-events-table tbody tr').length === 200);
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 1);
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 0, 'The redundant request log heading is removed');
const firstRow = page.locator('.usage-events-table tbody tr').first();
assert.equal(await firstRow.locator('.usage-td-total').getAttribute('title'), '1,200 tokens', 'The displayed total uses the recorded total instead of adding cache and reasoning again');
assert.equal(await firstRow.locator('.tone-input').getAttribute('aria-label'), 'Input: 1000');
@@ -175,7 +175,7 @@ function parseCsv(text) {
assert.equal(await firstRow.locator('.tone-cache-write').textContent(), '50');
assert.equal(await firstRow.locator('.usage-td-time small').count(), 1, 'The request date remains visible below its time');
assert.ok((await firstRow.locator('.usage-td-latency small').textContent()).includes('200'), 'The latency cell includes first-token latency');
assert.equal(await page.locator('.usage-events-summary .usage-page-size-select').count(), 0);
assert.equal(await page.locator('.usage-events-summary').count(), 0, 'There is no separate toolbar above the request table');
assert.equal(await page.locator('.usage-events-footer .usage-page-size-select').count(), 1, 'Pagination remains in the bottom footer');
// Multiple input updates can arrive before the next animation frame. None