fix(ui): improve responsive layouts and refine API access page
- Fix overflow, clipped controls, and toolbar overlap in narrow windows - Improve responsive layouts across usage, OAuth, agents, settings, and dialogs - Unify API category navigation and compact provider rows and actions - Preserve inherited provider priorities instead of displaying zero - Add responsive layout regression tests and dense provider fixtures - Bump proxy core version from 8.0.6 to 8.0.10
This commit is contained in:
1 parent
c7d58128b9
commit
5a5eab96e2
17 files changed
+1798
-153
No files matched your search
@@ -128,27 +128,42 @@ const desktopViewports = [
|
||||
const assertClientListScroll = async (label, { requireOverflow = true } = {}) => {
|
||||
const metrics = await page.locator('.agent-list-items').evaluate(list => {
|
||||
const style = getComputedStyle(list);
|
||||
const last = list.lastElementChild;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
const scrollTop = list.scrollTop;
|
||||
const overflowX = list.scrollWidth > list.clientWidth + 1;
|
||||
const overflowY = list.scrollHeight > list.clientHeight + 1;
|
||||
const original = {
|
||||
left: list.scrollLeft,
|
||||
top: list.scrollTop,
|
||||
snap: list.style.scrollSnapType,
|
||||
behavior: list.style.scrollBehavior,
|
||||
};
|
||||
list.style.scrollSnapType = 'none';
|
||||
list.style.scrollBehavior = 'auto';
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const lastRect = last?.getBoundingClientRect();
|
||||
const lastReachable = !!lastRect && lastRect.top >= listRect.top - 1 && lastRect.bottom <= listRect.bottom + 1;
|
||||
list.scrollTop = 0;
|
||||
const unreachable = Array.from(list.querySelectorAll('button')).filter(button => {
|
||||
const before = button.getBoundingClientRect();
|
||||
list.scrollLeft += before.left - listRect.left;
|
||||
list.scrollTop += before.top - listRect.top;
|
||||
const after = button.getBoundingClientRect();
|
||||
return after.left < listRect.left - 1 || after.right > listRect.right + 1
|
||||
|| after.top < listRect.top - 1 || after.bottom > listRect.bottom + 1;
|
||||
}).map(button => button.textContent.trim());
|
||||
const scrollable = (!overflowX || ['auto', 'scroll'].includes(style.overflowX))
|
||||
&& (!overflowY || ['auto', 'scroll'].includes(style.overflowY));
|
||||
list.scrollLeft = original.left;
|
||||
list.scrollTop = original.top;
|
||||
list.style.scrollSnapType = original.snap;
|
||||
list.style.scrollBehavior = original.behavior;
|
||||
return {
|
||||
clientHeight: list.clientHeight,
|
||||
scrollHeight: list.scrollHeight,
|
||||
overflowY: style.overflowY,
|
||||
scrollTop,
|
||||
lastReachable,
|
||||
overflows: overflowX || overflowY,
|
||||
scrollable,
|
||||
unreachable,
|
||||
};
|
||||
});
|
||||
assert.equal(metrics.overflowY, 'auto', `${label}: the client list must own vertical scrolling`);
|
||||
assert.equal(metrics.scrollable, true, `${label}: overflowing client options must remain scrollable`);
|
||||
if (requireOverflow) {
|
||||
assert.ok(metrics.scrollHeight > metrics.clientHeight + 1, `${label}: the complete client list must overflow internally`);
|
||||
assert.ok(metrics.scrollTop > 0, `${label}: the client list must accept scrolling`);
|
||||
assert.equal(metrics.overflows, true, `${label}: the fixture must exercise scrolling through the client list`);
|
||||
}
|
||||
assert.equal(metrics.lastReachable, true, `${label}: scrolling must reveal the final client`);
|
||||
assert.deepEqual(metrics.unreachable, [], `${label}: scrolling must reveal every client option`);
|
||||
};
|
||||
|
||||
const assertDesktopLayout = async (label, { requireClientOverflow = true } = {}) => {
|
||||
@@ -421,6 +436,8 @@ const desktopViewports = [
|
||||
`${shellNarrowLabel}: panels must stack at the same width after the workspace breakpoint`);
|
||||
assert.ok(shellNarrow.client.bottom <= shellNarrow.configuration.y + 1,
|
||||
`${shellNarrowLabel}: the client list must remain above the configuration panel`);
|
||||
assert.ok(shellNarrow.configuration.height >= shellNarrow.workbench.height / 2,
|
||||
`${shellNarrowLabel}: stacked client options must leave most of the workbench available for configuration`);
|
||||
assert.ok(Math.abs(shellNarrow.bottomGap - shellNarrow.paddingBottom) <= 1,
|
||||
`${shellNarrowLabel}: the stacked workbench must preserve the fixed window-edge gap`);
|
||||
assert.ok(shellNarrow.documentScrollHeight <= shellNarrow.documentClientHeight + 1,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const widths = [1440, 980, 854, 640];
|
||||
const locales = ['zh-CN', 'en'];
|
||||
|
||||
(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/provider-groups.html'] },
|
||||
logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 0, watch: null },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
|
||||
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
|
||||
const errors = [];
|
||||
|
||||
for (const locale of locales) for (const width of widths) {
|
||||
const label = `${locale} ${width}x600`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 600 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on('pageerror', error => errors.push(`${label}: ${error}`));
|
||||
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
|
||||
await page.goto(`${base}/tests/fixtures/provider-groups.html?layout=dense&locale=${locale}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.real-provider-row').length === 4
|
||||
&& document.querySelectorAll('.provider-row-title > span:not(.state-pill)').length === 4);
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
|
||||
const navigation = await page.locator('.provider-category-panel').evaluate(panel => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const buttons = [...panel.querySelectorAll('button')];
|
||||
return {
|
||||
count: buttons.length,
|
||||
rows: new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size,
|
||||
allVisible: buttons.every(button => {
|
||||
const item = button.getBoundingClientRect();
|
||||
return item.width > 0 && item.height > 0 && item.left >= rect.left - 1 && item.right <= rect.right + 1
|
||||
&& item.top >= rect.top - 1 && item.bottom <= rect.bottom + 1 && item.right <= innerWidth + 1;
|
||||
}),
|
||||
labelsFit: buttons.every(button => {
|
||||
const label = button.querySelector('span');
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(label);
|
||||
const text = range.getBoundingClientRect();
|
||||
const bounds = label.getBoundingClientRect();
|
||||
return text.left >= bounds.left - 1 && text.right <= bounds.right + 1
|
||||
&& text.top >= bounds.top - 1 && text.bottom <= bounds.bottom + 1;
|
||||
}),
|
||||
bottom: rect.bottom,
|
||||
listTop: document.querySelector('.provider-resource-panel').getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
assert.equal(navigation.count, 5, `${label}: every provider category remains available`);
|
||||
assert.equal(navigation.rows, 1, `${label}: categories retain a single top row`);
|
||||
assert.equal(navigation.allVisible, true, `${label}: all category controls fit without hidden columns`);
|
||||
assert.equal(navigation.labelsFit, true, `${label}: full category names stay readable`);
|
||||
assert.ok(navigation.bottom <= navigation.listTop + 1, `${label}: category navigation stays above the resource list`);
|
||||
|
||||
const overflow = await page.evaluate(() => [document.documentElement, document.body,
|
||||
document.querySelector('.content'), document.querySelector('.api-access-page')]
|
||||
.filter(element => element.scrollWidth > element.clientWidth + 1)
|
||||
.map(element => element.className || element.tagName));
|
||||
assert.deepEqual(overflow, [], `${label}: dense API content must not overflow the page horizontally`);
|
||||
|
||||
const groups = await page.evaluate(() => window.groupFixture.groups);
|
||||
const rows = page.locator('.real-provider-row');
|
||||
for (const group of groups) {
|
||||
const row = rows.filter({ has: page.locator('.provider-row-title strong', { hasText: group.name }) });
|
||||
const meta = row.locator('.provider-row-meta');
|
||||
assert.equal(await row.count(), 1, `${label}: each group has exactly one row`);
|
||||
assert.equal(await meta.isVisible(), true, `${label} ${group.name}: metadata stays visible`);
|
||||
const metaText = await meta.innerText();
|
||||
const expectedModels = locale === 'en' ? `${group.models.length} models` : `模型 ${group.models.length} 个`;
|
||||
if (group.models.length) assert.ok(metaText.includes(expectedModels), `${label} ${group.name}: model count is preserved`);
|
||||
const priorityLabel = locale === 'en' ? 'Priority' : '优先级';
|
||||
if (group.priority === null) assert.ok(!metaText.includes(priorityLabel), `${label}: inherited priority is not displayed as zero`);
|
||||
else assert.ok(metaText.includes(`${priorityLabel} ${group.priority}`), `${label} ${group.name}: priority, including zero, is preserved`);
|
||||
if (group.keys.length > 1) {
|
||||
const keyCount = locale === 'en' ? `${group.keys.length} keys total` : `共 ${group.keys.length} 个密钥`;
|
||||
assert.ok(metaText.includes(keyCount), `${label} ${group.name}: multiple keys stay grouped and counted`);
|
||||
}
|
||||
assert.equal(await row.locator('.provider-row-url').getAttribute('title'), group['base-url'], `${label}: full URL remains available`);
|
||||
const remark = row.locator('.provider-row-title > span:not(.state-pill)');
|
||||
assert.equal(await remark.getAttribute('title'), await remark.textContent(), `${label}: the full remark remains available when shortened`);
|
||||
|
||||
const layout = await row.evaluate(element => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const main = element.querySelector('.provider-row-main').getBoundingClientRect();
|
||||
const actions = element.querySelector('.provider-row-actions').getBoundingClientRect();
|
||||
const controls = [...element.querySelectorAll('.provider-row-actions > button, .provider-enabled-control')];
|
||||
const rects = controls.map(control => control.getBoundingClientRect());
|
||||
const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1
|
||||
&& Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
|
||||
return {
|
||||
mainOverlapsActions: overlap(main, actions),
|
||||
controlsContained: rects.every(rect => rect.width > 0 && rect.height > 0 && rect.left >= bounds.left - 1
|
||||
&& rect.right <= bounds.right + 1 && rect.top >= bounds.top - 1 && rect.bottom <= bounds.bottom + 1),
|
||||
controlsOverlap: rects.some((rect, index) => rects.slice(index + 1).some(next => overlap(rect, next))),
|
||||
};
|
||||
});
|
||||
assert.equal(layout.mainOverlapsActions, false, `${label} ${group.name}: details and actions do not overlap`);
|
||||
assert.equal(layout.controlsContained, true, `${label} ${group.name}: every action fits its own row`);
|
||||
assert.equal(layout.controlsOverlap, false, `${label} ${group.name}: action targets remain separate`);
|
||||
for (const control of await row.locator('.provider-row-actions > button, .provider-enabled-control, .provider-drag-handle').all()) {
|
||||
await control.scrollIntoViewIfNeeded();
|
||||
assert.equal(await control.evaluate(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
return rect.top >= -1 && rect.bottom <= innerHeight + 1 && !!hit && element.contains(hit);
|
||||
}), true, `${label} ${group.name}: actions and drag handle remain reachable by scrolling`);
|
||||
}
|
||||
}
|
||||
|
||||
const partial = rows.filter({ hasText: 'Mock Borealis partial' });
|
||||
assert.equal(await partial.locator('.state-pill').textContent(), locale === 'en' ? 'Some keys disabled' : '部分密钥已停用');
|
||||
assert.equal(await rows.filter({ hasText: 'Mock Dusk disabled' }).getByRole('checkbox').isChecked(), false);
|
||||
assert.deepEqual(await page.evaluate(() => window.groupFixture.writes), [], 'Layout checks must not change provider records');
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(errors, [], 'Dense API layouts must not produce runtime errors');
|
||||
console.log('PASS: dense API groups retain navigation, metadata and reachable actions at four widths in Chinese and English.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Vendored
+54
-5
@@ -2,22 +2,71 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { ApiAccessPage } from '../../src/pages/ApiAccessPage';
|
||||
import {
|
||||
ApiAccessPage,
|
||||
apiAccessRemarkLocatorFromRecord,
|
||||
providerRemarkIdentity,
|
||||
type ApiAccessRemarkLocator,
|
||||
type ProviderSection,
|
||||
} from '../../src/pages/ApiAccessPage';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', new URLSearchParams(location.search).get('locale') ?? 'en');
|
||||
const query = new URLSearchParams(location.search);
|
||||
const denseLayout = query.get('layout') === 'dense';
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', query.get('locale') ?? 'en');
|
||||
const fixture = window as typeof window & { groupFixture: { groups: Record<string, unknown>[], writes: unknown[], probes: unknown[] } };
|
||||
fixture.groupFixture = {
|
||||
groups: [{ name: 'Primary gateway', 'base-url': 'https://gateway.example.test/v1', priority: 2,
|
||||
groups: denseLayout ? [
|
||||
{
|
||||
name: 'Mock Atlas primary', 'base-url': 'https://atlas.example.test/v1', priority: 10,
|
||||
models: [{ name: 'mock-codex-large' }, { name: 'mock-codex-small' }, { name: 'mock-codex-fast' }],
|
||||
keys: [{ 'api-key': 'mock-atlas-key-one' }, { 'api-key': 'mock-atlas-key-two' }, { 'api-key': 'mock-atlas-key-three' }],
|
||||
},
|
||||
{
|
||||
name: 'Mock Borealis partial',
|
||||
'base-url': 'https://borealis-layout-only.example.test/a-deliberately-long-routing-path/organization-fictional-team/workspace-for-responsive-layout-verification/openai-compatible/v1',
|
||||
priority: 0, models: [{ name: 'mock-codex-reasoning' }, { name: 'mock-codex-compact' }],
|
||||
keys: [{ 'api-key': 'mock-borealis-active' }, { 'api-key': 'mock-borealis-paused', 'excluded-models': ['*'] }],
|
||||
},
|
||||
{
|
||||
name: 'Mock Cirrus gateway with a deliberately long fictional group name',
|
||||
'base-url': 'https://cirrus.example.test/v1', priority: null,
|
||||
models: [{ name: 'mock-codex-standard' }], keys: [{ 'api-key': 'mock-cirrus-only-key' }],
|
||||
},
|
||||
{
|
||||
name: 'Mock Dusk disabled', 'base-url': 'https://dusk.example.test/v1', priority: 5,
|
||||
'excluded-models': ['*'], models: [],
|
||||
keys: [{ 'api-key': 'mock-dusk-key-one' }, { 'api-key': 'mock-dusk-key-two' }],
|
||||
},
|
||||
] : [{ name: 'Primary gateway', 'base-url': 'https://gateway.example.test/v1', priority: 2,
|
||||
headers: { 'X-Group': 'shared' }, models: [{ name: 'gpt-original' }, { name: 'gpt-original', alias: 'gpt-fast' }],
|
||||
keys: [{ 'api-key': 'key-inherited', weight: 2, priority: null },
|
||||
{ 'api-key': 'key-overridden', weight: 5, priority: 0, 'proxy-url': 'direct', headers: { 'X-Key': 'second' }, models: [{ name: 'private-model' }], 'excluded-models': [], 'disable-cooling': false }],
|
||||
}, { name: 'Backup gateway', 'base-url': 'https://backup.example.test/v1', models: [{ name: 'backup-model' }], keys: [{ 'api-key': 'backup-key' }] }],
|
||||
writes: [], probes: [],
|
||||
};
|
||||
const denseRemarks = [
|
||||
'Fictional layout fixture · 三个测试密钥',
|
||||
'One fictional key paused · 用于检查部分启用状态',
|
||||
'仅用于响应式布局测试的虚构备注:故意保留很长的说明,验证窄窗口中的名称、备注、URL、密钥数量和操作仍然清楚。 Fictional layout-only remark with enough text to exercise wrapping and truncation without real credentials or services.',
|
||||
'Disabled fictional group · 无已配置模型',
|
||||
];
|
||||
const remarks = new Map<string, string>();
|
||||
if (denseLayout) fixture.groupFixture.groups.forEach((group, index) => {
|
||||
remarks.set(providerRemarkIdentity('codex-api-key', apiAccessRemarkLocatorFromRecord('codex-api-key', group)), denseRemarks[index]);
|
||||
});
|
||||
mockIPC((cmd, args: any) => {
|
||||
if (cmd === 'set_app_locale' || cmd === 'save_api_access_remark') return null;
|
||||
if (cmd === 'resolve_api_access_remarks') return args.queries.map(() => '');
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
if (cmd === 'resolve_api_access_remarks') return args.queries.map((item: ApiAccessRemarkLocator & { providerSection: ProviderSection }) =>
|
||||
denseLayout ? remarks.get(providerRemarkIdentity(item.providerSection, item)) ?? '' : '');
|
||||
if (cmd === 'save_api_access_remark') {
|
||||
if (denseLayout) {
|
||||
const update = args.update as { providerSection: ProviderSection; previousRecords: ApiAccessRemarkLocator[]; records: ApiAccessRemarkLocator[]; remark: string };
|
||||
update.previousRecords.forEach(record => remarks.delete(providerRemarkIdentity(update.providerSection, record)));
|
||||
update.records.forEach(record => remarks.set(providerRemarkIdentity(update.providerSection, record), update.remark));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (cmd !== 'management_request') throw new Error(`Unexpected ${cmd}`);
|
||||
const request = args.request;
|
||||
if (request.path === '/requests/api-call') {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const viewports = [
|
||||
{ width: 640, height: 600 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 964, height: 600 },
|
||||
{ width: 1280, height: 800 },
|
||||
];
|
||||
|
||||
(async () => {
|
||||
let server;
|
||||
let browser;
|
||||
let base = 'http://127.0.0.1:1420/';
|
||||
try {
|
||||
try {
|
||||
const response = await fetch(base, { signal: AbortSignal.timeout(3000) });
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
} catch {
|
||||
const { createServer } = await import('vite');
|
||||
const react = (await import('@vitejs/plugin-react')).default;
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
root: path.resolve(__dirname, '..'),
|
||||
plugins: [react()],
|
||||
logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 1421, strictPort: false },
|
||||
});
|
||||
await server.listen();
|
||||
base = `http://127.0.0.1:${server.httpServer.address().port}/`;
|
||||
}
|
||||
|
||||
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
|
||||
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
|
||||
let page;
|
||||
const errors = [];
|
||||
|
||||
const settle = () => page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const open = async (label, ready) => {
|
||||
await page.locator('.nav-section button').filter({ has: page.getByText(label, { exact: true }) }).click();
|
||||
await page.locator(ready).first().waitFor();
|
||||
await settle();
|
||||
};
|
||||
const tab = async (id, ready) => {
|
||||
await page.locator(`#${id}`).click();
|
||||
await page.locator(ready).first().waitFor();
|
||||
await settle();
|
||||
};
|
||||
const assertNoPageOverflow = async label => {
|
||||
const overflow = await page.evaluate(() => {
|
||||
const nodes = [document.documentElement, document.body,
|
||||
document.querySelector('.content'), document.querySelector('.content > .page')].filter(Boolean);
|
||||
return nodes.filter(node => node.scrollWidth > node.clientWidth + 1)
|
||||
.map(node => ({ element: node.className || node.tagName, client: node.clientWidth, scroll: node.scrollWidth }));
|
||||
});
|
||||
assert.deepEqual(overflow, [], `${label}: horizontal scrolling must stay inside intentional tables or navigation`);
|
||||
};
|
||||
const assertReachable = async (locator, label) => {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
assert.equal(await locator.evaluate(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0 || rect.left < -1 || rect.right > innerWidth + 1
|
||||
|| rect.top < -1 || rect.bottom > innerHeight + 1) return false;
|
||||
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
return !!hit && element.contains(hit);
|
||||
}), true, `${label}: the control must be visible and unobstructed after scrolling`);
|
||||
};
|
||||
const assertRequestToolbar = async label => {
|
||||
const metrics = await page.locator('.usage-events-panel').evaluate(panel => {
|
||||
const summary = panel.querySelector('.usage-events-summary');
|
||||
const scrollbar = panel.querySelector('.usage-table-top-scrollbar');
|
||||
const pagination = panel.querySelector('.usage-pagination-controls');
|
||||
const summaryRect = summary.getBoundingClientRect();
|
||||
const scrollbarRect = scrollbar.getBoundingClientRect();
|
||||
const paginationRect = pagination.getBoundingClientRect();
|
||||
const controls = [...pagination.querySelectorAll('button, select, .usage-pagination-info')];
|
||||
return {
|
||||
summaryBottom: summaryRect.bottom,
|
||||
scrollbarTop: scrollbarRect.top,
|
||||
controlsContained: controls.every(control => {
|
||||
const rect = control.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0
|
||||
&& rect.left >= paginationRect.left - 1 && rect.right <= paginationRect.right + 1
|
||||
&& rect.top >= paginationRect.top - 1 && rect.bottom <= paginationRect.bottom + 1
|
||||
&& rect.bottom <= summaryRect.bottom + 1;
|
||||
}),
|
||||
};
|
||||
});
|
||||
assert.ok(metrics.summaryBottom <= metrics.scrollbarTop + 1, `${label}: request summary must not overlap the horizontal scrollbar`);
|
||||
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their toolbar`);
|
||||
for (const control of await page.locator('.usage-pagination-controls button, .usage-page-size-select').all()) {
|
||||
await assertReachable(control, `${label} pagination`);
|
||||
}
|
||||
const scrolled = await page.locator('.usage-events-panel .usage-table-wrap').evaluate(table => {
|
||||
table.scrollLeft = table.scrollWidth;
|
||||
return { overflowing: table.scrollWidth > table.clientWidth, scrolled: table.scrollLeft > 0 };
|
||||
});
|
||||
assert.equal(scrolled.overflowing, true, `${label}: the full request table remains available beyond the viewport`);
|
||||
assert.equal(scrolled.scrolled, true, `${label}: request columns remain reachable by horizontal scrolling`);
|
||||
};
|
||||
const assertExpandedPricing = async () => {
|
||||
await page.getByRole('combobox', { name: '时间范围', exact: true }).selectOption('custom');
|
||||
await page.locator('.usage-custom-range').waitFor();
|
||||
await page.getByRole('button', { name: '手动添加', exact: true }).click();
|
||||
const editor = page.locator('.usage-price-editor');
|
||||
await editor.waitFor();
|
||||
await settle();
|
||||
// A clipped ancestor can be scrolled programmatically by scrollIntoView,
|
||||
// while still preventing the user from reaching the overflowing content.
|
||||
const clipped = await page.locator('.usage-pricing-table-wrap').evaluate(table => {
|
||||
const ancestors = [];
|
||||
for (let parent = table.parentElement; parent; parent = parent.parentElement) {
|
||||
if (['hidden', 'clip'].includes(getComputedStyle(parent).overflowY)
|
||||
&& parent.scrollHeight > parent.clientHeight + 1) ancestors.push(parent.className);
|
||||
}
|
||||
return ancestors;
|
||||
});
|
||||
assert.deepEqual(clipped, [], 'Expanded pricing content must remain reachable through user scrolling');
|
||||
await assertReachable(editor.getByRole('button', { name: '保存', exact: true }), '800x600 price editor save');
|
||||
await assertReachable(editor.getByRole('button', { name: '取消', exact: true }), '800x600 price editor cancel');
|
||||
const table = page.locator('.usage-pricing-table-wrap');
|
||||
await assertReachable(table, '800x600 price table');
|
||||
const scrollbarReachable = await table.evaluate(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const y = rect.bottom - 2;
|
||||
if (y < 0 || y >= innerHeight) return false;
|
||||
const hit = document.elementFromPoint(rect.left + rect.width / 2, y);
|
||||
if (!hit || !element.contains(hit)) return false;
|
||||
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
||||
if (getComputedStyle(parent).overflowY !== 'visible') {
|
||||
const bounds = parent.getBoundingClientRect();
|
||||
if (y < bounds.top || y > bounds.bottom) return false;
|
||||
}
|
||||
}
|
||||
element.scrollLeft = element.scrollWidth;
|
||||
return element.scrollLeft > 0;
|
||||
});
|
||||
assert.equal(scrollbarReachable, true, 'Expanded pricing table scrollbar must remain visible and usable');
|
||||
await editor.getByRole('button', { name: '取消', exact: true }).click();
|
||||
await editor.waitFor({ state: 'detached' });
|
||||
};
|
||||
|
||||
for (const viewport of viewports) {
|
||||
const label = `${viewport.width}x${viewport.height}`;
|
||||
// browser.newPage creates an isolated context, so persisted tabs, selected
|
||||
// clients and table preferences cannot leak between viewport cases.
|
||||
page = await browser.newPage({ viewport });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on('pageerror', error => errors.push(`${label}: ${error}`));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
|
||||
await page.addInitScript(() => localStorage.setItem('easy-cli-proxy-api.locale', 'zh-CN'));
|
||||
await page.goto(`${base}?mock=running`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.home-page .client-api-card').first().waitFor();
|
||||
// The native app does not include the development scenario picker overlay.
|
||||
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
|
||||
await settle();
|
||||
await assertNoPageOverflow(`${label} home`);
|
||||
|
||||
if (viewport.width === 964) {
|
||||
assert.equal(await page.locator('.sidebar-bottom').evaluate(footer => {
|
||||
const rect = footer.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= innerHeight + 1;
|
||||
}), true, `${label}: desktop sidebar footer must stay visible without scrolling the page`);
|
||||
await assertReachable(page.locator('.sidebar-contact'), `${label} sidebar contact`);
|
||||
}
|
||||
|
||||
await open('API 接入', '.real-provider-row');
|
||||
await assertNoPageOverflow(`${label} API access`);
|
||||
if (viewport.width === 964) {
|
||||
const rows = await page.locator('.provider-category-panel button').evaluateAll(buttons =>
|
||||
new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size);
|
||||
assert.equal(rows, 1, `${label}: provider navigation must not consume several rows in a short window`);
|
||||
await assertReachable(page.locator('.provider-category-panel button').last(), `${label} final provider category`);
|
||||
}
|
||||
await assertReachable(page.locator('.provider-row-actions button').first(), `${label} provider action`);
|
||||
|
||||
await open('OAuth', '.oauth-subpage-tabs');
|
||||
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('智能体配置', '.agent-config-panel');
|
||||
await page.waitForFunction(() => !document.querySelector('.agent-client-list-heading button')?.disabled);
|
||||
await tab('agent-subpage-tab-core', '#agent-subpage-panel-core');
|
||||
await assertNoPageOverflow(`${label} agents`);
|
||||
const configuration = page.locator('#agent-subpage-panel-core');
|
||||
assert.equal(await configuration.evaluate(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}), true, `${label}: the configuration area must not collapse below its tab bar`);
|
||||
const agentControl = configuration.locator('button, input:not([type="hidden"]), select').filter({ visible: true }).first();
|
||||
await assertReachable(agentControl, `${label} agent configuration`);
|
||||
|
||||
await open('使用记录', '.usage-tabs');
|
||||
await tab('usage-tab-overview', '.usage-overview-layout');
|
||||
await assertNoPageOverflow(`${label} usage overview`);
|
||||
for (const [id, ready] of [
|
||||
['analysis', '.usage-analysis-grid'],
|
||||
['events', '.usage-events-table tbody tr'],
|
||||
['pricing', '.usage-pricing-table-wrap tbody tr'],
|
||||
['data-management', '.usage-data-management-panel'],
|
||||
]) {
|
||||
await tab(`usage-tab-${id}`, ready);
|
||||
await assertNoPageOverflow(`${label} usage ${id}`);
|
||||
if (id === 'events') await assertRequestToolbar(label);
|
||||
if (id === 'pricing' && viewport.width === 800) await assertExpandedPricing();
|
||||
}
|
||||
|
||||
await open('高级功能', '#config-subpage-panel');
|
||||
for (const id of ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words']) {
|
||||
await tab(`config-subpage-tab-${id}`, `#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`);
|
||||
await assertNoPageOverflow(`${label} settings ${id}`);
|
||||
}
|
||||
|
||||
await open('版本管理', '.version-list-item');
|
||||
await assertNoPageOverflow(`${label} versions`);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
page = await browser.newPage({ viewport: { width: 640, height: 600 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on('pageerror', error => errors.push(`640x600 English: ${error}`));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
|
||||
await page.addInitScript(() => localStorage.setItem('easy-cli-proxy-api.locale', 'en'));
|
||||
await page.goto(`${base}?mock=running`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.home-page').waitFor();
|
||||
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
|
||||
await open('Advanced Features', '.config-subpage-tabs');
|
||||
for (const id of ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words']) {
|
||||
await tab(`config-subpage-tab-${id}`, `#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`);
|
||||
await assertNoPageOverflow(`640x600 English settings ${id}`);
|
||||
const button = page.locator(`#config-subpage-tab-${id}`);
|
||||
await assertReachable(button, `English settings ${id} tab`);
|
||||
assert.equal(await button.evaluate(element => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const text = range.getBoundingClientRect();
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return text.left >= bounds.left - 1 && text.right <= bounds.right + 1;
|
||||
}), true, `English ${id} label must fit inside its own clickable tab`);
|
||||
}
|
||||
await page.close();
|
||||
assert.deepEqual(errors, [], 'Responsive navigation must not produce runtime errors');
|
||||
console.log('PASS: responsive pages, short-window navigation, reachable controls, and independent request-table scrolling.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user