Files
EasyCLIProxyAPI/tests/auth-file-list-ui.cjs
lzt404 fab5159cd4 fix(ui): compact credential cards and stop overlapping content
Replace the wide credential table with a quota-style card grid so account, plan, status, requests, usage, and quota stay readable without colliding.
2026-10-08 18:36:58 +08:00

283 lines
22 KiB
JavaScript

const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs/promises');
(async () => {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
const server = await createServer({ configFile: false, root: path.resolve(__dirname, '..'), plugins: [react()],
optimizeDeps: { entries: ['tests/fixtures/auth-file-list.html'] }, logLevel: 'error',
server: { host: '127.0.0.1', port: 0, watch: null } });
let browser;
const screenshotDir = path.resolve('bin-work/auth-file-list-qa');
try {
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 }, timezoneId: 'Asia/Shanghai' });
page.setDefaultTimeout(10000);
const errors = [], externalRequests = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => {
if (route.request().url().startsWith(`${base}/`)) return route.continue();
externalRequests.push(route.request().url());
return route.abort();
});
await page.clock.install({ time: new Date('2026-10-03T04:00:00Z') });
const cards = () => page.locator('.auth-file-card');
const card = number => cards().filter({ has: page.locator(`.auth-card-filename[title="${String(number).padStart(2, '0')}-account.json"]`) });
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 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 < 980 && size.overflowX === 'auto'
)), `${label}: cards and the credential list 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 >= 1024) {
const heights = await page.locator('.auth-credential-row').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
assert.ok(heights.every(height => height >= 140 && height <= 420), `${label}: credential cards stay compact: ${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 { top: bounds.top, bottom: bounds.bottom, left: bounds.left, right: bounds.right, hidden: bounds.width === 0 };
}));
assert.equal(cells.length, 7, `${label}: each card keeps seven information sections`);
assert.equal(cells[1].hidden, false, `${label}: a known plan remains visible`);
assert.ok(cells[2].left >= cells[0].right - 1, `${label}: status stays beside the account`);
assert.ok(cells[1].top >= cells[0].bottom - 8, `${label}: plan stays on its own row`);
assert.ok(cells.slice(3).every(cell => cell.top >= cells[1].top), `${label}: activity, quota and actions follow the account`);
}
};
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);
await assertMainInformation('initial desktop', 1500);
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(7).waitFor();
assert.equal(await cards().count(), 2);
assert.equal(await next().isDisabled(), true);
assert.ok((await pagination().innerText()).includes('2 / 2'));
await page.locator('.auth-files-toolbar input').fill('account');
await card(1).waitFor();
assert.equal(await cards().count(), 10, 'Search resets pagination even when all credentials match');
assert.equal(await previous().isDisabled(), true);
await next().click();
await page.locator('.auth-files-toolbar select').first().selectOption({ label: 'Codex' });
await card(1).waitFor();
assert.equal(await previous().isDisabled(), true, 'Provider filter resets to the first page');
await page.locator('.auth-files-toolbar select').first().selectOption('all');
await next().click();
await page.locator('.auth-files-toolbar select').last().selectOption('disabled');
await card(3).waitFor();
assert.equal(await cards().count(), 2, 'Disabled filter retains matching rows');
assert.equal(await previous().isDisabled(), true);
await page.locator('.auth-files-toolbar select').last().selectOption('all');
await pagination().locator('select').selectOption('20');
await card(12).waitFor();
assert.equal(await cards().count(), 12, 'Page size twenty shows all twelve credentials');
assert.equal(await next().isDisabled(), true);
const detailsButton = card(1).locator('.auth-list-details-button');
assert.equal(await card(1).locator('.auth-list-details').isVisible(), false);
await detailsButton.click();
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('.auth-list-quota .credential-quota');
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), false);
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 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-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);
assert.equal(await card(1).getByRole('switch').getAttribute('aria-checked'), 'false');
let updates = await page.evaluate(() => window.authFileListFixture.requests.filter(request => request.method === 'PATCH'));
assert.equal(updates.length, 1);
assert.equal(updates[0].path, '/credentials/status');
assert.deepEqual(updates[0].body, { name: '01-account.json', disabled: true });
await card(1).getByRole('switch').click();
await page.waitForFunction(() => window.authFileListFixture.reads === 3);
updates = await page.evaluate(() => window.authFileListFixture.requests.filter(request => request.method === 'PATCH'));
assert.deepEqual(updates[1].body, { name: '01-account.json', disabled: false });
assert.equal(await card(1).getByRole('switch').getAttribute('aria-checked'), 'true');
assert.equal(await card(3).getByRole('switch').getAttribute('aria-checked'), 'false', 'Sibling status stays unchanged');
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), []);
// Issue #345: long Antigravity labels/details must not size implicit grid
// tracks beyond the quota cell or cover the adjacent action buttons.
for (const theme of ['light', 'dark']) {
for (const width of [1800, 1505, 1280, 1172, 1024, 390]) {
await page.setViewportSize({ width, height: 1000 });
await open(`theme=${theme}&locale=en&longQuota=1`);
for (const expanded of [false, true]) {
if (expanded) await card(1).locator('.credential-quota-more > summary').click();
const overflow = await card(1).locator('.auth-list-quota').evaluate(cell => {
const bounds = cell.getBoundingClientRect();
return [...cell.querySelectorAll('.credential-quota-rows, .credential-quota-row, .credential-quota-label, .credential-quota-label strong, .credential-quota-track, small')]
.filter(node => node.getClientRects().length)
.filter(node => {
const rect = node.getBoundingClientRect();
const row = node.closest('.credential-quota-row')?.getBoundingClientRect() || bounds;
return rect.left < row.left - 1 || rect.right > row.right + 1 || rect.right > bounds.right + 1;
}).map(node => node.className || node.tagName);
});
assert.deepEqual(overflow, [], `${theme} ${width} expanded=${expanded}: quota content stays inside its row and column`);
await assertFits(`long quota ${theme} ${width}`);
}
await page.screenshot({ path: path.join(screenshotDir, `long-quota-${theme}-${width}.png`), fullPage: true });
}
}
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 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 lastBounds = await cards().last().boundingBox();
await page.screenshot({ path: path.join(screenshotDir, 'reference-dense-preview.png'), clip: {
x: tableBounds.x, y: tableBounds.y, width: tableBounds.width,
height: lastBounds.y + lastBounds.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, []);
await page.goto(`${base}/tests/fixtures/auth-file-list.html?displayCases=1&locale=zh-CN&theme=light`);
await cards().nth(5).waitFor();
assert.equal(await cards().count(), 6, 'Display fixture renders every representative credential');
const disabledBadges = page.locator('.auth-status-badge.neutral');
assert.equal(await disabledBadges.count(), 5, 'Every disabled credential uses the neutral status treatment');
for (const badge of await disabledBadges.all()) assert.equal(await badge.innerText(), '已停用');
assert.equal(await page.locator('.auth-status-badge.success').innerText(), '可用');
const actionLabels = ['刷新', '设置', '模型', '复制', '删除'];
for (const label of actionLabels) {
const action = cards().first().getByRole('button', { name: label, exact: true });
assert.equal(await action.innerText(), label, `${label} is a visible text button`);
assert.equal(await action.locator('svg title').count(), 0, `${label} does not rely on an icon-only label`);
}
assert.equal(await cards().last().locator('.credential-quota-row-detail').innerText(), '付费 API 对话可用。xAI 暂不为此 OAuth 凭证提供额度总量数据。');
for (const width of [1680, 1366, 1180, 980, 390]) {
await page.setViewportSize({ width, height: 1000 });
const collisions = await page.locator('.auth-file-card').evaluateAll(nodes => nodes.flatMap(card => {
const buttons = [...card.querySelectorAll('.auth-card-actions button')].filter(button => button.getClientRects().length);
const boxes = buttons.map(button => button.getBoundingClientRect());
return boxes.flatMap((box, index) => boxes.slice(index + 1)
.filter(other => box.left < other.right - 1 && other.left < box.right - 1 && box.top < other.bottom - 1 && other.top < box.bottom - 1)
.map(other => ({ width: innerWidth, texts: [buttons[index].textContent, buttons[boxes.indexOf(other)].textContent] })));
}));
assert.deepEqual(collisions, [], `${width}: credential actions overlap ${JSON.stringify(collisions)}`);
const quotaOverflow = await page.locator('.auth-list-quota').evaluateAll(nodes => nodes.filter(node => node.scrollWidth > node.clientWidth + 1).map(node => node.textContent));
assert.deepEqual(quotaOverflow, [], `${width}: quota content overflows its column`);
await page.screenshot({ path: path.join(screenshotDir, `display-cases-${width}.png`), fullPage: true });
}
console.log('PASS: offline compact credential card grid 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; });