Files
EasyCLIProxyAPI/tests/agent-client-list-ui.cjs
lzt404 d7e39e8fdc Add Oh My Pi support through the OpenAI Responses API
Discover CPA models dynamically and route the managed provider to /v1/responses.
2026-10-08 18:36:58 +08:00

301 lines
18 KiB
JavaScript

// Run Vite on port 1421, then node tests/agent-client-list-ui.cjs.
// PLAYWRIGHT_MODULE and AGENT_CLIENT_TEST_URL can target an existing local setup.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const base = process.env.AGENT_CLIENT_TEST_URL || 'http://127.0.0.1:1421';
const storageKey = 'cpa-gui.agent-visible-clients.v1';
const automaticClients = ['Claude Code', 'Codex', 'OpenCode', 'Claude Desktop', 'Pi'];
const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
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());
fs.mkdirSync(screenshots, { recursive: true });
const activeClient = () => page.locator('.agent-list-items button.active strong').textContent();
const stored = () => page.evaluate(key => localStorage.getItem(key), storageKey);
const manage = () => page.getByRole('button', { name: /^(管理客户端|Manage Clients)$/, exact: true });
const dialog = () => page.getByRole('dialog', { name: /^(管理客户端|Manage Clients)$/, exact: true });
const checkbox = name => dialog().getByRole('checkbox', { name, exact: true });
const save = () => dialog().getByRole('button', { name: '保存', exact: true });
const cancel = () => dialog().getByRole('button', { name: /^(取消|Cancel)$/, exact: true });
const search = () => dialog().getByRole('searchbox', { name: '搜索客户端', exact: true });
const automatic = () => dialog().getByRole('button', { name: '恢复自动显示', exact: true });
const ready = () => page.waitForFunction(() => window.fixtureCalls?.some(call => call.cmd === 'get_agent_config_statuses')
&& document.querySelector('.agent-client-list-heading button')
&& !document.querySelector('.agent-client-list-heading button').disabled);
const waitClients = async names => {
await page.waitForFunction(expected => {
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 <= expected.length && actual.every(name => expected.includes(name))
&& expected.includes(selected) && list.scrollHeight <= list.clientHeight + 1;
}, names);
await manage().click();
assert.deepEqual(await dialog().locator('label:has(input:checked) strong').allTextContents(), names);
await cancel().click();
};
const open = async (extra = 'mixed-clients', saved = null) => {
if (page.url() !== 'about:blank') {
await page.evaluate(({ key, saved }) => {
if (saved === null) localStorage.removeItem(key);
else localStorage.setItem(key, saved);
}, { key: storageKey, saved });
}
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=codex&${extra}`, { waitUntil: 'domcontentloaded' });
await ready();
await page.evaluate(() => document.fonts.ready);
};
const refresh = async override => {
await page.evaluate(value => { window.fixtureClientStatusesOverride = value; }, override);
await page.locator('.agent-client-list-heading button').click();
await ready();
};
const closeAndFocus = async close => {
await close();
await dialog().waitFor({ state: 'hidden' });
assert.equal(await manage().evaluate(node => node === document.activeElement), true,
'closing the client manager returns keyboard focus to its trigger');
};
const assertNoHorizontalOverflow = async label => {
const metrics = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
assert.ok(metrics.scrollWidth <= metrics.width + 1, `${label}: the page must fit the viewport`);
const shown = page.getByRole('dialog');
if (await shown.count()) {
const bounds = await shown.evaluate(node => {
const rect = node.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: innerWidth, scrollWidth: node.scrollWidth, clientWidth: node.clientWidth };
});
assert.ok(bounds.left >= -1 && bounds.right <= bounds.width + 1, `${label}: the dialog must fit the viewport`);
assert.ok(bounds.scrollWidth <= bounds.clientWidth + 1, `${label}: the dialog must not scroll horizontally`);
}
};
// Installation, config-only and Pi plugin-only detection all keep clients accessible.
await open();
await waitClients(automaticClients);
assert.equal(await activeClient(), 'Codex');
assert.equal(await page.locator('.agent-list-items button.active').getAttribute('aria-pressed'), 'true');
assert.equal(await stored(), null, 'automatic defaults do not become a custom preference');
// Sorting applies to custom lists and detection refresh, without changing membership or selection.
const customIds = ['claude-desktop', 'codex', 'hermes'];
await open('mixed-clients', JSON.stringify(customIds));
await waitClients(['Codex', 'Claude Desktop', 'Hermes Agent']);
assert.deepEqual(await page.locator('.agent-list-items strong').allTextContents(), ['Codex', 'Claude Desktop', 'Hermes Agent']);
await refresh({ hermes: { installed: true } });
await waitClients(['Codex', 'Hermes Agent', 'Claude Desktop']);
assert.deepEqual(await page.locator('.agent-list-items strong').allTextContents(), ['Codex', 'Hermes Agent', 'Claude Desktop']);
assert.equal(await activeClient(), 'Codex');
assert.deepEqual(JSON.parse(await stored()), customIds, 'sorting must not rewrite saved membership');
await open();
await waitClients(automaticClients);
// Search reaches uninstalled clients; a cancelled draft never affects navigation or storage.
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 14);
await search().fill('herMES');
assert.equal(await dialog().getByRole('checkbox').count(), 1);
await checkbox('Hermes Agent').check();
await search().fill('no-such-client');
assert.equal(await dialog().getByRole('checkbox').count(), 0);
await search().fill('');
assert.equal(await checkbox('Hermes Agent').isChecked(), true, 'search preserves pending choices');
await closeAndFocus(() => cancel().click());
await waitClients(automaticClients);
assert.equal(await stored(), null);
assert.equal(await activeClient(), 'Codex');
await manage().click();
assert.equal(await checkbox('Hermes Agent').isChecked(), false, 'cancel discards the draft');
await checkbox('Hermes Agent').check();
await closeAndFocus(() => page.keyboard.press('Escape'));
assert.equal(await stored(), null, 'Escape also discards changes');
// A keyboard user cannot escape the dialog, and cannot save an empty client list.
await manage().click();
for (let index = 0; index < 20; index++) {
await page.keyboard.press('Tab');
assert.equal(await dialog().evaluate(node => node.contains(document.activeElement)), true, 'Tab stays in the modal');
}
for (const control of await dialog().getByRole('checkbox').all()) {
if (await control.isChecked() && await control.isEnabled()) await control.uncheck();
}
const remaining = await dialog().getByRole('checkbox', { checked: true }).count();
assert.ok(remaining <= 1);
if (remaining === 0) assert.equal(await save().isDisabled(), true, 'empty selection must disable Save');
else assert.equal(await dialog().getByRole('checkbox', { checked: true }).isDisabled(), true,
'if the last client cannot be removed, its checkbox must communicate that state');
await checkbox('Hermes Agent').check();
for (const name of automaticClients) if (await checkbox(name).isChecked()) await checkbox(name).uncheck();
assert.equal(await checkbox('Hermes Agent').isChecked(), true);
await closeAndFocus(() => save().click());
await waitClients(['Hermes Agent']);
assert.equal(await activeClient(), 'Hermes Agent', 'removing the active client selects the first remaining client');
assert.deepEqual(JSON.parse(await stored()), ['hermes']);
await page.reload();
await ready();
await waitClients(['Hermes Agent']);
assert.equal(await activeClient(), 'Hermes Agent', 'saved visibility is restored on reload');
// Detection updates automatic mode but never overrides a manually curated list.
const override = { 'kimi-code': { installed: true, configExists: true, version: '2.0' } };
await refresh(override);
await waitClients(['Hermes Agent']);
await manage().click();
await automatic().click();
assert.equal(await checkbox('Kimi Code').isChecked(), true);
assert.equal(await checkbox('Claude Desktop').isChecked(), true);
await closeAndFocus(() => cancel().click());
assert.deepEqual(JSON.parse(await stored()), ['hermes'], 'restoring automatic visibility is still a cancellable draft');
await manage().click();
await automatic().click();
await closeAndFocus(() => save().click());
assert.equal(await stored(), null, 'saving automatic mode clears the custom preference');
await waitClients(['Claude Code', 'Codex', 'OpenCode', 'Kimi Code', 'Claude Desktop', 'Pi', 'Hermes Agent']);
await refresh({ ...override,
'claude-code': { installed: false, configured: false, configExists: false },
openclaw: { installed: true },
});
await waitClients(['Codex', 'OpenCode', 'Kimi Code', 'OpenClaw', 'Claude Desktop', 'Pi', 'Hermes Agent']);
// Detection failure and a clean machine still offer a usable selected client and the full catalog.
await open('not-installed&no-plugin&fresh');
await waitClients(['Codex']);
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 14);
await closeAndFocus(() => cancel().click());
await open('mixed-clients&fail-detection');
await page.getByText(/模拟客户端检测失败/).waitFor();
await waitClients(['Codex']);
await refresh({});
await waitClients(automaticClients);
await open('mixed-clients&shell');
await waitClients(automaticClients);
await page.screenshot({ path: path.join(screenshots, 'automatic-desktop.png'), fullPage: true });
await manage().click();
await dialog().waitFor();
await page.screenshot({ path: path.join(screenshots, 'manager-desktop.png'), fullPage: true });
await closeAndFocus(() => cancel().click());
// Every saved client is reachable exactly once, in the same order, without a second switcher.
await page.setViewportSize({ width: 1368, height: 912 });
const savedClients = ['claude-code', 'claude-desktop', 'codex', 'deepseek-harness', 'opencode', 'omp', 'pi',
'grok-build', 'antigravity-cli', 'workbuddy', 'zcode', 'kimi-code', 'openclaw'];
const savedNames = ['Claude Code', 'Claude Desktop', 'Codex', 'DeepSeek Harness', 'OpenCode', 'Oh My Pi', 'Pi',
'Grok Build', 'Antigravity CLI', 'WorkBuddy', 'ZCode', 'Kimi Code', 'OpenClaw'];
await page.evaluate(({ key, ids }) => localStorage.setItem(key, JSON.stringify(ids)), { key: storageKey, ids: savedClients });
await page.goto(`${base}/tests/fixtures/agent-backups.html?shell&client=claude-code`, { waitUntil: 'domcontentloaded' });
await ready();
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();
return { height: list.clientHeight, scrollHeight: list.scrollHeight, scrollWidth: list.scrollWidth,
width: list.clientWidth, clipped: Array.from(list.children).some(child => {
const r = child.getBoundingClientRect();
return r.top < rect.top - 1 || r.bottom > rect.bottom + 1;
}) };
});
assert.ok(metrics.scrollHeight <= metrics.height + 1 && metrics.scrollWidth <= metrics.width + 1);
assert.equal(metrics.clipped, false, 'all rows are fully visible on each page');
};
const collectPages = async () => {
while (await previous().isEnabled()) await previous().click();
const names = [];
for (let index = 0; index < savedClients.length; 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(), savedNames, 'no saved client is omitted, duplicated or reordered');
assert.deepEqual(JSON.parse(await stored()), savedClients, '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()), savedClients, 'selecting leaves the management list unchanged');
await page.setViewportSize({ width: 360, height: 600 });
await page.waitForFunction(() => document.querySelector('.agent-list-items button.active strong')?.textContent === 'OpenClaw'
&& document.querySelectorAll('.agent-list-items button').length <= 2);
await assertFits();
assert.deepEqual(await collectPages(), savedNames, 'all managed clients remain reachable after resizing');
assert.deepEqual(JSON.parse(await stored()), savedClients);
// 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()), savedClients);
assert.equal(await activeClient(), 'OpenClaw');
await manage().click();
await checkbox('Hermes Agent').check();
await checkbox('OpenClaw').uncheck();
await closeAndFocus(() => save().click());
const updatedNames = [...savedNames.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 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]) {
for (const english of [false, true]) {
await page.setViewportSize({ width: 360, height: 800 });
await open(`mixed-clients&shell&${embedded ? 'embedded&' : ''}${english ? 'locale=en&theme=dark' : ''}`);
await waitClients(automaticClients);
const label = `${embedded ? 'embedded' : 'full'}-${english ? 'dark-en' : 'light-zh'}`;
await assertNoHorizontalOverflow(label);
const trigger = page.getByRole('button', { name: english ? 'Manage Clients' : '管理客户端', exact: true });
await trigger.click();
const modal = page.getByRole('dialog', { name: english ? 'Manage Clients' : '管理客户端', exact: true });
await modal.waitFor();
await assertNoHorizontalOverflow(`${label} manager`);
const hermes = modal.getByRole('checkbox', { name: 'Hermes Agent', exact: true });
await hermes.check();
assert.equal(await hermes.isChecked(), true, `${label}: the last catalog entry is reachable`);
await page.screenshot({ path: path.join(screenshots, `${label}-manager.png`), fullPage: true });
await page.keyboard.press('Escape');
await modal.waitFor({ state: 'hidden' });
assert.equal(await trigger.evaluate(node => node === document.activeElement), true);
}
}
assert.deepEqual(errors, []);
console.log('PASS: automatic detection, custom visibility, persistence, active selection, search, cancellation, empty selection, modal focus, retry, narrow layouts and dark English');
console.log(`Screenshots: ${screenshots}`);
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exit(1); });