Files
EasyCLIProxyAPI/tests/home-dashboard-ui.cjs
T
lzt404 3b11361f1b 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
2026-10-04 17:31:52 +08:00

372 lines
27 KiB
JavaScript

const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = process.env.HOME_DASHBOARD_TEST_BASE_URL || 'http://127.0.0.1:1421';
(async () => {
let server;
let browser;
try {
try { await fetch(base, { signal: AbortSignal.timeout(5000) }); } catch {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-home-dashboard', plugins: [react()], logLevel: 'error', optimizeDeps: { noDiscovery: true, include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@tauri-apps/api/core', '@tauri-apps/api/mocks', '@tauri-apps/api/event', 'lucide-react', 'react-markdown', '@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'] }, server: { host: '127.0.0.1', port: Number(new URL(base).port), strictPort: true, watch: null } });
await server.listen();
}
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
const errors = [];
const makePage = async (viewport = { width: 1280, height: 1050 }) => {
const page = await browser.newPage({ viewport });
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(60000);
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
return page;
};
const page = await makePage();
const showHealth = async (target = page, query = '') => {
await target.locator('.core-health-open').click();
await target.getByRole('dialog').waitFor();
if (!query.includes('loading') && !query.includes('modelError') && !query.includes('offline')) {
await target.locator('.core-health-table tbody tr').first().waitFor();
}
};
const closeHealth = async (target = page) => {
await target.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
await target.getByRole('dialog').waitFor({ state: 'detached' });
assert.equal(await target.locator('.core-health-open').evaluate(node => node === document.activeElement), true);
};
const open = async (query = '', target = page, showDialog = true) => {
const options = new URLSearchParams(query);
if (!options.has('locale')) options.set('locale', 'en');
await target.goto(`${base}/tests/fixtures/home-dashboard.html?${options}`, { waitUntil: 'domcontentloaded' });
await target.locator('.core-health-entry').waitFor();
if (showDialog) await showHealth(target, query);
};
const probes = target => target.evaluate(() => window.homeFixture.calls.filter(call => call.cmd === 'core_health_probe'));
const checkAll = target => target.locator('.core-health-actions').getByRole('button', { name: 'Check All', exact: true });
const awaitIdle = target => target.waitForFunction(() => !document.querySelector('.core-health-progress'));
const value = id => page.locator(`[data-stat="${id}"] .home-stat-value`);
const runtimeValue = label => page.locator('.control-panel .panel-detail-row').filter({ has: page.getByText(label, { exact: true }) }).locator('dd');
const meter = id => page.locator(`[data-stat="${id}"] .home-stat-track[role="meter"]`);
await open('', page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
fs.mkdirSync('misc', { recursive: true });
await page.screenshot({ path: 'misc/home-dashboard-initial.png', fullPage: true, animations: 'disabled' });
console.log('Initial homepage rendered: misc/home-dashboard-initial.png');
assert.equal(await page.locator('.home-stat-card').count(), 4);
assert.equal(await runtimeValue('Installation Status').innerText(), 'Installed');
assert.equal(await runtimeValue('Runtime Status').innerText(), 'Running');
assert.equal(await page.locator('.home-runtime-details .panel-detail-row').count(), 6, 'runtime details retain both statuses, both versions, PID, and port');
for (const [id, expected] of [['usage', '97.8%'], ['credentials', '11'], ['providerKeys', '2'], ['models', '6']]) {
assert.equal(await value(id).innerText(), expected, `incorrect ${id} aggregate`);
}
assert.match(await page.locator('[data-stat="credentials"] p').innerText(), /5 available · 6 not in rotation/);
assert.match(await page.locator('[data-stat="usage"] p').innerText(), /503 requests/);
assert.ok(Math.abs(Number(await meter('usage').getAttribute('aria-valuenow')) - 492 / 503 * 100) < 0.000001, 'success meter must use the measured success rate');
assert.ok(Math.abs(Number(await meter('credentials').getAttribute('aria-valuenow')) - 5 / 11 * 100) < 0.000001, 'credential meter must use the available share');
assert.equal(await page.getByRole('meter', { name: 'Available credential share', exact: true }).count(), 1);
assert.equal(await page.locator('[data-stat="providerKeys"] [role="meter"], [data-stat="models"] [role="meter"]').count(), 0, 'counts without a denominator must not display a ratio');
const usageQuery = await page.evaluate(() => window.homeFixture.calls.find(call => call.cmd === 'get_usage_overview').args.query);
assert.equal(Date.parse(usageQuery.end) - Date.parse(usageQuery.start), 86_400_000);
assert.equal((await probes(page)).length, 0, 'opening the dashboard must not perform inference');
assert.equal(await page.locator('.core-health-panel, .core-health-table, .core-health-dialog').count(), 0, 'the dashboard must show only the compact entry');
assert.ok((await page.locator('.core-health-entry').boundingBox()).height < 150, 'the collapsed entry must not take up a full panel');
assert.equal(await page.locator('.home-overview-heading .core-health-entry').count(), 1, 'health monitoring belongs in the overview header');
assert.equal(await page.locator('.home-dashboard > .core-health-entry').count(), 0, 'health monitoring must not leave an orphan entry below the overview');
const healthSummaryBounds = await page.locator('.core-health-entry-status').boundingBox();
assert.ok(healthSummaryBounds.width > 1 && healthSummaryBounds.height > 1, 'the health summary must stay visible beside its entry');
const access = page.locator('.home-access-panel');
assert.equal(await access.locator('.client-api-card').count(), 1, 'only the selected connection details should be shown');
assert.equal(await access.locator('.home-key-value').innerText(), '••••••••••••••••••••');
assert.equal(await access.getByText('test-client-access-key-never-real', { exact: true }).count(), 0);
await access.getByRole('button', { name: 'Show key', exact: true }).click();
assert.equal(await access.locator('.home-key-value').innerText(), 'test-client-access-key-never-real');
await access.getByRole('button', { name: 'Hide key', exact: true }).click();
await access.getByRole('button', { name: 'Copy authentication key', exact: true }).click();
await page.waitForFunction(() => window.homeFixture.copied.includes('test-client-access-key-never-real'));
const openaiTab = page.locator('#home-protocol-openai');
await openaiTab.focus();
await page.keyboard.press('ArrowRight');
assert.equal(await page.locator('#home-protocol-claude').getAttribute('aria-selected'), 'true');
assert.equal(await page.locator('#home-protocol-claude').evaluate(node => node === document.activeElement), true);
assert.equal(await page.getByRole('tabpanel').locator('code').innerText(), 'http://127.0.0.1:8317');
await page.getByRole('tabpanel').getByRole('button').click();
await page.waitForFunction(() => window.homeFixture.copied.includes('http://127.0.0.1:8317'));
await page.locator('#home-protocol-claude').focus();
await page.keyboard.press('End');
assert.equal(await page.locator('#home-protocol-gemini').getAttribute('aria-selected'), 'true');
await page.keyboard.press('Home');
assert.equal(await openaiTab.getAttribute('aria-selected'), 'true');
assert.equal(await page.getByRole('tabpanel').locator('code').innerText(), 'http://127.0.0.1:8317/v1');
await showHealth();
const dialog = page.getByRole('dialog', { name: 'Model Health', exact: true });
assert.equal(await dialog.getAttribute('aria-modal'), 'true');
assert.equal(await dialog.evaluate(node => node.contains(document.activeElement)), true, 'opening the modal must focus a control inside it');
assert.equal(await dialog.getByRole('checkbox').count(), 0, 'health checks must not offer automatic mode');
assert.equal(await dialog.getByText(/automatically|automatic check/i).count(), 0);
assert.equal((await probes(page)).length, 0, 'opening the modal must not perform inference');
const focusable = dialog.locator('button:not(:disabled), input:not(:disabled), summary, [tabindex="0"]');
await focusable.last().focus();
await page.keyboard.press('Tab');
assert.equal(await focusable.first().evaluate(node => node === document.activeElement), true, 'Tab must wrap inside the modal');
await page.keyboard.press('Shift+Tab');
assert.equal(await focusable.last().evaluate(node => node === document.activeElement), true, 'Shift+Tab must wrap inside the modal');
await page.getByRole('button', { name: 'Check gpt-5.2-codex', exact: true }).click();
await page.locator('.core-health-status.healthy').waitFor();
assert.deepEqual((await probes(page)).map(call => call.args), [{ model: 'gpt-5.2-codex', timeoutMs: 15000 }]);
assert.match(await page.locator('.core-health-table tbody tr').first().locator('.core-health-latency').innerText(), /184 ms.*642 ms/);
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'detached' });
assert.equal(await page.locator('.core-health-open').evaluate(node => node === document.activeElement), true, 'Escape must restore focus to the entry');
assert.notEqual(await page.evaluate(() => document.body.style.overflow), 'hidden', 'closing must release the page scroll lock');
await showHealth();
assert.equal(await page.locator('.core-health-status.healthy').count(), 1, 'completed results must survive closing and reopening');
assert.equal((await probes(page)).length, 1, 'reopening must not launch a check');
await page.locator('.core-health-backdrop').click({ position: { x: 2, y: 2 } });
await dialog.waitFor({ state: 'detached' });
assert.equal(await page.locator('.core-health-open').evaluate(node => node === document.activeElement), true, 'backdrop dismissal must restore focus');
await showHealth();
await page.locator('.core-health-search input').fill('custom/team');
assert.equal(await page.locator('.core-health-table tbody tr').count(), 1);
await page.locator('.core-health-search input').fill('not-a-real-model');
assert.equal(await page.locator('.core-health-table tbody tr').count(), 0);
await page.locator('.core-health-search input').clear();
await page.evaluate(() => {
window.homeFixture.probeFailures['gemini-3-pro'] = 'HTTP 503: fixture upstream unavailable';
window.homeFixture.probeFailures['deepseek-chat'] = 'deadline has elapsed';
});
await checkAll(page).click();
await page.waitForFunction(() => document.querySelectorAll('.core-health-status.failed').length === 2);
assert.equal((await probes(page)).length, 7);
assert.equal(await page.locator('.core-health-status.healthy').count(), 4);
const gemini = page.locator('.core-health-table tbody tr').filter({ hasText: 'gemini-3-pro' });
await gemini.locator('summary').click();
assert.match(await gemini.locator('details p').innerText(), /HTTP 503/);
console.log('Home data, connection tabs, clipboard, single/batch/error checks passed.');
await open();
await page.evaluate(() => { window.homeFixture.holdProbes = true; });
await checkAll(page).click();
await page.waitForFunction(() => window.homeFixture.probeReleases.length === 4);
assert.equal(await page.locator('.core-health-status.queued').count(), 2);
await page.getByRole('button', { name: 'Stop Checking', exact: true }).click();
await page.evaluate(() => window.homeFixture.releaseProbes());
await awaitIdle(page);
assert.equal((await probes(page)).length, 4, 'stop must not launch queued model requests');
assert.equal(await page.locator('.core-health-status.healthy').count(), 0, 'late probe results must not populate stopped checks');
await open();
await page.getByRole('button', { name: 'Check gpt-5.2-codex', exact: true }).click();
await page.locator('.core-health-status.healthy').waitFor();
await page.evaluate(() => { window.homeFixture.holdProbes = true; });
await checkAll(page).click();
await page.waitForFunction(() => window.homeFixture.probeReleases.length === 4);
assert.equal(await page.locator('.core-health-status.queued').count(), 2);
await closeHealth();
await page.evaluate(() => window.homeFixture.releaseProbes());
await showHealth();
await awaitIdle(page);
assert.equal((await probes(page)).length, 5, 'closing must not launch queued model requests');
assert.equal(await page.locator('.core-health-status.healthy').count(), 1, 'closing must keep completed results and discard late results');
await open();
await page.evaluate(() => { window.homeFixture.holdProbes = true; });
await checkAll(page).click();
await page.waitForFunction(() => window.homeFixture.probeReleases.length === 4);
await page.evaluate(() => window.homeFixture.changeConfig());
await page.waitForFunction(() => document.querySelector('#home-protocol-panel code')?.textContent.includes(':8318/'));
await page.evaluate(() => window.homeFixture.releaseProbes());
await awaitIdle(page);
assert.equal(await page.locator('.core-health-status.healthy').count(), 0, 'config replacement must discard pending results');
assert.equal((await probes(page)).length, 4);
await open('loading=1');
await page.waitForFunction(() => window.homeFixture.modelReleases.length > 0);
assert.equal(await checkAll(page).isDisabled(), true);
assert.equal(await page.locator('.core-health-empty .spin').count(), 1);
await page.evaluate(() => window.homeFixture.releaseModels());
await page.locator('.core-health-table tbody tr').first().waitFor();
await open('modelError=1');
await page.locator('.core-health-error').waitFor();
assert.equal(await checkAll(page).isDisabled(), true);
assert.equal(await value('models').innerText(), '—');
await page.evaluate(() => { window.homeFixture.modelError = ''; });
await page.getByRole('button', { name: 'Refresh Models', exact: true }).click();
await page.locator('.core-health-table tbody tr').first().waitFor();
await open('offline=1');
await page.getByRole('dialog').getByText('Start the core to check model health', { exact: true }).waitFor();
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await value('usage').innerText(), '97.8%');
assert.equal(await value('models').innerText(), '—');
assert.equal(await runtimeValue('Installation Status').innerText(), 'Installed');
assert.equal(await runtimeValue('Runtime Status').innerText(), 'Stopped');
assert.equal(await meter('usage').count(), 1, 'local usage stays measured while the core is stopped');
assert.equal(await meter('credentials').count(), 0, 'unavailable credential data must not display a ratio');
assert.equal(await checkAll(page).isDisabled(), true);
assert.equal(await page.evaluate(() => window.homeFixture.calls.filter(call => call.cmd === 'get_core_models').length), 0);
await open('noKey=1', page, false);
assert.equal(await access.locator('.home-key-value').count(), 0);
await open('keyError=1', page, false);
assert.equal(await access.locator('.home-field-error').count(), 1);
await open('tls=1', page, false);
assert.equal(await page.getByRole('tabpanel').locator('code').innerText(), 'https://127.0.0.1:8317/v1');
await open('noRequests=1&unknownCredentials=1', page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await value('usage').innerText(), '—', 'an empty request window has no measured success rate');
assert.equal(await page.locator('[data-stat="usage"] p').innerText(), 'No completed requests yet');
assert.equal(await value('credentials').innerText(), '11');
assert.match(await page.locator('[data-stat="credentials"] p').innerText(), /0 available · 0 not in rotation · 11 with unknown status/);
for (const id of ['usage', 'credentials']) {
assert.equal(await meter(id).count(), 0, `unmeasured ${id} must not display a zero-percent meter`);
assert.equal(await page.locator(`[data-stat="${id}"] .home-stat-track[aria-hidden="true"]`).count(), 1);
}
await open('noCredentials=1&usageError=1', page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await value('usage').innerText(), '—');
assert.equal(await value('credentials').innerText(), '0');
assert.equal(await meter('usage').count(), 0, 'a failed usage read must not display a ratio');
assert.equal(await meter('credentials').count(), 0, 'an empty credential list has no denominator');
console.log('Modal dismissal/focus/retention/cancellation, stop, stale configuration, loading/offline/retry/key/TLS checks passed.');
// Overview refreshes and elapsed five-minute periods must never trigger inference.
const timed = await makePage();
await timed.clock.install({ time: new Date('2026-10-03T04:00:00Z') });
await timed.clock.pauseAt(new Date('2026-10-03T04:00:01Z'));
await open('', timed);
assert.equal(await timed.getByRole('dialog').getByRole('checkbox').count(), 0);
for (let cycle = 1; cycle <= 2; cycle += 1) {
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 0, `the open dialog must stay idle after ${cycle} five-minute periods`);
}
assert.ok(await timed.evaluate(() => window.homeFixture.calls.filter(call => call.cmd === 'get_core_models').length) >= 6);
await checkAll(timed).click();
await timed.waitForFunction(() => document.querySelectorAll('.core-health-status.healthy').length === 6);
assert.equal((await probes(timed)).length, 6, 'manual Check All must probe every model once');
for (let cycle = 1; cycle <= 2; cycle += 1) {
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, `manual checks must not repeat after ${cycle} five-minute periods`);
}
await closeHealth(timed);
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'the closed dialog must not start another check');
await showHealth(timed);
assert.equal(await timed.locator('.core-health-status.healthy').count(), 6);
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'reopening must not schedule another check');
// Also stress loading=true and fresh-but-equal arrays on every refresh.
await open('view=panel', timed);
await checkAll(timed).click();
await timed.waitForFunction(() => document.querySelectorAll('.core-health-status.healthy').length === 6);
for (let cycle = 1; cycle <= 2; cycle += 1) {
await timed.evaluate(() => window.homeFixture.refreshPanel());
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'loading model lists must not schedule checks');
await timed.evaluate(() => window.homeFixture.resolvePanel());
await timed.clock.runFor(301000);
assert.equal((await probes(timed)).length, 6, 'refreshed model arrays must not schedule checks');
}
await checkAll(timed).click();
await timed.waitForFunction(() => window.homeFixture.calls.filter(call => call.cmd === 'core_health_probe').length === 12);
await awaitIdle(timed);
await timed.close();
console.log('Fake-clock manual-only checks passed: no inference while idle or across repeated five-minute periods, close/reopen, and model refreshes.');
fs.mkdirSync('misc', { recursive: true });
for (const [theme, width] of [['light', 1280], ['dark', 1280], ['light', 640], ['dark', 640], ['light', 390], ['dark', 390]]) {
const height = width < 640 ? 844 : 1050;
await page.setViewportSize({ width, height });
await open(`locale=zh-CN&theme=${theme}`, page, false);
await page.locator('.home-overview[aria-busy="false"]').waitFor();
assert.equal(await page.locator('.core-health-table').count(), 0);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${theme}/${width} collapsed homepage overflows horizontally`);
await page.screenshot({ path: `misc/home-dashboard-${theme}-${width}.png`, fullPage: true, animations: 'disabled' });
await showHealth();
await page.locator('.core-health-actions .primary-button').click();
await page.waitForFunction(() => document.querySelectorAll('.core-health-status.healthy').length === 6);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${theme}/${width} overflows horizontally`);
const bounds = await page.locator('.core-health-dialog').boundingBox();
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= width + 1 && bounds.y + bounds.height <= height + 1, `${theme}/${width} modal leaves the viewport`);
await page.screenshot({ path: `misc/home-health-dialog-${theme}-${width}.png`, animations: 'disabled' });
}
for (const [theme, width, height] of [['light', 1280, 900], ['dark', 1280, 900], ['light', 640, 600], ['dark', 640, 600]]) {
await page.setViewportSize({ width, height });
await page.evaluate(theme => {
localStorage.setItem('easy-cli-proxy-api.locale', 'zh-CN');
localStorage.setItem('easy-cli-proxy-api.theme', theme);
}, theme);
await page.goto(`${base}/?mock=running`, { waitUntil: 'domcontentloaded' });
await page.locator('.home-overview[aria-busy="false"]').waitFor();
const toolbar = page.locator('#browser-mock-toolbar');
if (await toolbar.count()) await toolbar.evaluate(node => { node.style.display = 'none'; });
assert.equal(await page.locator('.home-stat-card').count(), 4);
assert.equal(await page.locator('.home-overview-heading .core-health-open').count(), 1);
assert.equal(await page.locator('.home-dashboard > .core-health-entry').count(), 0);
assert.equal(await page.locator('.core-health-table').count(), 0);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${theme}/${width} app homepage overflows horizontally`);
await page.screenshot({ path: `misc/home-dashboard-app-${theme}-${width}.png`, animations: 'disabled' });
if (theme === 'light' && width === 1280) await page.screenshot({ path: 'misc/home-dashboard-app.png', animations: 'disabled' });
await showHealth();
const bounds = await page.locator('.core-health-dialog').boundingBox();
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= width + 1 && bounds.y + bounds.height <= height + 1, `${theme}/${width} app modal leaves the viewport`);
await page.screenshot({ path: `misc/home-health-dialog-app-${theme}-${width}.png`, animations: 'disabled' });
if (theme === 'light' && width === 1280) await page.screenshot({ path: 'misc/home-health-dialog-app.png', animations: 'disabled' });
}
// 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 {
if (browser) await browser.close();
if (server) await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });