- 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
257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
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; });
|