feat(config): redesign settings navigation and preserve v7 config migration
- reorganize advanced settings with horizontal navigation, search, and contextual help - remove LAN discovery and trusted proxy settings - add robust v7-to-v8 migration for custom, plugin, routing, TLS, and credential fields - preserve unsaved drafts during external configuration refresh - add migration and settings UI coverage
This commit is contained in:
1 parent
c89e2fb020
commit
fdf56ee7eb
22 files changed
+1497
-632
No files matched your search
@@ -42,9 +42,51 @@ const path = require('node:path');
|
||||
await page.locator('.nav-section').getByRole('button', { name: '高级功能' }).click();
|
||||
assert.equal(await page.locator('h1').count(), 1);
|
||||
const configTabs = page.locator('.config-subpage-tabs').getByRole('tab');
|
||||
assert.equal(await page.locator('.config-subpage-tabs').getAttribute('aria-orientation'), 'horizontal');
|
||||
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-oauth', 'config-subpage-tab-diagnostics', 'config-subpage-tab-extensions',
|
||||
'config-subpage-tab-software',
|
||||
]);
|
||||
assert.equal(await page.locator('.config-page [role="tabpanel"]').count(), 1);
|
||||
assert.deepEqual(await configTabs.evaluateAll((elements) => elements
|
||||
.map((element) => element.getAttribute('aria-controls'))
|
||||
.filter((id) => !id || !document.getElementById(id))), []);
|
||||
assert.equal(await configTabs.evaluateAll(elements => elements.every(element =>
|
||||
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');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
await page.keyboard.press('End');
|
||||
assert.equal(await page.locator('#config-subpage-tab-software').getAttribute('aria-selected'), 'true');
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'config-subpage-tab-software');
|
||||
await page.keyboard.press('Home');
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'config-subpage-tab-general');
|
||||
for (const key of ['ArrowDown', 'ArrowUp']) {
|
||||
assert.equal(await configTabs.first().evaluate((element, pressedKey) => {
|
||||
const event = new KeyboardEvent('keydown', { key: pressedKey, bubbles: true, cancelable: true });
|
||||
element.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}, 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);
|
||||
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'));
|
||||
assert.ok(await fieldHelp.getAttribute('aria-label'), 'Help icons need a meaningful accessible name');
|
||||
await fieldHelp.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await fieldHelp.getAttribute('aria-expanded'), 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await fieldHelp.getAttribute('aria-expanded'), 'false');
|
||||
const addKey = page.getByRole('button', { name: /新增鉴权密钥/ }).first();
|
||||
await addKey.focus();
|
||||
await addKey.click();
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
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 }];
|
||||
|
||||
(async () => {
|
||||
let server;
|
||||
let browser;
|
||||
let base = 'http://127.0.0.1:1420/';
|
||||
const runtimeErrors = [];
|
||||
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'] });
|
||||
|
||||
for (const locale of ['zh-CN', 'en']) {
|
||||
for (const viewport of viewports) {
|
||||
const label = `${locale} ${viewport.width}x${viewport.height}`;
|
||||
const page = await browser.newPage({ viewport });
|
||||
page.setDefaultTimeout(10000);
|
||||
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);
|
||||
await page.goto(`${base}?mock=running`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.app-shell').waitFor();
|
||||
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
|
||||
await page.locator('.nav-section').getByRole('button', {
|
||||
name: locale === 'en' ? 'Advanced Features' : '高级功能', exact: true,
|
||||
}).click();
|
||||
await page.locator('#config-subpage-panel').waitFor();
|
||||
|
||||
const settle = () => page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const tab = async id => {
|
||||
await page.locator(`#config-subpage-tab-${id}`).click();
|
||||
await page.locator(`#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`).waitFor();
|
||||
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`);
|
||||
};
|
||||
const noOverflow = async context => {
|
||||
await settle();
|
||||
const overflowing = await page.evaluate(() => [
|
||||
document.documentElement, document.body, document.querySelector('.content'),
|
||||
document.querySelector('.config-page'), document.querySelector('#config-subpage-panel'),
|
||||
document.querySelector('.config-search-results'),
|
||||
].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 reachable = async (locator, context) => {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
await locator.evaluate(element => element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }));
|
||||
await settle();
|
||||
const metrics = await locator.evaluate(element => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
|
||||
return {
|
||||
reachable: bounds.width > 0 && bounds.height > 0 && bounds.left >= -1 && bounds.right <= innerWidth + 1
|
||||
&& bounds.top >= -1 && bounds.bottom <= innerHeight + 1 && Boolean(hit && element.contains(hit)),
|
||||
bounds: { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom },
|
||||
hit: hit?.className || hit?.tagName,
|
||||
};
|
||||
});
|
||||
if (!metrics.reachable && process.env.SETTINGS_SCREENSHOT_DIR) {
|
||||
const fs = require('node:fs');
|
||||
fs.mkdirSync(path.resolve(process.env.SETTINGS_SCREENSHOT_DIR), { recursive: true });
|
||||
await page.screenshot({ path: path.join(path.resolve(process.env.SETTINGS_SCREENSHOT_DIR), 'settings-reachability-failure.png') });
|
||||
}
|
||||
assert.equal(metrics.reachable, true, `${label} ${context}: control must remain reachable: ${JSON.stringify(metrics)}`);
|
||||
};
|
||||
const hasDirtyMarker = async id => page.locator(`#config-subpage-tab-${id}`).evaluate(element =>
|
||||
Boolean(element.querySelector('.config-nav-dirty, .config-settings-dirty, [data-dirty="true"]'))
|
||||
|| /未保存|unsaved/i.test([element.getAttribute('aria-label'), element.getAttribute('title'), element.textContent].join(' ')));
|
||||
const search = page.getByLabel(locale === 'en' ? 'Search settings' : '搜索设置', { exact: true });
|
||||
const retrySection = page.locator('#config-retry-section-title').locator('xpath=ancestor::section[1]');
|
||||
const exerciseHelp = async (trigger, context) => {
|
||||
await reachable(trigger, `${context} help trigger`);
|
||||
const controlledId = await trigger.getAttribute('aria-controls');
|
||||
assert.ok(controlledId, `${label}: help triggers must identify their description`);
|
||||
const popover = page.locator(`[id="${controlledId}"]`);
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: help starts collapsed`);
|
||||
assert.equal(await popover.isVisible(), false, `${label}: descriptions stay hidden until requested`);
|
||||
await trigger.click();
|
||||
await popover.waitFor({ state: 'visible' });
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(await popover.getAttribute('role'), 'tooltip');
|
||||
assert.ok((await popover.innerText()).trim().length > 5, `${label}: expanded help must contain useful field guidance`);
|
||||
await noOverflow(`${context} help open`);
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: Escape closes requested help`);
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'true', `${label}: Enter opens field help`);
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: Space closes field help`);
|
||||
await trigger.click();
|
||||
await search.click();
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: clicking outside closes field help`);
|
||||
};
|
||||
|
||||
assert.equal(await page.getByRole('heading', { level: 1, name: locale === 'en' ? 'Advanced settings' : '高级设置', exact: true }).count(), 1);
|
||||
assert.equal(await page.locator('.config-page [role="tabpanel"]').count(), 1);
|
||||
assert.equal(await page.locator('.config-settings-header p').count(), 0,
|
||||
`${label}: settings header must not repeat layout explanations`);
|
||||
assert.equal(await page.locator('.config-nav-save-hint, .config-category-heading, .config-section-index').count(), 0,
|
||||
`${label}: categories should lead directly to their settings without redundant descriptions or jump navigation`);
|
||||
assert.deepEqual(await page.locator('.config-subpage-tabs [role="tab"]').evaluateAll(elements =>
|
||||
elements.map(element => element.id.replace('config-subpage-tab-', ''))), categories);
|
||||
assert.equal(await page.locator('.config-subpage-tabs [role="tab"]').evaluateAll(elements =>
|
||||
elements.every(element => element.getAttribute('aria-controls') === 'config-subpage-panel')), true);
|
||||
const navigation = page.locator('.config-subpage-tabs');
|
||||
assert.equal(await navigation.getAttribute('aria-orientation'), 'horizontal');
|
||||
assert.equal(await page.locator('.config-settings-sidebar, .config-page aside').count(), 0,
|
||||
`${label}: settings must not add another vertical sidebar`);
|
||||
await settle();
|
||||
const navigationLayout = await navigation.evaluate(element => {
|
||||
const header = document.querySelector('.config-settings-header').getBoundingClientRect();
|
||||
const panel = document.querySelector('#config-subpage-panel').getBoundingClientRect();
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const elements = [...element.querySelectorAll('[role="tab"]')];
|
||||
const buttons = elements.map(button => button.getBoundingClientRect());
|
||||
return {
|
||||
aboveContent: bounds.top >= header.bottom - 1 && bounds.bottom <= panel.top + 1,
|
||||
singleRow: Math.max(...buttons.map(button => button.top)) - Math.min(...buttons.map(button => button.top)) <= 1,
|
||||
labelsFit: elements.every((button, index) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(button);
|
||||
const text = range.getBoundingClientRect();
|
||||
return text.left >= buttons[index].left - 1 && text.right <= buttons[index].right + 1
|
||||
&& text.top >= buttons[index].top - 1 && text.bottom <= buttons[index].bottom + 1;
|
||||
}),
|
||||
overflowing: element.scrollWidth > element.clientWidth + 1,
|
||||
scrollable: ['auto', 'scroll'].includes(getComputedStyle(element).overflowX),
|
||||
};
|
||||
});
|
||||
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.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`);
|
||||
assert.equal(navigationLayout.scrollable, true, `${label}: narrow category navigation exposes horizontal scrolling`);
|
||||
assert.equal(await navigation.evaluate(element => {
|
||||
element.scrollLeft = element.scrollWidth;
|
||||
return element.scrollLeft > 0;
|
||||
}), true, `${label}: users can scroll to categories at the right edge`);
|
||||
}
|
||||
const finalCategory = page.locator('#config-subpage-tab-software');
|
||||
await reachable(finalCategory, 'rightmost horizontal category');
|
||||
await tab('software');
|
||||
assert.equal(await page.locator('.config-software-panel').isVisible(), true,
|
||||
`${label}: rightmost category remains activatable`);
|
||||
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);
|
||||
await templateVisible('management');
|
||||
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.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();
|
||||
await exerciseHelp(page.locator('#config-native-network .settings-help-trigger').first(), 'network field');
|
||||
assert.equal(await nativePort.inputValue(), initialPort, `${label}: requesting help must not edit the field`);
|
||||
|
||||
await tab('routing');
|
||||
assert.equal(await page.locator('#config-routing-section-title').isVisible(), true);
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), true);
|
||||
await templateVisible('routing-advanced');
|
||||
await templateVisible('extensions-concurrency');
|
||||
await templateVisible('extensions-plugins', false);
|
||||
await noOverflow('routing and stability');
|
||||
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`);
|
||||
await tab('routing');
|
||||
const routingOptions = await page.locator('.routing-segmented').evaluate(group => {
|
||||
const container = group.getBoundingClientRect();
|
||||
return [...group.querySelectorAll('button')].map(button => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(button);
|
||||
const text = range.getBoundingClientRect();
|
||||
return {
|
||||
label: button.textContent.trim(),
|
||||
contained: bounds.left >= container.left - 1 && bounds.right <= container.right + 1
|
||||
&& bounds.top >= container.top - 1 && bounds.bottom <= container.bottom + 1,
|
||||
readable: text.left >= bounds.left - 1 && text.right <= bounds.right + 1
|
||||
&& text.top >= bounds.top - 1 && text.bottom <= bounds.bottom + 1,
|
||||
height: bounds.height,
|
||||
};
|
||||
});
|
||||
});
|
||||
assert.equal(routingOptions.length, 3);
|
||||
assert.ok(routingOptions.every(option => option.contained && option.readable && option.height >= 32),
|
||||
`${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);
|
||||
await noOverflow('model and request behavior');
|
||||
await view('aliases');
|
||||
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');
|
||||
await page.locator('.config-sensitive-words-row input').first().waitFor();
|
||||
await noOverflow('sensitive words');
|
||||
|
||||
await tab('oauth');
|
||||
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`);
|
||||
await tab('diagnostics');
|
||||
assert.equal(await page.locator('.config-diagnostics-panel').isVisible(), true);
|
||||
await templateVisible('diagnostics');
|
||||
await templateVisible('extensions-inflight');
|
||||
await noOverflow('diagnostics');
|
||||
await tab('extensions');
|
||||
await templateVisible('extensions-plugins');
|
||||
await templateVisible('extensions-concurrency', false);
|
||||
await templateVisible('extensions-inflight', false);
|
||||
await noOverflow('extensions');
|
||||
await tab('software');
|
||||
assert.equal(await page.locator('.config-software-panel').isVisible(), true);
|
||||
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.
|
||||
await search.fill(locale === 'en' ? 'Retries & cooldowns' : '重试与冷却');
|
||||
const results = page.locator('.config-search-results');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await noOverflow('search results');
|
||||
await reachable(results.getByRole('button').first(), 'search result');
|
||||
await results.getByRole('button', { name: locale === 'en' ? /Retries & cooldowns/ : /重试与冷却/ }).first().click();
|
||||
assert.equal(await page.locator('#config-subpage-tab-routing').getAttribute('aria-selected'), 'true');
|
||||
await reachable(page.locator('#config-retry-section-title'), 'search target');
|
||||
await search.fill(locale === 'en' ? 'Sensitive Words' : '敏感词');
|
||||
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));
|
||||
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`);
|
||||
await page.waitForFunction(() => document.querySelector('#template-field-request-payload-0')?.contains(document.activeElement));
|
||||
if (viewport.width === 1280) {
|
||||
await payloadField.locator('button.structured-add').first().click();
|
||||
await payloadField.locator('input').first().waitFor();
|
||||
await search.fill(locale === 'en' ? 'Default values' : '补充缺失参数');
|
||||
await results.getByRole('button').first().click();
|
||||
await page.waitForFunction(() => document.querySelector('#template-field-request-payload-0')?.contains(document.activeElement)
|
||||
&& document.activeElement?.matches('input, textarea, select'));
|
||||
await page.locator('#config-group-request-payload').getByRole('button', {
|
||||
name: locale === 'en' ? 'Discard changes' : '放弃本组修改', exact: true,
|
||||
}).click();
|
||||
}
|
||||
await search.fill('');
|
||||
await search.fill('settings-that-do-not-exist-2026');
|
||||
assert.equal(await results.getByRole('button').count(), 0);
|
||||
await noOverflow('empty search');
|
||||
await search.fill('');
|
||||
|
||||
if (process.env.SETTINGS_SCREENSHOT_DIR && viewport.width === 640) {
|
||||
const fs = require('node:fs');
|
||||
const directory = path.resolve(process.env.SETTINGS_SCREENSHOT_DIR);
|
||||
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') });
|
||||
await tab('routing');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, locale === 'en' ? 'settings-routing-640-en.png' : 'settings-routing-640.png') });
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
if (process.env.SETTINGS_SCREENSHOT_DIR && viewport.width === 1280 && locale === 'zh-CN') {
|
||||
const fs = require('node:fs');
|
||||
const directory = path.resolve(process.env.SETTINGS_SCREENSHOT_DIR);
|
||||
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') });
|
||||
await tab('routing');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-routing.png') });
|
||||
await page.locator('.sidebar-theme-selector').getByRole('button', { name: '暗色', exact: true }).click();
|
||||
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') });
|
||||
await page.locator('.sidebar-theme-selector').getByRole('button', { name: '亮色', exact: true }).click();
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
// Exercise drafts and save feedback once per locale; the responsive cases
|
||||
// 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`);
|
||||
await tab('routing');
|
||||
const retryInput = retrySection.locator('input[type="text"]').first();
|
||||
await retryInput.fill('5');
|
||||
assert.equal(await hasDirtyMarker('routing'), true, `${label}: core drafts mark their category`);
|
||||
await tab('software');
|
||||
assert.equal(await hasDirtyMarker('routing'), true, `${label}: dirty category remains marked while inactive`);
|
||||
await tab('routing');
|
||||
assert.equal(await retryInput.inputValue(), '5', `${label}: changing categories preserves core drafts`);
|
||||
const retrySave = retrySection.getByRole('button', { name: locale === 'en' ? /Save/ : /保存/ });
|
||||
await retrySave.click();
|
||||
await page.waitForFunction(button => {
|
||||
return button?.disabled && !/正在保存|保存中|saving/i.test(button.textContent);
|
||||
}, 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]');
|
||||
await management.getByRole('button', { name: locale === 'en' ? 'Discard changes' : '放弃本组修改', exact: true }).click();
|
||||
assert.equal(await remoteManagement.isChecked(), false);
|
||||
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.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -196,7 +196,7 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
const values: Array<[string, unknown]> = [
|
||||
['routing/strategy', 'weighted-round-robin'], ['routing/session-affinity-subagents', false],
|
||||
['routing/cooldown/transient-error-cooldown-seconds', -1], ['routing/retry/max-retry-interval', -1],
|
||||
['requests/streaming/keepalive-seconds', 0], ['server/trusted-proxies', []],
|
||||
['requests/streaming/keepalive-seconds', 0], ['plugins/store-sources', []],
|
||||
['client/codex/enable-apply-patch', false], ['client/codex/optimize-multi-agent-v2', true],
|
||||
['multimedia/disable-image-generation', 'passthrough'],
|
||||
['oauth/settings', { codex: [{ name: 'gpt-template', 'max-context-length': 524288 }] }],
|
||||
|
||||
@@ -214,10 +214,25 @@ const viewports = [
|
||||
}
|
||||
|
||||
await open('高级功能', '#config-subpage-panel');
|
||||
for (const id of ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words']) {
|
||||
for (const id of ['general', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software']) {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
await page.getByLabel('搜索设置', { exact: true }).fill('重试');
|
||||
await page.locator('.config-search-results button').first().waitFor();
|
||||
await settle();
|
||||
await assertNoPageOverflow(`${label} settings search results`);
|
||||
await page.getByLabel('搜索设置', { exact: true }).fill('');
|
||||
|
||||
await open('版本管理', '.version-list-item');
|
||||
await assertNoPageOverflow(`${label} versions`);
|
||||
@@ -233,7 +248,7 @@ const viewports = [
|
||||
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']) {
|
||||
for (const id of ['general', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software']) {
|
||||
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}`);
|
||||
@@ -246,6 +261,10 @@ const viewports = [
|
||||
return text.left >= bounds.left - 1 && text.right <= bounds.right + 1;
|
||||
}), true, `English ${id} label must fit inside its own clickable tab`);
|
||||
}
|
||||
await page.getByLabel('Search settings', { exact: true }).fill('retry');
|
||||
await page.locator('.config-search-results button').first().waitFor();
|
||||
await settle();
|
||||
await assertNoPageOverflow('640x600 English settings search results');
|
||||
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.');
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
templateFieldSaveValue, templateFieldValidation, type TemplateConfigField,
|
||||
} from '../src/services/templateConfig';
|
||||
import { createBrowserMockRuntime } from '../src/mocks/browserMockRuntime';
|
||||
import { generalTemplateGroups, networkTemplateGroups, requestTemplateGroups, routingTemplateGroups } from '../src/services/generalTemplateFields';
|
||||
import { generalTemplateGroups, requestTemplateGroups, routingTemplateGroups } from '../src/services/generalTemplateFields';
|
||||
import { templateMessages } from '../src/i18n/templateConfig';
|
||||
import { oauthTemplateGroups } from '../src/services/oauthTemplateFields';
|
||||
|
||||
@@ -13,7 +13,7 @@ const change = (path: string[], value: unknown, expected: unknown = null, expect
|
||||
|
||||
describe('template configuration drafts', () => {
|
||||
test('all general settings labels and help messages include Japanese', () => {
|
||||
const groups = [...generalTemplateGroups, ...networkTemplateGroups, ...requestTemplateGroups, ...routingTemplateGroups];
|
||||
const groups = [...generalTemplateGroups, ...requestTemplateGroups, ...routingTemplateGroups];
|
||||
const messages = [...Object.values(templateMessages), ...groups.flatMap((group) => [group.title, group.description, ...group.fields.flatMap((item) => [item.label, item.description, ...item.options?.map((option) => option.label) ?? []])])].filter((value) => value !== undefined);
|
||||
for (const message of messages) {
|
||||
expect(typeof message).toBe('object');
|
||||
@@ -85,9 +85,9 @@ describe('template configuration drafts', () => {
|
||||
});
|
||||
|
||||
describe('extended browser mock persistence', () => {
|
||||
test('hot-reloadable discovery, diagnostics and plugins do not ask for a restart', async () => {
|
||||
test('hot-reloadable diagnostics and plugins do not ask for a restart', async () => {
|
||||
const runtime = createBrowserMockRuntime('running');
|
||||
const changes = [change(['server', 'discovery', 'enabled'], true), change(['observability', 'pprof', 'enable'], true), change(['plugins', 'enabled'], true), change(['plugins', 'dir'], 'custom-plugins')];
|
||||
const changes = [change(['observability', 'pprof', 'enable'], true), change(['plugins', 'enabled'], true), change(['plugins', 'dir'], 'custom-plugins')];
|
||||
expect(await runtime.invoke('save_extended_core_config', { changes })).toMatchObject({ restartRequired: false });
|
||||
});
|
||||
test('credential directory changes and restoring defaults require a restart', async () => {
|
||||
@@ -101,7 +101,7 @@ describe('extended browser mock persistence', () => {
|
||||
test('saves offline, supports explicit overrides, removal and preserves unrelated values', async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createBrowserMockRuntime('stopped', (event) => events.push(event));
|
||||
const changes = [change(['plugins', 'enabled'], false), change(['plugins', 'configs'], {}), change(['server', 'trusted-proxies'], [])];
|
||||
const changes = [change(['plugins', 'enabled'], false), change(['plugins', 'configs'], {}), change(['management', 'disable-auto-update-panel'], false)];
|
||||
const result = await runtime.invoke('save_extended_core_config', { changes }) as { config: Record<string, unknown>; restartRequired: boolean };
|
||||
expect(readTemplatePath(result.config, ['plugins', 'enabled'])).toEqual({ exists: true, value: false });
|
||||
expect(readTemplatePath(result.config, ['plugins', 'configs']).value).toEqual({});
|
||||
|
||||
Reference in new issue
Block a user