feat: redesign advanced settings and client navigation, fix config compatibility
- Reorganize settings cards and improve responsive layouts, save states, and navigation - Move model aliases to a dedicated tab and improve search and keyboard interactions - Add pagination to the agent client list and update localized management text - Use public model IDs consistently for Codex selection and catalogs - Fix v8 migration and preserve keys, providers, and aliases in mixed configurations - Improve the stop-core button styling and expand UI and configuration regression tests
This commit is contained in:
1 parent
2649df000c
commit
245319be2d
29 files changed
+1065
-282
No files matched your search
@@ -16,6 +16,7 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1460, height: 1060 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
|
||||
@@ -38,7 +39,7 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
const list = document.querySelector('.agent-list-items');
|
||||
const actual = Array.from(list.querySelectorAll('button strong'), node => node.textContent);
|
||||
const selected = list.querySelector('button.active strong')?.textContent;
|
||||
return actual.length > 0 && actual.length <= 6 && actual.every(name => expected.includes(name))
|
||||
return actual.length > 0 && actual.length <= expected.length && actual.every(name => expected.includes(name))
|
||||
&& expected.includes(selected) && list.scrollHeight <= list.clientHeight + 1;
|
||||
}, names);
|
||||
await manage().click();
|
||||
@@ -175,14 +176,18 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
await page.screenshot({ path: path.join(screenshots, 'manager-desktop.png'), fullPage: true });
|
||||
await closeAndFocus(() => cancel().click());
|
||||
|
||||
// Reproduce the reported desktop size with twelve previously saved clients.
|
||||
// Every saved client is reachable exactly once, in the same order, without a second switcher.
|
||||
await page.setViewportSize({ width: 1368, height: 912 });
|
||||
const twelve = ['claude-code', 'claude-desktop', 'codex', 'deepseek-harness', 'opencode', 'pi',
|
||||
'grok-build', 'antigravity-cli', 'workbuddy', 'zcode', 'kimi-code', 'openclaw'];
|
||||
const twelveNames = ['Claude Code', 'Claude Desktop', 'Codex', 'DeepSeek Harness', 'OpenCode', 'Pi',
|
||||
'Grok Build', 'Antigravity CLI', 'WorkBuddy', 'ZCode', 'Kimi Code', 'OpenClaw'];
|
||||
await page.evaluate(({ key, ids }) => localStorage.setItem(key, JSON.stringify(ids)), { key: storageKey, ids: twelve });
|
||||
await page.goto(`${base}/tests/fixtures/agent-backups.html?shell&client=claude-code`, { waitUntil: 'domcontentloaded' });
|
||||
await ready();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.agent-list-items button').length === 6);
|
||||
const previous = () => page.getByRole('button', { name: '上一页客户端', exact: true });
|
||||
const next = () => page.getByRole('button', { name: '下一页客户端', exact: true });
|
||||
const namesOnPage = () => page.locator('.agent-list-items strong').allTextContents();
|
||||
const assertFits = async () => {
|
||||
const metrics = await page.locator('.agent-list-items').evaluate(list => {
|
||||
const rect = list.getBoundingClientRect();
|
||||
@@ -193,29 +198,63 @@ const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
|
||||
}) };
|
||||
});
|
||||
assert.ok(metrics.scrollHeight <= metrics.height + 1 && metrics.scrollWidth <= metrics.width + 1);
|
||||
assert.equal(metrics.clipped, false, 'all shortcuts are fully visible without hiding overflowing rows');
|
||||
assert.equal(metrics.clipped, false, 'all rows are fully visible on each page');
|
||||
};
|
||||
await assertFits();
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve, 'limiting shortcuts preserves saved preferences');
|
||||
const right = await page.locator('.agent-config-scroll-region').evaluate(node => [node.clientHeight, node.scrollHeight]);
|
||||
assert.ok(right[1] <= right[0] + 1, 'Claude Code settings fit at the reported desktop size');
|
||||
const switchClient = () => page.getByRole('button', { name: '切换客户端', exact: true });
|
||||
await switchClient().click();
|
||||
const picker = page.getByRole('dialog', { name: '切换客户端', exact: true });
|
||||
await picker.getByRole('searchbox').fill('herMES');
|
||||
await picker.getByRole('button', { name: 'Hermes Agent', exact: true }).click();
|
||||
assert.equal(await activeClient(), 'Hermes Agent', 'a client outside the first six remains in the shortcuts after selection');
|
||||
assert.ok(JSON.parse(await stored()).includes('hermes'), 'switching to a hidden client brings it back');
|
||||
assert.equal(await switchClient().evaluate(node => node === document.activeElement), true);
|
||||
await assertFits();
|
||||
const collectPages = async () => {
|
||||
while (await previous().isEnabled()) await previous().click();
|
||||
const names = [];
|
||||
for (let index = 0; index < 13; index++) {
|
||||
await assertFits();
|
||||
names.push(...await namesOnPage());
|
||||
if (await next().isDisabled()) break;
|
||||
await next().click();
|
||||
}
|
||||
return names;
|
||||
};
|
||||
assert.equal(await page.getByRole('button', { name: '切换客户端', exact: true }).count(), 0);
|
||||
assert.deepEqual(await collectPages(), twelveNames, 'no saved client is omitted, duplicated or reordered');
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve, 'browsing leaves the management list unchanged');
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('cpa-gui.agent-selected-client.v1')), 'claude-code',
|
||||
'browsing pages does not select a different client');
|
||||
const lastPage = await namesOnPage();
|
||||
await page.locator('.agent-list-items button').filter({ has: page.getByText('OpenClaw', { exact: true }) }).click();
|
||||
assert.deepEqual(await namesOnPage(), lastPage, 'selecting a client does not replace or shuffle any row');
|
||||
assert.equal(await activeClient(), 'OpenClaw');
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve, 'selecting leaves the management list unchanged');
|
||||
await page.setViewportSize({ width: 360, height: 600 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.agent-list-items button').length < 6);
|
||||
await page.waitForFunction(() => document.querySelector('.agent-list-items button.active strong')?.textContent === 'OpenClaw'
|
||||
&& document.querySelectorAll('.agent-list-items button').length <= 2);
|
||||
await assertFits();
|
||||
assert.equal(await activeClient(), 'Hermes Agent', 'resizing always retains the active client');
|
||||
assert.deepEqual(await collectPages(), twelveNames, 'all managed clients remain reachable after resizing');
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve);
|
||||
// Add/remove only in management. Cancel is inert; Save controls the entire list.
|
||||
await manage().click();
|
||||
await checkbox('Hermes Agent').check();
|
||||
await checkbox('OpenClaw').uncheck();
|
||||
await closeAndFocus(() => cancel().click());
|
||||
assert.deepEqual(JSON.parse(await stored()), twelve);
|
||||
assert.equal(await activeClient(), 'OpenClaw');
|
||||
await manage().click();
|
||||
await checkbox('Hermes Agent').check();
|
||||
await checkbox('OpenClaw').uncheck();
|
||||
await closeAndFocus(() => save().click());
|
||||
const updatedNames = [...twelveNames.filter(name => name !== 'OpenClaw'), 'Hermes Agent'];
|
||||
assert.equal(await activeClient(), 'Claude Code', 'explicitly removing the current client selects a remaining client');
|
||||
assert.deepEqual(await collectPages(), updatedNames);
|
||||
await page.locator('.agent-list-items button').filter({ has: page.getByText('Hermes Agent', { exact: true }) }).click();
|
||||
assert.equal(await page.evaluate(() => window.fixtureCalls.some(call =>
|
||||
/^(update_agent_config|set_agent_config_enabled|launch_agent)$/.test(call.cmd))), false,
|
||||
'list management and navigation must not write config or launch a client');
|
||||
const savedAfterManagement = await stored();
|
||||
await page.reload();
|
||||
await ready();
|
||||
assert.equal(await stored(), savedAfterManagement);
|
||||
assert.deepEqual(await collectPages(), updatedNames, 'saved management choices survive reload');
|
||||
await page.setViewportSize({ width: 1368, height: 912 });
|
||||
await switchClient().click();
|
||||
await picker.getByRole('button', { name: 'Claude Code', exact: true }).click();
|
||||
await page.locator('.agent-workbench').screenshot({ path: path.join(screenshots, 'shortcuts-desktop.png') });
|
||||
await page.locator('.agent-workbench').screenshot({ path: path.join(screenshots, 'client-pagination.png') });
|
||||
assert.equal(await page.evaluate(() => window.fixtureCalls.some(call =>
|
||||
/^(update_agent_config|set_agent_config_enabled|launch_agent)$/.test(call.cmd))), false,
|
||||
'list management and navigation must not write config or launch a client');
|
||||
|
||||
// Both entry points remain usable with narrow windows and English text in dark mode.
|
||||
for (const embedded of [false, true]) {
|
||||
|
||||
@@ -130,7 +130,7 @@ const desktopViewports = [
|
||||
return metrics;
|
||||
};
|
||||
|
||||
const assertClientShortcuts = async label => {
|
||||
const assertClientPage = async label => {
|
||||
const metrics = await page.locator('.agent-list-items').evaluate(list => {
|
||||
const rect = list.getBoundingClientRect();
|
||||
const buttons = Array.from(list.querySelectorAll('button'));
|
||||
@@ -141,9 +141,9 @@ const desktopViewports = [
|
||||
return r.top < rect.top - 1 || r.bottom > rect.bottom + 1 || r.left < rect.left - 1 || r.right > rect.right + 1;
|
||||
}).map(button => button.textContent) };
|
||||
});
|
||||
assert.ok(metrics.count >= 1 && metrics.count <= 6, `${label}: show at most six shortcuts`);
|
||||
assert.equal(metrics.overflow, false, `${label}: shortcuts must fit without scrolling`);
|
||||
assert.deepEqual(metrics.clipped, [], `${label}: no shortcut may be clipped`);
|
||||
assert.ok(metrics.count >= 1 && metrics.count <= 13, `${label}: render a nonempty client page`);
|
||||
assert.equal(metrics.overflow, false, `${label}: each client page must fit without scrolling`);
|
||||
assert.deepEqual(metrics.clipped, [], `${label}: no client row may be clipped`);
|
||||
};
|
||||
|
||||
const assertDesktopLayout = async label => {
|
||||
@@ -159,7 +159,7 @@ const desktopViewports = [
|
||||
assert.ok(Math.abs(clientList.y - configuration.y) <= 1,
|
||||
`${label}: the desktop panels must share a top edge`);
|
||||
await assertNaturalPanel(label);
|
||||
await assertClientShortcuts(label);
|
||||
await assertClientPage(label);
|
||||
return { clientList, configuration };
|
||||
};
|
||||
|
||||
@@ -175,7 +175,7 @@ const desktopViewports = [
|
||||
assert.ok(clientList.y + clientList.height <= configuration.y + 1,
|
||||
`${label}: the client list must stay above the configuration panel`);
|
||||
await assertNaturalPanel(label, { checkControls: false });
|
||||
await assertClientShortcuts(label);
|
||||
await assertClientPage(label);
|
||||
const pageWidth = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
@@ -324,7 +324,7 @@ const desktopViewports = [
|
||||
`${label}: fixed-height desktop layout must not create a far-right page scrollbar`);
|
||||
assert.ok(metrics.documentScrollWidth <= metrics.documentClientWidth + 1,
|
||||
`${label}: fixed-height desktop layout must not create horizontal page scrolling`);
|
||||
await assertClientShortcuts(label);
|
||||
await assertClientPage(label);
|
||||
await assertShellScrollRegion(label, { requireOverflow: requireRightOverflow });
|
||||
return metrics;
|
||||
};
|
||||
@@ -344,10 +344,10 @@ const desktopViewports = [
|
||||
const label = `${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/core`;
|
||||
await assertDesktopLayout(label);
|
||||
if (client === 'zcode' && viewport.height === 941) {
|
||||
const status = page.locator('.agent-list-items button').filter({ hasText: 'Claude Code' }).locator('small');
|
||||
const status = page.locator('.agent-list-items button').filter({ hasText: 'ZCode' }).locator('small');
|
||||
const longModel = 'gemini-3.8-flash-high-with-a-long-client-status-for-layout-verification';
|
||||
await page.evaluate(model => {
|
||||
window.fixtureClientStatusesOverride = { 'claude-code': { appliedModel: model } };
|
||||
window.fixtureClientStatusesOverride = { 'zcode': { appliedModel: model } };
|
||||
}, longModel);
|
||||
await page.locator('.agent-client-list-heading button').click();
|
||||
await page.waitForFunction(model => Array.from(document.querySelectorAll('.agent-list-items small'))
|
||||
@@ -440,7 +440,7 @@ const desktopViewports = [
|
||||
`${shellNarrowLabel}: the stacked workbench must preserve the fixed window-edge gap`);
|
||||
assert.ok(shellNarrow.documentScrollHeight <= shellNarrow.documentClientHeight + 1,
|
||||
`${shellNarrowLabel}: the bounded one-column shell must not leak scrolling to the page`);
|
||||
await assertClientShortcuts(shellNarrowLabel);
|
||||
await assertClientPage(shellNarrowLabel);
|
||||
await assertShellScrollRegion(shellNarrowLabel);
|
||||
|
||||
// At phone width the shell follows Usage records and returns to natural document scrolling.
|
||||
@@ -456,7 +456,7 @@ const desktopViewports = [
|
||||
`${shellPhoneLabel}: phone-width client list must remain above configuration content`);
|
||||
assert.ok(shellPhone.documentScrollWidth <= shellPhone.documentClientWidth + 1,
|
||||
`${shellPhoneLabel}: natural document flow must not create horizontal scrolling`);
|
||||
await assertClientShortcuts(shellPhoneLabel);
|
||||
await assertClientPage(shellPhoneLabel);
|
||||
await assertShellScrollRegion(shellPhoneLabel, { natural: true });
|
||||
|
||||
for (const viewport of [{ width: 640, height: 700 }, { width: 360, height: 941 }]) {
|
||||
|
||||
@@ -8,17 +8,21 @@ const assert = require('node:assert/strict');
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1421/') ? route.continue() : route.abort());
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
const tab = name => page.getByRole('tab', { name, exact: true });
|
||||
const button = name => page.getByRole('button', { name, exact: true });
|
||||
const client = name => ({ click: async () => {
|
||||
const shortcut = page.locator('.agent-list-items button').filter({ has: page.getByText(name, { exact: true }) });
|
||||
if (await shortcut.count()) await shortcut.click();
|
||||
else {
|
||||
await button('切换客户端').click();
|
||||
await page.getByRole('dialog', { name: '切换客户端', exact: true }).getByRole('button', { name, exact: true }).click();
|
||||
const row = page.locator('.agent-list-items button').filter({ has: page.getByText(name, { exact: true }) });
|
||||
const previous = button('上一页客户端');
|
||||
while (await previous.isEnabled()) await previous.click();
|
||||
for (let index = 0; index < 13 && !(await row.count()); index++) {
|
||||
const next = button('下一页客户端');
|
||||
assert.ok(await next.isEnabled(), `${name} must be reachable in the saved client list`);
|
||||
await next.click();
|
||||
}
|
||||
await row.click();
|
||||
} });
|
||||
const active = async name => {
|
||||
await page.waitForFunction(name => Array.from(document.querySelectorAll('[role=tab]'))
|
||||
|
||||
@@ -46,7 +46,7 @@ const path = require('node:path');
|
||||
assert.equal(await page.locator('.config-settings-sidebar').count(), 0);
|
||||
assert.equal(await page.locator('.config-settings-header p, .config-nav-save-hint, .config-category-heading, .config-section-index').count(), 0);
|
||||
assert.deepEqual(await configTabs.evaluateAll(elements => elements.map(element => element.id)), [
|
||||
'config-subpage-tab-general', 'config-subpage-tab-routing', 'config-subpage-tab-requests',
|
||||
'config-subpage-tab-general', 'config-subpage-tab-aliases', 'config-subpage-tab-routing', 'config-subpage-tab-requests',
|
||||
'config-subpage-tab-oauth', 'config-subpage-tab-diagnostics', 'config-subpage-tab-extensions',
|
||||
'config-subpage-tab-software',
|
||||
]);
|
||||
@@ -58,7 +58,7 @@ const path = require('node:path');
|
||||
element.getAttribute('aria-controls') === 'config-subpage-panel')), true);
|
||||
await configTabs.first().focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await page.locator('#config-subpage-tab-routing').getAttribute('aria-selected'), 'true');
|
||||
assert.equal(await page.locator('#config-subpage-tab-aliases').getAttribute('aria-selected'), 'true');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
await page.keyboard.press('End');
|
||||
@@ -75,9 +75,8 @@ const path = require('node:path');
|
||||
}, key), false, `${key} must remain available for normal page scrolling`);
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
}
|
||||
assert.equal(await page.locator('#config-view-access').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await page.locator('#config-view-connection').getAttribute('role'), null);
|
||||
assert.equal(await page.getByLabel('搜索设置', { exact: true }).count(), 1);
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0);
|
||||
const fieldHelp = page.locator('#template-field-management-0 .settings-help-trigger').first();
|
||||
assert.equal(await fieldHelp.getAttribute('aria-expanded'), 'false');
|
||||
assert.ok(await fieldHelp.getAttribute('aria-controls'));
|
||||
|
||||
+136
-30
@@ -2,8 +2,8 @@ const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const categories = ['general', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software'];
|
||||
const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { width: 1280, height: 800 }];
|
||||
const categories = ['general', 'aliases', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software'];
|
||||
const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { width: 1280, height: 800 }, { width: 1600, height: 1000 }];
|
||||
|
||||
(async () => {
|
||||
let server;
|
||||
@@ -36,6 +36,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
const label = `${locale} ${viewport.width}x${viewport.height}`;
|
||||
const page = await browser.newPage({ viewport });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.on('pageerror', error => runtimeErrors.push(`${label}: ${error}`));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
|
||||
await page.addInitScript(language => localStorage.setItem('easy-cli-proxy-api.locale', language), locale);
|
||||
@@ -57,12 +58,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
assert.equal(await page.locator(`#config-subpage-tab-${id}`).getAttribute('aria-selected'), 'true');
|
||||
await settle();
|
||||
};
|
||||
const view = async id => {
|
||||
await page.locator(`#config-view-${id}`).click();
|
||||
assert.equal(await page.locator(`#config-view-${id}`).getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await page.locator(`#config-view-${id}`).getAttribute('role'), null);
|
||||
await settle();
|
||||
};
|
||||
const templateVisible = async (id, visible = true) => {
|
||||
assert.equal(await page.locator(`#template-group-${id}`).isVisible(), visible, `${label}: ${id} visibility`);
|
||||
};
|
||||
@@ -75,6 +70,29 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
].filter(Boolean).filter(element => element.scrollWidth > element.clientWidth + 1)
|
||||
.map(element => ({ element: element.className || element.tagName, width: element.clientWidth, scroll: element.scrollWidth })));
|
||||
assert.deepEqual(overflowing, [], `${label} ${context}: settings must fit without horizontal overflow`);
|
||||
const narrowLists = await page.evaluate(() => {
|
||||
const width = document.querySelector('.config-settings-content').getBoundingClientRect().width;
|
||||
return [...document.querySelectorAll('#config-native-keys, #config-native-aliases, #config-native-sensitive-words, .template-config-card:has([data-field-type="custom"], [data-field-type="json"], [data-field-type="string-list"])')]
|
||||
.filter(card => card.id !== 'config-group-extensions-plugins' && card.getBoundingClientRect().width > 0 && Math.abs(card.getBoundingClientRect().width - width) > 1)
|
||||
.map(card => card.id);
|
||||
});
|
||||
assert.deepEqual(narrowLists, [], `${label} ${context}: expandable lists and rules occupy independent full rows`);
|
||||
if (viewport.width >= 1240) {
|
||||
const incompleteRows = await page.evaluate(() => {
|
||||
const content = document.querySelector('.config-settings-content').getBoundingClientRect();
|
||||
const cards = [...document.querySelectorAll('.config-settings-cards > section, .template-config-card, #config-native-aliases, #config-native-sensitive-words')]
|
||||
.map(card => ({ id: card.id, box: card.getBoundingClientRect() })).filter(card => card.box.width > 0);
|
||||
return cards.filter(card => {
|
||||
if (['config-native-software', 'config-group-extensions-plugins'].includes(card.id)) {
|
||||
return Math.abs(card.box.left + card.box.width / 2 - content.left - content.width / 2) > 1;
|
||||
}
|
||||
const peers = cards.filter(peer => Math.abs(peer.box.top - card.box.top) < 2);
|
||||
return Math.abs(Math.min(...peers.map(peer => peer.box.left)) - content.left) > 1
|
||||
|| Math.abs(Math.max(...peers.map(peer => peer.box.right)) - content.right) > 1;
|
||||
}).map(card => card.id);
|
||||
});
|
||||
assert.deepEqual(incompleteRows, [], `${label} ${context}: card rows must not leave an orphan half-width card`);
|
||||
}
|
||||
};
|
||||
const reachable = async (locator, context) => {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
@@ -163,7 +181,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
};
|
||||
});
|
||||
assert.equal(navigationLayout.aboveContent, true, `${label}: category navigation belongs above settings content`);
|
||||
assert.equal(navigationLayout.singleRow, true, `${label}: all seven categories must stay in a single horizontal row`);
|
||||
assert.equal(navigationLayout.singleRow, true, `${label}: all eight categories must stay in a single horizontal row`);
|
||||
assert.equal(navigationLayout.labelsFit, true, `${label}: complete category labels must fit their own buttons`);
|
||||
if (viewport.width === 640) {
|
||||
assert.equal(navigationLayout.overflowing, true, `${label}: narrow navigation keeps readable labels through scrolling`);
|
||||
@@ -181,19 +199,48 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
await reachable(page.locator('#config-subpage-tab-general'), 'first horizontal category');
|
||||
|
||||
await tab('general');
|
||||
await view('access');
|
||||
assert.equal(await page.locator('.config-keys-panel').isVisible(), true);
|
||||
assert.equal(await page.locator('#config-native-network').isVisible(), true, 'connection settings share the service page');
|
||||
assert.equal(await page.locator('#config-native-tls').isVisible(), true, 'TLS settings share the service page');
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, 'secondary pagination is removed');
|
||||
const sectionStyle = await page.locator('.config-keys-panel').evaluate(node => {
|
||||
const style = getComputedStyle(node);
|
||||
return { radius: style.borderRadius, shadow: style.boxShadow, background: style.backgroundColor };
|
||||
});
|
||||
assert.equal(sectionStyle.radius, '12px');
|
||||
assert.notEqual(sectionStyle.background, 'rgba(0, 0, 0, 0)', 'settings groups have their own surface');
|
||||
if (viewport.width >= 1240) {
|
||||
const keys = await page.locator('#config-native-keys').boundingBox();
|
||||
const management = await page.locator('#config-native-management').boundingBox();
|
||||
const content = await page.locator('.config-settings-content').boundingBox();
|
||||
assert.ok(Math.abs(keys.width - content.width) <= 1, 'the expandable key list occupies its own full row');
|
||||
assert.ok(management.y >= keys.y + keys.height, 'fixed settings follow the independent key list');
|
||||
const network = await page.locator('#config-native-network').boundingBox();
|
||||
const tls = await page.locator('#config-native-tls').boundingBox();
|
||||
assert.ok(Math.abs(network.y - tls.y) <= 1 && Math.abs(network.height - tls.height) <= 1, 'paired native cards align at both edges');
|
||||
}
|
||||
await templateVisible('management');
|
||||
{
|
||||
const row = await page.locator('#template-field-management-0').boundingBox();
|
||||
const toggle = await page.locator('#template-field-management-0 .switch-control').boundingBox();
|
||||
const caption = await page.locator('#template-field-management-0 .template-config-label').boundingBox();
|
||||
assert.ok(Math.abs(row.x + row.width - toggle.x - toggle.width) <= 1, `${label}: switches align with the end of their setting row`);
|
||||
assert.ok(Math.abs(caption.y + caption.height / 2 - toggle.y - toggle.height / 2) <= 1, `${label}: switch and label stay on the same line`);
|
||||
}
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), false);
|
||||
await templateVisible('diagnostics', false);
|
||||
await noOverflow('service access');
|
||||
await exerciseHelp(page.locator('#template-field-management-0 .settings-help-trigger').first(), 'management template');
|
||||
await view('connection');
|
||||
assert.equal(await page.locator('#config-network-section-title').isVisible(), true);
|
||||
assert.equal(await page.locator('#config-tls-section-title').isVisible(), true);
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), false);
|
||||
await noOverflow('service connection');
|
||||
const nativePort = page.locator('#config-native-network .config-network-port-field input');
|
||||
assert.ok((await nativePort.boundingBox()).width <= 160, `${label}: port input stays compact`);
|
||||
if (viewport.width >= 964) {
|
||||
const secret = await page.locator('.config-secret-input').boundingBox();
|
||||
assert.ok(secret.width <= 420, `${label}: secret input must not stretch across the page`);
|
||||
}
|
||||
assert.equal(await page.getByLabel(locale === 'en' ? 'Port' : '端口', { exact: true }).count(), 1,
|
||||
`${label}: field help must not alter the input's accessible label`);
|
||||
const initialPort = await nativePort.inputValue();
|
||||
@@ -205,11 +252,36 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), true);
|
||||
await templateVisible('routing-advanced');
|
||||
await templateVisible('extensions-concurrency');
|
||||
if (viewport.width >= 964) {
|
||||
const toggle = await page.locator('#config-native-routing .switch-control').boundingBox();
|
||||
const ttl = await page.locator('#config-input-config-network-sessionTtl').boundingBox();
|
||||
assert.ok(toggle.width > 0 && ttl.width > 0, `${label}: controls remain visible inside their card`);
|
||||
assert.ok(ttl.width <= 200, `${label}: duration input stays compact`);
|
||||
const retryInput = await page.locator('#config-input-config-network-requestRetry').boundingBox();
|
||||
assert.ok(retryInput.width <= 160, `${label}: retry count stays compact`);
|
||||
}
|
||||
await templateVisible('extensions-plugins', false);
|
||||
await noOverflow('routing and stability');
|
||||
if (viewport.width >= 964) {
|
||||
const value = await page.locator('#config-input-config-network-requestRetry').boundingBox();
|
||||
const caption = await page.locator('label[for="config-input-config-network-requestRetry"]').boundingBox();
|
||||
assert.ok(Math.abs(value.y + value.height / 2 - caption.y - caption.height / 2) <= 2,
|
||||
`${label}: compact numeric settings keep labels next to their values`);
|
||||
if (viewport.width === 1280) {
|
||||
assert.ok((await retrySection.boundingBox()).height < 460,
|
||||
`${label}: paired routing cards should not create excessive vertical whitespace`);
|
||||
}
|
||||
}
|
||||
await page.locator('.template-config-card:visible').last().scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
const pinnedCategory = await page.locator('#config-subpage-tab-routing').evaluate(element => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
|
||||
return bounds.top >= 0 && bounds.bottom <= innerHeight && Boolean(hit && element.contains(hit));
|
||||
});
|
||||
assert.equal(pinnedCategory, true, `${label}: categories remain visible and clickable while scrolling long forms`);
|
||||
await tab('general');
|
||||
assert.equal(await page.locator('#config-view-connection').getAttribute('aria-pressed'), 'true',
|
||||
`${label}: returning to a category restores its last secondary view`);
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, `${label}: secondary pagination is removed`);
|
||||
await tab('routing');
|
||||
const routingOptions = await page.locator('.routing-segmented').evaluate(group => {
|
||||
const container = group.getBoundingClientRect();
|
||||
@@ -233,14 +305,58 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
`${label}: all routing strategy labels need complete text and usable button bounds: ${JSON.stringify(routingOptions)}`);
|
||||
|
||||
await tab('requests');
|
||||
await view('behavior');
|
||||
for (const id of ['request-behavior', 'codex-client', 'multimedia', 'request-payload']) await templateVisible(id);
|
||||
if (viewport.width >= 1240) {
|
||||
const left = await page.locator('#config-group-codex-client').boundingBox();
|
||||
const right = await page.locator('#config-group-multimedia').boundingBox();
|
||||
assert.ok(Math.abs(left.y - right.y) <= 1 && Math.abs(left.height - right.height) <= 1, 'paired template cards align at both edges');
|
||||
const leftSave = await page.locator('#config-group-codex-client .template-config-actions button').last().boundingBox();
|
||||
const rightSave = await page.locator('#config-group-multimedia .template-config-actions button').last().boundingBox();
|
||||
assert.ok(Math.abs(leftSave.y + leftSave.height - rightSave.y - rightSave.height) <= 1, 'paired save buttons share a bottom baseline');
|
||||
}
|
||||
await noOverflow('model and request behavior');
|
||||
await view('aliases');
|
||||
assert.equal(await page.locator('#config-native-aliases').isVisible(), false, 'aliases are separate from model and request settings');
|
||||
await tab('aliases');
|
||||
await templateVisible('request-behavior', false);
|
||||
assert.equal(await page.locator('#config-native-sensitive-words').isVisible(), false);
|
||||
assert.equal(await page.locator('.thinking-alias-page').isVisible(), true);
|
||||
await reachable(page.locator('.thinking-alias-page').getByRole('button', { name: locale === 'en' ? 'Create Alias' : '创建别名', exact: true }), 'model alias creation');
|
||||
await noOverflow('model aliases');
|
||||
await view('sensitive-words');
|
||||
// Exercise the editor inside the settings page, where ancestor CSS applies.
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme);
|
||||
await page.locator('.thinking-alias-page > .management-header button').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor();
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'thinking-model-search');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await dialog.isVisible(), true, 'Escape closes the model picker before the editor');
|
||||
assert.equal(await dialog.getByRole('listbox').count(), 0);
|
||||
const surface = await dialog.evaluate(node => {
|
||||
const style = getComputedStyle(node);
|
||||
const bounds = node.getBoundingClientRect();
|
||||
return {
|
||||
background: style.backgroundColor, radius: parseFloat(style.borderRadius),
|
||||
padding: style.padding, withinViewport: bounds.left >= 0 && bounds.top >= 0
|
||||
&& bounds.right <= innerWidth && bounds.bottom <= innerHeight,
|
||||
};
|
||||
});
|
||||
assert.ok(!/rgba\([^)]*,\s*0\)$/.test(surface.background) && surface.background !== 'transparent', `${label} ${theme}: editor needs an opaque surface`);
|
||||
assert.ok(surface.radius > 0, `${label} ${theme}: editor retains its own rounded container`);
|
||||
assert.equal(surface.padding, '0px', `${label} ${theme}: page spacing must not override editor layout`);
|
||||
assert.equal(surface.withinViewport, true, `${label} ${theme}: editor fits the window`);
|
||||
await reachable(dialog.getByRole('button', { name: locale === 'en' ? 'Cancel' : '取消', exact: true }), 'alias dialog cancel');
|
||||
if (process.env.SETTINGS_SCREENSHOT_DIR) {
|
||||
const fs = require('node:fs');
|
||||
const directory = path.resolve(process.env.SETTINGS_SCREENSHOT_DIR);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(directory, `alias-dialog-${locale}-${viewport.width}-${theme}.png`) });
|
||||
}
|
||||
await dialog.getByRole('button', { name: locale === 'en' ? 'Cancel' : '取消', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
}
|
||||
await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||
await tab('requests');
|
||||
await page.locator('.config-sensitive-words-row input').first().waitFor();
|
||||
await noOverflow('sensitive words');
|
||||
|
||||
@@ -248,8 +364,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
for (const id of ['oauth-common', 'oauth-models', 'oauth-codex', 'oauth-media', 'oauth-claude', 'oauth-others']) await templateVisible(id);
|
||||
await noOverflow('upstream credentials');
|
||||
await tab('requests');
|
||||
assert.equal(await page.locator('#config-view-sensitive-words').getAttribute('aria-pressed'), 'true',
|
||||
`${label}: content-filter navigation is remembered across category changes`);
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, `${label}: secondary pagination is removed`);
|
||||
await tab('diagnostics');
|
||||
assert.equal(await page.locator('.config-diagnostics-panel').isVisible(), true);
|
||||
await templateVisible('diagnostics');
|
||||
@@ -265,7 +380,7 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
await noOverflow('application preferences');
|
||||
|
||||
// Search remains available across categories, and result activation must
|
||||
// select both the category and its secondary view before locating a card.
|
||||
// select the category before locating the integrated section.
|
||||
await search.fill(locale === 'en' ? 'Retries & cooldowns' : '重试与冷却');
|
||||
const results = page.locator('.config-search-results');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
@@ -278,19 +393,17 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await results.getByRole('button').first().click();
|
||||
assert.equal(await page.locator('#config-subpage-tab-requests').getAttribute('aria-selected'), 'true');
|
||||
assert.equal(await page.locator('#config-view-sensitive-words').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await page.locator('.config-sensitive-words-row input').first().isVisible(), true);
|
||||
await page.waitForFunction(() => document.querySelector('#config-native-sensitive-words')?.contains(document.activeElement));
|
||||
await search.fill(locale === 'en' ? 'thinking aliases' : '思考');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await results.getByRole('button').first().click();
|
||||
assert.equal(await page.locator('#config-view-aliases').getAttribute('aria-pressed'), 'true');
|
||||
await page.waitForFunction(() => document.querySelector('#config-native-aliases')?.contains(document.activeElement));
|
||||
assert.equal(await page.locator('#config-subpage-tab-aliases').getAttribute('aria-selected'), 'true');
|
||||
await search.fill(locale === 'en' ? 'Default values' : '补充缺失参数');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await results.getByRole('button').first().click();
|
||||
assert.equal(await page.locator('#config-subpage-tab-requests').getAttribute('aria-selected'), 'true');
|
||||
assert.equal(await page.locator('#config-view-behavior').getAttribute('aria-pressed'), 'true');
|
||||
const payloadField = page.locator('#template-field-request-payload-0');
|
||||
assert.equal(await payloadField.locator('details.template-config-custom').evaluate(element => element.open), true,
|
||||
`${label}: search expands structured controls before locating a field`);
|
||||
@@ -318,7 +431,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
await page.setViewportSize({ width: 640, height: 900 });
|
||||
await tab('general');
|
||||
await view('access');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, locale === 'en' ? 'settings-access-640-en.png' : 'settings-access-640.png') });
|
||||
@@ -335,7 +447,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await tab('general');
|
||||
await view('access');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-access.png') });
|
||||
@@ -347,7 +458,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-routing-dark.png') });
|
||||
await tab('general');
|
||||
await view('access');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-access-dark.png') });
|
||||
@@ -359,7 +469,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
// above use fresh browser contexts so preferences never leak between them.
|
||||
if (viewport.width === 1280) {
|
||||
await tab('general');
|
||||
await view('access');
|
||||
const remoteManagement = page.locator('#template-management-0');
|
||||
await remoteManagement.check();
|
||||
assert.equal(await hasDirtyMarker('general'), true, `${label}: template drafts mark their category`);
|
||||
@@ -378,7 +487,6 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
}, await retrySave.elementHandle());
|
||||
assert.equal(await hasDirtyMarker('routing'), false, `${label}: saving clears the category marker`);
|
||||
await tab('general');
|
||||
await view('access');
|
||||
assert.equal(await remoteManagement.isChecked(), true, `${label}: saving another category preserves template drafts`);
|
||||
assert.equal(await hasDirtyMarker('general'), true);
|
||||
const management = page.locator('#template-group-management').locator('xpath=ancestor::section[1]');
|
||||
@@ -387,20 +495,18 @@ const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { w
|
||||
assert.equal(await hasDirtyMarker('general'), false, `${label}: discarding clears the category marker`);
|
||||
|
||||
await tab('requests');
|
||||
await view('sensitive-words');
|
||||
const sensitiveWord = page.locator('.config-sensitive-words-row input').first();
|
||||
await sensitiveWord.fill('settings-navigation-draft');
|
||||
await tab('extensions');
|
||||
assert.equal(await hasDirtyMarker('requests'), true, `${label}: sensitive-word drafts mark their category`);
|
||||
await tab('requests');
|
||||
await view('sensitive-words');
|
||||
assert.equal(await sensitiveWord.inputValue(), 'settings-navigation-draft', `${label}: sensitive-word drafts survive category changes`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(runtimeErrors, [], 'Settings navigation must not produce runtime errors');
|
||||
console.log('PASS: seven settings categories, contextual views, search, retained drafts, save feedback, and Chinese/English responsive layouts.');
|
||||
console.log('PASS: eight settings categories including independent model aliases, adaptive cards, search, retained drafts, save feedback, and Chinese/English responsive layouts.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const { mkdirSync } = require('node:fs');
|
||||
const base = process.env.ALIAS_TEST_BASE_URL || 'http://127.0.0.1:1423';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1423/')
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/')
|
||||
? route.continue() : route.abort());
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
await page.goto('http://127.0.0.1:1423/tests/fixtures/model-alias-editor.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`${base}/tests/fixtures/model-alias-editor.html`, { waitUntil: 'domcontentloaded' });
|
||||
const writes = () => page.evaluate(() => window.fixtureCalls.filter(call => call.cmd === 'create_thinking_alias'));
|
||||
await page.getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
@@ -229,16 +229,7 @@ const viewports = [
|
||||
await tab(`config-subpage-tab-${id}`, `#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`);
|
||||
await assertNoPageOverflow(`${label} settings ${id}`);
|
||||
}
|
||||
for (const [category, views] of [['general', ['access', 'connection']], ['requests', ['behavior', 'aliases', 'sensitive-words']]]) {
|
||||
await tab(`config-subpage-tab-${category}`, '#config-subpage-panel');
|
||||
for (const view of views) {
|
||||
await page.locator(`#config-view-${view}`).click();
|
||||
await settle();
|
||||
assert.equal(await page.locator(`#config-view-${view}`).getAttribute('aria-pressed'), 'true');
|
||||
await assertNoPageOverflow(`${label} settings ${category}/${view}`);
|
||||
await assertReachable(page.locator(`#config-view-${view}`), `${label} settings ${view} navigation`);
|
||||
}
|
||||
}
|
||||
assert.equal(await page.locator('.config-settings-views').count(), 0, 'settings are integrated without secondary pagination');
|
||||
await page.getByLabel('搜索设置', { exact: true }).fill('重试');
|
||||
await page.locator('.config-search-results button').first().waitFor();
|
||||
await settle();
|
||||
|
||||
@@ -121,7 +121,7 @@ async function audit(page, scope, label) {
|
||||
await page.locator('.auth-file-card').first().waitFor();
|
||||
await audit(page, '.auth-files-page', `${theme} credential cards`);
|
||||
await screenshot(`credential-cards-${theme}`);
|
||||
await page.locator('.auth-file-card').first().getByRole('button', { name: '设置', exact: true }).click();
|
||||
await page.locator('.auth-file-card').first().getByRole('button', { name: '凭证设置', exact: true }).click();
|
||||
await page.locator('.credential-settings-body[aria-busy="false"]').waitFor();
|
||||
await audit(page, '.credential-settings-dialog', `${theme} credential settings`);
|
||||
await screenshot(`credential-settings-${theme}`);
|
||||
|
||||
Reference in new issue
Block a user