Files
EasyCLIProxyAPI/tests/claude-desktop-models-ui.cjs
T
lzt404 81597fcf91 feat(claude-desktop): support custom model aliases
- Add editable model lists, alias suggestions, and usage guidance
- Ignore unselected rows and validate alias conflicts and naming rules
- Fix alias routing through disabled or excluded providers
- Preserve model routes when restoring legacy backups
- Repair legacy metadata and remove unsupported context fields
- Add regression tests for validation and configuration recovery
2026-09-18 18:17:31 +08:00

228 lines
15 KiB
JavaScript

const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = []; page.on('pageerror', error => errors.push(String(error)));
const open = async query => {
await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?client=claude-desktop&' + query, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: '添加模型', exact: true }).waitFor();
};
const rows = () => page.locator('.agent-desktop-model-row');
const originalPicker = index => rows().nth(index).locator('button.agent-model-trigger');
const originalName = index => originalPicker(index).locator('strong').innerText();
const idInput = index => rows().nth(index).getByRole('combobox', { name: '别名(Claude系列模型可留空)', exact: true });
const fillAlias = async (index, alias) => {
await idInput(index).fill(alias);
await idInput(index).press('Escape');
};
const chooseOriginal = async (index, model) => {
await originalPicker(index).click();
await rows().nth(index).locator('.agent-model-search input').fill(model);
await page.getByRole('option').filter({ has: page.getByText(model, { exact: true }) }).click();
};
const save = () => page.getByRole('button', { name: '更新配置', exact: true });
const add = async (model, alias) => {
await page.getByRole('button', { name: '添加模型', exact: true }).click();
const index = await rows().count() - 1;
if (model) await chooseOriginal(index, model);
await fillAlias(index, alias);
};
for (const mode of ['', 'embedded']) {
await open(mode);
assert.equal(await rows().count(), 3);
assert.deepEqual(await Promise.all([0, 1, 2].map(index => idInput(index).inputValue())),
['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5']);
assert.ok(await save().isDisabled());
const addButton = page.getByRole('button', { name: '添加模型', exact: true });
assert.equal(await addButton.locator('svg').count(), 0);
assert.ok(await addButton.evaluate(button => button.classList.contains('primary-button')));
assert.ok((await rows().first().boundingBox()).height < 100);
assert.doesNotMatch(await page.locator('.agent-desktop-models').innerText(), /先拒绝|左侧选择或输入/);
await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-defaults-${mode || 'full'}.png`), fullPage: true });
const help = page.getByRole('button', { name: '使用说明', exact: true });
await help.click();
const dialog = page.getByRole('dialog', { name: '使用说明', exact: true });
await dialog.waitFor();
assert.ok((await dialog.boundingBox()).height < 600);
assert.equal(await dialog.getByRole('listitem').count(), 3);
assert.match(await dialog.innerText(), /只允许使用 Claude.*无需映射/s);
assert.match(await dialog.innerText(), /轮询调度.*期望的模型/s);
assert.match(await dialog.innerText(), /claude-opus-\*.*claude-sonnet-\*.*claude-haiku-\*.*不能包含.*gpt.*grok.*gemini.*deepseek/s);
await page.screenshot({ path: path.join(process.env.TEMP, 'claude-desktop-help.png'), fullPage: true });
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'detached' });
assert.ok(await help.evaluate(button => button === document.activeElement));
await help.click();
await dialog.locator('.agent-desktop-help-actions').getByRole('button', { name: '关闭', exact: true }).click();
const selectedIndex = mode ? 1 : 0;
const selectedAlias = await idInput(selectedIndex).inputValue();
await chooseOriginal(selectedIndex, 'gpt-two');
assert.equal(await page.getByText('请选择或填写原模型。', { exact: true }).count(), 0);
assert.ok(await save().isEnabled());
await page.evaluate(() => window.fixtureRemount());
await rows().nth(2).waitFor();
assert.equal(await originalName(selectedIndex), 'gpt-two');
assert.equal(await originalName(selectedIndex === 0 ? 1 : 0), '选择模型');
assert.ok(await save().isEnabled());
await save().click();
await page.getByText('配置已更新。', { exact: true }).waitFor();
const partialCall = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'update_agent_config').at(-1));
assert.equal(partialCall.args.model, 'gpt-two');
assert.deepEqual(partialCall.args.claudeDesktopModelMappings.desktopModels,
[{ model: 'gpt-two', alias: selectedAlias, context1m: false }]);
await page.evaluate(() => window.fixtureRemount());
await rows().first().waitFor();
assert.equal(await rows().count(), 1);
await open(mode);
for (let i = 0; i < 3; i++) await rows().last().getByRole('button', { name: '移除模型' }).click();
await page.evaluate(() => window.fixtureRemount());
await addButton.waitFor();
assert.equal(await rows().count(), 0);
await add('gpt-two', 'my-model');
assert.ok(await save().isDisabled());
assert.equal(await page.locator('.agent-desktop-models select').count(), 0);
assert.equal(await page.locator('.agent-desktop-models datalist').count(), 0);
assert.equal(await page.locator('.agent-desktop-models details').count(), 0);
assert.doesNotMatch(await page.locator('.agent-desktop-models').innerText(), /映射|CPA/);
const left = await originalPicker(0).boundingBox();
const right = await rows().nth(0).locator('.agent-model-editable-trigger').boundingBox();
assert.ok(left.x + left.width <= right.x && left.y === right.y);
assert.equal(left.height, right.height);
assert.equal(await idInput(0).getAttribute('placeholder'), '不重名');
await originalPicker(0).click();
const originals = await page.locator('.agent-model-option strong').allTextContents();
assert.deepEqual(originals, ['gpt-one', 'gpt-two']);
assert.equal(await page.getByRole('option', { selected: true }).locator('strong').innerText(), 'gpt-two');
await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-original-dropdown-${mode || 'full'}.png`), fullPage: true });
await rows().nth(0).locator('.agent-model-search input').press('Escape');
await idInput(0).click();
const suggestions = await page.locator('.agent-model-option strong').allTextContents();
assert.ok(suggestions.includes('claude-sonnet-4-6'));
assert.ok(suggestions.every(id => !id.includes('cpa')));
await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-alias-dropdown-${mode || 'full'}.png`), fullPage: true });
await page.getByRole('option').filter({ has: page.getByText('claude-sonnet-4-6', { exact: true }) }).click();
assert.equal(await idInput(0).inputValue(), 'claude-sonnet-4-6');
assert.ok(await save().isEnabled());
await add('gpt-one', 'claude-sonnet-4-6');
await page.getByText('别名或原模型 ID 重复,请为每一行使用不同的 ID。', { exact: true }).waitFor();
assert.ok(await save().isDisabled());
await fillAlias(1, 'claude-opus-4-6');
await rows().nth(1).getByRole('checkbox').check();
await add('', 'claude-haiku-4-5');
assert.equal(await originalName(2), '选择模型');
assert.ok(await save().isEnabled());
assert.equal(await rows().nth(2).getByText('请选择或填写原模型。', { exact: true }).count(), 0);
await rows().last().getByRole('button', { name: '移除模型' }).click();
await fillAlias(0, '');
assert.ok(await save().isEnabled());
await page.getByRole('tab', { name: '配置管理', exact: true }).click();
await page.getByRole('tab', { name: '基础配置', exact: true }).click();
assert.equal(await rows().count(), 2);
await save().click();
await page.waitForFunction(() => window.fixtureCalls.some(c => c.cmd === 'update_agent_config'));
const call = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'update_agent_config').at(-1));
assert.equal(call.args.model, 'gpt-two');
assert.deepEqual(call.args.claudeDesktopModelMappings.desktopModels, [
{ model: 'gpt-two', alias: '', context1m: false },
{ model: 'gpt-one', alias: 'claude-opus-4-6', context1m: true },
]);
await page.getByText('配置已更新。', { exact: true }).waitFor();
await page.evaluate(() => window.fixtureRemount());
await rows().nth(1).waitFor();
assert.equal(await idInput(0).inputValue(), '');
assert.equal(await originalName(0), 'gpt-two');
await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-models-${mode || 'full'}.png`), fullPage: true });
for (let i = 0; i < 3; i++) await add(`claude-custom-${i}`, '');
assert.equal(await rows().count(), 5);
assert.ok(await save().isEnabled());
for (let i = 0; i < 3; i++) await rows().last().getByRole('button', { name: '移除模型' }).click();
await rows().nth(1).getByRole('button', { name: '移除模型' }).click();
assert.ok(await save().isEnabled());
await rows().first().getByRole('button', { name: '移除模型' }).click();
assert.ok(await save().isDisabled());
}
for (const query of ['claude-models', 'claude-alias-models']) {
await open(query);
const opusWarning = rows().nth(0).getByText('默认别名「claude-opus-5」已存在于模型列表中,请切换其他别名,避免调用到其他模型。', { exact: true });
await opusWarning.waitFor();
await rows().nth(1).getByText('默认别名「claude-sonnet-5」已存在于模型列表中,请切换其他别名,避免调用到其他模型。', { exact: true }).waitFor();
await chooseOriginal(2, 'gpt-one');
assert.ok(await save().isEnabled());
assert.equal(await page.getByText('请选择或填写原模型。', { exact: true }).count(), 0);
await chooseOriginal(0, 'gpt-one');
await opusWarning.waitFor();
assert.ok(await save().isDisabled());
await page.screenshot({ path: path.join(process.env.TEMP, `claude-desktop-default-conflict-${query}.png`), fullPage: true });
await fillAlias(0, 'claude-opus-personal');
assert.equal(await opusWarning.count(), 0);
assert.ok(await save().isEnabled());
}
await open('saved-desktop-alias');
assert.equal(await rows().count(), 1);
assert.equal(await idInput(0).inputValue(), 'claude-opus-5');
assert.equal(await rows().first().getByRole('status').count(), 0);
await rows().first().getByRole('checkbox').check();
assert.ok(await save().isEnabled());
await save().click();
await page.getByText('配置已更新。', { exact: true }).waitFor();
await open('claude-models');
for (let i = 0; i < 2; i++) await rows().last().getByRole('button', { name: '移除模型' }).click();
await chooseOriginal(0, 'claude-opus-5');
assert.equal(await idInput(0).inputValue(), '');
assert.equal(await idInput(0).getAttribute('placeholder'), 'Claude系列模型默认不映射');
await rows().first().getByText('Claude系列模型默认不映射', { exact: true }).waitFor();
assert.ok(await save().isEnabled());
await fillAlias(0, ' CLAUDE-OPUS-5 ');
await rows().first().getByText('别名与原模型相同,无需重复设置,将直接使用原模型 ID。', { exact: true }).waitFor();
assert.ok(await save().isEnabled());
await page.screenshot({ path: path.join(process.env.TEMP, 'claude-desktop-same-model-notice.png'), fullPage: true });
await fillAlias(0, 'claude-sonnet-5');
await rows().first().getByText('默认别名「claude-sonnet-5」已存在于模型列表中,请切换其他别名,避免调用到其他模型。', { exact: true }).waitFor();
assert.ok(await save().isDisabled());
await fillAlias(0, 'claude-personal-model');
await chooseOriginal(0, 'manual-model-not-in-list');
assert.ok(await save().isEnabled());
assert.equal(await rows().first().getByText('Claude系列模型默认不映射', { exact: true }).count(), 0);
await chooseOriginal(0, 'two words');
await rows().first().getByText('原模型 ID 格式无效,请去掉空白字符或缩短名称。', { exact: true }).waitFor();
assert.ok(await save().isDisabled());
await chooseOriginal(0, 'manual-model-not-in-list');
await save().click();
await page.getByText('配置已更新。', { exact: true }).waitFor();
const manualCall = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'update_agent_config').at(-1));
assert.deepEqual(manualCall.args.claudeDesktopModelMappings.desktopModels,
[{ model: 'manual-model-not-in-list', alias: 'claude-personal-model', context1m: false }]);
await open('legacy-desktop-direct');
assert.equal(await rows().count(), 1);
assert.equal(await originalName(0), 'claude-sonnet-custom-7');
assert.equal(await idInput(0).inputValue(), '');
await open('legacy-desktop');
assert.equal(await rows().count(), 3);
assert.equal(await idInput(0).inputValue(), 'claude-opus-4-6');
assert.ok(await rows().first().getByRole('checkbox').isChecked());
assert.ok(await save().isEnabled());
for (const width of [640, 360]) {
await page.setViewportSize({ width, height: 900 });
assert.ok(await page.locator('.agent-desktop-models').evaluate(el => el.scrollWidth <= el.clientWidth));
const section = await page.locator('.agent-desktop-models').boundingBox();
for (const name of ['使用说明', '添加模型']) {
const button = await page.getByRole('button', { name, exact: true }).boundingBox();
assert.ok(button.x >= section.x && button.x + button.width <= section.x + section.width);
}
}
await page.screenshot({ path: path.join(process.env.TEMP, 'claude-desktop-models-narrow.png'), fullPage: true });
await open('no-models');
for (let i = 0; i < 3; i++) await rows().last().getByRole('button', { name: '移除模型' }).click();
await add('claude-sonnet-custom-7', '');
assert.ok(await save().isEnabled());
await save().click();
await page.getByText('配置已更新。', { exact: true }).waitFor();
assert.deepEqual(errors, []);
console.log('PASS: shared original model picker, editable alias suggestions, three-point help, custom IDs, save/remount, legacy migration and narrow layout');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });