Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ async function openCatalog(page: Page, options: { category: string; search: stri
await page.getByRole('button', { name: '添加连接', exact: true }).click();
const catalog = page.locator('[data-maka-contract="provider-catalog"]');
await expect(catalog).toBeVisible();
// The catalog level lands on its search field before anything else is
// touched: it is what a user arrives here to do, and the shared route-focus
// hook is what puts them there.
await expect(catalog.getByPlaceholder('搜索服务商')).toBeFocused();
await catalog.getByRole('combobox', { name: '分类', exact: true }).click();
await page.getByRole('option', { name: options.category, exact: true }).click();
await catalog.getByPlaceholder('搜索服务商').fill(options.search);
Expand Down Expand Up @@ -128,11 +132,33 @@ test('adds a catalog provider through the canonical API-key setup page', async (
// the page that owns every next move — no hunting for the new row.
await expect(setup).toHaveCount(0);
await expect(detail).toBeVisible();
// The level itself takes focus, not its back button, and it is a region
// named by its own heading so the landing is announced.
await expect(detail).toBeFocused();
await expect(detail).toHaveAttribute('role', 'region');
await expect(page.getByRole('region', { name: 'Cerebras' })).toBeVisible();
const detailMark = detail.locator('.providerLogo[data-provider="cerebras"] img');
await expect(detailMark).toBeVisible();
expect(await detailMark.evaluate(colorAssetRenderContract)).toEqual(COLOR_ASSET_RENDER_CONTRACT);
});

await test.step('going back lands where the user came from', async () => {
await page.getByRole('button', { name: '返回模型连接', exact: true }).click();
await expect(detail).toHaveCount(0);
// This detail was reached by saving a new provider, not by opening a row,
// so there is no row to go back to and the primary action takes the ring.
await expect(page.getByRole('button', { name: '添加连接', exact: true })).toBeFocused();

// Opened from a row, the way back is that row — the ring returns to where
// the user left, not to the top of the list.
await connection.click();
await expect(detail).toBeVisible();
await page.getByRole('button', { name: '返回模型连接', exact: true }).click();
await expect(connection).toBeFocused();
await connection.click();
await expect(detail).toBeVisible();
});

await test.step('the detail replaces a key and manages enabled and default models', async () => {
// A settled credential is a row, not a form: it reports its state and
// carries one control. The input only exists while the user is changing it.
Expand Down
145 changes: 134 additions & 11 deletions apps/desktop/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,33 +42,156 @@ test('subagent presets can be reviewed and edited in desktop settings', async ({
profile: 'local_read',
connectionSlug: connection.slug,
model: connection.enabledModelIds?.[0] ?? connection.defaultModel,
enabled: true,
// Seeded DISABLED on purpose: the editor's own switch reads from the
// preset, so this run is what proves saving an unrelated field makes
// the round trip without quietly re-enabling a preset the user
// turned off.
enabled: false,
}],
},
});
});

await page.getByRole('button', { name: '展开侧边栏' }).click();
await page.getByRole('button', { name: '设置' }).click();
await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click();
const navItem = settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true });
await navItem.click();

const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '子 Agent', exact: true })).toBeVisible();
await expect(settings.getByText('E2E 快速阅读', { exact: true })).toBeVisible();
await expect(settings.getByText('可用', { exact: true })).toBeVisible();
// The row's switch states the disabled preset; a badge beside it would be the
// same fact twice.
const rowSwitch = settings.getByRole('switch', { name: '启用: E2E 快速阅读' });
await expect(rowSwitch).not.toBeChecked();
// Arriving is not navigating: focus stays on the settings nav item the user
// just clicked. Only a level change moves it.
await expect(navItem).toBeFocused();

await settings.getByRole('button', { name: '编辑', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '编辑子 Agent' });
const description = dialog.getByRole('textbox', { name: '适用场景' });
await description.fill('快速阅读代码,并总结关键调用链。');
await dialog.getByRole('button', { name: '保存', exact: true }).click();
// The editor is a route level, not a dialog: the list is replaced in place
// and the back affordance is the only way out.
await settings.getByRole('button', { name: '配置“E2E 快速阅读”' }).click();
await expect(settings.getByRole('heading', { name: 'E2E 快速阅读', exact: true })).toBeVisible();
await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeHidden();
// A level change moves focus to the level itself; without it the chevron
// that had focus unmounts and a keyboard user restarts from document.body.
await expect(settings.locator('[data-maka-contract="subagent-detail"]')).toBeFocused();
// The level owns the whole preset, so it carries the two things the list row
// deliberately does not: the settled id, and deletion.
await expect(settings.getByText('e2e-fast-reader', { exact: true })).toBeVisible();
await expect(settings.getByRole('button', { name: '删除', exact: true })).toBeVisible();
// Renaming is the one edit that could re-key the preset: the id derives from
// the name while creating, and an existing preset must never follow it.
await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E 快速阅读 v2');
await settings.getByRole('textbox', { name: '适用场景' }).fill('快速阅读代码,并总结关键调用链。');
await settings.getByRole('button', { name: '保存', exact: true }).click();

await expect(dialog).toBeHidden();
await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeVisible();
await expect(settings.getByText('快速阅读代码,并总结关键调用链。', { exact: true })).toBeVisible();
// Returning to the list puts focus back on the row the user left from.
await expect(settings.locator('[data-subagent-preset="e2e-fast-reader"]')).toBeFocused();
await expect.poll(async () => page.evaluate(async () => {
const current = await window.maka.settings.get();
const preset = current.subagents.presets[0];
return { id: preset?.id, name: preset?.name, description: preset?.description, enabled: preset?.enabled };
})).toEqual({
id: 'e2e-fast-reader',
name: 'E2E 快速阅读 v2',
description: '快速阅读代码,并总结关键调用链。',
enabled: false,
});
});

test('deleting a subagent preset is reversible until the confirm is accepted', async ({ window: page }) => {
await page.evaluate(async () => {
const connections = await window.maka.connections.list();
const connection = connections[0];
if (!connection) throw new Error('E2E subagent settings requires a seeded connection');
await window.maka.settings.update({
subagents: {
presets: [{
id: 'e2e-doomed',
name: 'E2E 待删除',
description: '这个配置会在本次测试里被删除。',
profile: 'local_read',
connectionSlug: connection.slug,
model: connection.enabledModelIds?.[0] ?? connection.defaultModel,
enabled: true,
}],
},
});
});

await page.getByRole('button', { name: '展开侧边栏' }).click();
await page.getByRole('button', { name: '设置' }).click();
await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click();

const settings = page.getByRole('main', { name: '设置内容' });
await settings.getByRole('button', { name: '配置“E2E 待删除”' }).click();
const deleteButton = settings.getByRole('button', { name: '删除', exact: true });

// Cancelling the confirm has to leave the preset alone — the destructive path
// is the one place where "it did nothing" cannot be checked by eye.
await deleteButton.click();
const confirm = page.getByRole('alertdialog');
await expect(confirm).toBeVisible();
await confirm.getByRole('button', { name: '取消', exact: true }).click();
await expect(confirm).toBeHidden();
await expect(settings.getByText('e2e-doomed', { exact: true })).toBeVisible();

await deleteButton.click();
await expect(confirm).toBeVisible();
await confirm.getByRole('button', { name: '删除', exact: true }).click();
await expect(confirm).toBeHidden();

// Deletion is the only way the row a user came from can be missing, so it is
// the only thing that exercises the focus fallback.
await expect(settings.getByText('E2E 待删除', { exact: true })).toBeHidden();
await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeFocused();
await expect.poll(async () => page.evaluate(async () => {
const current = await window.maka.settings.get();
return current.subagents.presets.length;
})).toBe(0);
});

test('a subagent preset can be created disabled and then enabled from its row', async ({ window: page }) => {
await page.evaluate(async () => {
await window.maka.settings.update({ subagents: { presets: [] } });
});

await page.getByRole('button', { name: '展开侧边栏' }).click();
await page.getByRole('button', { name: '设置' }).click();
await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click();

const settings = page.getByRole('main', { name: '设置内容' });
// The create branch is a structurally different tree from the edit branch —
// a typed id instead of a settled one, and no delete section — so it needs
// its own journey rather than riding on the edit one.
await settings.getByRole('button', { name: '添加子 Agent' }).click();
await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E Web Research');
// The id derives from the name until the user takes it over.
await expect(settings.getByRole('textbox', { name: 'subagent_id' })).toHaveValue('e2e-web-research');
// Taking the id over stops the derivation for good: a later name edit must
// not walk over what the user typed.
await settings.getByRole('textbox', { name: 'subagent_id' }).fill('web-research-owned');
await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E Web Research 2');
await expect(settings.getByRole('textbox', { name: 'subagent_id' })).toHaveValue('web-research-owned');
await settings.getByRole('textbox', { name: '适用场景' }).fill('查找外部资料。');
await settings.getByRole('switch', { name: '启用', exact: true }).click();
await settings.getByRole('button', { name: '创建', exact: true }).click();

await expect(settings.getByText('E2E Web Research 2', { exact: true })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => {
const current = await window.maka.settings.get();
const preset = current.subagents.presets[0];
return { id: preset?.id, enabled: preset?.enabled };
})).toEqual({ id: 'web-research-owned', enabled: false });

await settings.getByRole('switch', { name: '启用: E2E Web Research 2' }).click();
await expect.poll(async () => page.evaluate(async () => {
const current = await window.maka.settings.get();
return current.subagents.presets[0]?.description;
})).toBe('快速阅读代码,并总结关键调用链。');
return current.subagents.presets[0]?.enabled;
})).toBe(true);
});

test('remote access prioritizes a configured channel that needs attention', async ({ window: page }) => {
Expand Down
102 changes: 93 additions & 9 deletions apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { LlmConnection, SubagentPreset } from '@maka/core';
import {
nextSubagentDraftForName,
resolveSubagentRoute,
subagentPresetAvailability,
suggestSubagentPresetId,
} from '../../renderer/settings/subagent-preset-presentation.js';
Expand Down Expand Up @@ -39,15 +41,26 @@ describe('subagentPresetAvailability', () => {
kind: 'available',
tone: 'success',
});
assert.equal(subagentPresetAvailability(preset({ enabled: false }), []).kind, 'disabled');
assert.equal(subagentPresetAvailability(preset(), []).kind, 'missing_connection');
assert.equal(
subagentPresetAvailability(preset(), [connection({ enabled: false })]).kind,
'connection_disabled',
);
assert.equal(
subagentPresetAvailability(preset({ model: 'deepseek-reasoner' }), [connection()]).kind,
'model_disabled',
// The tone is asserted with the kind, not separately: it is what decides
// the badge variant, so a broken route quietly turning green is a pure-data
// regression this file is the only place to catch. `available` and
// `disabled` render no badge — the switch beside the row says the second —
// but they stay in the value range so callers branch on one thing.
assert.deepEqual(subagentPresetAvailability(preset({ enabled: false }), []), {
kind: 'disabled',
tone: 'neutral',
});
assert.deepEqual(subagentPresetAvailability(preset(), []), {
kind: 'missing_connection',
tone: 'destructive',
});
assert.deepEqual(subagentPresetAvailability(preset(), [connection({ enabled: false })]), {
kind: 'connection_disabled',
tone: 'warning',
});
assert.deepEqual(
subagentPresetAvailability(preset({ model: 'deepseek-reasoner' }), [connection()]),
{ kind: 'model_disabled', tone: 'warning' },
);
});
});
Expand All @@ -62,3 +75,74 @@ describe('suggestSubagentPresetId', () => {
);
});
});

describe('nextSubagentDraftForName', () => {
const draft = { name: '', id: '' };

it('derives the id from the name until the user takes the id over', () => {
assert.deepEqual(nextSubagentDraftForName(draft, 'Fast Code Reader', false, new Set()), {
name: 'Fast Code Reader',
id: 'fast-code-reader',
});
// Once the user has typed an id, a later name edit must not overwrite it —
// an editor that silently drops this gate looks identical on screen.
assert.deepEqual(
nextSubagentDraftForName({ name: 'Fast', id: 'my-own-id' }, 'Fast Reader', true, new Set()),
{ name: 'Fast Reader', id: 'my-own-id' },
);
});

it('keeps other draft fields untouched and resolves collisions', () => {
assert.deepEqual(
nextSubagentDraftForName(
{ name: '', id: '', model: 'glm-4.7' },
'Fast Code Reader',
false,
new Set(['fast-code-reader']),
),
{ name: 'Fast Code Reader', id: 'fast-code-reader-2', model: 'glm-4.7' },
);
});

it('falls back to a safe id for a name with no ASCII, so two of them differ', () => {
const first = nextSubagentDraftForName(draft, '快速阅读', false, new Set());
assert.equal(first.id, 'subagent');
assert.equal(
nextSubagentDraftForName(draft, '网页研究', false, new Set([first.id])).id,
'subagent-2',
);
});
});

describe('resolveSubagentRoute', () => {
const fastReader = preset();

it('resolves an edit route to the preset it names', () => {
assert.deepEqual(resolveSubagentRoute({ kind: 'edit', presetId: 'fast-reader' }, [fastReader]), {
level: 'edit',
preset: fastReader,
});
});

it('renders the list for an edit route whose preset is gone', () => {
// The whole reason this is a function: `preset: null` is what the editor
// reads as "new", so an edit level that kept rendering with a vanished
// preset would be its create branch — and saving appends rather than
// updates. Falling back to the list is what keeps those two apart.
assert.deepEqual(resolveSubagentRoute({ kind: 'edit', presetId: 'gone' }, [fastReader]), {
level: 'list',
preset: null,
});
});

it('carries list and create through untouched', () => {
assert.deepEqual(resolveSubagentRoute({ kind: 'list' }, [fastReader]), {
level: 'list',
preset: null,
});
assert.deepEqual(resolveSubagentRoute({ kind: 'create' }, []), {
level: 'create',
preset: null,
});
});
});
Loading
Loading