Files
EasyCLIProxyAPI/tests/agent-client-list-ui.cjs
T
lzt404 2649df000c feat: support DeepSeek Harness Desktop and refine management UI
- Detect and launch DeepSeek Harness Desktop on Windows
- Improve agent client selection and responsive layouts
- Unify dashboard runtime and connection card styling
- Streamline plugin navigation, cards, and installation dialogs
- Update translations and regression tests
2026-10-04 17:31:52 +08:00

249 lines
15 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', 'Claude Desktop', 'Codex', 'OpenCode', '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);
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 <= 6 && 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');
// Search reaches uninstalled clients; a cancelled draft never affects navigation or storage.
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 13);
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([...automaticClients, 'Kimi Code', 'Hermes Agent']);
await refresh({ ...override,
'claude-code': { installed: false, configured: false, configExists: false },
openclaw: { installed: true },
});
await waitClients(['Claude Desktop', 'Codex', 'OpenCode', 'Pi', 'Kimi Code', 'OpenClaw', '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(), 13);
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());
// Reproduce the reported desktop size with twelve previously saved clients.
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'];
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 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 shortcuts are fully visible without hiding overflowing rows');
};
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();
await page.setViewportSize({ width: 360, height: 600 });
await page.waitForFunction(() => document.querySelectorAll('.agent-list-items button').length < 6);
await assertFits();
assert.equal(await activeClient(), 'Hermes Agent', 'resizing always retains the active client');
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') });
// 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); });