feat(auth-files): redesign credential management as a row-based list
- Display credentials, plans, status, request statistics, quotas, and actions - Add pagination, enable/disable switches, expandable details, and compact quota views - Preserve model cooldown details, countdowns, and reset controls - Support responsive layouts, light/dark themes, and localization - Add component and UI regression tests
This commit is contained in:
1 parent
d29a1af69b
commit
cc24f8419f
35 files changed
+1504
-116
No files matched your search
@@ -0,0 +1,152 @@
|
||||
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-cooldown.html'] }, logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 0, watch: null } });
|
||||
let browser;
|
||||
const screenshotDir = path.resolve('bin-work/auth-file-cooldown-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: 1280, height: 900 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
|
||||
await page.clock.install({ time: new Date('2026-10-03T04:00:00Z') });
|
||||
const card = name => page.locator('.auth-file-card').filter({ has: page.locator('.auth-card-identity strong', { hasText: name }) });
|
||||
const primary = () => card('01-primary.json');
|
||||
const other = () => card('02-other.json');
|
||||
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 open = async query => {
|
||||
await page.goto(`${base}/tests/fixtures/auth-file-cooldown.html?${query || ''}`);
|
||||
await primary().waitFor();
|
||||
};
|
||||
const releaseReset = () => page.evaluate(() => window.cooldownFixture.releaseReset());
|
||||
const confirmReset = async target => {
|
||||
await clear(target).click();
|
||||
await confirmation().waitFor();
|
||||
await confirmation().getByRole('button', { name: '清除冷却', exact: true }).click();
|
||||
await page.waitForFunction(() => Boolean(window.cooldownFixture.releaseReset));
|
||||
};
|
||||
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');
|
||||
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();
|
||||
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}`);
|
||||
}
|
||||
assert.equal(await details.locator('time').count(), 4, 'Every cooldown and its observation timestamp are available');
|
||||
const beforeTick = await primary().locator('.auth-health-countdown').innerText();
|
||||
await page.clock.runFor(3100);
|
||||
assert.notEqual(await primary().locator('.auth-health-countdown').innerText(), beforeTick, 'Countdown advances without a list reload');
|
||||
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');
|
||||
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');
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'details-light-desktop.png'), fullPage: true });
|
||||
|
||||
await clear(primary()).click();
|
||||
await confirmation().waitFor();
|
||||
assert.ok((await confirmation().innerText()).includes('01-primary.json'));
|
||||
assert.deepEqual(await requests(), [], 'Opening confirmation does not submit a reset');
|
||||
await confirmation().getByRole('button', { name: '取消', exact: true }).click();
|
||||
assert.deepEqual(await requests(), [], 'Cancel leaves the backend untouched');
|
||||
assert.equal(await primary().locator('.auth-health-records li').count(), 3);
|
||||
|
||||
const otherDeadline = await other().locator('.auth-health-records time').getAttribute('datetime');
|
||||
const otherBefore = await other().locator('.auth-health-countdown').innerText();
|
||||
await confirmReset(primary());
|
||||
const pending = primary().locator('.auth-file-health button');
|
||||
assert.equal(await pending.isDisabled(), true, 'Pending reset prevents duplicate submission');
|
||||
await pending.evaluate(button => button.click());
|
||||
const sent = await requests();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].method, 'POST');
|
||||
assert.equal(sent[0].path, '/routing/cooldown/reset');
|
||||
assert.deepEqual(sent[0].body, { auth_index: 'auth-primary-123' }, 'Only the selected auth_index is sent; no all/model scope');
|
||||
assert.equal(await primary().locator('.auth-health-records li').count(), 3, 'Pending request retains existing cooldown details');
|
||||
await page.clock.runFor(3000);
|
||||
await releaseReset();
|
||||
await page.waitForFunction(() => window.cooldownFixture.reads === 2);
|
||||
await primary().locator('.auth-file-health').waitFor({ state: 'detached' });
|
||||
assert.equal(await other().locator('.auth-health-records li').count(), 1);
|
||||
assert.equal(await other().locator('.auth-health-records time').getAttribute('datetime'), otherDeadline);
|
||||
assert.notEqual(await other().locator('.auth-health-countdown').innerText(), otherBefore, 'A sibling timer continues instead of restarting or clearing');
|
||||
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 confirmReset(primary());
|
||||
await releaseReset();
|
||||
await page.getByText(/Fixture cooldown reset failed/).waitFor();
|
||||
assert.equal(await primary().locator('.auth-health-records li').count(), 3, 'A rejected reset retains the displayed records');
|
||||
assert.equal(await clear(primary()).isEnabled(), true, 'Failure allows a deliberate retry');
|
||||
assert.equal(await page.evaluate(() => window.cooldownFixture.reads), 1, 'Failure does not pretend a refreshed successful state');
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'reset-failure.png'), fullPage: true });
|
||||
|
||||
await open();
|
||||
await confirmReset(card('03-disabled-runtime.json'));
|
||||
await releaseReset();
|
||||
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');
|
||||
|
||||
await open();
|
||||
await confirmReset(primary());
|
||||
await page.evaluate(() => { window.cooldownFixture.holdNextRead = true; });
|
||||
await page.getByRole('button', { name: '刷新', exact: true }).click();
|
||||
await page.waitForFunction(() => Boolean(window.cooldownFixture.releaseRead));
|
||||
await releaseReset();
|
||||
await page.waitForFunction(() => window.cooldownFixture.reads === 3 && window.cooldownFixture.completedReads === 2);
|
||||
await primary().waitFor();
|
||||
assert.equal(await primary().locator('.auth-file-health').count(), 0);
|
||||
await page.evaluate(() => window.cooldownFixture.releaseRead());
|
||||
await page.waitForFunction(() => window.cooldownFixture.completedReads === 3);
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await primary().locator('.auth-file-health').count(), 0, 'An older list response cannot resurrect cleared cooldowns');
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
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 => ({
|
||||
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 clear(primary()).click();
|
||||
await confirmation().waitFor();
|
||||
assert.equal(await confirmation().evaluate(node => node.scrollWidth <= node.clientWidth + 1), true, 'Confirmation fits viewport');
|
||||
await page.screenshot({ path: path.join(screenshotDir, `confirmation-${theme}-${width}.png`), fullPage: true });
|
||||
await confirmation().screenshot({ path: path.join(screenshotDir, `dialog-${theme}-${width}.png`) });
|
||||
await confirmation().getByRole('button', { name: '取消', exact: true }).click();
|
||||
}
|
||||
}
|
||||
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(`Screenshots: ${screenshotDir}`);
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,146 @@
|
||||
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 } });
|
||||
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 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)}`);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `${label}: page overflow`);
|
||||
};
|
||||
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 next().click();
|
||||
await card(11).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'));
|
||||
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);
|
||||
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), false);
|
||||
await extraQuota.locator('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');
|
||||
await page.keyboard.press('Escape');
|
||||
await card(1).locator('.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');
|
||||
|
||||
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();
|
||||
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' });
|
||||
for (const { theme, width, locale } of cases) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await open(`theme=${theme}&locale=${locale}`);
|
||||
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 card(2).locator('.auth-health-summary').click();
|
||||
await card(4).locator('.credential-quota-more > summary').click();
|
||||
await assertFits(`${locale} ${theme} ${width} expanded`);
|
||||
await card(4).screenshot({ path: path.join(screenshotDir, `expanded-quota-${locale}-${theme}-${width}.png`) });
|
||||
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.unhandled), []);
|
||||
}
|
||||
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(`Screenshots: ${screenshotDir}`);
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
|
||||
import { AuthFileRequestStatus } from '../src/components/AuthFileRequestStatus';
|
||||
import type { QuotaState } from '../src/services/quotaService';
|
||||
|
||||
const renderQuota = (quota: QuotaState, compact = true) => renderToStaticMarkup(
|
||||
<I18nProvider><AuthFileQuotaPanel file={{ name: 'test.json', provider: 'codex' }} quota={quota}
|
||||
disabled={false} onRefresh={() => {}} onReset={() => {}} compact={compact} /></I18nProvider>,
|
||||
);
|
||||
|
||||
const renderRequests = (file: Record<string, unknown>, compact = true) => renderToStaticMarkup(
|
||||
<I18nProvider><AuthFileRequestStatus file={file} compact={compact} /></I18nProvider>,
|
||||
);
|
||||
|
||||
describe('compact credential panels', () => {
|
||||
it('keeps extra quota rows accessible through native details and preserves reset information', () => {
|
||||
const quota: QuotaState = {
|
||||
status: 'success', plan: 'Plan only shown in the plan column', resetCredits: 2, resetCreditsApplicable: 0,
|
||||
rows: [
|
||||
{ label: 'Five hour', remainingPercent: 24 },
|
||||
{ label: 'Weekly', remainingPercent: 68 },
|
||||
{ label: 'Extra model', remainingPercent: 0, detail: 'Additional quota detail' },
|
||||
],
|
||||
};
|
||||
const compact = renderQuota(quota);
|
||||
const beforeDetails = compact.split('<details')[0];
|
||||
expect(beforeDetails).toContain('Five hour');
|
||||
expect(beforeDetails).toContain('Weekly');
|
||||
expect(beforeDetails).not.toContain('Extra model');
|
||||
expect(compact).toMatch(/<details[^>]*><summary>Details \(\+1\)<\/summary>.*Extra model.*Additional quota detail.*<\/details>/);
|
||||
expect(compact).toContain('title="Reset Quota"');
|
||||
expect(compact).toContain('2 manual resets');
|
||||
expect(compact).toContain('Currently applicable: 0');
|
||||
expect(compact).not.toContain('credential-quota-refresh');
|
||||
expect(compact).not.toContain(quota.plan!);
|
||||
|
||||
const full = renderQuota(quota, false);
|
||||
expect(full).not.toContain('<details');
|
||||
expect(full).toContain(quota.plan!);
|
||||
expect(full).toContain('credential-quota-refresh');
|
||||
});
|
||||
|
||||
it('preserves quota loading and error feedback in compact mode', () => {
|
||||
expect(renderQuota({ status: 'loading', rows: [], pendingAction: 'reset' })).toContain('aria-busy="true"');
|
||||
expect(renderQuota({ status: 'error', rows: [], error: 'Quota server unavailable' })).toContain('Quota server unavailable');
|
||||
});
|
||||
|
||||
it('uses the latest nonempty request bucket time while retaining all twenty interactive buckets', () => {
|
||||
const file = {
|
||||
success: 91, failed: 9, modified: 'Unrelated modification time',
|
||||
recent_requests: [
|
||||
{ time: '09:00–09:10', success: 4, failed: 0 },
|
||||
{ time: '09:10–09:20', success: 0, failed: 1 },
|
||||
{ time: '09:20–09:30', success: 0, failed: 0 },
|
||||
],
|
||||
};
|
||||
const compact = renderRequests(file);
|
||||
expect(compact).toMatch(/auth-file-requests-latest[^>]*>09:10–09:20<\/div>/);
|
||||
expect(compact.match(/class="auth-file-request-block"/g)).toHaveLength(20);
|
||||
expect(compact).toContain('aria-label="09:10–09:20');
|
||||
expect(compact).not.toContain('Unrelated modification time');
|
||||
expect(compact).not.toContain('auth-file-requests-rate');
|
||||
expect(compact).not.toContain('auth-file-requests-counts');
|
||||
expect(renderRequests(file, false)).toContain('auth-file-requests-counts');
|
||||
});
|
||||
|
||||
it('does not invent a recent request time for empty, unavailable or untimed statistics', () => {
|
||||
const empty = renderRequests({ recent_requests: [{ time: '10:00–10:10', success: 0, failed: 0 }] });
|
||||
const unavailable = renderRequests({});
|
||||
const untimed = renderRequests({ recent_requests: [{ success: 1, failed: 0 }] });
|
||||
for (const html of [empty, unavailable, untimed]) {
|
||||
expect(html).toMatch(/auth-file-requests-latest[^>]*>—<\/div>/);
|
||||
}
|
||||
expect(empty).toContain('No requests');
|
||||
expect(unavailable).toContain('The core did not provide recent request statistics');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { authFileCooldownResetIndex, resetAuthFileCooldown } from '../src/services/authFiles';
|
||||
import {
|
||||
cooldownReasonKey,
|
||||
cooldownRemainingSeconds,
|
||||
normalizeAuthFileCooldowns,
|
||||
summarizeAuthFileCooldowns,
|
||||
} from '../src/services/authFileHealth';
|
||||
|
||||
describe('credential cooldown reset', () => {
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
|
||||
it('uses only an auth index and accepts runtime and disabled credentials', () => {
|
||||
expect(authFileCooldownResetIndex({ auth_index: ' auth-one ', disabled: true })).toBe('auth-one');
|
||||
expect(authFileCooldownResetIndex({ authIndex: 0, runtime_only: true })).toBe('0');
|
||||
expect(authFileCooldownResetIndex({ auth_index: 'runtime-key', account_type: 'api_key' })).toBe('runtime-key');
|
||||
expect(authFileCooldownResetIndex({ auth_index: null, authIndex: 'alias' })).toBe('alias');
|
||||
expect(authFileCooldownResetIndex({ name: 'file.json', id: 'auth-id' })).toBeUndefined();
|
||||
for (const auth_index of [undefined, null, '', ' ', {}, [], true, NaN, Infinity, -1, 1.5]) {
|
||||
expect(authFileCooldownResetIndex({ auth_index })).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('posts a raw auth_index operation through the v8 management bridge', async () => {
|
||||
const requests: unknown[] = [];
|
||||
mockIPC((command, args) => {
|
||||
expect(command).toBe('management_request');
|
||||
requests.push(args?.request);
|
||||
return { status: 'ok', auth_index: 'auth-one', models: ['model-a', 'model-b'] };
|
||||
});
|
||||
expect(await resetAuthFileCooldown(' auth-one ')).toEqual({
|
||||
status: 'ok', auth_index: 'auth-one', models: ['model-a', 'model-b'],
|
||||
});
|
||||
expect(requests).toEqual([{
|
||||
method: 'POST', path: '/routing/cooldown/reset', body: { auth_index: 'auth-one' },
|
||||
query: undefined, timeoutMs: undefined,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('rejects a missing index before making a request', async () => {
|
||||
let called = false;
|
||||
const api = { post: async () => { called = true; return {}; } };
|
||||
await expect(resetAuthFileCooldown(' ', api)).rejects.toThrow();
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a successful acknowledgement for the selected credential', async () => {
|
||||
for (const response of [undefined, null, [], {},
|
||||
{ status: 'error', auth_index: 'auth-one', error: 'operation failed' },
|
||||
{ status: 'ok', auth_index: 'another-index' },
|
||||
{ status: 'ok' },
|
||||
{ status: 'ok', auth_index: 'auth-one', models: [null] },
|
||||
{ status: 'ok', auth_index: 'auth-one', models: 'model-a' },
|
||||
]) {
|
||||
await expect(resetAuthFileCooldown('auth-one', { post: async () => response })).rejects.toThrow();
|
||||
}
|
||||
for (const models of [[], null, undefined]) {
|
||||
expect(await resetAuthFileCooldown('auth-one', {
|
||||
post: async () => ({ status: 'ok', auth_index: 'auth-one', models }),
|
||||
})).toEqual({ status: 'ok', auth_index: 'auth-one', models: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves backend and transport failures without reporting successful reset', async () => {
|
||||
for (const message of [
|
||||
'Management API error (404): auth not found',
|
||||
'Management API error (404): 404 page not found',
|
||||
'Management API error (401): unauthorized',
|
||||
'Management API error (500): failed to reset quota',
|
||||
'Management API request failed: connection refused',
|
||||
]) {
|
||||
const failure = new Error(message);
|
||||
mockIPC(() => { throw failure; });
|
||||
await expect(resetAuthFileCooldown('auth-one')).rejects.toBe(failure);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cooldown snapshot and timer invariants', () => {
|
||||
const receivedAtMs = 10_000;
|
||||
const model = {
|
||||
scope: 'model', model_key: 'model-a', reason: 'quota',
|
||||
retry_at: '2040-01-01T00:00:32Z', remaining_seconds: 32,
|
||||
};
|
||||
|
||||
it('counts each model once and does not infer timers from diagnostic backoff', () => {
|
||||
const snapshot = normalizeAuthFileCooldowns([
|
||||
model, { ...model, remaining_seconds: 10, backoff_level: 100 },
|
||||
], receivedAtMs)!;
|
||||
const record = snapshot.records![1];
|
||||
expect(cooldownRemainingSeconds(record, receivedAtMs, receivedAtMs - 5000)).toBe(10);
|
||||
expect(cooldownRemainingSeconds(record, receivedAtMs, receivedAtMs + 1001)).toBe(9);
|
||||
expect(cooldownRemainingSeconds(record, receivedAtMs, receivedAtMs + 10_000)).toBe(0);
|
||||
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs)).toMatchObject({ modelCount: 1, earliestSeconds: 10 });
|
||||
const elapsed = summarizeAuthFileCooldowns(snapshot, receivedAtMs + 40_000);
|
||||
expect(elapsed.rows).toHaveLength(2);
|
||||
expect(elapsed).toMatchObject({ elapsed: true, modelCount: 0, earliestSeconds: 0 });
|
||||
});
|
||||
|
||||
it('treats absent, unknown, fresh empty and expired snapshots as separate states', () => {
|
||||
expect(normalizeAuthFileCooldowns(undefined, receivedAtMs)).toBeUndefined();
|
||||
expect(normalizeAuthFileCooldowns(null, receivedAtMs)?.records).toBeNull();
|
||||
expect(normalizeAuthFileCooldowns([], receivedAtMs)?.records).toEqual([]);
|
||||
const expired = normalizeAuthFileCooldowns([model], receivedAtMs)!;
|
||||
expect(summarizeAuthFileCooldowns(expired, receivedAtMs + 33_000).elapsed).toBe(true);
|
||||
const refreshed = normalizeAuthFileCooldowns([], receivedAtMs + 33_000)!;
|
||||
expect(summarizeAuthFileCooldowns(refreshed, receivedAtMs + 33_000).elapsed).toBe(false);
|
||||
expect(refreshed.records).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes diagnostics without displaying arbitrary reason values or invalid dates', () => {
|
||||
for (const reason of [null, {}, 429, true, '']) {
|
||||
const snapshot = normalizeAuthFileCooldowns([{ ...model, reason }], receivedAtMs, 'invalid');
|
||||
expect(snapshot?.observedAt).toBeUndefined();
|
||||
expect(snapshot?.records?.[0].reason).toBe('unknown');
|
||||
}
|
||||
for (const reason of ['future_reason', '__proto__', 'constructor', '<script>']) {
|
||||
const record = normalizeAuthFileCooldowns([{ ...model, reason }], receivedAtMs)!.records![0];
|
||||
expect(cooldownReasonKey(record.reason)).toBe('authFiles.health.reason.unknown');
|
||||
}
|
||||
expect(normalizeAuthFileCooldowns([
|
||||
{ ...model, remaining_seconds: Number.MAX_SAFE_INTEGER },
|
||||
], receivedAtMs)?.records).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AuthFileHealthStatus } from '../src/components/AuthFileHealthStatus';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
|
||||
const receivedAtMs = Date.now();
|
||||
const cooldown = {
|
||||
scope: 'model', model_key: 'model-a', reason: 'quota',
|
||||
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;
|
||||
} = {}) => renderToStaticMarkup(<I18nProvider>
|
||||
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} onReset={() => {}} {...options} />
|
||||
</I18nProvider>);
|
||||
|
||||
describe('credential cooldown actions', () => {
|
||||
it('makes model cooldowns and the clear action visible without opening details', () => {
|
||||
const html = render({ status: 'error', cooldowns: [cooldown] });
|
||||
const summary = html.slice(0, html.indexOf('</summary>'));
|
||||
expect(summary).toContain('<strong>1 models cooling down</strong>');
|
||||
expect(summary).toContain('Quota or rate limit');
|
||||
expect(html.slice(html.indexOf('</details>'))).toContain('Clear cooldown</button>');
|
||||
expect(html).toContain('HTTP 429');
|
||||
});
|
||||
|
||||
it.each([undefined, null, [], {}, [null], [{ ...cooldown, remaining_seconds: 0 }]].map((cooldowns) => ({ cooldowns })))(
|
||||
'does not expose clear for absent, empty or unknown cooldowns: %j', ({ cooldowns }) => {
|
||||
expect(render({ status: 'error', cooldowns })).not.toContain('Clear cooldown</button>');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps elapsed cooldowns resettable without reporting restored availability', () => {
|
||||
const html = render({ status: 'active', cooldowns: [cooldown] }, { receivedAtMs: receivedAtMs - 60_000 });
|
||||
expect(html).toContain('Cooldown timer elapsed');
|
||||
expect(html).toContain('Elapsed · confirmation needed');
|
||||
expect(html).toContain('Clear cooldown</button>');
|
||||
expect(html).not.toContain('Available');
|
||||
});
|
||||
|
||||
it('honors unavailable and pending reset actions', () => {
|
||||
const file = { status: 'error', cooldowns: [cooldown] };
|
||||
expect(render(file, { onReset: undefined })).not.toContain('<button');
|
||||
expect(render(file, { resetDisabled: true })).toMatch(/<button[^>]*disabled=""/);
|
||||
const pending = render(file, { resetting: true });
|
||||
expect(pending).toMatch(/<button[^>]*disabled=""[^>]*aria-busy="true"/);
|
||||
expect(pending).toContain('Clearing…</button>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AuthFileUsageSummary } from '../src/components/AuthFileUsageSummary';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
|
||||
const renderSummary = (file: Record<string, unknown>) => renderToStaticMarkup(
|
||||
<I18nProvider><AuthFileUsageSummary file={file} /></I18nProvider>,
|
||||
);
|
||||
|
||||
describe('credential cumulative usage', () => {
|
||||
it('uses cumulative counters even when recent requests have a different success rate', () => {
|
||||
const html = renderSummary({ success: 1900, failed: 100, recent_requests: [{ success: 0, failed: 20 }] });
|
||||
expect(html).toContain('aria-label="Total Requests: 2,000"');
|
||||
expect(html).toContain('aria-label="Success rate 95%"');
|
||||
expect(html).toContain('aria-label="Success 1,900"');
|
||||
expect(html).toContain('aria-label="Failed 100"');
|
||||
expect(html).toContain('Totals since the core started; reset on restart.');
|
||||
expect(html).toContain('Core session totals');
|
||||
expect(html).not.toContain('tokens');
|
||||
});
|
||||
|
||||
it('preserves unavailable counters instead of treating a recent-only window or partial count as totals', () => {
|
||||
const recentOnly = renderSummary({ recent_requests: [{ success: 12, failed: 0 }] });
|
||||
expect(recentOnly).toContain('aria-label="Total Requests: —"');
|
||||
expect(recentOnly).toContain('aria-label="Success rate —"');
|
||||
const partial = renderSummary({ success: 12 });
|
||||
expect(partial).toContain('aria-label="Success 12"');
|
||||
expect(partial).toContain('aria-label="Failed —"');
|
||||
expect(partial).toContain('aria-label="Total Requests: —"');
|
||||
expect(partial).toContain('aria-label="Success rate —"');
|
||||
});
|
||||
|
||||
it('shows zero requests without inventing a success percentage', () => {
|
||||
const html = renderSummary({ success: 0, failed: 0 });
|
||||
expect(html).toContain('aria-label="Total Requests: 0"');
|
||||
expect(html).toContain('aria-label="Success rate —"');
|
||||
});
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/auth-file-cooldown.tsx"></script></body></html>
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
// Fictional metadata only. All management IPC is handled locally by this fixture.
|
||||
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();
|
||||
type MockFile = Record<string, unknown> & { name: string; cooldowns?: Record<string, unknown>[] | null };
|
||||
type Request = { path: string; method: string; body?: Record<string, unknown> };
|
||||
const timer = (seconds: number, model?: string, reason = 'quota', status = 429) => ({
|
||||
scope: model ? 'model' : 'credential', reason, model_key: model,
|
||||
retry_at: new Date(startedAt + seconds * 1000).toISOString(), remaining_seconds: seconds,
|
||||
http_status: status, backoff_level: 0,
|
||||
});
|
||||
const files: MockFile[] = [
|
||||
{ name: '01-primary.json', auth_index: 'auth-primary-123', status: 'error', unavailable: true,
|
||||
status_message: 'quota exhausted', cooldowns: [timer(55), timer(55, 'gpt-fictional'), timer(180, 'claude-fictional', 'transient_error', 503)] },
|
||||
{ name: '02-other.json', auth_index: 'auth-other-456', status: 'error',
|
||||
cooldowns: [timer(45, 'model-with-a-deliberately-long-name-for-responsive-wrapping-verification', 'model_not_supported', 404)] },
|
||||
{ name: '03-disabled-runtime.json', auth_index: 'auth-runtime-789', status: 'disabled', disabled: true,
|
||||
runtime_only: true, cooldowns: [timer(120, 'runtime-model')] },
|
||||
{ name: '04-expired-soon.json', auth_index: 'auth-expired-321', status: 'error', cooldowns: [timer(2, 'expired-model')] },
|
||||
{ name: '05-empty.json', auth_index: 'auth-empty', status: 'active', cooldowns: [] },
|
||||
{ name: '06-null.json', auth_index: 'auth-null', status: 'error', cooldowns: null },
|
||||
{ name: '07-absent.json', auth_index: 'auth-absent', status: 'error' },
|
||||
{ name: '08-no-index.json', status: 'error', cooldowns: [timer(120, 'missing-index-model')] },
|
||||
];
|
||||
const state = {
|
||||
files,
|
||||
reads: 0,
|
||||
resetRequests: [] as Request[],
|
||||
failReset: params.has('resetError'),
|
||||
releaseReset: undefined as (() => void) | undefined,
|
||||
holdNextRead: false,
|
||||
releaseRead: undefined as (() => void) | undefined,
|
||||
completedReads: 0,
|
||||
};
|
||||
(window as typeof window & { cooldownFixture: typeof state }).cooldownFixture = state;
|
||||
|
||||
mockIPC(async (cmd, args) => {
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
if (cmd !== 'management_request') throw new Error(`Unhandled fixture command: ${cmd}`);
|
||||
const request = args?.request as Request;
|
||||
if (request.path === '/credentials' && request.method === 'GET') {
|
||||
state.reads += 1;
|
||||
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
||||
const snapshot = {
|
||||
observed_at: new Date().toISOString(),
|
||||
files: state.files.map(file => ({
|
||||
provider: 'codex', source: 'file', size: 1024, updated_at: '2026-10-01T00:00:00Z',
|
||||
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),
|
||||
})) } : {}),
|
||||
})),
|
||||
};
|
||||
if (state.holdNextRead) {
|
||||
state.holdNextRead = false;
|
||||
await new Promise<void>(resolve => { state.releaseRead = resolve; });
|
||||
state.releaseRead = undefined;
|
||||
}
|
||||
state.completedReads += 1;
|
||||
return snapshot;
|
||||
}
|
||||
if (request.path === '/routing/cooldown/reset' && request.method === 'POST') {
|
||||
state.resetRequests.push(structuredClone(request));
|
||||
await new Promise<void>(resolve => { state.releaseReset = resolve; });
|
||||
state.releaseReset = undefined;
|
||||
if (state.failReset) throw new Error('Fixture cooldown reset failed');
|
||||
const target = state.files.find(file => file.auth_index === request.body?.auth_index);
|
||||
if (!target) throw new Error('Fixture reset requires one known auth_index');
|
||||
target.cooldowns = [];
|
||||
target.unavailable = false;
|
||||
target.status_message = '';
|
||||
if (!target.disabled) target.status = 'active';
|
||||
return { status: 'ok', auth_index: target.auth_index, models: ['gpt-fictional', 'claude-fictional'] };
|
||||
}
|
||||
throw new Error(`Unhandled fixture request: ${request.method} ${request.path}`);
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider>
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar" aria-hidden="true" />
|
||||
<div className="workspace"><main className="content"><AuthFileManagementPage /></main></div>
|
||||
</div>
|
||||
</I18nProvider>,
|
||||
);
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/auth-file-list.tsx"></script></body></html>
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
|
||||
import { updateQuotaCache } from '../../src/services/quotaCache';
|
||||
import { quotaKey, type QuotaState } from '../../src/services/quotaService';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
// Fictional credentials and local IPC only; this fixture never connects to an upstream service.
|
||||
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();
|
||||
type Request = { path: string; method: string; body?: Record<string, unknown>; query?: Record<string, string> };
|
||||
const files: Record<string, unknown>[] = Array.from({ length: 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',
|
||||
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',
|
||||
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) => ({
|
||||
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',
|
||||
retry_at: new Date(startedAt + 180000).toISOString(), remaining_seconds: 180, http_status: 429, backoff_level: 0 }] : [],
|
||||
}));
|
||||
|
||||
updateQuotaCache(Object.fromEntries(files.map((file, index) => {
|
||||
const quota: QuotaState = {
|
||||
status: 'success', plan: String(file.plan_type),
|
||||
subscriptionActiveUntil: index === 0 ? '2026-11-01T00:00:00Z' : undefined,
|
||||
resetCredits: index === 0 ? 2 : undefined,
|
||||
resetCreditsApplicable: index === 0 ? 0 : undefined,
|
||||
rows: index === 4 ? [{ label: 'Paid API account', remainingPercent: null, detail: 'Upstream does not report remaining quota.' }]
|
||||
: [
|
||||
{ label: '5h', remainingPercent: index === 1 ? 0 : index === 2 ? 8 : 72 - index * 3, resetAtMs: startedAt + 3 * 3600000 },
|
||||
{ label: 'Weekly', remainingPercent: 83 - index * 5, resetAtMs: startedAt + 5 * 86400000 },
|
||||
...(index === 3 ? [
|
||||
{ label: 'Additional model A', remainingPercent: 34, resetAtMs: startedAt + 3600000 },
|
||||
{ label: 'Additional model B', remainingPercent: null, detail: 'No quota limit supplied for this model.' },
|
||||
] : []),
|
||||
],
|
||||
};
|
||||
return [quotaKey(file), quota];
|
||||
})));
|
||||
|
||||
const state = { files, requests: [] as Request[], unhandled: [] as string[], reads: 0 };
|
||||
(window as typeof window & { authFileListFixture: typeof state }).authFileListFixture = state;
|
||||
mockIPC(async (cmd, args) => {
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
if (cmd !== 'management_request') {
|
||||
state.unhandled.push(cmd);
|
||||
throw new Error(`Unhandled fixture command: ${cmd}`);
|
||||
}
|
||||
const request = args?.request as Request;
|
||||
state.requests.push(structuredClone(request));
|
||||
if (request.path === '/credentials' && request.method === 'GET') {
|
||||
state.reads += 1;
|
||||
return { observed_at: new Date().toISOString(), files: structuredClone(state.files) };
|
||||
}
|
||||
if (request.path === '/credentials/status' && request.method === 'PATCH') {
|
||||
const target = state.files.find(file => file.name === request.body?.name);
|
||||
if (!target || typeof request.body?.disabled !== 'boolean') throw new Error('Fixture status update needs a known name and boolean');
|
||||
target.disabled = request.body.disabled;
|
||||
target.status = target.disabled ? 'disabled' : 'active';
|
||||
return { status: 'ok' };
|
||||
}
|
||||
if (request.path === '/credentials' && request.method === 'DELETE') {
|
||||
const index = state.files.findIndex(file => file.name === request.query?.name);
|
||||
if (index < 0) throw new Error('Fixture deletion requires a known filename');
|
||||
state.files.splice(index, 1);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
state.unhandled.push(`${request.method} ${request.path}`);
|
||||
throw new Error(`Unhandled fixture request: ${request.method} ${request.path}`);
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider><div className="app-shell">
|
||||
<aside className="sidebar" aria-hidden="true" />
|
||||
<div className="workspace"><main className="content"><AuthFileManagementPage /></main></div>
|
||||
</div></I18nProvider>,
|
||||
);
|
||||
@@ -23,16 +23,16 @@ describe('简易模式、首页、配置与版本管理导航', () => {
|
||||
test('内核运行后解锁其他功能页', () => {
|
||||
expect(canOpenAppPage('config', true)).toBe(true);
|
||||
expect(canOpenAppPage('agents', true)).toBe(true);
|
||||
expect(canOpenAppPage('quota', true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth 子页面导航', () => {
|
||||
test('认证文件和额度查询收纳在 OAuth 页面内', () => {
|
||||
expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles', 'quota']);
|
||||
test('OAuth 页面保留登录和认证文件,额度查询独立导航', () => {
|
||||
expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles']);
|
||||
expect(oauthSubpages.map((page) => page.labelKey)).toEqual([
|
||||
'oauth.title',
|
||||
'authFiles.title',
|
||||
'quota.title',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('控件自身反馈', () => {
|
||||
expect(attributes?.find((attribute) => attribute.name.getText(source) === 'className')?.initializer?.getText(source))
|
||||
.toBe("{`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`}");
|
||||
expect(attributes?.find((attribute) => attribute.name.getText(source) === 'disabled')?.initializer?.getText(source))
|
||||
.toBe('{busy || !isOAuthCredentialFile(file)}');
|
||||
.toBe('{busy || resettingCooldown || !isOAuthCredentialFile(file)}');
|
||||
const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
|
||||
expect(styles).toMatch(/\.real-auth-file-row\.disabled\s*>\s*:not\(\.auth-file-actions\)\s*\{\s*opacity:\s*0\.68;/);
|
||||
expect(styles).not.toMatch(/\.real-auth-file-row\.disabled\s*\{[^}]*opacity:/);
|
||||
|
||||
@@ -189,12 +189,16 @@ const viewports = [
|
||||
await assertReachable(page.locator('.provider-row-actions button').first(), `${label} provider action`);
|
||||
|
||||
await open('OAuth', '.oauth-subpage-tabs');
|
||||
assert.equal(await page.locator('#oauth-subpage-tab-quota').count(), 0, `${label}: quota has a separate sidebar entry`);
|
||||
await tab('oauth-subpage-tab-login', '.oauth-card');
|
||||
await assertNoPageOverflow(`${label} OAuth login`);
|
||||
await tab('oauth-subpage-tab-authFiles', '.auth-file-card');
|
||||
await assertNoPageOverflow(`${label} OAuth credentials`);
|
||||
await tab('oauth-subpage-tab-quota', '.real-quota-card');
|
||||
await assertNoPageOverflow(`${label} OAuth quota`);
|
||||
await open('额度查询', '.quota-page .real-quota-card');
|
||||
assert.equal(await page.locator('.nav-section button.active').innerText(), '额度查询', `${label}: quota activates its sidebar entry`);
|
||||
assert.equal(await page.locator('.quota-page h1').innerText(), '额度查询', `${label}: standalone quota page has its own heading`);
|
||||
assert.equal(await page.locator('.oauth-subpage-tabs').count(), 0, `${label}: standalone quota page does not contain OAuth navigation`);
|
||||
await assertNoPageOverflow(`${label} quota`);
|
||||
|
||||
await open('智能体配置', '.agent-config-panel');
|
||||
await page.waitForFunction(() => !document.querySelector('.agent-client-list-heading button')?.disabled);
|
||||
|
||||
Reference in new issue
Block a user