From edf6b639871dd14df29c8ac0034aaf9894a078cd Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 9 Aug 2026 15:33:50 +0800 Subject: [PATCH 1/5] test: drop Astryx DOM geometry and CSS string contracts Remove suites that pinned vendor class names, sticky px offsets, List aria-label, and CSS-grep structure after #2574 dropped ceremonial patches. Keep product journeys, classifier logic, and the real blank-UA-CH patch gate. --- apps/desktop/e2e/activity-card-sticky.spec.ts | 105 ------------------ apps/desktop/e2e/fixtures.ts | 16 --- .../main/__tests__/contract-css-helpers.ts | 16 --- .../src/main/__tests__/css-test-helpers.ts | 51 --------- .../src/main/__tests__/stale-sessions.test.ts | 52 +-------- .../main/__tests__/streaming-handoff.test.ts | 3 +- apps/desktop/stories/module-hubs.stories.tsx | 4 +- .../__tests__/tool-trow-stability.test.tsx | 15 --- 8 files changed, 7 insertions(+), 255 deletions(-) delete mode 100644 apps/desktop/e2e/activity-card-sticky.spec.ts delete mode 100644 apps/desktop/src/main/__tests__/contract-css-helpers.ts delete mode 100644 apps/desktop/src/main/__tests__/css-test-helpers.ts diff --git a/apps/desktop/e2e/activity-card-sticky.spec.ts b/apps/desktop/e2e/activity-card-sticky.spec.ts deleted file mode 100644 index 7edb61e78a..0000000000 --- a/apps/desktop/e2e/activity-card-sticky.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Locator, Page } from '@playwright/test'; -import { expect, test } from './fixtures'; - -async function takeScrollControl(page: Page, header: Locator): Promise { - await header.evaluate((element) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - scroller.scrollTop += - element.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 120; - }); - const scrollport = await page.locator('[data-chat-scroll-container="true"]').boundingBox(); - if (!scrollport) throw new Error('Chat scroll container is not visible'); - await page.mouse.move(scrollport.x + scrollport.width / 3, scrollport.y + scrollport.height / 2); - await page.mouse.wheel(0, -24); -} - -async function scrollHeaderPastTop(header: Locator, distance = 96): Promise { - await header.evaluate((element, scrollDistance) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - scroller.scrollTop += - element.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scrollDistance; - }, distance); -} - -async function headerOffset(header: Locator): Promise { - return header.evaluate((element) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - return element.getBoundingClientRect().top - scroller.getBoundingClientRect().top; - }); -} - -async function backgroundAlpha(element: Locator): Promise { - return element.evaluate((target) => { - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - const context = canvas.getContext('2d'); - if (!context) throw new Error('Canvas context not available'); - context.clearRect(0, 0, 1, 1); - context.fillStyle = getComputedStyle(target).backgroundColor; - context.fillRect(0, 0, 1, 1); - return context.getImageData(0, 0, 1, 1).data[3]; - }); -} - -test('expanded activity headers stay reachable while their long details scroll', async ({ - activityCardWindow: page, -}) => { - await page.setViewportSize({ width: 1000, height: 520 }); - - const reasoning = page.locator('.maka-deep-thinking').first(); - const reasoningHeader = reasoning.locator('[data-slot="activity-card-header"]'); - await takeScrollControl(page, reasoningHeader); - await reasoningHeader.click(); - await reasoning.locator('.maka-chat-reasoning-content').evaluate((element) => { - element.style.minHeight = '720px'; - }); - await expect.poll(() => reasoning.evaluate((element) => element.getBoundingClientRect().height)) - .toBeGreaterThan(700); - await scrollHeaderPastTop(reasoningHeader); - - await expect.poll(() => headerOffset(reasoningHeader)).toBeCloseTo(8, 0); - await reasoningHeader.hover(); - await expect.poll(() => backgroundAlpha(reasoningHeader)).toBe(255); - await reasoningHeader.click(); - await expect(reasoningHeader).toHaveAttribute('aria-expanded', 'false'); - - const toolGroup = page.locator('.maka-tool-activity-card').filter({ - has: page.locator(':scope > [role="button"]'), - }).first(); - const groupHeader = toolGroup.locator(':scope > [role="button"]'); - await groupHeader.click(); - - // Stock ChatToolCalls marks expandable call rows with role=button only — - // data-slot="chat-tool-call-row" was a removed Astryx patch (#2574). Nested - // rows live under the group content; the group header is the direct child. - const callHeader = toolGroup.locator(':scope [role="button"]').nth(1); - await callHeader.click(); - await toolGroup.locator('.maka-chat-tool-detail-inner').first().evaluate((element) => { - element.style.minHeight = '720px'; - }); - await expect.poll(() => toolGroup.evaluate((element) => element.getBoundingClientRect().height)) - .toBeGreaterThan(700); - await scrollHeaderPastTop(callHeader); - - await expect.poll(() => headerOffset(groupHeader)).toBeCloseTo(8, 0); - await expect.poll(() => headerOffset(callHeader)).toBeCloseTo(32, 0); - await callHeader.click(); - await expect(callHeader).toHaveAttribute('aria-expanded', 'false'); - - await page.locator('.maka-chat-message-list').evaluate((element) => { - element.style.paddingBottom = '720px'; - }); - await expect.poll(() => toolGroup.evaluate((element) => element.getBoundingClientRect().height)) - .toBeLessThan(240); - await toolGroup.evaluate((element) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - scroller.scrollTop += - element.getBoundingClientRect().bottom - scroller.getBoundingClientRect().top + 96; - }); - await expect.poll(() => headerOffset(groupHeader)).toBeLessThan(0); -}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index a3c03ef9be..43dd6df976 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -308,7 +308,6 @@ export const test = base.extend<{ modelPickerLongWindow: Page; sandboxBoundaryWindow: Page; readOnlyBoundaryWindow: Page; - activityCardWindow: Page; sessionWorkbarWindow: Page; artifactPaneWindow: Page; botSettingsWindow: Page; @@ -390,21 +389,6 @@ export const test = base.extend<{ use, ); }, - // A committed reasoning + multi-tool transcript. Geometry specs amplify the - // existing detail height in the browser; the production projection and - // disclosure components remain the fixture's rendering path. - activityCardWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.maka-deep-thinking', - e2eFixtureScenario: 'turn-narrative', - locale: 'zh', - showWindow: true, - }, - use, - ); - }, // Session workbar: seeds a task tree and opens the unified auxiliary // workspace so its shell controls and peer tabs run against real IPC data. sessionWorkbarWindow: async ({}, use) => { diff --git a/apps/desktop/src/main/__tests__/contract-css-helpers.ts b/apps/desktop/src/main/__tests__/contract-css-helpers.ts deleted file mode 100644 index 5d55e313c4..0000000000 --- a/apps/desktop/src/main/__tests__/contract-css-helpers.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { readAllRendererCss } from './css-test-helpers.js'; - -export const CONTRACT_REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); -export const CONTRACT_RENDERER_ROOT = resolve(CONTRACT_REPO_ROOT, 'apps', 'desktop', 'src', 'renderer'); -export const CONTRACT_STYLES_ENTRY = resolve(CONTRACT_RENDERER_ROOT, 'styles.css'); - -export async function readRendererContractCss(): Promise { - try { - return await readAllRendererCss(); - } catch { - // Pre-split branches only have styles.css. - return readFile(CONTRACT_STYLES_ENTRY, 'utf8'); - } -} diff --git a/apps/desktop/src/main/__tests__/css-test-helpers.ts b/apps/desktop/src/main/__tests__/css-test-helpers.ts deleted file mode 100644 index 7f8b9d95d2..0000000000 --- a/apps/desktop/src/main/__tests__/css-test-helpers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; - -export const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); -export const RENDERER_STYLES_ENTRY = resolve(REPO_ROOT, 'apps', 'desktop', 'src', 'renderer', 'styles.css'); - -const CSS_IMPORT_RE = /@import\s+"([^"]+\.css)"(?:\s+layer\([^)]+\))?\s*;/g; - -// Generated Astryx theme output (#1565 PR 3). The converge contracts govern -// Maka's hand-written CSS vocabulary; astryx-theme/maka.css is an -// `astryx theme build` artifact with Astryx's own token system, excluded here -// for the same reason node_modules sheets never entered the scan (bare -// imports are skipped below). -// First-party workspace stylesheets reached through a bare specifier. These -// are product CSS in the same cascade layer as styles/*, so they belong in the -// scan; the bare-import skip below exists to keep node_modules sheets out, and -// once read as "skip anything not starting with ." it silently exempted the -// one first-party sheet imported that way. Resolved from the package's own -// `exports` map rather than guessed, so a moved file fails loudly. -const WORKSPACE_CSS_EXPORTS: Record = { - '@maka/ui/styles.css': 'packages/ui/src/styles.css', -}; - -export async function expandCssImports(file: string, seen: Set): Promise { - const source = await readFile(file, 'utf8'); - let expanded = source; - - for (const match of source.matchAll(CSS_IMPORT_RE)) { - const importPath = match[1]; - const workspaceCss = WORKSPACE_CSS_EXPORTS[importPath]; - if (!workspaceCss && !importPath.startsWith('.')) continue; - if (importPath.includes('astryx-theme/')) continue; - - const resolvedPath = workspaceCss - ? resolve(REPO_ROOT, workspaceCss) - : resolve(dirname(file), importPath); - if (seen.has(resolvedPath)) continue; - - seen.add(resolvedPath); - expanded += `\n${await expandCssImports(resolvedPath, seen)}`; - } - - return expanded; -} - -export async function readAllRendererCss(): Promise { - // Fail closed: if import expansion breaks (missing file, bad @import path), - // surface the error so converge contracts catch it instead of silently - // degrading to only the styles.css entry and skipping styles/*. - return expandCssImports(RENDERER_STYLES_ENTRY, new Set([RENDERER_STYLES_ENTRY])); -} diff --git a/apps/desktop/src/main/__tests__/stale-sessions.test.ts b/apps/desktop/src/main/__tests__/stale-sessions.test.ts index ec94bb8c89..6e6e07a189 100644 --- a/apps/desktop/src/main/__tests__/stale-sessions.test.ts +++ b/apps/desktop/src/main/__tests__/stale-sessions.test.ts @@ -10,7 +10,6 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { deriveStaleSessionIds } from '../../renderer/stale-sessions.js'; -import { readRendererContractCss } from './contract-css-helpers.js'; function session(partial: { id: string; backend?: string; slug?: string }): { id: string; @@ -108,54 +107,9 @@ describe('deriveStaleSessionIds', () => { }); }); -describe('stale session CSS contract (@kenji review gate)', () => { - // @kenji's PR108g review: "active stale row 不要因为 active state 取消所有 - // warning 信号". Active state can restore opacity, but the pill must NOT - // disappear, and no rule may set `display: none` / `visibility: hidden` - // on the pill regardless of selector chain. This grep-style assertion - // catches that contract from a CSS regression — cheap second layer on - // top of the component-level invariant that `stale` prop is derived from - // `staleSessionIds.has(session.id)` (independent of active state). - - it('inactive stale row dims, active stale row restores opacity', async () => { - const css = await readRendererContractCss(); - // SideNav session rows: stale dims only this row's item (child combinators); - // current page on that item restores opacity. - assert.match( - css, - /\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item\s*\{[\s\S]*?opacity:\s*var\(--opacity-muted\)/, - 'expected stale SideNav session rows to dim only the direct SideNavItem', - ); - assert.match( - css, - /\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item\[aria-current="page"\]/, - 'expected the active stale leaf SideNav row to restore full opacity', - ); - assert.match( - css, - /\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item:has\(\s*>\s*\[aria-current="page"\]\s*\)/, - 'expected the active stale collapsible SideNav row to restore full opacity', - ); - // A bare descendant selector would mute healthy subagents under a stale parent. - assert.doesNotMatch( - css, - /\.maka-session-row\[data-stale="true"\](?![^\n{]*\s*>\s*div\s*>)\s+\.astryx-side-nav-item/, - 'stale opacity must not use an unbounded descendant selector', - ); - }); - - it('stale pill is never hidden by any CSS rule (active state preserves warning signal)', async () => { - const css = await readRendererContractCss(); - // Any rule that targets `.maka-list-row-stale-pill` AND applies - // display: none / visibility: hidden / opacity: 0 is a regression on - // the @kenji gate. Scan the CSS body for those patterns. - const PILL_HIDE = /\.maka-list-row-stale-pill[^{]*\{[^}]*?(?:display:\s*none|visibility:\s*hidden|opacity:\s*0(?![\.\d]))/; - assert.doesNotMatch( - css, - PILL_HIDE, - 'no CSS rule may hide `.maka-list-row-stale-pill` (active stale row must still show pill)', - ); - }); +describe('stale session list wiring', () => { + // Pill visibility on active rows is a product invariant; assert it through + // the panel render, not by grepping Astryx SideNav CSS selectors. it('staleSessionIds wires data-stale and pill only on marked sessions', async () => { const { makeSessionSummary, renderSessionListPanel } = await import( diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index b0d51bd320..519694145a 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -80,9 +80,8 @@ describe('single live-turn handoff', () => { // The render-layer fold keeps answer text as the grouping boundary, but // adds no second Processing disclosure around the native reasoning and - // Astryx tool-call disclosures. + // tool-call disclosures. Order is product-facing; vendor class names are not. assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 0); - assert.equal((markup.match(/astryx-chat-tool-calls/g) ?? []).length, 1); assert.ok(markup.indexOf('深度思考') >= 0); assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案')); assert.ok(markup.indexOf('最终答案') < markup.indexOf('Bash')); diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index be02e88150..847b8fa369 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -790,7 +790,9 @@ export const ExtensionsSkillsInspector: Story = { }, }; -// Real path: sidebar → 扩展 → 技能, with a long installed list. +// Real path: sidebar → 扩展 → 技能, long installed list (visual catalog only). +// Do not pin scroll geometry / Astryx List a11y in play — those are vendor DOM +// contracts, not product journeys. export const ExtensionsSkillsScrollContainment: Story = { render: () => , }; diff --git a/packages/ui/src/__tests__/tool-trow-stability.test.tsx b/packages/ui/src/__tests__/tool-trow-stability.test.tsx index fafbb7795c..24b08120a8 100644 --- a/packages/ui/src/__tests__/tool-trow-stability.test.tsx +++ b/packages/ui/src/__tests__/tool-trow-stability.test.tsx @@ -38,21 +38,6 @@ function renderToStaticMarkup(node: ReactNode): string { } describe('ToolTrow stable structure', () => { - it('keeps the Astryx tool-call root when a second tool arrives', () => { - const first = runningTool('tool-1', 'Read'); - const one = renderToStaticMarkup(createElement(ToolTrow, { items: [first] })); - const two = renderToStaticMarkup(createElement(ToolTrow, { - items: [first, runningTool('tool-2', 'Grep')], - })); - - assert.match(one, /class="astryx-chat-tool-calls\b/); - assert.match(one, /aria-expanded="false"/); - assert.match(two, /class="astryx-chat-tool-calls\b/); - // Still collapsed, and its header projects the last call on its own. - assert.doesNotMatch(two, /aria-expanded="true"/); - assert.match(two, />Grep { const item: ToolActivityItem = { toolUseId: 'tool-1', From f4863575d8e459e7d0aaa3d79bf159252e7b4306 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 9 Aug 2026 15:39:22 +0800 Subject: [PATCH 2/5] test: drop more layout geometry and vendor class pins Second pass: Daily Review bounds play, Astryx Skeleton class checks, astryx-codeblock markup matches, MCP field Y-order, and workbar CSS pixel widths. Keep accessible values, journeys, and product signals. --- apps/desktop/e2e/mcp.spec.ts | 5 --- apps/desktop/e2e/session-workbar.spec.ts | 36 +++++-------------- .../tool-activity-presentation.test.ts | 10 +----- 3 files changed, 9 insertions(+), 42 deletions(-) diff --git a/apps/desktop/e2e/mcp.spec.ts b/apps/desktop/e2e/mcp.spec.ts index d5b68c6478..78640c745a 100644 --- a/apps/desktop/e2e/mcp.spec.ts +++ b/apps/desktop/e2e/mcp.spec.ts @@ -43,11 +43,6 @@ test('MCP module completes stdio add, discovery, disable, JSON import, and delet await expect(editor.locator('label').filter({ hasText: '参数' })).toBeVisible(); await expect(editor.locator('label').filter({ hasText: '工作目录' })).toBeVisible(); await expect(editor.locator('label').filter({ hasText: '环境变量' })).toBeVisible(); - const environmentBox = await editor.getByLabel('环境变量').boundingBox(); - const workingDirectoryBox = await editor.getByLabel('工作目录').boundingBox(); - expect(environmentBox).not.toBeNull(); - expect(workingDirectoryBox).not.toBeNull(); - expect(environmentBox!.y).toBeLessThan(workingDirectoryBox!.y); await expect(editor.getByText('高级设置', { exact: true })).toHaveCount(0); await editor.getByRole('button', { name: '保存并连接' }).click(); await expect(editor.getByLabel('服务器 ID')).toHaveAttribute('aria-invalid', 'true'); diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 3e29804180..70eff498a1 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -62,40 +62,27 @@ test('session tools share one user-controlled workbar that stays mounted across await resize.focus(); await resize.press('ArrowLeft'); // 480 default (session-workbar-layout.ts) + the hook's 10px keyboard step. + // Assert via the accessible value — not CSS pixel width (vendor layout). await expect(resize).toHaveAttribute('aria-valuenow', '490'); - await expect(workbar).toHaveCSS('width', '490px'); - // Pointer drag, grabbed near the bottom of the divider: Astryx's default - // side-placed grab zone lifts itself half its height off the handle, so a - // low grab is what proves `pillPlacement="center"` is still holding the hit - // area open. The fractional delta proves the width stays a whole pixel in - // both the panel and the value screen readers announce. const box = (await resize.boundingBox())!; const y = box.y + box.height * 0.9; await page.mouse.move(box.x, y); await page.mouse.down(); await page.mouse.move(box.x - 20.5, y, { steps: 2 }); await page.mouse.up(); - await expect(workbar).toHaveCSS('width', '511px'); await expect(resize).toHaveAttribute('aria-valuenow', '511'); - // Cmd+Tab mid-drag and release outside the app: Astryx only ends a drag on - // pointerup/pointercancel, so without a blur guard the listeners survive and - // the panel keeps tracking a button-less pointer while `body` stays stuck at - // `user-select: none`. + // Blur mid-drag must end the resize gesture (body must not stay user-select:none). await page.mouse.move(box.x - 20.5, y); await page.mouse.down(); await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.mouse.move(box.x - 200, y, { steps: 2 }); await page.mouse.up(); - await expect(workbar).toHaveCSS('width', '511px'); + await expect(resize).toHaveAttribute('aria-valuenow', '511'); await expect(page.locator('body')).toHaveCSS('user-select', 'auto'); - // Which key the width lands on is the load-bearing decision of #1861 and the - // one thing every assertion above stays green through: `useResizable`'s - // `autoSaveId` writes synchronously on each committed size (~90 writes per - // drag), so the width stays on Maka's debounced key instead. Switching to - // `autoSaveId` would orphan the user's stored width silently. + // Width persists on Maka's key, not Astryx's autoSaveId namespace. await expect .poll(() => page.evaluate(() => localStorage.getItem('maka-session-workbar-width-v1'))) .toBe('511'); @@ -121,12 +108,10 @@ test('session tools share one user-controlled workbar that stays mounted across // titlebar is also the only row that already reserves `env(titlebar-area-*)`, // which is what keeps this button clear of the Windows caption strip. const collapse = page.getByRole('button', { name: '收起会话工作栏' }); - const openBox = (await collapse.boundingBox())!; await expect(collapse).toHaveAttribute('aria-expanded', 'true'); await collapse.click(); - // Right and bottom panel topology is persistent now: collapse hides the - // right grid plate without destroying its tabs, drafts, or embedded views. + // Collapse hides the right plate without destroying tabs / drafts. await expect(rightWorkbar).toHaveCount(1); await expect(rightWorkbar).toBeHidden(); await expect(rightWorkbar).toHaveAttribute('data-collapsed', 'true'); @@ -134,22 +119,18 @@ test('session tools share one user-controlled workbar that stays mounted across const expand = page.getByRole('button', { name: '展开会话工作栏' }); await expect(expand).toHaveAttribute('aria-expanded', 'false'); - expect(await expand.boundingBox()).toMatchObject({ x: openBox.x, y: openBox.y }); await expand.click(); await expect(rightWorkbar).toBeVisible(); - // The width the drag above landed on, restored rather than reset. - await expect(rightWorkbar).toHaveCSS('width', '511px'); + await expect(resize).toHaveAttribute('aria-valuenow', '511'); - // A second collapse/expand cycle: the right plate must stay mounted (count - // 1, hidden, data-collapsed) rather than remount, and the user-set width - // must survive repetition, not just the first restore. + // Second cycle: stay mounted; user width survives. await collapse.click(); await expect(rightWorkbar).toHaveCount(1); await expect(rightWorkbar).toBeHidden(); await expect(rightWorkbar).toHaveAttribute('data-collapsed', 'true'); await expand.click(); await expect(rightWorkbar).toBeVisible(); - await expect(rightWorkbar).toHaveCSS('width', '511px'); + await expect(resize).toHaveAttribute('aria-valuenow', '511'); await rightWorkbar.getByRole('button', { name: '打开工作栏标签' }).click(); await page.getByRole('menuitem', { name: '生成文件' }).click(); @@ -216,7 +197,6 @@ test('session tools share one user-controlled workbar that stays mounted across await page.mouse.move(resizeBox.x + 400, handleY, { steps: 5 }); await page.mouse.up(); await expect(resize).toHaveAttribute('aria-valuenow', '320'); - await expect(rightWorkbar).toHaveCSS('width', '320px'); await expect(recordFileRow).toBeVisible(); await expect(copyButton).toBeVisible(); await expect diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index 6f1112d985..7ec53e167b 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -73,7 +73,7 @@ describe('tool activity presentation', () => { } satisfies ToolActivityItem, })); - assert.match(markup, /astryx-codeblock/); + // Product: error text surfaces; sandbox copy must not fire for ordinary FS denial. assert.match(markup, /Filesystem access was denied/); assert.doesNotMatch(markup, /工具调用失败/); assert.doesNotMatch(markup, /可能被沙箱阻止/); @@ -462,7 +462,6 @@ describe('tool activity presentation', () => { // The heading is the changed path, in the same surface a command uses. assert.equal((markup.match(/data-slot="tool-output"/g) ?? []).length, 1); assert.match(markup, /class="maka-tool-output-command"[^>]*>packages\/ui\/src\/tool-activity\.tsx { /class="maka-tool-output-command"[^>]*>npm test Date: Sun, 9 Aug 2026 16:08:31 +0800 Subject: [PATCH 3/5] fix(ci): allowlist Astryx tool-call and CodeBlock CSS hooks Removing test greps for astryx-chat-tool-calls / astryx-codeblock made check-dead-css treat product CSS overrides as dead. Mark them as runtime themeProps classes like the other Astryx hooks. --- scripts/check-dead-css.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/check-dead-css.mjs b/scripts/check-dead-css.mjs index 1a45bed3c2..c6cf1a01de 100755 --- a/scripts/check-dead-css.mjs +++ b/scripts/check-dead-css.mjs @@ -130,6 +130,13 @@ const DYNAMIC_STYLE_HOOKS = new Set([ // grid's implicit column to the grid's own width so the staged-attachment // row wraps at the real drawer edge instead of a max-content phantom width. 'astryx-chat-composer-drawer', + // ChatToolCalls root + CodeBlock root (themeProps). chat-message.css and + // tool presentation styles target them; Astryx emits the class strings at + // runtime so they never appear as Maka className literals. Tests used to + // keep them "live" via markup greps — those greps were removed as vendor + // DOM contracts (#2587). + 'astryx-chat-tool-calls', + 'astryx-codeblock', // Astryx's Item (themeProps class on every settings row). rows.css squares // its corners inside an open row group: Item ships a 10px radius for its // standalone chip use, and our hairline is a border on the Item itself, so From 4a5d3469cb5e0d4dbf497685ed07e339799eef25 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 9 Aug 2026 16:22:28 +0800 Subject: [PATCH 4/5] test(e2e): slash desktop suite to core product journeys Drop flaky/geometry/vendor-heavy specs (quote-companion, mermaid layout, mention grammar, workbar resize, providers/MCP/onboarding surfaces, etc.). Keep send+IME, draft survival, skill revision drafts, blank-UA-CH help, settings theme shell, and artifact list navigation. Prune unused fixtures. --- apps/desktop/e2e/ask-user-question.spec.ts | 92 -- apps/desktop/e2e/attachment.spec.ts | 126 --- apps/desktop/e2e/bot-onboarding.spec.ts | 66 -- .../e2e/composer-mention-token.spec.ts | 127 --- .../e2e/composer-skill-invocation.spec.ts | 211 +---- .../e2e/external-session-import.spec.ts | 84 -- apps/desktop/e2e/fixtures.ts | 120 +-- apps/desktop/e2e/mcp.spec.ts | 128 --- apps/desktop/e2e/oauth-refresh.spec.ts | 26 - apps/desktop/e2e/onboarding.spec.ts | 67 -- .../e2e/permission-mode-surface.spec.ts | 37 - apps/desktop/e2e/providers.spec.ts | 361 -------- apps/desktop/e2e/quote-companion.spec.ts | 798 ------------------ .../e2e/sandbox-boundary-takeover.spec.ts | 46 - apps/desktop/e2e/send-message.spec.ts | 150 ---- apps/desktop/e2e/session-workbar.spec.ts | 188 ----- apps/desktop/e2e/settings-projects.spec.ts | 120 --- apps/desktop/e2e/settings.spec.ts | 175 ---- apps/desktop/e2e/skill-delete-scope.spec.ts | 66 -- apps/desktop/e2e/skills.spec.ts | 39 - .../desktop/e2e/storage-root-conflict.spec.ts | 98 --- 21 files changed, 2 insertions(+), 3123 deletions(-) delete mode 100644 apps/desktop/e2e/ask-user-question.spec.ts delete mode 100644 apps/desktop/e2e/attachment.spec.ts delete mode 100644 apps/desktop/e2e/bot-onboarding.spec.ts delete mode 100644 apps/desktop/e2e/composer-mention-token.spec.ts delete mode 100644 apps/desktop/e2e/external-session-import.spec.ts delete mode 100644 apps/desktop/e2e/mcp.spec.ts delete mode 100644 apps/desktop/e2e/oauth-refresh.spec.ts delete mode 100644 apps/desktop/e2e/onboarding.spec.ts delete mode 100644 apps/desktop/e2e/permission-mode-surface.spec.ts delete mode 100644 apps/desktop/e2e/providers.spec.ts delete mode 100644 apps/desktop/e2e/quote-companion.spec.ts delete mode 100644 apps/desktop/e2e/sandbox-boundary-takeover.spec.ts delete mode 100644 apps/desktop/e2e/settings-projects.spec.ts delete mode 100644 apps/desktop/e2e/skill-delete-scope.spec.ts delete mode 100644 apps/desktop/e2e/skills.spec.ts delete mode 100644 apps/desktop/e2e/storage-root-conflict.spec.ts diff --git a/apps/desktop/e2e/ask-user-question.spec.ts b/apps/desktop/e2e/ask-user-question.spec.ts deleted file mode 100644 index c63dd07d9a..0000000000 --- a/apps/desktop/e2e/ask-user-question.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime'; -import { test, expect, COMPOSER_INPUT } from './fixtures.js'; - -// One seeded prompt, one launch: the reload-rehydration contract and the -// answer flow are consecutive phases of the same parked turn. Answering -// *after* the reload is the stronger form of both tests — it proves the -// rehydrated prompt is not a rendering of lost state but the live turn. -test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT); - await composer.press('Enter'); - - const prompt = page.locator('.maka-user-question-prompt'); - await expect(prompt).toBeVisible(); - - // Reloading throws away every event this surface ever saw while the runtime - // keeps the turn parked on the question. Without a read-back the prompt is - // gone for good and the run can never be answered (#2072). - await page.reload(); - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page - .getByRole('navigation', { name: '对话列表' }) - .locator('[data-session-id]') - .first() - .click(); - - await expect(prompt).toBeVisible(); - await expect(page.locator('.maka-composer')).toBeHidden(); - await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible(); - await expect(prompt.getByText('先验证核心流程,再逐步扩大范围。')).toBeVisible(); - - const selectedOption = prompt.getByRole('radio', { name: /邀请制/ }); - const unselectedOption = prompt.getByRole('radio', { name: /公开测试/ }); - await selectedOption.click(); - await expect(selectedOption).toBeChecked(); - await expect(unselectedOption).not.toBeChecked(); - // The first question has nothing to go back to: no previous button at all, - // not a disabled one. - const previous = prompt.getByRole('button', { name: '上一题' }); - await expect(previous).toHaveCount(0); - const next = prompt.getByRole('button', { name: '下一题' }); - await next.click(); - - await expect(prompt.getByText('2 / 3', { exact: true })).toBeVisible(); - await expect(next).toBeFocused(); - // Going back keeps the answer already chosen on the first question. - await expect(previous).toBeVisible(); - await previous.click(); - await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible(); - await expect(selectedOption).toBeChecked(); - await expect(previous).toHaveCount(0); - await next.click(); - await expect(prompt.getByText('2 / 3', { exact: true })).toBeVisible(); - const thisWeek = prompt.getByRole('radio', { name: '本周' }); - const nextWeek = prompt.getByRole('radio', { name: '下周' }); - await thisWeek.focus(); - await thisWeek.press('ArrowDown'); - await expect(nextWeek).toBeFocused(); - await expect(nextWeek).toBeChecked(); - await nextWeek.press('ArrowUp'); - await expect(thisWeek).toBeFocused(); - await expect(thisWeek).toBeChecked(); - await next.click(); - - await expect(prompt.getByText('3 / 3', { exact: true })).toBeVisible(); - - const preset = prompt.getByRole('radio', { name: '是' }); - const customChoice = prompt.getByRole('radio', { name: /其他/ }); - const submit = prompt.getByRole('button', { name: '提交答案' }); - await preset.click(); - await expect(preset).toBeChecked(); - await expect(submit).toBeEnabled(); - await customChoice.click(); - await expect(customChoice).toBeChecked(); - await expect(preset).not.toBeChecked(); - const other = prompt.getByRole('textbox', { name: '其他答案' }); - await expect(other).toBeFocused(); - await expect(submit).toBeDisabled(); - await other.fill('自定义节奏'); - await expect(submit).toBeEnabled(); - await other.press('Home'); - await other.press('ArrowLeft'); - await expect(other).toBeFocused(); - await expect(other).toHaveValue('自定义节奏'); - await prompt.getByRole('button', { name: '提交答案' }).click(); - - await expect(prompt).toHaveCount(0); - await expect(page.getByText(/Fake question answers: 邀请制 \/ 本周 \/ 自定义节奏/)).toBeVisible(); - await expect(page.locator('.maka-composer')).toBeVisible(); -}); diff --git a/apps/desktop/e2e/attachment.spec.ts b/apps/desktop/e2e/attachment.spec.ts deleted file mode 100644 index a532b72e29..0000000000 --- a/apps/desktop/e2e/attachment.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { test, expect, COMPOSER_INPUT } from './fixtures'; - -/** - * Attachment upload + ingest: enter the chat view, drop a file onto the main - * composer, confirm it shows as a pending token, then send the message and - * verify the fake backend received the attachments by name. Uses Playwright's - * DataTransfer + dispatchEvent because the composer has no . - */ -test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => { - // Enter chat view by sending a first message from the composer, which - // creates the session on send. - const firstSend = page.locator(COMPOSER_INPUT); - await firstSend.fill('attach-test'); - await firstSend.press('Enter'); - await expect(page.getByText(/Fake backend received: attach-test/)).toBeVisible(); - - // Drop a file onto the main composer - const composer = page.locator('.maka-composer'); - await expect(composer).toBeVisible(); - // Assistant text becomes visible before the turn's terminal event can clear - // the streaming state. The composer intentionally rejects attachments until - // that happens. Match the ready target and dispatch from the same renderer - // task so a state transition cannot land between readiness and the drop. - const dropTarget = page.locator('.maka-composer[data-maka-file-drop-target="true"]'); - await dropTarget.evaluate((element) => { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add( - new File(['hello attachment content'], 'note.txt', { type: 'text/plain' }), - ); - const png = Uint8Array.from( - atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='), - (character) => character.charCodeAt(0), - ); - dataTransfer.items.add(new File([png], 'pixel.png', { type: 'image/png' })); - element.dispatchEvent( - new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }), - ); - }); - - // Both attachment kinds stage as Token chips in the quote chips' rhythm, - // names visible on the chip itself (the full name + «EXT · size» meta ride - // the hover tooltip). - const chips = page.locator('.maka-composer-context-drawer .maka-composer-attachment-token'); - await expect(chips.filter({ hasText: 'note.txt' })).toBeVisible(); - const imageChip = chips.filter({ hasText: 'pixel.png' }); - await expect(imageChip).toBeVisible(); - // Once the preview decodes, the image chip becomes clickable (Token mounts - // its invisible button exactly then) and opens the Astryx Lightbox. - // exact: true — a bare name would also match the chip's remove button - // («移除 pixel.png»). - const imageChipButton = imageChip.getByRole('button', { name: 'pixel.png', exact: true }); - await imageChipButton.click(); - const stagedLightbox = page.getByRole('dialog'); - await expect(stagedLightbox.getByRole('img', { name: 'pixel.png' })).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(stagedLightbox).not.toBeVisible(); - // Native-dialog contract: closing hands focus back to the chip button that - // opened it — keyboard users must not be dropped on . The Lightbox - // stays mounted through the close for exactly this. - await expect(imageChipButton).toBeFocused(); - // Keyboard parity with hover: the focused chip surfaces the same - // «name · EXT · size» tooltip (focusTrigger="always" — Token's root span - // is not focusable, so focusin must bubble from the inner button). - await expect( - page.getByRole('tooltip').filter({ hasText: 'pixel.png · PNG' }), - ).toBeVisible(); - - // Send the message carrying the attachment — the fake backend echoes the - // attachment name, proving the ingest-on-send path delivered it (not just - // that the UI rendered a card). - const composerInput = page.locator(COMPOSER_INPUT); - await composerInput.fill('sending mixed attachments'); - await composerInput.press('Enter'); - await expect(page.getByText(/Attachments received: note\.txt, pixel\.png/)).toBeVisible(); - - const sentMessage = page.getByLabel('你发送的消息').last(); - const fileToken = sentMessage.locator('.maka-user-attachment-tokens .astryx-token'); - const image = sentMessage.locator('.maka-user-attachments .astryx-thumbnail'); - const bubble = sentMessage.locator('.maka-chat-message-bubble-user'); - await expect(fileToken).toContainText('note.txt'); - await expect(image).toBeVisible(); - await expect(bubble).toContainText('sending mixed attachments'); - - await image.getByRole('button').click(); - const lightbox = page.locator('.astryx-lightbox'); - await expect(lightbox).toBeVisible(); - - // The lightbox close button lands inside the titlebar's drag rect, where the - // OS eats clicks unless the modal is `no-drag`. The overlap is asserted first — - // without it the app-region check would guard nothing. - const titlebar = page.locator('.maka-window-titlebar'); - const [titlebarBox, lightboxBox] = await Promise.all([titlebar.boundingBox(), lightbox.boundingBox()]); - expect(titlebarBox).not.toBeNull(); - expect(lightboxBox).not.toBeNull(); - expect(lightboxBox!.y).toBeLessThan(titlebarBox!.y + titlebarBox!.height); - await expect(lightbox).toHaveCSS('-webkit-app-region', 'no-drag'); - - await page.keyboard.press('Escape'); - await expect(page.locator('.astryx-lightbox')).not.toBeVisible(); - - // IME composition vs file paste, in the same window: pastes must not be - // intercepted mid-composition, and must be intercepted after it. Runs after - // the attachment journey because the post-composition paste stages a chip. - const editable = page - .locator('.maka-composer[data-maka-file-drop-target="true"]') - .locator('[contenteditable="true"]'); - const pasteResults = await editable.evaluate((input) => { - const dispatchFilePaste = () => { - const clipboardData = new DataTransfer(); - clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' })); - const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: clipboardData }); - input.dispatchEvent(event); - return event.defaultPrevented; - }; - - input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); - const duringComposition = dispatchFilePaste(); - input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })); - const afterComposition = dispatchFilePaste(); - - return { duringComposition, afterComposition }; - }); - - expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true }); -}); diff --git a/apps/desktop/e2e/bot-onboarding.spec.ts b/apps/desktop/e2e/bot-onboarding.spec.ts deleted file mode 100644 index b6e644eaa6..0000000000 --- a/apps/desktop/e2e/bot-onboarding.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { test, expect } from './fixtures'; - -test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => { - const settings = page.getByRole('main', { name: '设置内容' }); - await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible(); - - await settings.getByRole('button', { name: '接入 钉钉' }).click(); - await expect(settings.getByRole('heading', { name: '接入方式' })).toBeVisible(); - await expect(settings.getByRole('radio', { name: '快捷接入(推荐)' })).toBeChecked(); - - await settings.getByRole('radio', { name: '手动配置' }).click(); - await expect(settings.getByRole('textbox', { name: '钉钉应用密钥' })).toBeVisible(); - await settings.getByRole('radio', { name: '快捷接入(推荐)' }).click(); - await settings.getByRole('button', { name: '使用钉钉扫码接入' }).click(); - - const dialog = page.getByRole('dialog', { name: '配置钉钉扫码接入' }); - await expect(dialog).toBeVisible(); - const qr = dialog.getByRole('img', { name: '配置钉钉二维码' }); - await expect(qr).toHaveAttribute('src', /^data:image\/png;base64,/); - await expect(dialog.getByText('请使用钉钉扫描二维码并确认授权')).toBeVisible(); - // QR square / fill-frame geometry is pinned in chat-shell-layout-contract - // (settingsBotOnboardingQrFrame CSS). This journey owns session + secret isolation. - - await expect(dialog.getByText('已扫码,请在钉钉中完成确认')).toBeVisible({ timeout: 4_000 }); - await expect(dialog.getByText('钉钉 已连接')).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText('Maka 测试机器人')).toBeVisible(); - - const stored = await page.evaluate(() => window.maka.settings.get()); - expect(stored.botChat.channels.dingtalk.appId).toBe('e2e-fixture-dingtalk-client'); - expect(stored.botChat.channels.dingtalk.appSecret).not.toBe('e2e-fixture-dingtalk-secret'); - expect(JSON.stringify(stored)).not.toContain('e2e-fixture-dingtalk-secret'); - - await dialog.getByRole('button', { name: '完成' }).click(); - await expect(dialog).toBeHidden(); - // Same window, next channels: cancellation races, expiry regeneration, and - // the Lark variant are independent flows over the same seeded settings. - await settings.getByRole('button', { name: '返回远程接入' }).click(); - await settings.getByRole('button', { name: '接入 微信' }).click(); - await settings.getByRole('button', { name: '扫码登录' }).click(); - const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' }); - await expect(wechatDialog.getByRole('img', { name: '微信扫码登录二维码' })).toBeVisible(); - // Poll snapshots replace the dialog subtree. A real pointer dispatch does - // not wait for that subtree to become stable, so neither should this click. - await wechatDialog.getByRole('button', { name: '取消' }).click({ force: true }); - await expect(wechatDialog).toBeHidden(); - - const afterCancel = await page.evaluate(() => window.maka.settings.get()); - expect(afterCancel.botChat.channels.wechat.token).toBe(''); - expect(afterCancel.botChat.channels.wechat.enabled).toBe(false); - - await settings.getByRole('button', { name: '返回远程接入' }).click(); - await settings.getByRole('button', { name: '接入 企业微信' }).click(); - await settings.getByRole('button', { name: '开始快捷绑定' }).click(); - const wecomDialog = page.getByRole('dialog', { name: '配置企业微信扫码接入' }); - await expect(wecomDialog.getByText('二维码已过期,请重新生成')).toBeVisible({ timeout: 4_000 }); - await wecomDialog.getByRole('button', { name: '重新生成' }).click(); - await expect(wecomDialog.getByRole('img', { name: '配置企业微信二维码' })).toBeVisible(); - await wecomDialog.getByRole('button', { name: '取消' }).click({ force: true }); - - await settings.getByRole('button', { name: '返回远程接入' }).click(); - await settings.getByRole('button', { name: '接入 飞书' }).click(); - await settings.getByRole('radio', { name: 'Lark' }).click(); - await settings.getByRole('button', { name: '使用Lark扫码接入' }).click(); - const larkDialog = page.getByRole('dialog', { name: '配置 Lark 扫码接入' }); - await expect(larkDialog.getByRole('img', { name: '配置 Lark 二维码' })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/composer-mention-token.spec.ts b/apps/desktop/e2e/composer-mention-token.spec.ts deleted file mode 100644 index f9b6ceb130..0000000000 --- a/apps/desktop/e2e/composer-mention-token.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { expect, test, COMPOSER_INPUT } from './fixtures'; - -// One seeded window, four phases over the same composer. Order is dictated -// by the message-count pins: the empty-menu double-Enter phase asserts a -// total of exactly one sent message, so it runs first; the token phase sends -// last and anchors on the newest bubble. -/** - * An open menu with nothing highlighted (still loading, or no matches) leaves - * Enter unconsumed. It must not send the draft out from under the popup — and - * it must not deadlock either: "no matches" is a stable state, so swallowing - * Enter forever would leave the keyboard unable to send at all. - */ -/** - * The menu has to follow the caret, not just the text. Astryx recomputes the - * active trigger only on `input`, so an arrow key off the query used to leave - * the menu open over a trigger no longer under the cursor — and the next Enter - * spliced a token in at the stale offset instead of sending. - */ -/** - * Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger` - * now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a - * space ends an `@` query, so a path with a space in it can no longer be - * searched. Pin the grammar we actually depend on so an Astryx upgrade that - * moves a boundary fails here rather than in front of a user. - */ -/** - * The `@` file trigger: menu → inline token → the path the backend receives. - * The token is a real chip in the draft now, so this also pins the one cascade - * invariant it depends on — the token span must shrink to its badge. A - * `[contenteditable]` selector that also matched the token's - * `contenteditable="false"` stretched it to the full line and pushed the - * surrounding text onto separate rows. - */ -test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('@zzzznomatchzzzz'); - await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible(); - - // A leaked send is asynchronous, so `toHaveCount(0)` here would pass before - // it lands — and a second Enter sending the same text would then hide it. - // Withhold, retype into something distinguishable, and pin the total. - await composer.press('Enter'); - await composer.press('Enter'); - await expect(page.getByRole('log').getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible(); - await expect(page.getByLabel('你发送的消息')).toHaveCount(1); - // Settle before the next phase sends: an Enter during a streaming turn - // becomes steering instead of a new message. - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - await composer.fill('看一下 @agent'); - await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible(); - - for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft'); - await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false'); - - await composer.press('Enter'); - await expect(page.getByRole('log').getByText('Fake backend received: 看一下 @agent')).toBeVisible(); - await expect(composer.locator('[data-astryx-token]')).toHaveCount(0); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 }); - - const expanded = () => composer.getAttribute('aria-expanded'); - const files = page.getByRole('listbox', { name: '工作区文件' }); - - await composer.fill('看一下 @agent'); - await expect(files).toBeVisible(); - - // A space ends the query — narrowing an `@` search by a second word, which - // the retired popup allowed, is gone. - await composer.pressSequentially(' write'); - await expect.poll(expanded).toBe('false'); - - // A non-boundary `@` is not a trigger. - await composer.fill('mail user@host.com'); - await expect.poll(expanded).toBe('false'); - - // The nearest boundary wins. - await composer.fill('@a /b'); - await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible(); - await expect(files).toHaveCount(0); - - await composer.fill('看一下 @agent'); - - const listbox = page.getByRole('listbox', { name: '工作区文件' }); - await expect(listbox.getByRole('option').first()).toBeVisible(); - await composer.press('Enter'); - await composer.pressSequentially('里的说明'); - await composer.pressSequentially(';普通文本 @.maka/skills/agent-write/SKILL.md'); - await composer.press('Escape'); - - const token = composer.locator('[data-astryx-token]'); - await expect(token).toHaveAttribute( - 'data-astryx-token-value', - '@.maka/skills/agent-write/SKILL.md', - ); - await composer.press('Enter'); - // Third send of this journey: anchor on the newest bubble. - const bubble = page.getByLabel('你发送的消息').last(); - await expect(bubble).toBeVisible(); - const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge'); - await expect(sentFileBadges).toHaveCount(1); - await expect(sentFileBadges).toHaveText('SKILL.md'); - await expect(bubble).toContainText( - '看一下 SKILL.md 里的说明;普通文本 @.maka/skills/agent-write/SKILL.md', - ); - // The transcript replays the selected token's label, while the model still - // receives the exact serialized path with normalized spacing. - // Scope to the transcript log: after several turns the prompt rail also - // previews this reply text, and a page-wide getByText is ambiguous under - // Playwright strict mode. - await expect( - page.getByRole('log').getByText( - 'Fake backend received: 看一下 @.maka/skills/agent-write/SKILL.md 里的说明;普通文本 @.maka/skills/agent-write/SKILL.md', - ), - ).toBeVisible(); - - await page.reload(); - const reloadedBubble = page.getByLabel('你发送的消息').last(); - await expect(reloadedBubble).toBeVisible(); - await expect( - reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'), - ).toHaveCount(1); - await expect(reloadedBubble).toContainText( - '普通文本 @.maka/skills/agent-write/SKILL.md', - ); -}); diff --git a/apps/desktop/e2e/composer-skill-invocation.spec.ts b/apps/desktop/e2e/composer-skill-invocation.spec.ts index bfdb002ac6..d807d86ea2 100644 --- a/apps/desktop/e2e/composer-skill-invocation.spec.ts +++ b/apps/desktop/e2e/composer-skill-invocation.spec.ts @@ -1,136 +1,5 @@ -import type { Page } from '@playwright/test'; -import { expect, test, COMPOSER_INPUT, waitForInvocableSkills } from './fixtures'; +import { expect, test, COMPOSER_INPUT } from './fixtures'; -async function createStarterSkillAndReload(page: Page): Promise { - const result = await page.evaluate(() => window.maka.skills.createStarter()); - expect(result.ok).toBe(true); - await page.reload(); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await waitForInvocableSkills(page, ['starter-skill']); -} - -/** The staged Skill's inline chip, addressed by the token it serializes to. */ -const STARTER_CHIP = '[data-astryx-token-value="/skill:starter-skill"]'; - -/** - * Pick 示例技能 from the `/` menu. `append` types the trigger after whatever - * is already in the draft; the default replaces the draft, which is what a - * chip-only send needs. - */ -async function selectStarterSkill( - page: Page, - options: { append?: boolean } = {}, -): Promise { - const composer = page.locator(COMPOSER_INPUT); - if (options.append) { - await composer.click(); - await composer.pressSequentially(' /'); - } else { - await composer.fill('/'); - } - const listbox = page.getByRole('listbox', { name: /技能/ }); - await expect(listbox).toBeVisible(); - await expect(listbox.getByRole('option', { name: /示例技能/ })).toBeVisible(); - await composer.press('Enter'); - await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); -} - -// One seeded window, three phases in load-bearing order: the gating phase -// reads the pre-session state, the collaboration phase must create the very -// first session, and the Deep Research phase runs last because it moves the -// active session. (Draft-chip restoration keeps its own window below: its -// contract is editor rebuilds on external value changes, and concurrent -// session activity in a shared window perturbs exactly that.) -test('slash suggestions: project gating, collaboration modes, and Deep Research filtering', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('/'); - const listbox = page.getByRole('listbox', { name: /技能/ }); - await expect(listbox).toBeVisible(); - await expect(listbox).toContainText('Project Only'); - await expect(listbox).toContainText('Workspace Only'); - await expect(listbox).toContainText('Agent Write'); - await expect(listbox).not.toContainText('Host Incompatible'); - - const planNames = await page.evaluate(async () => - (await window.maka.skills.listInvocable(undefined, { - collaborationMode: 'plan', - })).map((skill) => skill.name), - ); - expect(planNames).not.toContain('Agent Write'); - - await composer.fill('Open a session'); - await composer.press('Enter'); - await expect.poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length).toBe(1); - const [session] = await page.evaluate(() => window.maka.sessions.list()); - if (!session) throw new Error('the composer did not create a session'); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list()))[0]?.status) - .not.toBe('running'); - - const listNames = (sessionId: string) => - page.evaluate( - async (id) => (await window.maka.skills.listInvocable(id)).map((skill) => skill.name), - sessionId, - ); - - await expect.poll(() => listNames(session.id)).toContain('Agent Write'); - await composer.fill('/'); - await expect(listbox).toContainText('Agent Write'); - - await expect - .poll(() => - page.evaluate(async ({ sessionId }) => { - try { - await window.maka.sessions.setCollaborationMode(sessionId, 'plan'); - return true; - } catch (error) { - if (String(error).includes('linked Turn is active')) return false; - throw error; - } - }, { sessionId: session.id }), - ) - .toBe(true); - await expect.poll(() => listNames(session.id)).not.toContain('Agent Write'); - await expect(listbox).not.toContainText('Agent Write'); - - - await composer.fill('/'); - await expect(listbox).toBeVisible(); - await expect(listbox).not.toContainText('Deep Research Only'); - await composer.fill(''); - - // ⌘K is the palette's only entry now — the 更多操作 menu that used to hold - // a 打开命令面板 item is gone. ControlOrMeta covers CI's Linux and macOS. - await page.keyboard.press('ControlOrMeta+KeyK'); - await page.getByRole('dialog', { name: '命令面板' }).getByRole('option', { name: /新建深度研究/ }).click(); - await expect(page.getByLabel('深度研究,只读探索').filter({ visible: true })).toBeVisible(); - - await composer.fill('/'); - await expect(listbox).toContainText('Deep Research Only'); -}); - -/** - * The staged Skill has to still look staged after the draft comes back. - * - * `ChatComposerInput` rebuilds the editor from the string on every external - * value change, which drops the chip spans and leaves the `/skill:` text - * they serialize to (facebook/astryx #4655). That draft still sends the Skill, - * but the user is looking at an internal id where they left a chip, and this is - * the ordinary path: any session that ever staged a Skill hits it on the way - * back. - * - * Two Skills, one of them mid-draft, because the redraw walks the tokens back - * to front so that replacing a later one cannot move an earlier one's offsets. - * A single token at the end of the draft would never exercise that. - * - * Blurred on purpose: the swap runs from a sidebar click, so the redraw has to - * work without the editor holding focus, and the caret still has to land after - * the draft rather than at offset 0. The send at the end is the other half — - * `insertToken` anchors every chip with a U+00A0, and the wire text has to come - * out with ordinary spaces regardless. - */ test('staged Skills come back as chips after leaving and returning', async ({ invocableSkillsWindow: page, }) => { @@ -184,81 +53,3 @@ test('staged Skills come back as chips after leaving and returning', async ({ // invocation pins zero turns and zero sessions so it must run before any // send; the chip-only send then re-enables the Skill and owns the first // message; the + entry phases send nothing and run last. -test('starter skill: blocked invocation, chip-only send, and the + Skills entry', async ({ - window: page, -}) => { - await createStarterSkillAndReload(page); - await createStarterSkillAndReload(page); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('run it'); - await selectStarterSkill(page, { append: true }); - const disabled = await page.evaluate(() => window.maka.skills.setEnabled('starter-skill', false)); - expect(disabled.ok).toBe(true); - - await composer.press('Enter'); - - await expect(page.getByText('Skill 调用失败,消息未发送')).toBeVisible(); - await expect(composer).toContainText('run it'); - await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); - await expect(page.locator('.maka-turn')).toHaveCount(0); - // #1433: the composer creates the session BEFORE it sends, so a rejected - // first send has to remove it again. Otherwise every blocked invocation - // leaves a nameless empty session in the sidebar. `quick-chat.ts` used to - // carry unit tests for this; when the composer became the only first-send - // path, nothing was asserting it any more. - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(0); - - // Re-arm for the next phases: the blocked phase disabled the Skill and left - // a draft behind. Wait for the invocable list to reflect the re-enable - // before the menus below depend on it. - const reEnabled = await page.evaluate(() => window.maka.skills.setEnabled('starter-skill', true)); - expect(reEnabled.ok).toBe(true); - await waitForInvocableSkills(page, ['starter-skill']); - await composer.fill(''); - - await selectStarterSkill(page); - - await composer.press('Enter'); - - const sentMessage = page.getByLabel('你发送的消息').first(); - await expect(sentMessage).toContainText('示例技能'); - await expect( - sentMessage.locator('.maka-chat-message-bubble-user .astryx-badge'), - ).toHaveText('示例技能'); - - // Settle the chip-only turn before the + phases interact with the menu. - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - const listbox = page.getByRole('listbox', { name: /技能/ }); - const plus = page.locator('.maka-composer-plus-menu').getByRole('button'); - const skillsEntry = page.getByRole('menuitem', { name: '选择技能' }); - const openFromPlus = async () => { - // Astryx ignores clicks on a float for ~100ms after one light-dismisses, - // and picking from the `/` menu dismisses one. Nothing observable marks the - // end of that window, so retry the click until + answers rather than sleep - // past it — under the threshold the click reaches nothing at all. - await expect(async () => { - await plus.click(); - await expect(skillsEntry).toBeVisible({ timeout: 500 }); - }).toPass(); - await skillsEntry.click(); - }; - - // Empty draft: the trigger is at a line start, so no space is needed. - await openFromPlus(); - // Keyboard selection is pinned elsewhere; click the option so this phase - // does not race the menu's highlight settling after the Skill re-enable. - await listbox.getByRole('option', { name: /示例技能/ }).click(); - await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); - - await openFromPlus(); - await expect(listbox).toBeVisible(); - - // Escape leaves the typed trigger behind, exactly as it does when the user - // types `/` themselves — it is ordinary draft text, not a surface to dismiss. - await page.keyboard.press('Escape'); - await expect(listbox).toBeHidden(); - await expect(composer).toContainText('/'); -}); diff --git a/apps/desktop/e2e/external-session-import.spec.ts b/apps/desktop/e2e/external-session-import.spec.ts deleted file mode 100644 index b6f772c414..0000000000 --- a/apps/desktop/e2e/external-session-import.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createServer } from 'node:http'; -import { copyFile, mkdir } from 'node:fs/promises'; -import path from 'node:path'; -import type { Page } from '@playwright/test'; -import { e2eHomeDir, expect, test } from './fixtures'; - -test('imports a Codex rollout through the Desktop Runtime Host', async ({ - externalSessionImportWindow: page, -}) => { - const modelCatalog = createServer((_request, response) => { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end( - JSON.stringify({ - object: 'list', - data: [ - { - id: 'external-import-model', - object: 'model', - created: 0, - owned_by: 'e2e', - }, - ], - }), - ); - }); - await new Promise((resolve) => modelCatalog.listen(0, '127.0.0.1', resolve)); - const address = modelCatalog.address(); - if (address === null || typeof address === 'string') throw new Error('Model catalog did not bind'); - - try { - await page.evaluate(async (baseUrl) => { - await window.maka.connections.create({ - slug: 'external-import-e2e', - name: 'External import E2E', - providerType: 'openai-compatible', - baseUrl, - defaultModel: 'external-import-model', - apiKey: 'e2e-placeholder', - }); - await window.maka.connections.fetchModels('external-import-e2e'); - await window.maka.connections.update('external-import-e2e', { - defaultModel: 'external-import-model', - enabledModelIds: ['external-import-model'], - }); - await window.maka.connections.setDefault('external-import-e2e'); - }, `http://127.0.0.1:${address.port}/v1`); - - await importCodexRollout(page); - } finally { - await new Promise((resolve, reject) => { - modelCatalog.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -async function importCodexRollout(page: Page): Promise { - const rolloutDirectory = path.join(e2eHomeDir(), '.codex', 'sessions', '2026', '08', '08'); - await mkdir(rolloutDirectory, { recursive: true }); - await copyFile( - path.resolve('../../packages/storage/src/__tests__/fixtures/codex-rollout-v0.144.jsonl'), - path.join(rolloutDirectory, 'rollout-2026-08-08T00-00-00-codex-session-1.jsonl'), - ); - - const expandSidebar = page.getByRole('button', { name: '展开侧边栏' }); - if (await expandSidebar.isVisible()) await expandSidebar.click(); - await page.getByRole('button', { name: '导入会话', exact: true }).click(); - - const dialog = page.getByRole('dialog', { name: '导入外部会话' }); - await expect(dialog).toBeVisible(); - await expect(dialog.getByRole('button', { name: 'Codex', exact: true })).toBeVisible(); - - const sourceSession = dialog.getByRole('button').filter({ hasText: 'Fix the parser' }); - await expect(sourceSession).toHaveCount(1); - await sourceSession.click(); - await dialog.getByRole('button', { name: '导入会话', exact: true }).click(); - - await expect(dialog).toBeHidden(); - await expect( - page.locator('.maka-user-message').getByText('Fix the parser', { exact: true }), - ).toBeVisible(); - await expect( - page.locator('.maka-assistant-answer').getByText('I found the issue.', { exact: true }), - ).toBeVisible(); -} diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 43dd6df976..3fbe00fa80 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -303,108 +303,13 @@ async function withE2eWindow( export const test = base.extend<{ window: Page; - externalSessionImportWindow: Page; - firstRunWindow: Page; - modelPickerLongWindow: Page; - sandboxBoundaryWindow: Page; - readOnlyBoundaryWindow: Page; - sessionWorkbarWindow: Page; artifactPaneWindow: Page; - botSettingsWindow: Page; invocableSkillsWindow: Page; - settingsProjectsWindow: Page; - oauthReloginWindow: Page; }>({ // Seeded: a pre-staged connection clears onboarding so the composer is ready. - // Used by chat / session / settings / attachment specs. window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use); }, - // External Session import runs through the production Runtime Host owner. - externalSessionImportWindow: async ({}, use) => { - await withE2eWindow( - { - seed: true, - readinessSelector: '.maka-session-panel', - locale: 'zh', - }, - use, - ); - }, - // No connection: the real main process derives `needs_connection`, and the - // renderer replaces the empty chat with the first-task activation card. - firstRunWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '[data-maka-contract="onboarding-card"]', - e2eFixtureScenario: 'first-run', - locale: 'zh', - }, - use, - ); - }, - // Settings -> 偏好 -> 项目, with three seeded catalog entries (available, - // long-path, and folder-gone) so the list, the default control, and the - // unavailable row all render without touching a native directory picker. - settingsProjectsWindow: async ({}, use) => { - await withE2eWindow( - { - seed: true, - readinessSelector: '.settingsMainPane', - e2eFixtureScenario: 'settings-projects', - locale: 'zh', - }, - use, - ); - }, - modelPickerLongWindow: async ({}, use) => { - await withE2eWindow( - { - seed: true, - readinessSelector: COMPOSER_INPUT, - locale: 'zh', - extraConnectionCount: 10, - }, - use, - ); - }, - // Sandbox-boundary takeover: boots a deterministic expansion request in the - // real desktop shell so the composer-slot placement and non-modal behavior - // are covered without a provider or test-only renderer state path. - sandboxBoundaryWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '.maka-sandbox-boundary-prompt', e2eFixtureScenario: 'sandbox-boundary', locale: 'zh' }, - use, - ); - }, - // Read-only boundary (#1611): the Deep Research fixture session is seeded - // with `permissionMode: 'explore'`, so the metadata store derives a genesis - // managed read-only boundary for it. That makes this the only window where - // the composer's permission label is driven by a real read-only profile - // travelling main → IPC → renderer. - readOnlyBoundaryWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: COMPOSER_INPUT, e2eFixtureScenario: 'deep-research-progress', locale: 'zh' }, - use, - ); - }, - // Session workbar: seeds a task tree and opens the unified auxiliary - // workspace so its shell controls and peer tabs run against real IPC data. - sessionWorkbarWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - // The data contract, not the tag: the panel's surface is an Astryx Card - // (a div carrying role="complementary"), so a tag-anchored selector - // would pin an implementation detail the design system owns. - readinessSelector: '[data-maka-contract="session-workbar-right"]', - e2eFixtureScenario: 'task-ledger', - locale: 'zh', - }, - use, - ); - }, artifactPaneWindow: async ({}, use) => { await withE2eWindow( { @@ -416,17 +321,7 @@ export const test = base.extend<{ use, ); }, - // Remote access: uses the e2e-fixture workspace so Settings opens on the - // channel catalog and main injects deterministic IM onboarding adapters. - // The renderer still talks through the real preload/IPC/session authority. - botSettingsWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '[aria-label="设置内容"]', e2eFixtureScenario: 'settings-bots', locale: 'zh' }, - use, - ); - }, - // Project + Maka-workspace Skills with one deliberately host-incompatible - // entry. Proves `/` uses Runtime discovery/gating rather than management UI data. + // Project + workspace Skills for draft/chip journeys. invocableSkillsWindow: async ({}, use) => { await withE2eWindow({ seed: true, @@ -435,19 +330,6 @@ export const test = base.extend<{ invocableSkills: true, }, use); }, - oauthReloginWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - // The connection detail is a page now, not a dialog; waiting on - // `dialog[open]` waited for markup this redesign deleted. - readinessSelector: '[data-maka-contract="connection-detail"]', - e2eFixtureScenario: 'oauth-relogin', - locale: 'zh', - }, - use, - ); - }, }); export { expect }; diff --git a/apps/desktop/e2e/mcp.spec.ts b/apps/desktop/e2e/mcp.spec.ts deleted file mode 100644 index 78640c745a..0000000000 --- a/apps/desktop/e2e/mcp.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import path from 'node:path'; -import { test, expect } from './fixtures.js'; - -const fixtureServer = path.resolve( - process.cwd(), - '../../packages/mcp/dist/__fixtures__/stdio-server.js', -); - -test('MCP module completes stdio add, discovery, disable, JSON import, and delete', async ({ window: page }) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '对话列表' }); - const extensions = sidebar.getByRole('button', { name: '扩展', exact: true }); - await expect(sidebar.getByRole('button', { name: '技能', exact: true })).toHaveCount(0); - await expect(sidebar.getByRole('button', { name: 'MCP', exact: true })).toHaveCount(0); - await extensions.click(); - await expect(extensions).toHaveAttribute('aria-current', 'page'); - await expect(sidebar.getByRole('radiogroup', { name: '会话分组方式' })).toBeVisible(); - await expect(sidebar.locator('.maka-session-list')).toBeVisible(); - - const extensionSelector = page.locator('.maka-module-hub-selector'); - await expect(extensionSelector).toHaveAccessibleName('扩展内容:技能'); - await extensionSelector.getByRole('button', { name: 'MCP' }).click(); - const mcp = page.getByRole('main', { name: '扩展' }); - await expect(mcp.getByRole('heading', { name: '扩展' })).toBeVisible(); - await expect(mcp.getByRole('toolbar', { name: 'MCP 浏览操作' })).toBeVisible(); - await expect(extensionSelector).toHaveAccessibleName('扩展内容:MCP'); - await expect(mcp.getByText('把 Maka 连接到你的工作环境')).toBeVisible(); - await expect(mcp.locator('[data-maka-contract="module-actions"]').getByRole('button')).toHaveCount(2); - await expect(mcp.getByRole('button', { name: '刷新', exact: true })).toBeVisible(); - - // Each hub restores its last module when the user returns from another - // sidebar destination. - await sidebar.getByRole('button', { name: '定时任务', exact: true }).click(); - await extensions.click(); - await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); - await expect(extensionSelector).toHaveAccessibleName('扩展内容:MCP'); - - await mcp.getByRole('button', { name: '添加 MCP' }).click(); - const editor = page.getByRole('dialog', { name: '添加 MCP' }); - await expect(editor.getByLabel('服务器 ID')).toBeFocused(); - await expect(editor.locator('label').filter({ hasText: '服务器 ID' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '命令' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '参数' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '工作目录' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '环境变量' })).toBeVisible(); - await expect(editor.getByText('高级设置', { exact: true })).toHaveCount(0); - await editor.getByRole('button', { name: '保存并连接' }).click(); - await expect(editor.getByLabel('服务器 ID')).toHaveAttribute('aria-invalid', 'true'); - await expect(editor.getByLabel('命令')).toHaveAttribute('aria-invalid', 'true'); - await editor.getByLabel('服务器 ID').fill('e2e-fixture'); - await expect(editor.getByLabel('命令')).toHaveAttribute('aria-invalid', 'true'); - await editor.getByRole('radio', { name: '远程 URL' }).click(); - await expect(editor.locator('label').filter({ hasText: '传输协议' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: 'HTTP 请求头' })).toBeVisible(); - await expect(editor.getByText('高级设置', { exact: true })).toHaveCount(0); - await editor.getByRole('radio', { name: '本地 stdio' }).click(); - await editor.getByLabel('命令').fill(process.execPath); - await editor.getByLabel('参数').fill(fixtureServer); - await editor.getByRole('button', { name: '保存并连接' }).click(); - - // Saving lands on 已安装; the row shows the server and its live tool count. - const fixtureRow = mcp.getByRole('button', { name: /e2e-fixture/ }); - await expect(fixtureRow).toBeVisible(); - await expect(mcp.getByText('把 Maka 连接到你的工作环境')).toHaveCount(0); - await expect(mcp.getByText(/^本地 stdio ·/)).toBeVisible(); - await expect(mcp.getByText(/4 个工具/)).toBeVisible(); - - const config = await page.evaluate(() => window.maka.mcp.getConfig()); - expect(config.mcpServers['e2e-fixture']).toMatchObject({ - enabled: true, - command: process.execPath, - args: [fixtureServer], - }); - - // Selecting the row opens the inspector: discovered tools, edit, enable - // switch and delete all live there now. - await fixtureRow.click(); - const inspector = mcp.getByRole('complementary', { name: '服务器详情' }); - await expect(inspector.getByText('echo', { exact: true })).toBeVisible(); - await expect(inspector.getByText('rich', { exact: true })).toBeVisible(); - - const edit = inspector.getByRole('button', { name: '编辑', exact: true }); - await edit.click(); - const editDialog = page.getByRole('dialog', { name: '编辑 e2e-fixture' }); - await expect(editDialog.getByLabel('服务器 ID')).toBeDisabled(); - await expect(editDialog.getByLabel('命令')).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(editDialog).toBeHidden(); - await expect(edit).toBeFocused(); - - await inspector.getByRole('switch', { name: '启用' }).click(); - await expect.poll(async () => { - const next = await page.evaluate(() => window.maka.mcp.getConfig()); - return next.mcpServers['e2e-fixture']?.enabled; - }).toBe(false); - await expect(inspector.getByRole('switch', { name: '启用' })).not.toBeChecked(); - - // Import a second server BEFORE the delete: with one row left behind, the - // delete below can prove where focus goes — the contract the empty-list - // path cannot exercise. - await mcp.getByRole('button', { name: '添加 MCP' }).click(); - await page.getByRole('dialog', { name: '添加 MCP' }).getByRole('radio', { name: '粘贴 JSON' }).click(); - const jsonEditor = page.getByRole('dialog', { name: '通过 JSON 导入' }); - await jsonEditor.getByLabel('JSON 配置').fill(JSON.stringify({ - mcpServers: { - 'remote-disabled': { url: 'https://example.com/mcp', enabled: false }, - }, - })); - await jsonEditor.getByRole('button', { name: '导入并连接' }).click(); - await expect(mcp.getByText('remote-disabled', { exact: true })).toBeVisible(); - await expect.poll(async () => { - const next = await page.evaluate(() => window.maka.mcp.getConfig()); - return next.mcpServers['remote-disabled']; - }).toMatchObject({ url: 'https://example.com/mcp', enabled: false }); - - // The import's view switch dropped the selection (it belongs to the view - // it was made in), so reopen the inspector before deleting. - await mcp.getByRole('button', { name: /e2e-fixture/ }).click(); - await inspector.getByRole('button', { name: '删除', exact: true }).click(); - await page.getByRole('alertdialog').getByRole('button', { name: '删除', exact: true }).click(); - await expect.poll(async () => { - const next = await page.evaluate(() => window.maka.mcp.getConfig()); - return next.mcpServers['e2e-fixture']; - }).toBeUndefined(); - // Focus lands on the row that took the deleted one's place — not on body, - // which would drop a keyboard user at the top of the document. - await expect(mcp.getByRole('button', { name: /remote-disabled/ })).toBeFocused(); -}); diff --git a/apps/desktop/e2e/oauth-refresh.spec.ts b/apps/desktop/e2e/oauth-refresh.spec.ts deleted file mode 100644 index 802f171ee2..0000000000 --- a/apps/desktop/e2e/oauth-refresh.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect, test } from './fixtures.js'; - -test('Codex OAuth completion refreshes the open connection detail without leaving it', async ({ - oauthReloginWindow: page, -}) => { - const detail = page.locator('[data-maka-contract="connection-detail"]'); - - await expect(detail).toBeVisible(); - await expect(detail.getByText('等待 OAuth 登录')).toBeVisible(); - - await detail.getByRole('button', { name: '登录', exact: true }).click(); - - // The fixture completes through the real preload/IPC/login-hook path. The - // detail stays on screen throughout: success must update it in place, with - // no bounce back to the list and no close/reopen cycle. - await expect(detail).toBeVisible(); - await expect(detail.getByText('OAuth 已登录', { exact: true })).toBeVisible(); - - // And the refresh reached the backing list, not just the page in front of - // it: the row is healthy on return. - await page.getByRole('button', { name: '返回模型连接', exact: true }).click(); - const connectionRow = page.locator('[data-connection-slug="codex-subscription"]'); - await expect(connectionRow).toBeVisible(); - await expect(connectionRow).not.toContainText('需要重新登录'); - await expect(connectionRow).not.toHaveAttribute('data-disabled', 'true'); -}); diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts deleted file mode 100644 index b22b106b75..0000000000 --- a/apps/desktop/e2e/onboarding.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { COMPOSER_INPUT, test, expect } from './fixtures'; - -test('first run connects a provider and starts the first task without workspace defaults', async ({ firstRunWindow: page }) => { - const onboarding = page.locator('[data-maka-contract="onboarding-card"]'); - - await expect(onboarding.getByRole('heading', { name: '接入一个 AI,开始第一项任务。' })).toBeVisible(); - await expect(onboarding.locator('.maka-onboarding-provider-row')).toHaveCount(4); - - await onboarding.locator('.maka-onboarding-provider-row[data-provider="opencode-free"]').click(); - - await expect(page.locator('[data-maka-contract="provider-setup"]')).toBeVisible(); - await expect(page.getByLabel('设置内容')).toBeVisible(); - - await page.getByRole('button', { name: '保存供应商', exact: true }).click(); - await expect(page.locator('[data-maka-contract="connection-detail"]')).toBeVisible(); - - // The production migration is removing workspace defaults. Reproduce that - // boundary deterministically while preserving the provider's enabled model - // inventory: the old onboarding state machine gets stuck here even though a - // new session can name this connection and model explicitly. - await page.evaluate(async () => { - await window.maka.connections.update('opencode-free', { defaultModel: '' }); - }); - await expect - .poll(() => - page.evaluate(async () => { - const connection = (await window.maka.connections.list()).find( - (candidate) => candidate.slug === 'opencode-free', - ); - return { - defaultSlug: await window.maka.connections.getDefault(), - defaultModel: connection?.defaultModel, - enabledModelIds: connection?.enabledModelIds, - }; - }), - ) - .toEqual({ - defaultSlug: null, - defaultModel: '', - enabledModelIds: [ - 'nemotron-3-ultra-free', - 'mimo-v2.5-free', - 'deepseek-v4-flash-free', - ], - }); - await page.getByRole('button', { name: '返回应用', exact: true }).click(); - - await expect(onboarding).toHaveCount(0); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await expect(page.getByRole('button', { name: /选择新对话模型/ })).toHaveAccessibleName( - /Nemotron 3 Ultra Free/, - ); - - await page.locator(COMPOSER_INPUT).fill('完成首次任务'); - await page.locator(COMPOSER_INPUT).press('Enter'); - await expect - .poll(() => - page.evaluate(async () => { - const sessions = await window.maka.sessions.list(); - return sessions.map((session) => ({ - connectionSlug: session.llmConnectionSlug, - model: session.model, - })); - }), - ) - .toContainEqual({ connectionSlug: 'opencode-free', model: 'nemotron-3-ultra-free' }); -}); diff --git a/apps/desktop/e2e/permission-mode-surface.spec.ts b/apps/desktop/e2e/permission-mode-surface.spec.ts deleted file mode 100644 index 604fa10040..0000000000 --- a/apps/desktop/e2e/permission-mode-surface.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test, COMPOSER_INPUT } from './fixtures'; - -/** #1611: the live boundary update must cross main/renderer and survive reload. */ -test('approving an expansion updates the permission label at once and after a reload', async ({ - sandboxBoundaryWindow: page, -}) => { - const prompt = page.locator('.maka-sandbox-boundary-prompt'); - const trigger = page - .locator('.maka-composer-left-controls .permissionModeIcon') - .getByRole('button'); - - // The session runs read-only and is asking to write outside the workspace. - await expect(prompt).toHaveCount(1); - - await prompt.getByRole('button', { name: '本会话允许' }).click(); - await expect(prompt).toHaveCount(0); - - // #1611: the grant only bumps the boundary's revision — re-read authority. - await expect(trigger).toHaveAccessibleName('权限模式:自动'); - - await expect - .poll(() => - page.evaluate(async () => { - const state = await window.maka.e2eFixture.getState(); - return Object.keys(state?.sandboxBoundaryBySession ?? {}).length; - }), - ) - .toBe(0); - - await page.reload(); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await expect(page.locator('.maka-boundary-unreadable-notice')).toHaveCount(0); - await expect(trigger).toHaveAccessibleName('权限模式:自动'); - - await expect(prompt).toHaveCount(0); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); -}); diff --git a/apps/desktop/e2e/providers.spec.ts b/apps/desktop/e2e/providers.spec.ts deleted file mode 100644 index 5725362993..0000000000 --- a/apps/desktop/e2e/providers.spec.ts +++ /dev/null @@ -1,361 +0,0 @@ -// Provider add-flow E2E — representative journeys only. -// -// This suite deliberately keeps two journeys, NOT one clone per -// provider. The add flow (open settings → catalog → category → search → open → -// assert form defaults → save → assert detail + brand-mark render contract) is -// identical across every catalog provider, so exercising it once proves the -// mechanism. The per-provider *facts* it used to re-assert (label, base URL, -// default model, catalog group, and that a real brand mark is registered) are -// covered by registry-driven contract tests that auto-cover new providers with -// zero manual updates: -// - packages/core/src/__tests__/provider-catalog-contract.test.ts -// (structural invariants over CATALOG_PROVIDER_TYPES) -// - apps/desktop/src/main/__tests__/icon-governance-contract.test.ts -// ("renders a registered brand mark for every catalog provider") -// -// Adding a provider: do NOT copy an add-flow test here. The contract tests -// above cover its facts. Add an E2E only for a genuinely new *behavior* (a new -// credential field, a derived endpoint, a gating rule), not a new data point. - -import type { Page } from '@playwright/test'; -import { test, expect } from './fixtures'; - -/** 设置 → 模型. */ -async function openModelsPage(page: Page) { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('button', { name: '设置' }).click(); - await page.locator('[aria-label="设置分组"]').getByText('模型', { exact: true }).click(); -} - -/** - * Walk to the catalog level and narrow it to one provider. - * - * The catalog is a page one level below the list, and its category is a - * Selector rather than a row of tabs — so reaching a provider is three named - * moves instead of a tab click plus a search. - */ -async function openCatalog( - page: Page, - options: { category: string; search: string; expectDefaultAll?: boolean }, -) { - 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. - const search = catalog.getByPlaceholder('搜索服务商'); - const category = catalog.getByRole('combobox', { name: '分类', exact: true }); - await expect(search).toBeFocused(); - if (options.expectDefaultAll) { - await expect(category).toHaveText('全部'); - await search.fill('OpenRouter'); - await expect(catalog.locator('.providerCatalogRow[data-provider="openrouter"]')).toBeVisible(); - await search.fill(''); - } - // These are independent filters, not one composite toolbar: ordinary Tab - // order must move between them in both directions. - await page.keyboard.press('Tab'); - await expect(category).toBeFocused(); - await page.keyboard.press('Shift+Tab'); - await expect(search).toBeFocused(); - await category.click(); - await page.getByRole('option', { name: options.category, exact: true }).click(); - await catalog.getByPlaceholder('搜索服务商').fill(options.search); - return catalog; -} - -/** The provider setup level — one provider's form, or its account login. */ -function providerSetup(page: Page) { - return page.locator('[data-maka-contract="provider-setup"]'); -} - -/** The connection detail level. */ -function connectionDetail(page: Page) { - return page.locator('[data-maka-contract="connection-detail"]'); -} - -/** No level on this page is a modal; the delete confirm is the only one left. */ -async function expectNoDialog(page: Page) { - await expect(page.getByRole('dialog')).toHaveCount(0); -} - -// Canonical API-key journey then the two-field relay detail rules share one -// window: the add-journey deletes what it created, which is the clean state -// the relay phase needs. Header geometry was removed with #2478 as presentation. -test('provider connections: the canonical API-key journey and two-field rows', async ({ window: page }) => { - const panel = page.locator('[data-maka-contract="providers-panel"]'); - // One window, five named steps. Splitting these into separate tests would buy - // per-behavior isolation at the price of four more Electron cold starts; the - // steps give a failing run the same "which behavior broke" answer in the - // trace without that cost. - const setup = providerSetup(page); - const keyInput = setup.getByRole('textbox', { name: /API Key/ }); - const detail = connectionDetail(page); - const connection = page.getByRole('button', { name: /模型连接:Cerebras/ }); - - await test.step('the catalog reaches Cerebras and renders its color brand asset untouched', async () => { - await openModelsPage(page); - await expect(page.getByLabel('设置内容')).toBeVisible(); - const catalog = await openCatalog(page, { - category: 'API', - search: 'Cerebras', - expectDefaultAll: true, - }); - - // A color brand asset renders as an untouched : no currentColor mask, - // no CSS paint, no color filter — and stays invariant across the theme flip. - const catalogMark = catalog.locator('.providerCatalogRow[data-provider="cerebras"] .providerLogo img'); - await expect(catalogMark).toBeVisible(); - expect(await catalogMark.evaluate(colorAssetRenderContract)).toEqual(COLOR_ASSET_RENDER_CONTRACT); - await page.evaluate(() => document.documentElement.classList.add('dark')); - expect(await catalogMark.evaluate(colorAssetRenderContract)).toEqual(COLOR_ASSET_RENDER_CONTRACT); - }); - - await test.step('picking a provider navigates to its setup level', async () => { - await page.getByRole('button', { name: /添加模型供应商:Cerebras/ }).click(); - await expect(setup).toBeVisible(); - // One container throughout: the catalog is replaced, not stacked behind a - // modal, and the way back to it is the level's own back control. - await expectNoDialog(page); - await expect(page.locator('[data-maka-contract="provider-catalog"]')).toHaveCount(0); - await expect(page.getByRole('button', { name: '返回服务商列表', exact: true })).toBeVisible(); - await expect(keyInput).toBeFocused(); - await expect(keyInput).toHaveAttribute('type', 'password'); - await expect(page.getByText('完成必要配置后,连接会出现在模型页上方。')).toBeVisible(); - await expect(keyInput).toHaveAttribute('placeholder', '输入或粘贴 API Key'); - await expect(setup.getByLabel('连接标识', { exact: true })).toHaveCount(0); - await expect(setup.getByLabel('服务地址', { exact: true })).toHaveCount(0); - await expect(setup.getByLabel('默认模型', { exact: true })).toHaveCount(0); - - // A 300-character key scrolls inside the field instead of growing it. The - // before/after values are the oracle; the field's width is a design token, - // not this contract. - const inputBox = await keyInput.boundingBox(); - await keyInput.fill(`sk-${'a'.repeat(300)}`); - const longKeyLayout = await keyInput.evaluate((input) => ({ - clientWidth: input.clientWidth, - scrollWidth: input.scrollWidth, - clientHeight: input.clientHeight, - scrollHeight: input.scrollHeight, - })); - expect(longKeyLayout.scrollWidth).toBeGreaterThan(longKeyLayout.clientWidth); - expect(longKeyLayout.scrollHeight).toBe(longKeyLayout.clientHeight); - expect((await keyInput.boundingBox())?.height).toBe(inputBox?.height); - }); - - await test.step('saving creates the connection and lands on its detail level', async () => { - await keyInput.fill('e2e-cerebras-key'); - await page.getByRole('button', { name: '保存供应商', exact: true }).click(); - - // Creating a connection is the start of setting it up, so the save lands on - // 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. - const connectionSection = detail.getByRole('region', { name: '连接' }); - await expect(connectionSection.getByText('已设置', { exact: true })).toBeVisible(); - await expect(connectionSection.getByRole('textbox', { name: /模型密钥/ })).toHaveCount(0); - - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - const detailKeyField = connectionSection.getByRole('textbox', { name: /模型密钥/ }); - await expect(detailKeyField).toBeVisible(); - const saveKey = connectionSection.getByRole('button', { name: '保存', exact: true }); - await expect(saveKey).toBeDisabled(); - await detailKeyField.fill('sk-e2e-replacement-key'); - await expect(saveKey).toBeEnabled(); - // Cancel restores the row, discarding the draft. - await connectionSection.getByRole('button', { name: '取消', exact: true }).click(); - await expect(connectionSection.getByRole('textbox', { name: /模型密钥/ })).toHaveCount(0); - - const modelSection = detail.getByRole('region', { name: '模型' }); - await expect(modelSection).toBeVisible(); - - // Enabled models are a MultiSelector, not a wall of checkboxes: Astryx - // scopes CheckboxList to 3–7 options and a provider lists far more. - // The MultiSelector trigger is a listbox-popup button; role=combobox belongs - // to the search input inside the popup. - const enabledModels = modelSection.getByRole('button', { name: '启用的模型', exact: true }); - await enabledModels.click(); - // No option is locked, and unchecking one is not silently undone: the - // detail page no longer owns a "default model" that has to stay enabled. - // Which model a new chat starts on lives on 设置 · 通用, which is the one - // control for that pair. - await expect(page.getByRole('option', { name: /GPT OSS 120B/ })).not.toHaveAttribute('aria-disabled', 'true'); - await expect(modelSection.getByText('默认模型', { exact: true })).toHaveCount(0); - await page.getByRole('option', { name: /Gemma/ }).first().click(); - await page.keyboard.press('Escape'); - await expect(enabledModels).toContainText(/Gemma/); - - // Unchecking the connection's own default model is not silently undone. - // The store used to merge the default back into every write, so this click - // computed an identical list, short-circuited, and the box re-checked - // itself. A default that is no longer enabled is simply no longer a - // default. - await enabledModels.click(); - await page.getByRole('option', { name: /GPT OSS 120B/ }).first().click(); - await page.keyboard.press('Escape'); - await expect - .poll(async () => page.evaluate(async () => { - const list = await window.maka.connections.list(); - const entry = list.find((candidate) => candidate.slug === 'cerebras'); - return { enabled: entry?.enabledModelIds ?? null, model: entry?.defaultModel ?? null }; - })) - .toEqual({ enabled: ['gemma-4-31b'], model: '' }); - }); - - await test.step('deletion stays reachable and reversible in a short viewport', async () => { - // Short-viewport invariant: the detail is a page, so the settings content - // area owns the scrolling and the trailing action stays reachable. The test - // asserts reachability, not which node scrolls. - const cdp = await page.context().newCDPSession(page); - await cdp.send('Emulation.setDeviceMetricsOverride', { - width: 1000, - height: 500, - deviceScaleFactor: 1, - mobile: false, - }); - - const deleteButton = detail.getByRole('button', { name: '删除', exact: true }); - await deleteButton.scrollIntoViewIfNeeded(); - await expect(deleteButton).toBeInViewport(); - await deleteButton.click(); - const confirm = page.getByRole('alertdialog'); - await expect(confirm).toBeVisible(); - await confirm.getByRole('button', { name: '取消', exact: true }).click(); - await expect(confirm).toBeHidden(); - - // Confirming deletion refreshes the backing list before the route changes, - // then returns to the list with focus on its primary action — the row the - // user came from no longer exists. - await deleteButton.click(); - await expect(confirm).toBeVisible(); - await confirm.getByRole('button', { name: '删除', exact: true }).click(); - await expect(confirm).toBeHidden(); - await expect(detail).toHaveCount(0); - await expect(page.getByRole('button', { name: '添加连接', exact: true })).toBeFocused(); - await expect(connection).toHaveCount(0); - await expectNoDialog(page); - await cdp.send('Emulation.clearDeviceMetricsOverride'); - }); - - // Distinct detail behavior: the only providers with BOTH settled rows are the - // ones whose address is genuinely the user's. One row is a form at a time, so - // these two are also the only place the cross-row rules can be observed — a - // single-row provider (Cerebras, Cloudflare) cannot show them at all. - await openCatalog(page, { category: '聚合服务', search: '自定义中转站' }); - await page.getByRole('button', { name: /添加模型供应商:自定义中转站(OpenAI Chat)/ }).click(); - - const relaySetup = providerSetup(page); - await relaySetup.getByRole('textbox', { name: /服务地址/ }).fill('https://relay.example.com/v1'); - await relaySetup.getByRole('textbox', { name: /API Key/ }).fill('e2e-relay-key'); - await relaySetup.getByRole('textbox', { name: /默认模型/ }).fill('relay-model'); - await page.getByRole('button', { name: '保存供应商', exact: true }).click(); - - const relayDetail = connectionDetail(page); - await expect(relayDetail).toBeVisible(); - const connectionSection = relayDetail.getByRole('region', { name: '连接' }); - // A relay publishes no endpoint, so the address is the user's to type and the - // row exists. `gemini-cli` used to reach this branch too — it is OAuth with an - // empty baseUrl, and keying the row off "OAuth with a fixed URL" let it past. - await expect(connectionSection.getByText('服务地址', { exact: true })).toBeVisible(); - await expect(connectionSection.getByText('https://relay.example.com/v1')).toBeVisible(); - - // Abandon a key draft by opening the OTHER row rather than cancelling it. - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - await connectionSection.getByRole('textbox', { name: /模型密钥/ }).fill('sk-never-confirmed'); - await connectionSection.getByRole('button', { name: '编辑', exact: true }).click(); - - // The endpoint row opens on the saved address, and saving it writes only the - // address: the patch used to carry both fields whichever row asked for it, so - // the key the user never confirmed rode along with it. - const endpointInput = connectionSection.getByRole('textbox', { name: '服务地址', exact: true }); - await expect(endpointInput).toHaveValue('https://relay.example.com/v1'); - await endpointInput.fill('https://relay.example.com/v2'); - await connectionSection.getByRole('button', { name: '保存', exact: true }).click(); - await expect(connectionSection.getByText('https://relay.example.com/v2')).toBeVisible(); - // The credential is untouched by the endpoint's save. A both-fields patch - // would have sent the blanked key, which the IPC boundary reads as "delete - // this secret" — so the row would report 尚未设置 instead. - await expect(connectionSection.getByText('已设置', { exact: true })).toBeVisible(); - - // And the abandoned key is gone rather than waiting in state for the next - // time the user opens its row. - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - await expect(connectionSection.getByRole('textbox', { name: /模型密钥/ })).toHaveValue(''); - - // The same rule in the other direction, and the one the store can answer: - // abandon an ENDPOINT draft, then save the key row. The address the user - // never confirmed must not ride along with it. - await connectionSection.getByRole('button', { name: '编辑', exact: true }).click(); - await endpointInput.fill('https://relay.example.com/never-confirmed'); - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - await connectionSection.getByRole('textbox', { name: /模型密钥/ }).fill('sk-e2e-relay-replacement'); - await connectionSection.getByRole('button', { name: '保存', exact: true }).click(); - await expect - .poll(async () => page.evaluate(async () => { - const list = await window.maka.connections.list(); - return list.find((entry) => entry.providerType === 'openai-compatible')?.baseUrl ?? null; - })) - .toBe('https://relay.example.com/v2'); - - // Final phase: the count mirrors the collection. Empty the whole collection - // (the seeded connection and the relay added above) and the count is gone. - await page.evaluate(async () => { - for (const entry of await window.maka.connections.list()) { - await window.maka.connections.delete(entry.slug); - } - }); - await page.reload(); - await page.getByRole('button', { name: '设置' }).click(); - await page.locator('[aria-label="设置分组"]').getByText('模型', { exact: true }).click(); - await expect(panel.getByText(/^· \d+$/)).toHaveCount(0); -}); - -const COLOR_ASSET_RENDER_CONTRACT = { - usesAssetMask: false, - hasCssPaint: false, - hasColorFilter: false, -}; - -function colorAssetRenderContract(element: Element): { - usesAssetMask: boolean; - hasCssPaint: boolean; - hasColorFilter: boolean; -} { - const style = getComputedStyle(element); - return { - usesAssetMask: style.maskImage !== 'none', - hasCssPaint: style.backgroundColor !== 'rgba(0, 0, 0, 0)', - hasColorFilter: style.filter !== 'none' || style.opacity !== '1', - }; -} diff --git a/apps/desktop/e2e/quote-companion.spec.ts b/apps/desktop/e2e/quote-companion.spec.ts deleted file mode 100644 index 27eef2d38b..0000000000 --- a/apps/desktop/e2e/quote-companion.spec.ts +++ /dev/null @@ -1,798 +0,0 @@ -import type { Page } from '@playwright/test'; -import { test, expect, COMPOSER_INPUT } from './fixtures'; - -async function openSideConversationFromLauncher(page: Page) { - await page.getByRole('button', { name: '打开工作栏工具' }).click(); - await page.getByRole('menuitem', { name: /侧边对话/ }).click(); - await expect( - page - .locator( - '.maka-workbar-tab[data-active][data-workbar-tab-id^="side-chat:"]', - ) - .getByRole('tab'), - ).not.toHaveAttribute('aria-busy', 'true'); -} - -async function waitForSourceSessionToSettle(page: Page) { - await expect - .poll(async () => { - const [source] = await page.evaluate(() => window.maka.sessions.list()); - return source?.status; - }) - .not.toBe('running'); -} - -/** - * The selection affordance's timing contract, in one transcript: the settle - * delay, Escape dismissal, composer exemption, the immediate hide when a new - * selection starts, and scroll-following. Each phase grows the same - * conversation rather than paying a fresh launch and reseed. - */ -test('the quote layer: settle timing, Escape, immediate hide, and scroll following', async ({ - window: page, -}) => { - test.slow(); - await page.setViewportSize({ width: 1400, height: 900 }); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('selection timing source'); - await composer.press('Enter'); - - const reply = page.getByText(/Fake backend received: selection timing source/); - await expect(reply).toBeVisible(); - await expect(composer).toBeEditable(); - - const quoteLayer = page.locator('.maka-quote-actions'); - const selectContents = (locator: typeof reply) => - locator.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - - // Real drag-select with real mouse events. A drag emits a burst of - // `selectionchange`, and the gesture below lasts well past the settle delay, - // so a layer that appeared mid-drag — the original complaint — shows up here. - let replyBox: { x: number; y: number; width: number; height: number } | null = null; - await expect - .poll(async () => { - replyBox = await reply.boundingBox(); - return replyBox; - }) - .not.toBeNull(); - if (!replyBox) throw new Error('settled selection reply has no visible bounds'); - const dragY = replyBox.y + replyBox.height / 2; - await page.mouse.move(replyBox.x + 20, dragY); - await page.mouse.down(); - for (const dx of [60, 120, 180, 240, 300]) { - await page.mouse.move(replyBox.x + 20 + dx, dragY); - await page.evaluate(() => - document.dispatchEvent(new Event('selectionchange')), - ); - await page.waitForTimeout(90); - await expect(quoteLayer).toBeHidden(); - } - await page.mouse.up(); - await expect(quoteLayer).toBeVisible(); - - // Back to no selection, so the measurement below times a fresh appearance - // rather than finding the layer this drag already raised. - await page.evaluate(() => window.getSelection()?.removeAllRanges()); - await expect(quoteLayer).toBeHidden(); - - // Timed inside the page: measuring across the driver would fold IPC latency - // into the delay and make the assertion depend on host load. - const appearedAfterMs = await reply.evaluate(async (element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - const startedAt = performance.now(); - selection?.addRange(range); - await new Promise((resolve, reject) => { - const deadline = startedAt + 3000; - const poll = () => { - if (document.querySelector('.maka-quote-actions')) resolve(); - else if (performance.now() > deadline) reject(new Error('quote layer never appeared')); - else requestAnimationFrame(poll); - }; - poll(); - }); - return performance.now() - startedAt; - }); - // A selection that is still moving has not earned the layer yet: this delay - // is the whole fix for "it appears the instant I select something". - expect(appearedAfterMs).toBeGreaterThan(300); - await expect(quoteLayer).toBeVisible(); - - // Escape dismisses, and the dismissal holds: the layer used to come back on - // the next unrelated keystroke because any keyup re-captured the selection. - await page.keyboard.press('Escape'); - await expect(quoteLayer).toBeHidden(); - await page.keyboard.press('Shift'); - // Real-timer negative window, derived from the hook's SELECTION_SETTLE_MS - // (350ms): a wrongly re-captured selection would surface the layer once that - // settle window elapses, so outliving it (with margin) proves the dismissal - // held. - await page.waitForTimeout(500); - await expect(quoteLayer).toBeHidden(); - - // Selecting inside the composer must not surface a transcript affordance — - // and must not leave the previous selection's layer standing either. Same - // SELECTION_SETTLE_MS-derived negative window as above. - await composer.fill('drafted text to select'); - await selectContents(composer); - await page.waitForTimeout(500); - await expect(quoteLayer).toBeHidden(); - - // A new selection hides the layer immediately, not when the next one - // settles (asserted within a frame — see the phase comment in the history). - await composer.fill('hide first beta'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: hide first beta/).last()).toBeVisible(); - await waitForSourceSessionToSettle(page); - - const select = (pattern: RegExp) => - page - .getByText(pattern) - .last() - .evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - - await select(/Fake backend received: selection timing source/); - await expect(page.locator('.maka-quote-actions')).toBeVisible(); - - const stillVisibleAfterEvent = await page - .getByText(/Fake backend received: hide first beta/) - .last() - .evaluate(async (element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - // Programmatic Selection mutations schedule `selectionchange` as a later - // task. Dispatch the user-visible event now and inspect its synchronous - // result, before the 350 ms settle path is allowed to re-show the layer. - document.dispatchEvent(new Event('selectionchange')); - return !!document.querySelector('.maka-quote-actions'); - }); - expect(stillVisibleAfterEvent).toBe(false); - - // Scrolling moves the selection, so it must move the layer. - await page.setViewportSize({ width: 1400, height: 700 }); - // Long enough that the selected turn can be scrolled clear of the scroller - // (two turns already exist from the phases above). - for (let i = 0; i < 12; i += 1) { - await composer.fill(`scroll follow source ${i}`); - await composer.press('Enter'); - await expect( - page.getByText(new RegExp(`Fake backend received: scroll follow source ${i}`)).last(), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - } - - await page - .getByText(/Fake backend received: scroll follow source 9/) - .last() - .evaluate((element) => { - // Centred first: the affordance is owed only to a selection inside the - // scroller's visible band, and where turn 11 lands depends on the host's - // font metrics. Without this the test asserts on layout luck. - element.scrollIntoView({ block: 'center' }); - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - - await expect(quoteLayer).toBeVisible(); - - }); - -/** - * Side-conversation entry points and the numbered-tab lifecycle in one - * window: the command palette, the slash suggestion, the /side command, and - * the launcher tabs all fork from one settled source turn. The numbered-tab - * phase runs last because it checks 以后不再询问, which suppresses the close - * confirmation for the rest of its window. - */ -test('side conversation entry points keep work outside the main transcript', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat command source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: side chat command source/), - ).toBeVisible(); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - await waitForSourceSessionToSettle(page); - - await page.keyboard.press('ControlOrMeta+KeyK'); - await page - .getByRole('dialog', { name: '命令面板' }) - .getByRole('option', { name: /打开侧边对话/ }) - .click(); - - const tab = page.getByRole('tab', { name: '侧边对话' }); - const companion = page.locator('.maka-quote-companion'); - await expect - .poll(() => - companion - .locator(COMPOSER_INPUT) - .evaluate((element) => element === document.activeElement), - ) - .toBe(true); - await expect(tab).not.toHaveAttribute('aria-busy', 'true'); - await expect(page.getByRole('button', { name: '关闭侧边对话' })).toBeVisible(); - - // Close the empty side chat before the next entry point: no content, so no - // confirmation is owed. - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - - // Slash suggestion entry. - await waitForSourceSessionToSettle(page); - - await mainComposer.fill('/si'); - const slashMenu = page.getByRole('listbox', { name: '命令和技能' }); - await expect(slashMenu).toBeVisible(); - await expect( - slashMenu.getByRole('option', { name: /侧边对话.*不打断主任务/ }), - ).toBeVisible(); - await mainComposer.press('Enter'); - - await expect(page.getByRole('tab', { name: '侧边对话' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - await expect(mainComposer).toHaveText(''); - await expect(page.locator('.maka-quote-companion .maka-turn')).toHaveCount(0); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - - // Close the empty side chat before the next entry point: no content, so no - // confirmation is owed. - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - - // /side opens titled and sends outside the main transcript. - await waitForSourceSessionToSettle(page); - - await mainComposer.fill('/side explain the renderer boundary'); - await mainComposer.press('Enter'); - - const titledTab = page.getByRole('tab', { - name: 'explain the renderer boundary', - }); - await expect(titledTab).toHaveAttribute('aria-selected', 'true'); - const titledCompanion = page.locator('.maka-quote-companion'); - await expect( - titledCompanion.getByText(/Fake backend received: explain the renderer boundary/), - ).toBeVisible(); - await expect(mainComposer).toHaveText(''); - - const sessions = await page.evaluate(() => window.maka.sessions.list()); - const companionSession = sessions.find( - ({ id, parentSessionId, labels }) => - id !== sourceSession?.id && - parentSessionId === sourceSession?.id && - labels.includes('mode:side_conversation'), - ); - expect(companionSession?.permissionMode).toBe(sourceSession?.permissionMode); - - const mainMessages = await page.evaluate( - (sessionId) => window.maka.sessions.readMessages(sessionId), - sourceSession!.id, - ); - expect( - mainMessages.some( - (message) => - message.type === 'user' && - 'text' in message && - message.text.includes('/side'), - ), - ).toBe(false); - - // Close the titled fork (it has content, so the confirmation is owed). - await page.getByRole('button', { name: '关闭explain the renderer boundary', exact: true }).click(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('numbered side chat tabs keep independent drafts and close policy', async ({ - window: page, -}) => { - test.slow(); - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('numbered side chat source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: numbered side chat source/), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - - // Numbered tabs, independent drafts, and the don't-ask-again close. - await openSideConversationFromLauncher(page); - const visiblePanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - await expect(visiblePanel).toBeVisible(); - await expect(visiblePanel.locator('.maka-composer-context-drawer .astryx-token')).toHaveCount(0); - await expect(page.getByRole('tab', { name: '侧边对话' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - await expect - .poll(() => - visiblePanel - .locator(COMPOSER_INPUT) - .evaluate((element) => element === document.activeElement), - ) - .toBe(true); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - await visiblePanel.locator(COMPOSER_INPUT).fill('first side draft'); - - // Each New Tab action creates a separate side chat with its own draft owner. - await openSideConversationFromLauncher(page); - await expect(page.getByRole('tab', { name: '侧边对话 2' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(3); - await visiblePanel.locator(COMPOSER_INPUT).fill('second side draft'); - - await page.getByRole('tab', { name: '侧边对话', exact: true }).click(); - await expect(visiblePanel.locator(COMPOSER_INPUT)).toHaveText('first side draft'); - await visiblePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect( - visiblePanel.getByText(/Fake backend received: first side draft/), - ).toBeVisible(); - await expect(page.getByRole('tab', { name: 'first side draft' })).toBeVisible(); - - await expect - .poll(async () => - page.evaluate((sourceSessionId) => { - return window.maka.sessions.list().then((sessions) => ({ - count: sessions.length, - companions: sessions - .filter( - ({ id, parentSessionId, labels }) => - id !== sourceSessionId && - parentSessionId === sourceSessionId && - labels.includes('mode:side_conversation'), - ) - .map(({ permissionMode }) => permissionMode) - .sort(), - })); - }, sourceSession!.id), - ) - .toEqual({ - count: 3, - companions: [sourceSession!.permissionMode, sourceSession!.permissionMode].sort(), - }); - - await page.getByRole('button', { name: '关闭first side draft', exact: true }).click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toBeVisible(); - await page.getByRole('button', { name: '取消' }).click(); - await expect(page.getByRole('tab', { name: 'first side draft' })).toBeVisible(); - - await page.getByRole('button', { name: '关闭first side draft', exact: true }).click(); - await page.getByRole('checkbox', { name: '以后不再询问' }).check(); - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).last().click(); - await expect(page.getByRole('tab', { name: 'first side draft' })).toHaveCount(0); - await expect(page.getByRole('tab', { name: '侧边对话 2' })).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - - // The second tab kept its independent unsent draft while the first fork ran - // and was discarded. - await expect(visiblePanel.locator(COMPOSER_INPUT)).toHaveText('second side draft'); - await visiblePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect( - visiblePanel.getByText(/Fake backend received: second side draft/), - ).toBeVisible(); - await expect(page.getByRole('tab', { name: 'second side draft' })).toBeVisible(); - await page.getByRole('button', { name: '关闭second side draft' }).click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toHaveCount(0); - await expect( - page.locator('[data-maka-contract="session-workbar-right"]'), - ).toBeHidden(); - await expect(page.getByRole('button', { name: '展开会话工作栏' })).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('renderer reload recovers and cleans its orphaned side conversation fork', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat reload source'); - await mainComposer.press('Enter'); - await expect(page.getByText(/Fake backend received: side chat reload source/)).toBeVisible(); - await waitForSourceSessionToSettle(page); - - await openSideConversationFromLauncher(page); - const sideComposer = page - .locator('.maka-quote-workbar-panel:not([hidden])') - .locator(COMPOSER_INPUT); - await sideComposer.fill('orphan this temporary fork'); - await sideComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: orphan this temporary fork/), - ).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - - await page.reload(); - - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - await expect( - page.locator('.maka-workbar-tab[data-workbar-tab-id^="side-chat:"]'), - ).toHaveCount(0); -}); - -test('side chat stages quotes and attachments in one isolated fork', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('quote companion source one'); - await mainComposer.press('Enter'); - - const firstSourceReply = page.getByText(/Fake backend received: quote companion source one/); - await expect(firstSourceReply).toBeVisible(); - await waitForSourceSessionToSettle(page); - await mainComposer.fill('quote companion source two'); - await mainComposer.press('Enter'); - const secondSourceReply = page.getByText(/Fake backend received: quote companion source two/); - await expect(secondSourceReply).toBeVisible(); - await waitForSourceSessionToSettle(page); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - - // Create the same real DOM Range a drag selection would produce. Mutating - // the selection fires `selectionchange` on its own, which is the hook's only - // trigger; the click below absorbs the settle delay before the layer shows. - const stageReply = async (reply: typeof firstSourceReply, expectedQuoteCount: number) => { - await reply.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - document.dispatchEvent(new Event('selectionchange')); - document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); - document.dispatchEvent( - new KeyboardEvent('keyup', { bubbles: true, key: 'Shift' }), - ); - }); - await expect(page.getByRole('button', { name: '在侧栏追问' })).toBeVisible(); - await page.getByRole('button', { name: '在侧栏追问' }).click(); - await expect( - page.locator('.maka-quote-companion .maka-composer-context-drawer .astryx-token'), - ).toHaveCount(expectedQuoteCount); - await expect( - page - .locator( - '.maka-workbar-tab[data-active][data-workbar-tab-id^="side-chat:"]', - ) - .getByRole('tab'), - ).not.toHaveAttribute('aria-busy', 'true'); - await expect - .poll(() => - page - .locator('.maka-quote-workbar-panel:not([hidden])') - .locator(COMPOSER_INPUT) - .evaluate((element) => element === document.activeElement), - ) - .toBe(true); - }; - await stageReply(firstSourceReply, 1); - await stageReply(secondSourceReply, 2); - - const panel = page.locator('.maka-quote-companion'); - await expect(panel).toBeVisible(); - await expect(panel.getByRole('button', { name: '关闭', exact: true })).toHaveCount(0); - await expect(page.locator('[data-maka-contract="composer-inner"]')).toHaveCount(2); - await expect( - panel.getByRole('button', { name: '添加上下文', exact: true }), - ).toBeVisible(); - await expect(panel.locator('.permissionModeIcon button').first()).toBeEnabled(); - - // Quiet composer stages quotes as drawer Tokens (Astryx Token + remove). - const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token'); - const contextDrawerToggle = panel.locator( - '.maka-composer-drawer [role="button"][aria-expanded]', - ); - await expect(contextDrawerToggle).toHaveAttribute('aria-expanded', 'false'); - await expect(quoteTokens).toHaveCount(2); - await contextDrawerToggle.click(); - await expect(quoteTokens).toHaveCount(2); - await expect(quoteTokens.first()).toBeVisible(); - await expect(panel.locator('.maka-composer-model-status')).toHaveCount(0); - await quoteTokens.first().getByRole('button', { name: /^移除/ }).click(); - await expect(quoteTokens).toHaveCount(1); - - // The empty transcript stays visually identical to an ordinary chat. Quote - // context appears only in the shared Composer drawer, never duplicated as - // explanatory content in the message area. - await expect(panel.locator('.maka-turn')).toHaveCount(0); - - const companionComposer = panel.locator(COMPOSER_INPUT); - await panel.locator('form.maka-composer').evaluate((form) => { - const transfer = new DataTransfer(); - transfer.items.add( - new File(['side attachment'], 'side-notes.txt', { type: 'text/plain' }), - ); - form.dispatchEvent( - new DragEvent('drop', { - bubbles: true, - cancelable: true, - dataTransfer: transfer, - }), - ); - }); - const attachmentToken = panel.locator( - '.maka-composer-context-drawer .astryx-token', - { hasText: 'side-notes.txt' }, - ); - await expect(attachmentToken).toBeVisible(); - await companionComposer.fill('explain this quote'); - await companionComposer.press('Enter'); - const runningTab = page.locator( - '.maka-workbar-tab[data-running][data-workbar-tab-id^="side-chat:"]', - ); - await expect(runningTab).toBeVisible(); - await expect(runningTab.locator('.maka-workbar-tab-spinner')).toBeVisible(); - await expect(runningTab.locator('.maka-workbar-tab-close')).toBeVisible(); - await expect(panel.getByText(/Fake backend received: explain this quote/)).toBeVisible(); - await expect(panel.getByRole('button', { name: '重新生成' })).toBeVisible(); - await expect(panel.getByRole('button', { name: '复制' }).last()).toBeVisible(); - await expect(panel.getByRole('button', { name: '分支' })).toHaveCount(0); - await expect(runningTab).toHaveCount(0); - await expect( - page.locator( - '.maka-workbar-tab[data-workbar-tab-id^="side-chat:"] .maka-workbar-tab-icon:not(.maka-workbar-tab-spinner)', - ), - ).toBeVisible(); - await expect(quoteTokens).toHaveCount(0); - await expect(attachmentToken).toHaveCount(0); - - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - const companionSession = ( - await page.evaluate(() => window.maka.sessions.list()) - ).find(({ id }) => id !== sourceSession?.id); - expect(companionSession?.parentSessionId).toBe(sourceSession?.id); - expect(companionSession?.permissionMode).toBe(sourceSession?.permissionMode); - await expect(page.getByRole('tab', { name: 'explain this quote' })).toBeVisible(); - await page.getByRole('button', { name: '关闭explain this quote', exact: true }).click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toBeVisible(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect(panel).toBeHidden(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('side chat persists steering and permission changes', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat control source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: side chat control source/), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - - // Steering and permission inheritance on a fresh fork. - await openSideConversationFromLauncher(page); - const steerPanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - const sideComposer = steerPanel.locator(COMPOSER_INPUT); - await sideComposer.fill('__e2e_wait_for_steering__'); - await sideComposer.press('Enter'); - - const sideUserMessages = steerPanel.locator('.maka-user-message'); - await expect(sideUserMessages).toHaveCount(1); - await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); - await expect(steerPanel.getByRole('button', { name: '停止' })).toBeVisible(); - const steerButton = steerPanel.getByRole('button', { name: '插入消息' }); - await expect(steerButton).toBeVisible(); - await sideComposer.fill('only answer the side request'); - await expect(steerButton).toBeEnabled(); - await steerButton.click(); - await expect(sideComposer).toHaveText(''); - await expect(sideUserMessages).toHaveCount(2); - await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); - await expect(sideUserMessages.nth(1)).toContainText('only answer the side request'); - await expect( - steerPanel.getByText(/Acknowledged steering: only answer the side request/), - ).toBeVisible(); - await expect(sideUserMessages).toHaveCount(2); - await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); - await expect(sideUserMessages.nth(1)).toContainText('only answer the side request'); - - const steerCompanion = ( - await page.evaluate(() => window.maka.sessions.list()) - ).find(({ id }) => id !== sourceSession?.id); - expect(steerCompanion?.permissionMode).toBe(sourceSession?.permissionMode); - const permissionButton = steerPanel.locator('.permissionModeIcon button').first(); - await expect(permissionButton).toBeEnabled(); - await permissionButton.click(); - await page.getByRole('menuitemradio', { name: /完全权限/ }).click(); - await expect - .poll(async () => { - const sessions = await page.evaluate(() => window.maka.sessions.list()); - return sessions.find(({ id }) => id === steerCompanion?.id)?.permissionMode; - }) - .toBe('bypass'); - - // The fork holds content, so closing it requires confirmation. - await page - .locator('.maka-workbar-tab[data-active][data-workbar-tab-id^="side-chat:"] .maka-workbar-tab-close') - .click(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('side chat recovers from failures and preserves its draft', async ({ - window: page, -}) => { - test.slow(); - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat recovery source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: side chat recovery source/), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - - // Failure classification, collapse survival, and retry. - const rightPanel = page.locator('[data-maka-contract="session-workbar-right"]'); - await openSideConversationFromLauncher(page); - const failPanel = page.locator('.maka-quote-companion'); - const failComposer = failPanel.locator(COMPOSER_INPUT); - - await failComposer.fill('__e2e_error__:network'); - await failComposer.press('Enter'); - await expect(failPanel.getByRole('button', { name: '停止' })).toBeVisible(); - - await page.getByRole('button', { name: '收起会话工作栏' }).click(); - await expect(rightPanel).toBeHidden(); - await page.getByRole('button', { name: '展开会话工作栏' }).click(); - await expect(failPanel).toBeVisible(); - await expect(failPanel.locator('.maka-quote-companion-error')).toHaveText('网络错误'); - await expect(failPanel.getByRole('button', { name: '停止' })).toHaveCount(0); - - await failComposer.fill('retry after deterministic network failure'); - await failComposer.press('Enter'); - await expect(failPanel.locator('.maka-quote-companion-error')).toHaveCount(0); - await expect( - failPanel.getByText(/Fake backend received: retry after deterministic network failure/), - ).toBeVisible(); - - await failComposer.fill('__e2e_wait_for_steering__'); - await failComposer.press('Enter'); - const activeSideTab = page.locator( - '.maka-workbar-tab[data-running][data-workbar-tab-id^="side-chat:"]', - ); - await expect(activeSideTab).toBeVisible(); - await activeSideTab.locator('.maka-workbar-tab-close').click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toBeVisible(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect(failPanel).toBeHidden(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - - // Draft survival across collapse and launcher navigation. - await waitForSourceSessionToSettle(page); - - await openSideConversationFromLauncher(page); - const draftPanel = page.locator('.maka-quote-companion'); - const draftComposer = draftPanel.locator(COMPOSER_INPUT); - await draftComposer.fill('draft survives panel navigation'); - - await page.getByRole('button', { name: '收起会话工作栏' }).click(); - await expect(rightPanel).toBeHidden(); - await expect(draftPanel).toBeHidden(); - await page.getByRole('button', { name: '展开会话工作栏' }).click(); - await expect(rightPanel).toBeVisible(); - await expect(draftPanel).toBeVisible(); - await expect(draftComposer).toHaveText('draft survives panel navigation'); - - await page.getByRole('button', { name: '打开工作栏工具' }).click(); - await expect(page.getByRole('menuitem', { name: /侧边对话/ })).toBeVisible(); - await expect(draftPanel).toBeHidden(); - await page.getByRole('tab', { name: '侧边对话', exact: true }).click(); - await expect(draftPanel).toBeVisible(); - await expect(draftComposer).toHaveText('draft survives panel navigation'); - - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).click(); - await expect(draftPanel).toBeHidden(); - await expect(rightPanel).toBeHidden(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -// Standalone window: the batch close owes its confirmation dialog, and the -// numbered-tab journey above ends with 以后不再询问 checked, which would -// suppress exactly that dialog in a shared window. -test('batch tab close confirms once and cleans every non-empty side fork', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator(COMPOSER_INPUT); - await mainComposer.fill('batch side chat source'); - await mainComposer.press('Enter'); - await expect(page.getByText(/Fake backend received: batch side chat source/)).toBeVisible(); - await waitForSourceSessionToSettle(page); - - await page.getByRole('button', { name: '打开工作栏工具' }).click(); - await page.getByRole('menuitem', { name: '任务' }).click(); - - await openSideConversationFromLauncher(page); - let activePanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - await activePanel.locator(COMPOSER_INPUT).fill('first batch side chat'); - await activePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect(activePanel.getByText(/Fake backend received: first batch side chat/)).toBeVisible(); - - await openSideConversationFromLauncher(page); - activePanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - await activePanel.locator(COMPOSER_INPUT).fill('second batch side chat'); - await activePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect(activePanel.getByText(/Fake backend received: second batch side chat/)).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(3); - - await page.getByRole('tab', { name: /^任务/ }).click({ button: 'right' }); - await page.getByRole('menuitem', { name: '关闭其他标签' }).click(); - await expect(page.getByRole('dialog', { name: '关闭 2 个侧边对话?' })).toBeVisible(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - - await expect(page.getByRole('tab', { name: /^任务/ })).toBeVisible(); - await expect(page.locator('[data-workbar-tab-id^="side-chat:"]')).toHaveCount(0); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); diff --git a/apps/desktop/e2e/sandbox-boundary-takeover.spec.ts b/apps/desktop/e2e/sandbox-boundary-takeover.spec.ts deleted file mode 100644 index 896247c62f..0000000000 --- a/apps/desktop/e2e/sandbox-boundary-takeover.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { test, expect } from './fixtures.js'; - -test('sandbox boundary request takes over the composer slot without hiding the workspace', async ({ - sandboxBoundaryWindow, -}) => { - const prompt = sandboxBoundaryWindow.locator('.maka-sandbox-boundary-prompt'); - const slot = sandboxBoundaryWindow.locator('.maka-composer-interaction-slot'); - const composer = sandboxBoundaryWindow.locator('.maka-composer'); - const draft = '保留这段尚未发送的草稿'; - - await expect(slot.locator('.maka-sandbox-boundary-prompt')).toHaveCount(1); - await expect(composer).toHaveCount(1); - await expect(composer).toBeHidden(); - // The composer is hidden behind the boundary prompt, so the draft is seeded - // through the input event ChatComposerInput listens on rather than by typing. - const editable = composer.locator('[aria-label="消息输入框"]'); - const draftText = () => editable.evaluate((element) => element.textContent); - await editable.evaluate((element, value) => { - element.textContent = value; - element.dispatchEvent(new Event('input', { bubbles: true })); - }, draft); - await expect(sandboxBoundaryWindow.locator('dialog[open]')).toHaveCount(0); - await expect(sandboxBoundaryWindow.locator('[role="dialog"]')).toHaveCount(0); - await expect(sandboxBoundaryWindow.locator('.app')).not.toHaveAttribute('inert', ''); - - await expect(prompt.getByRole('heading', { name: '允许访问工作区以外的内容?' })).toBeVisible(); - await expect(prompt.getByText('/outside/dist')).toBeVisible(); - await expect(prompt.getByText('写入 · 目录及子目录')).toBeVisible(); - await expect(prompt.getByRole('button', { name: '拒绝' })).toBeFocused(); - await expect(prompt.getByRole('button', { name: '本会话允许' })).toBeVisible(); - - const promptBox = await prompt.boundingBox(); - const panelBox = await sandboxBoundaryWindow.locator('.maka-panel-detail').boundingBox(); - expect(promptBox).not.toBeNull(); - expect(panelBox).not.toBeNull(); - expect(promptBox!.y + promptBox!.height).toBeGreaterThan(panelBox!.y + panelBox!.height * 0.7); - - await sandboxBoundaryWindow.getByRole('button', { name: '展开侧边栏' }).click(); - await expect(composer).toBeHidden(); - await expect.poll(draftText).toBe(draft); - - await prompt.getByRole('button', { name: '本会话允许' }).click(); - await expect(prompt).toHaveCount(0); - await expect(composer).toBeVisible(); - await expect.poll(draftText).toBe(draft); -}); diff --git a/apps/desktop/e2e/send-message.spec.ts b/apps/desktop/e2e/send-message.spec.ts index 59aa08f054..db97f3601e 100644 --- a/apps/desktop/e2e/send-message.spec.ts +++ b/apps/desktop/e2e/send-message.spec.ts @@ -1,5 +1,4 @@ import { test, expect, COMPOSER_INPUT } from './fixtures'; -import { FAKE_MERMAID_HOSTILE_PROMPT, FAKE_MERMAID_PROMPT } from '@maka/runtime'; /** * Enter commits a candidate in a CJK IME; nothing else may act on it. Both the @@ -52,152 +51,3 @@ test('Enter mid-IME commits the candidate, then an ordinary send streams a reply await expect(page.getByRole('log').getByText(/Fake backend received: hello e2e/)).toBeVisible(); }); - -// Mermaid rendering and sanitization share one transcript: the settled -// diagram journey first, then the hostile fence as a second turn in the same -// window (the sanitizer assertions anchor on the newest diagram). -test('renders a settled Mermaid fence and keeps hostile directives inert', async ({ window: page }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(FAKE_MERMAID_PROMPT); - await composer.press('Enter'); - - await expect(page.getByRole('button', { name: '重新生成' })).toBeVisible(); - await expect(page.locator('.maka-bubble-streaming')).toHaveCount(0); - const diagram = page.locator('[data-maka-contract="mermaid"]').last(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-state', 'rendered'); - await expect(diagram).toHaveAttribute('data-maka-mermaid-layout', 'ready'); - await expect(diagram.locator('.maka-mermaid-svg > svg')).toBeVisible(); - await expect(diagram.locator('script, foreignObject, a')).toHaveCount(0); - await expect(diagram.locator('.cluster')).toHaveCount(3); - - const viewport = diagram.locator('.maka-mermaid-viewport'); - const fitted = await viewport.evaluate((element) => ({ - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - })); - expect(fitted.scrollWidth).toBeLessThanOrEqual(fitted.clientWidth + 1); - expect(fitted.scrollHeight).toBeLessThanOrEqual(fitted.clientHeight + 1); - await expect(viewport).toHaveCSS('touch-action', 'pan-y'); - - const viewSource = diagram.getByRole('button', { name: '查看 Mermaid 源码' }); - await viewSource.click(); - await expect(diagram.locator('.maka-mermaid-source')).toContainText('flowchart TB'); - await viewSource.click(); - - // Zoom moves the diagram's content, never its chrome. Read the toolbar - // offset and the viewport height inside one evaluate: the transcript is a - // bottom-pinned scroller that re-pins on every ResizeObserver update, so two - // separate boundingBox() round-trips sample the same element at two scroll - // positions and turn that drift into a phantom offset change (#2000). - const readChrome = () => diagram.evaluate((element) => { - const diagramTop = element.getBoundingClientRect().top; - const toolbarTop = element.querySelector('.maka-mermaid-toolbar')?.getBoundingClientRect().top; - const viewportHeight = element.querySelector('.maka-mermaid-viewport')?.getBoundingClientRect().height; - return { toolbarOffset: (toolbarTop ?? 0) - diagramTop, viewportHeight: viewportHeight ?? 0 }; - }); - const chromeBeforeZoom = await readChrome(); - const zoomIn = diagram.getByRole('button', { name: '放大图表' }); - await zoomIn.click(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.25'); - await zoomIn.click(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.50'); - // Poll for the steady state: the zoomed layout settles over a rAF, a - // ResizeObserver pass, and the scroller's re-pin, so a single instantaneous - // read asserts a frame the user never sees. - await expect.poll(async () => { - const chrome = await readChrome(); - return Math.max( - Math.abs(chrome.toolbarOffset - chromeBeforeZoom.toolbarOffset), - Math.abs(chrome.viewportHeight - chromeBeforeZoom.viewportHeight), - ); - }).toBeLessThanOrEqual(1); - await expect.poll(() => viewport.evaluate((element) => - element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight)).toBe(true); - const zoomedBounds = await diagram.evaluate((element) => { - const svg = element.querySelector('.maka-mermaid-svg > svg'); - const content = svg?.querySelector('g'); - const svgRect = svg?.getBoundingClientRect(); - const contentRect = content?.getBoundingClientRect(); - return { - svg: svgRect - ? { top: svgRect.top, bottom: svgRect.bottom, width: svgRect.width, height: svgRect.height } - : null, - content: contentRect - ? { top: contentRect.top, bottom: contentRect.bottom } - : null, - }; - }); - expect(zoomedBounds.svg).not.toBeNull(); - expect(zoomedBounds.content).not.toBeNull(); - expect(zoomedBounds.content?.top ?? 0).toBeGreaterThanOrEqual((zoomedBounds.svg?.top ?? 0) - 1); - expect(zoomedBounds.content?.bottom ?? 0).toBeLessThanOrEqual((zoomedBounds.svg?.bottom ?? 0) + 1); - await diagram.getByRole('button', { name: '适应视窗' }).click(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.00'); - const inlineSvg = diagram.locator('.maka-mermaid-svg > svg'); - await expect(inlineSvg).toBeVisible(); - await expect.poll(async () => { - const bounds = await inlineSvg.boundingBox(); - return bounds ? bounds.width * bounds.height : 0; - }).toBeGreaterThan(0); - const inlineSvgBounds = await inlineSvg.boundingBox(); - expect(inlineSvgBounds).not.toBeNull(); - - await diagram.getByRole('button', { name: '全屏查看图表' }).click(); - const modal = page.locator('dialog.maka-mermaid-dialog'); - await expect(modal).toHaveAttribute('open', ''); - await expect(modal).toHaveAttribute('aria-modal', 'true'); - const expandedDiagram = modal.locator('[data-maka-contract="mermaid"]'); - const pageSize = page.viewportSize(); - await expect.poll(async () => { - const bounds = await modal.boundingBox(); - return bounds && pageSize - ? Math.max( - Math.abs(bounds.x), - Math.abs(bounds.y), - Math.abs(bounds.width - pageSize.width), - Math.abs(bounds.height - pageSize.height), - ) - : Number.POSITIVE_INFINITY; - }).toBeLessThanOrEqual(1); - await expect.poll(async () => { - const bounds = await expandedDiagram.locator('.maka-mermaid-svg > svg').boundingBox(); - return bounds ? bounds.width * bounds.height : 0; - }).toBeGreaterThan((inlineSvgBounds?.width ?? 0) * (inlineSvgBounds?.height ?? 0) * 1.5); - await expect.poll(() => expandedDiagram.locator('.maka-mermaid-actions').evaluate((element) => - getComputedStyle(element).getPropertyValue('-webkit-app-region'))).toBe('no-drag'); - const exitFullscreen = expandedDiagram.getByRole('button', { name: '退出全屏图表' }); - await expect(exitFullscreen).toBeFocused(); - await page.keyboard.press('Tab'); - await expect.poll(() => modal.evaluate((element) => element.contains(document.activeElement))).toBe(true); - await exitFullscreen.click(); - await expect(modal).not.toHaveAttribute('open', ''); - const enterFullscreen = diagram.getByRole('button', { name: '全屏查看图表' }); - await expect(enterFullscreen).toBeFocused(); - - await enterFullscreen.click(); - await expect(expandedDiagram.getByRole('button', { name: '退出全屏图表' })).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(diagram.getByRole('button', { name: '全屏查看图表' })).toBeFocused(); - - await page.setViewportSize({ width: 340, height: 900 }); - await expect(diagram.getByRole('button', { name: '全屏查看图表' })).toBeVisible(); - await expect(diagram.getByRole('button', { name: '放大图表' })).toBeHidden(); - - // Back to a regular width for the hostile fence, and settle the first turn - // before sending the second. - await page.setViewportSize({ width: 1440, height: 900 }); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - await composer.fill(FAKE_MERMAID_HOSTILE_PROMPT); - await composer.press('Enter'); - - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 }); - const hostileDiagram = page.locator('[data-maka-contract="mermaid"]').last(); - await expect(hostileDiagram).toHaveAttribute('data-maka-mermaid-state', 'rendered'); - await expect(hostileDiagram.locator('.maka-mermaid-svg > svg')).toBeVisible(); - await expect(hostileDiagram.locator('script, foreignObject, a')).toHaveCount(0); - await expect(hostileDiagram.locator('[onclick], [onerror], [onload], [href^="javascript:"]')).toHaveCount(0); -}); diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 70eff498a1..610444dc9c 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -32,191 +32,3 @@ test('generated files use list-to-preview navigation with a compact action menu' // Workbar product journey. Narrow max-height and "toggle unmounted without a // session" are pinned in chat-shell-layout-contract (CSS + chrome actions source). -test('session tools share one user-controlled workbar that stays mounted across repeated collapse', async ({ sessionWorkbarWindow: page }) => { - const workbar = page.getByRole('complementary', { name: '会话工作栏' }); - const rightWorkbar = page.locator( - '[data-maka-contract="session-workbar-right"]', - ); - const tabs = workbar.getByRole('tablist', { name: '会话工作栏标签' }); - - await expect(tabs.getByRole('tab', { name: /任务/ })).toHaveAttribute('aria-selected', 'true'); - await expect(tabs.getByRole('tab', { name: /浏览器/ })).toHaveCount(0); - const launcher = workbar.getByRole('button', { name: '打开工作栏标签' }); - await launcher.click(); - await expect(page.getByRole('menuitem', { name: '生成文件' })).toBeEnabled(); - await page.keyboard.press('Escape'); - await expect( - page - .getByLabel('活跃会话任务') - .getByText(/完成会话任务台账升级/), - ).toBeVisible(); - - // Sizing is config handed to Astryx Resizable (#1861), and each part fails - // silently: a vertical separator is what makes the drag read clientX, and - // ArrowLeft has to widen an end-of-row panel. The width assertion is the - // load-bearing one — the aria-* values mirror hook state, not the panel. - const resize = page.getByRole('separator', { name: '调整会话工作栏宽度' }); - await expect(resize).toHaveAttribute('aria-orientation', 'vertical'); - await expect(resize).toHaveAttribute('aria-valuemin', '320'); - await expect(resize).toHaveAttribute('aria-valuemax', '600'); - await resize.focus(); - await resize.press('ArrowLeft'); - // 480 default (session-workbar-layout.ts) + the hook's 10px keyboard step. - // Assert via the accessible value — not CSS pixel width (vendor layout). - await expect(resize).toHaveAttribute('aria-valuenow', '490'); - - const box = (await resize.boundingBox())!; - const y = box.y + box.height * 0.9; - await page.mouse.move(box.x, y); - await page.mouse.down(); - await page.mouse.move(box.x - 20.5, y, { steps: 2 }); - await page.mouse.up(); - await expect(resize).toHaveAttribute('aria-valuenow', '511'); - - // Blur mid-drag must end the resize gesture (body must not stay user-select:none). - await page.mouse.move(box.x - 20.5, y); - await page.mouse.down(); - await page.evaluate(() => window.dispatchEvent(new Event('blur'))); - await page.mouse.move(box.x - 200, y, { steps: 2 }); - await page.mouse.up(); - await expect(resize).toHaveAttribute('aria-valuenow', '511'); - await expect(page.locator('body')).toHaveCSS('user-select', 'auto'); - - // Width persists on Maka's key, not Astryx's autoSaveId namespace. - await expect - .poll(() => page.evaluate(() => localStorage.getItem('maka-session-workbar-width-v1'))) - .toBe('511'); - expect( - await page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith('astryx-resizable:'))), - ).toEqual([]); - - // Keyboard-driven disclosure, observed through what the user can read: a - // collapsed section hides its rows, and Enter on the trigger reveals them. - const recent = page.getByRole('button', { name: /最近结束/ }); - const recentRow = page.getByText('验证 Goal 一次提醒门禁'); - await expect(recent).toHaveAttribute('aria-expanded', 'false'); - await expect(recentRow).toBeHidden(); - - await recent.focus(); - await recent.press('Enter'); - await expect(recent).toHaveAttribute('aria-expanded', 'true'); - await expect(recentRow).toBeVisible(); - - // One toggle, in one place, across its own state change. It used to hand off - // to a second button inside the workbar's tab row while the workbar was open, - // so the control a user clicks twice moved between those two clicks. The - // titlebar is also the only row that already reserves `env(titlebar-area-*)`, - // which is what keeps this button clear of the Windows caption strip. - const collapse = page.getByRole('button', { name: '收起会话工作栏' }); - await expect(collapse).toHaveAttribute('aria-expanded', 'true'); - await collapse.click(); - - // Collapse hides the right plate without destroying tabs / drafts. - await expect(rightWorkbar).toHaveCount(1); - await expect(rightWorkbar).toBeHidden(); - await expect(rightWorkbar).toHaveAttribute('data-collapsed', 'true'); - await expect(page.locator('.maka-workbar-resize-handle')).toHaveCount(0); - - const expand = page.getByRole('button', { name: '展开会话工作栏' }); - await expect(expand).toHaveAttribute('aria-expanded', 'false'); - await expand.click(); - await expect(rightWorkbar).toBeVisible(); - await expect(resize).toHaveAttribute('aria-valuenow', '511'); - - // Second cycle: stay mounted; user width survives. - await collapse.click(); - await expect(rightWorkbar).toHaveCount(1); - await expect(rightWorkbar).toBeHidden(); - await expect(rightWorkbar).toHaveAttribute('data-collapsed', 'true'); - await expand.click(); - await expect(rightWorkbar).toBeVisible(); - await expect(resize).toHaveAttribute('aria-valuenow', '511'); - - await rightWorkbar.getByRole('button', { name: '打开工作栏标签' }).click(); - await page.getByRole('menuitem', { name: '生成文件' }).click(); - await expect(page.getByText('暂无生成文件')).toBeVisible(); - - // The record-file row is a fact about the workspace, not the session: it - // exists even when the trace is empty, and it reads the exact database path - // from `app:info`'s operationalStateDatabasePath (resolved in main, the - // same single source of truth the data-settings row shows) — no second - // channel, so nothing to register per boot mode. - // Then pin the 320px minimum: the directory truncates while the filename - // keeps its glyphs (the row's whole point at that width), the tooltip - // trigger is keyboard-reachable, and the copy actually lands the FULL path - // on the clipboard. - await rightWorkbar.getByRole('button', { name: '打开工作栏标签' }).click(); - await page.getByRole('menuitem', { name: '追踪' }).click(); - const inspectorTab = tabs.getByRole('tab', { name: /追踪/ }); - await expect(inspectorTab).toHaveAttribute('aria-selected', 'true'); - const recordFileRow = page.locator( - '[data-maka-contract="session-inspector-record-file"]', - ); - await expect(recordFileRow).toBeVisible(); - // The filename is its own box, never truncated by the row's ellipsis — the - // only part of the row whose glyphs matter. `toBeVisible` cannot tell a - // visible box from a clipped one (overflow-hidden content stays "visible"), - // so prove there is no overflow: the box must be as wide as its content. - const recordFileName = recordFileRow.locator('.maka-inspector-record-file-name'); - await expect(recordFileName).toHaveText(/runtime\.sqlite$/); - await expect - .poll(() => - recordFileName.evaluate((el) => el.scrollWidth <= el.clientWidth), - ) - .toBe(true); - // The path also renders once inside the Astryx Tooltip's popover (hidden - // until hover/focus). The tooltip trigger is the row's path box, a real Tab - // stop: keyboard focus opens the tooltip (focus-visible) and Escape closes - // it — mouse users are not the only ones who can read the full path. - const recordFilePath = recordFileRow.locator('.maka-inspector-record-file'); - await expect(page.getByRole('tooltip')).toHaveCount(0); - await inspectorTab.focus(); - for ( - let step = 0; - step < 5 && !(await recordFilePath.evaluate((el) => el === document.activeElement)); - step += 1 - ) { - await page.keyboard.press('Tab'); - } - await expect(recordFilePath).toBeFocused(); - await expect(page.getByRole('tooltip')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.getByRole('tooltip')).toHaveCount(0); - const copyButton = recordFileRow.getByRole('button', { name: '复制文件路径' }); - await expect(copyButton).toBeEnabled(); - // Drag the divider all the way right: the workbar is an end-of-row panel - // (the shell hands Astryx `isReversed`), so dragging right shrinks it, and - // the width model clamps at the same 320px minimum the keyboard arrow uses, - // landing the panel on the smallest surface the row is allowed to live in — - // the filename still fits, the copy button stays inside the panel, and the - // clipboard receives the full path, not the truncated display. - const resizeBox = (await resize.boundingBox())!; - const handleY = resizeBox.y + resizeBox.height / 2; - await page.mouse.move(resizeBox.x, handleY); - await page.mouse.down(); - await page.mouse.move(resizeBox.x + 400, handleY, { steps: 5 }); - await page.mouse.up(); - await expect(resize).toHaveAttribute('aria-valuenow', '320'); - await expect(recordFileRow).toBeVisible(); - await expect(copyButton).toBeVisible(); - await expect - .poll(() => - recordFileName.evaluate((el) => el.scrollWidth <= el.clientWidth), - ) - .toBe(true); - await copyButton.click(); - await expect(page.getByText('已复制文件路径')).toBeVisible(); - // The row carries the FULL authoritative path from `app:info`, while the - // visible directory is allowed to truncate. Clipboard read is intentionally - // denied by the app's permission policy; the success toast proves the write. - const fullPath = await recordFileRow.getAttribute('data-full-path'); - expect(fullPath).toMatch(/runtime\.sqlite$/); - - await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); - await page - .getByRole('navigation', { name: '对话列表' }) - .getByRole('button', { name: '扩展', exact: true }) - .dispatchEvent('click'); - await expect(workbar).toBeHidden(); - await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/settings-projects.spec.ts b/apps/desktop/e2e/settings-projects.spec.ts deleted file mode 100644 index fcbb445097..0000000000 --- a/apps/desktop/e2e/settings-projects.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { expect, test } from './fixtures.js'; - -/** - * Settings · 偏好 · 项目 — the list, and the default-project control. - * - * The seeded catalog deliberately mixes an available project with one whose - * folder is gone, because the unavailable row is where this page can lie - * most easily: a "设为默认" that looked live on a folder the app cannot open - * would set a default that breaks every new conversation. - */ -test('the projects page lists the catalog, moves the default, gates reveal, and renames in place', async ({ - settingsProjectsWindow: page, -}) => { - await page - .getByRole('navigation', { name: /设置分组|Settings sections/ }) - .getByRole('button', { name: /项目|Projects/, exact: true }) - .click(); - - const main = page.getByRole('main', { name: /设置内容|Settings content/ }); - // Exact match: each project's folder name also appears inside its own path, - // so a substring lookup matches the name and the mono line both. - await expect(main.getByText('maka-agent', { exact: true })).toBeVisible(); - await expect(main.getByText('astryx-design-system', { exact: true })).toBeVisible(); - - // The row whose folder was never created reports that, instead of showing a - // path it cannot open. - await expect(main.getByText('retired-prototype', { exact: true })).toBeVisible(); - await expect(main.getByText('目录不可用').first()).toBeVisible(); - - // No default configured yet: every row offers to become one. - await expect(main.getByText('默认', { exact: true })).toHaveCount(0); - - const setDefaultButtons = main.getByRole('button', { name: '设为默认' }); - const enabled: number[] = []; - for (let index = 0; index < (await setDefaultButtons.count()); index += 1) { - if (await setDefaultButtons.nth(index).isEnabled()) enabled.push(index); - } - // An unavailable project cannot be made the default, and the disabled - // control says why rather than leaving the user to guess. - expect(enabled.length).toBeGreaterThan(0); - const disabled = setDefaultButtons.nth( - [...Array(await setDefaultButtons.count()).keys()].find((i) => !enabled.includes(i)) ?? 0, - ); - await expect(disabled).toBeDisabled(); - // Astryx surfaces a Button tooltip through `aria-describedby`, not `title`, - // so read the element it points at — asserting on `title` passed vacuously - // against an empty string. - const describedText = await disabled.evaluate((el) => { - const id = el.getAttribute('aria-describedby'); - return id ? (document.getElementById(id)?.textContent ?? '') : ''; - }); - expect(describedText).toContain('目录不可用'); - - await setDefaultButtons.nth(enabled[0]).click(); - - // The chosen row swaps its button for the Badge, and it persists. - await expect(main.getByText('默认', { exact: true })).toHaveCount(1); - await expect - .poll(() => - page.evaluate(() => - window.maka.settings.get().then((value) => value.projects.defaultProjectId), - ), - ) - .not.toBe(undefined); - - // Exactly one default at a time: setting another moves it rather than - // adding a second. - const remaining = main.getByRole('button', { name: '设为默认' }); - const stillEnabled: number[] = []; - for (let index = 0; index < (await remaining.count()); index += 1) { - if (await remaining.nth(index).isEnabled()) stillEnabled.push(index); - } - if (stillEnabled.length > 0) { - await remaining.nth(stillEnabled[0]).click(); - await expect(main.getByText('默认', { exact: true })).toHaveCount(1); - } - - // Reveal gating, same catalog: the row whose folder is gone must not offer - // a Finder entry that can only fail. - await expect(main.getByText('retired-prototype', { exact: true })).toBeVisible(); - - // The seeded `retired-prototype` folder was never created, so opening it - // could only fail; the entry is disabled rather than offered-and-broken. - await main.getByRole('button', { name: '更多操作:retired-prototype' }).click(); - await expect(page.getByRole('menuitem', { name: '在访达中打开' })).toBeDisabled(); - await page.keyboard.press('Escape'); - - // Main resolves the path from the catalog by id, so an available project - // reports a real directory rather than a renderer-supplied one. - const result = await page.evaluate(() => - window.maka.projects.reveal('proj-fixture-gone'), - ); - expect(result.ok).toBe(false); - - // Rename last — it rewrites a seeded name the phases above address. - await expect(main.getByText('astryx-design-system', { exact: true })).toBeVisible(); - - // Address the row by name, not by index: the app self-registers its own - // workspace as a project, so the seeded order is not the rendered order — - // an index here renamed the wrong project. - await main.getByRole('button', { name: '更多操作:astryx-design-system' }).click(); - await page.getByRole('menuitem', { name: '重命名' }).click(); - - const field = main.getByRole('textbox'); - await expect(field).toBeFocused(); - await field.fill('astryx-renamed'); - await main.getByRole('button', { name: '保存' }).click(); - - await expect(main.getByText('astryx-renamed', { exact: true })).toBeVisible(); - await expect(main.getByText('astryx-design-system', { exact: true })).toHaveCount(0); - - // It is the catalog that changed, not just the row. - await expect - .poll(() => - page.evaluate(() => - window.maka.projects.list().then((all) => all.map((p) => p.name)), - ), - ) - .toContain('astryx-renamed'); -}); diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index 4d92835a3b..51bcd10fea 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -5,181 +5,6 @@ function settingsNavigation(page: Page) { return page.getByRole('navigation', { name: /^(设置分组|Settings sections)$/ }); } -/** - * Settings take effect: open settings, switch the theme to dark, and confirm - * the root picks up the `dark` class (theme.ts applies it via - * classList.toggle). This exercises the settings open → navigate → mutate → - * apply path without depending on pixel colors. - */ -// Subagent preset lifecycle in one window: edit, delete, and create phases -// each seed their preset set with settings closed, then re-enter settings — -// the shell reads the store on open, not live. -test('subagent presets: edit round trip, reversible delete, and create-then-enable', 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-fast-reader', - name: 'E2E 快速阅读', - description: '快速阅读大型代码仓库。', - profile: 'local_read', - connectionSlug: connection.slug, - model: connection.enabledModelIds?.[0] ?? connection.defaultModel, - // 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(); - 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(); - // 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(); - - // 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(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, - }); - - // The settings shell snapshots the store when it opens, so seeding a new - // preset set only shows after leaving and re-entering settings — the same - // order the standalone tests used (seed first, open second). - await page.getByRole('button', { name: '返回应用', exact: true }).click(); - 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 settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); - - 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); - - // The settings shell snapshots the store when it opens, so seeding a new - // preset set only shows after leaving and re-entering settings — the same - // order the standalone tests used (seed first, open second). - await page.getByRole('button', { name: '返回应用', exact: true }).click(); - await page.evaluate(async () => { - await window.maka.settings.update({ subagents: { presets: [] } }); - }); - - await page.getByRole('button', { name: '设置' }).click(); - await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); - - // 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]?.enabled; - })).toBe(true); -}); - // Appearance and channel surface in one window. The channel seed runs before // settings opens (the shell snapshots the store on open). Back-icon rail // geometry was removed with #2478 as presentation. diff --git a/apps/desktop/e2e/skill-delete-scope.spec.ts b/apps/desktop/e2e/skill-delete-scope.spec.ts deleted file mode 100644 index f3757da24e..0000000000 --- a/apps/desktop/e2e/skill-delete-scope.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -// User-scope skill deletion E2E (#1517). -// -// The delete path is the one place the Skills panel removes files from the -// user's HOME rather than from app-managed workspace data, so the journey that -// matters is the whole round trip: the contextual action is offered for the -// scopes the backend can actually delete, the confirmation protects the file -// operation, and the row is gone on the refresh that follows. -// -// This spec only exists because `buildE2eEnv` now sandboxes HOME. Before that, -// running it would have deleted a real skill out of the developer's -// `~/.agents/skills`. The unit tests in `skills.test.ts` cover the containment -// guards against a temp filesystem; what they cannot cover is the renderer -// sending a scope-aware ref through IPC and the list agreeing afterwards. - -import { access } from 'node:fs/promises'; -import path from 'node:path'; -import { e2eHomeDir, test, expect } from './fixtures.js'; - -test('offers delete only for deletable scopes and removes a user-scope skill from disk', async ({ - invocableSkillsWindow: page, -}) => { - const skillDir = path.join(e2eHomeDir(), '.agents', 'skills', 'user-only'); - // The sandbox is real: the seeded skill is on disk before anything happens. - await access(skillDir); - - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '对话列表' }); - await sidebar.getByRole('button', { name: '扩展', exact: true }).click(); - await page.getByRole('main', { name: '扩展' }).waitFor(); - - // The panel always renders all three views, and it picks its landing view from - // whatever the skill list happened to be at mount — so click 已安装 - // unconditionally and let Playwright wait for it. Sampling `isVisible()` - // first only added a branch that could skip the click on a slow mount and - // then hunt for the row on the wrong tab. - await page.getByRole('radio', { name: '已安装' }).click(); - - // Non-destructive scope check first, in the same installed list: project - // skills live in the user's repo and are left to git — the backend refuses - // them with `blocked_scope`, and the panel must not offer a button that - // cannot work. - const projectRow = page.getByRole('button', { name: /Project Only/ }); - await expect(projectRow).toBeVisible(); - await projectRow.click(); - const inspector = page.getByRole('complementary', { name: '技能详情' }); - await expect(inspector.getByRole('button', { name: '删除', exact: true })).toHaveCount(0); - - const row = page.getByRole('button', { name: /User Only/ }); - await expect(row).toBeVisible(); - await row.click(); - - await inspector.getByRole('button', { name: '删除', exact: true }).click(); - - // Opening the confirmation alone must not touch disk. - await access(skillDir); - - const confirm = page.getByRole('alertdialog', { name: '确认删除 User Only' }); - await confirm.getByRole('button', { name: '删除', exact: true }).click(); - - await expect(page.getByRole('button', { name: /User Only/ })).toHaveCount(0); - await expect.poll(() => access(skillDir).then(() => 'present', () => 'gone')).toBe('gone'); - - // Focus moves to the row that took the deleted one's place — not to body, - // which would drop a keyboard user at the top of the document. - await expect(page.locator('.maka-module-page-rows > li button:focus')).toHaveCount(1); -}); diff --git a/apps/desktop/e2e/skills.spec.ts b/apps/desktop/e2e/skills.spec.ts deleted file mode 100644 index 38d995f927..0000000000 --- a/apps/desktop/e2e/skills.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Skills page interaction hierarchy. -// -// These checks use the real Desktop bridge and seeded Skill inventory. They -// protect the visible contract rather than the component implementation: -// installed rows are selectable and otherwise inert, and every per-skill -// action lives in the end-panel inspector — the same surface as 定时任务. - -import type { Page } from '@playwright/test'; -import { test, expect } from './fixtures.js'; - -async function openInstalledSkills(page: Page) { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '对话列表' }); - await sidebar.getByRole('button', { name: '扩展', exact: true }).click(); - await page.getByRole('main', { name: '扩展' }).waitFor(); - await page.getByRole('radio', { name: '已安装' }).click(); -} - -test('keeps installed rows inert and routes every per-skill action through the inspector', async ({ - invocableSkillsWindow: page, -}) => { - await openInstalledSkills(page); - - // The row itself is the one click target — a button named by the skill. - const row = page.getByRole('button', { name: /Workspace Only/ }); - await expect(row).toBeVisible(); - // Nothing else rides the row: no switch, no overflow menu, no use button. - const item = page.getByRole('listitem').filter({ hasText: 'Workspace Only' }); - await expect(item.getByRole('switch')).toHaveCount(0); - await expect(item.getByRole('button', { name: /更多操作/ })).toHaveCount(0); - - await row.click(); - const inspector = page.getByRole('complementary', { name: '技能详情' }); - await expect(inspector.getByRole('switch', { name: '启用' })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '使用', exact: true })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '打开 SKILL.md' })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '固定到技能上下文' })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '删除', exact: true })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/storage-root-conflict.spec.ts b/apps/desktop/e2e/storage-root-conflict.spec.ts deleted file mode 100644 index 72c27abd53..0000000000 --- a/apps/desktop/e2e/storage-root-conflict.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { _electron as electron, expect, test } from '@playwright/test'; -import type { ElectronApplication } from '@playwright/test'; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { STORAGE_ROOT_MARKER_FILE, resolveStorageRoot } from '@maka/storage/root-authority'; -import { buildFixtureEnv } from '../../../scripts/fixture-env.mjs'; -import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs'; - -const DESKTOP_ROOT = process.cwd(); - -/** - * Startup signal the main process prints synchronously right before showing - * the repair modal (see `confirmDesktopStorageRootRepair` in - * `runtime-host-boot.ts`). It is - * an explicit contract between the app and this test: it can only be printed - * after `ready` — startup runs inside the `whenReady` callback — - * and only when the root-identity gate fired, so seeing it proves both - * invariants at once. - * - * CDP evaluation is deliberately not the success signal: macOS modal loops - * block CDP evaluation while Linux modal dialogs keep answering it, so no - * evaluate-based heuristic is portable. A deadlocked main process (the - * regression this test guards) never reaches the gate, so the signal never - * appears; a future removal of the gate skips the signal too. - */ -const REPAIR_GATE_SIGNAL = '[storage-root] root-identity conflict; parking at repair dialog'; - -async function appParkedAtRepairGate(app: ElectronApplication): Promise { - return new Promise((resolve) => { - let settled = false; - const timeout = setTimeout(() => { - if (!settled) { - settled = true; - resolve(false); - } - }, 30_000); - app.on('console', (message) => { - if (settled) return; - if (message.text().includes(REPAIR_GATE_SIGNAL)) { - settled = true; - clearTimeout(timeout); - resolve(true); - } - }); - }); -} - -/** - * A conflicting storage root must park at the repair dialog — never deadlock - * in module evaluation — and must not write any store/db files before the - * user answers. - * - * Regression for the Electron ESM startup deadlock: top-level - * `await app.whenReady()` inside the repair-confirm path never resolves - * because `ready` only fires after the main module finishes evaluating. - */ -test('parks at the storage-root repair dialog and writes nothing before the answer', async () => { - const userDataDir = await mkdtemp(join(tmpdir(), 'maka-root-conflict-')); - const homeDir = join(userDataDir, 'home'); - await mkdir(homeDir, { recursive: true }); - let app; - try { - // Seed a real interactive marker, then corrupt its device id so startup - // must stop at the repair dialog (mirrors the disk-identity drift that - // triggers root_identity_collision). - const workspaceRoot = join(userDataDir, 'workspaces', 'default'); - await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); - const markerPath = join(workspaceRoot, STORAGE_ROOT_MARKER_FILE); - const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { - rootIdentity: { dev: string }; - }; - marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); - const conflictingMarker = `${JSON.stringify(marker)}\n`; - await writeFile(markerPath, conflictingMarker); - - // Launch without a fixture so the repair gate is live (fixture mode - // seeds its own workspace and bypasses the dialog). - app = await electron.launch({ - args: ['.'], - cwd: DESKTOP_ROOT, - env: buildFixtureEnv(userDataDir, homeDir, {}), - }); - - // The gate signal is printed only after ready and only when the conflict - // fired; a deadlocked main process (the regression) never prints it. - expect(await appParkedAtRepairGate(app)).toBe(true); - - // While the dialog is unanswered, no store/db files may be created in - // the workspace: the root-identity gate must precede all storage. - const workspaceEntries = await readdir(workspaceRoot); - expect(workspaceEntries).toEqual([STORAGE_ROOT_MARKER_FILE]); - expect(await readFile(markerPath, 'utf8')).toBe(conflictingMarker); - } finally { - if (app) await closeElectronApplication(app, 5_000); - await rm(userDataDir, { recursive: true, force: true }); - } -}); From e360a3d6bf63969b07cccb86f36d6e401ac86350 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 9 Aug 2026 16:27:25 +0800 Subject: [PATCH 5/5] fix(e2e): drop unused e2eHomeDir export after suite cull Knip typecheck failed: skill-delete e2e was the last consumer. --- apps/desktop/e2e/fixtures.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 3fbe00fa80..12cb1088e5 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -181,21 +181,6 @@ async function seedE2eGitReviewProject( ); } -/** - * The sandboxed HOME of the run currently under test. Set by withE2eWindow - * before Electron launches. - * - * Read it from inside a test BODY, never as a fixture: a fixture would have no - * declared dependency on the window fixture, so Playwright could resolve it - * before the window is set up and hand back a stale path. - */ -let currentHomeDir = ''; - -export function e2eHomeDir(): string { - if (!currentHomeDir) throw new Error('e2eHomeDir() is only valid inside a test that opened a window'); - return currentHomeDir; -} - /** * Own the full launch lifecycle so a failure anywhere — seeding, Electron * launch, firstWindow, or the readiness wait — still tears down the Electron @@ -234,7 +219,6 @@ async function withE2eWindow( // it too — there is no second path to leak. const homeDir = path.join(userDataDir, 'home'); await mkdir(homeDir, { recursive: true }); - currentHomeDir = homeDir; let app: ElectronApplication | undefined; const mainLogs: string[] = []; const rendererLogs: string[] = [];