Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
26 changes: 26 additions & 0 deletions apps/desktop/e2e/providers.spec.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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