diff --git a/apps/desktop/e2e/about-page.spec.ts b/apps/desktop/e2e/about-page.spec.ts deleted file mode 100644 index ad34ab3b23..0000000000 --- a/apps/desktop/e2e/about-page.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { ensureSidebarExpanded, expect, test } from './fixtures'; - -test('About renders channel facts, support, and privacy on a dev checkout', async ({ window: page }) => { - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: '设置' }).click(); - await page.getByRole('button', { name: '关于', exact: true }).click(); - - // The channel token must agree with the version string: the fixture app is a - // dev checkout, so it reads 本地开发版. - await expect(page.getByText('本地开发版', { exact: true })).toBeVisible(); - await expect(page.getByText('本地开发构建,不检查更新。')).toBeVisible(); - - // A dev checkout follows no feed, so the whole update row is absent — it is - // not a disabled button next to a sentence repeating the line above it. - await expect(page.getByRole('button', { name: '检查更新' })).toHaveCount(0); - - // Support lives outside the info conditional: usable even when `app.info` - // fails, which is exactly when a user reaches for it. Each row-end control is - // named by its row, not by the verb on its face. - await expect(page.getByRole('heading', { name: '支持', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: '复制诊断信息' })).toBeEnabled(); - await expect(page.getByRole('link', { name: '报告问题' })).toBeVisible(); - await expect(page.getByRole('button', { name: '键盘快捷键' })).toBeEnabled(); - - // Three commitments, not the old wall of five bullets. - const privacyList = page.getByRole('list', { name: '隐私承诺' }); - await expect(privacyList.getByRole('listitem')).toHaveCount(3); -}); diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts deleted file mode 100644 index 884bba0052..0000000000 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ /dev/null @@ -1,354 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend'; -import type { CDPSession, Locator, Page } from '@playwright/test'; -import { awaitSendReady, ensureSidebarExpanded, expect, test, COMPOSER_INPUT } from './fixtures'; -import { auditAxTree } from '../../../scripts/ax-tree-audit.mjs'; -import { groupedNav } from '../src/renderer/settings/settings-nav'; - -function exactNameWithOptionalBadge(label: string): RegExp { - const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`^${escaped}(?: Beta)?$`); -} - -async function assertAxHealth(cdp: CDPSession, surface: string): Promise { - const result = await cdp.send('Accessibility.getFullAXTree'); - const audit = auditAxTree(result.nodes); - expect(audit.problems, `${surface} exposes an unhealthy AX tree`).toEqual([]); -} - -async function openSettings(page: Page): Promise { - const sidebarToggle = page.getByRole('button', { name: '展开侧边栏' }); - if (await sidebarToggle.isVisible()) await sidebarToggle.click(); - await page.getByRole('button', { name: '设置', exact: true }).click(); - await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); -} - -async function tabTo(page: Page, target: Locator, label: string, limit = 30): Promise { - for (let index = 0; index < limit; index += 1) { - if (await target.evaluate((element) => element === document.activeElement)) return; - await page.keyboard.press('Tab'); - } - expect( - await target.evaluate((element) => element === document.activeElement), - `${label} is not reachable within ${limit} Tab presses`, - ).toBe(true); -} - -async function enterMainFromSkipLink(page: Page): Promise { - await page.evaluate(() => { - document.body.tabIndex = -1; - document.body.focus(); - }); - const skipLink = page.getByRole('link', { name: '跳到主要内容' }); - await tabTo(page, skipLink, 'skip link', 10); - await page.keyboard.press('Enter'); - await expect(page.getByRole('main')).toBeFocused(); - await page.evaluate(() => document.body.removeAttribute('tabindex')); -} - -test('every settings page exposes named actionable controls', async ({ window: page }) => { - await openSettings(page); - const cdp = await page.context().newCDPSession(page); - const navigation = page.getByRole('navigation', { name: '设置分组' }); - await expect(page.getByRole('main')).toHaveCount(1); - const sectionLabels = (await navigation.getByRole('button').allTextContents()) - .map((label) => label.trim().replace(/\s*Beta$/, '')) - .filter((label) => label.length > 0 && label !== '返回应用'); - const expectedSectionLabels = groupedNav('zh-CN') - .flatMap(({ items }) => items) - .filter(({ enabled }) => enabled) - .map(({ label }) => label); - expect(sectionLabels, 'settings navigation must expose every enabled page in source order').toEqual( - expectedSectionLabels, - ); - - for (const section of sectionLabels) { - const sectionButton = navigation.getByRole('button', { - name: exactNameWithOptionalBadge(section), - }); - await sectionButton.click(); - await expect(sectionButton).toHaveAttribute('aria-current', 'page'); - await expect(page.getByRole('heading', { name: section, exact: true })).toBeVisible(); - await assertAxHealth(cdp, `settings/${section}`); - } -}); - -test('module pages and global overlays expose named actionable controls', async ({ - window: page, -}) => { - const cdp = await page.context().newCDPSession(page); - await expect(page.getByRole('link', { name: '跳到主要内容' })).toHaveCount(1); - await expect(page.getByRole('link', { name: 'Skip to content' })).toHaveCount(0); - const sidebarToggle = page.getByRole('button', { name: '展开侧边栏' }); - if (await sidebarToggle.isVisible()) await sidebarToggle.click(); - const navigation = page.getByRole('navigation', { name: '任务列表' }); - - await navigation.getByRole('button', { name: '扩展', exact: true }).click(); - await expect(page.getByRole('main')).toHaveCount(1); - await expect(page.getByRole('region', { name: '扩展', exact: true })).toBeVisible(); - const extensionsNavigation = page.getByRole('navigation', { name: /扩展内容/ }); - await expect( - extensionsNavigation.getByRole('button', { name: '技能', exact: true }), - ).toHaveAttribute('aria-current', 'true'); - await assertAxHealth(cdp, 'extensions/skills'); - const mcpButton = extensionsNavigation.getByRole('button', { name: 'MCP', exact: true }); - await mcpButton.click(); - await expect(mcpButton).toHaveAttribute('aria-current', 'true'); - await assertAxHealth(cdp, 'extensions/mcp'); - - await navigation.getByRole('button', { name: /定时任务/ }).click(); - const automationsNavigation = page.getByRole('navigation', { name: /定时任务内容/ }); - await expect( - automationsNavigation.getByRole('button', { name: '定时任务', exact: true }), - ).toHaveAttribute('aria-current', 'true'); - await assertAxHealth(cdp, 'automations/scheduled-tasks'); - const dailyReviewButton = automationsNavigation.getByRole('button', { - name: '每日回顾', - exact: true, - }); - await dailyReviewButton.click(); - await expect(dailyReviewButton).toHaveAttribute('aria-current', 'true'); - await assertAxHealth(cdp, 'automations/daily-review'); - - await page.keyboard.press('Shift+Slash'); - const keyboardHelpDialog = page.getByRole('dialog', { name: '键盘快捷键' }); - await expect(keyboardHelpDialog).toBeVisible(); - await assertAxHealth(cdp, 'overlay/keyboard-help'); - await page.keyboard.press('Escape'); - await expect(keyboardHelpDialog).toBeHidden(); - - await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); - const commandPaletteDialog = page.getByRole('dialog', { name: '命令面板' }); - await expect(commandPaletteDialog).toBeVisible(); - await assertAxHealth(cdp, 'overlay/command-palette'); - await page.keyboard.press('Escape'); -}); - -test('data-backed conversation exposes an open workbar face and keyboard access to tools, models, and Graph', async ({ - accessibilityNarrativeWindow: page, -}) => { - const cdp = await page.context().newCDPSession(page); - await expect(page.getByRole('region', { name: /对话:/ })).toBeVisible(); - // The scenario opens a face beside the transcript so the AX sweep below sees - // the workbar's own tree, not an empty column. Which face is incidental — it - // was the Task face until that face was retired — so this asserts the strip - // reports one, rather than reaching into the face's contents. - const workbar = page.getByRole('complementary', { name: '任务工作栏' }); - await expect(workbar).toBeVisible(); - await expect(workbar.getByRole('tab', { name: '变更' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - await assertAxHealth(cdp, 'conversation/data-backed'); - - await expect(page.getByRole('main')).toHaveCount(1); - await enterMainFromSkipLink(page); - - const toolCall = page.getByRole('button', { name: /^检查测试状态/ }); - await tabTo(page, toolCall, 'tool result'); - await page.keyboard.press('Enter'); - await expect(toolCall).toHaveAttribute('aria-expanded', 'true'); - await expect(page.locator('[data-slot="tool-output"]')).toContainText('core 41 passing'); - await assertAxHealth(cdp, 'conversation/tool-result-expanded'); - await page.keyboard.press('Enter'); - await expect(toolCall).toHaveAttribute('aria-expanded', 'false'); - - const modelSwitcher = page.getByRole('button', { name: '切换当前任务模型' }); - await tabTo(page, modelSwitcher, 'model picker'); - await page.keyboard.press('Enter'); - await expect(page.getByRole('menuitemradio', { name: /glm-5\.1/ })).toBeVisible(); - await assertAxHealth(cdp, 'conversation/model-picker'); - const availableModel = page.getByRole('menuitemradio', { name: 'glm-4.5', exact: true }); - await expect(availableModel).toBeFocused(); - await page.keyboard.press('Enter'); - await expect(modelSwitcher).toContainText('glm-4.5'); - - const composer = page.locator(COMPOSER_INPUT); - await tabTo(page, composer, 'composer', 60); - await page.keyboard.insertText('/graph on'); - const send = page.getByRole('button', { name: '发送' }); - await tabTo(page, send, 'Send button', 20); - await awaitSendReady(page); - await page.keyboard.press('Enter'); - await expect(page.getByText('Graph Mode 已开启', { exact: true })).toBeVisible(); - await assertAxHealth(cdp, 'overlay/graph-mode-toast'); - - const graphPanel = page.getByRole('region', { name: 'Agent Graph' }); - await expect(graphPanel).toBeVisible(); - await expect(graphPanel).toContainText('等待主 Agent 创建 operator…'); - const collapseGraph = graphPanel.getByRole('button', { name: '收起 Agent Graph' }); - await tabTo(page, collapseGraph, 'Graph collapse', 60); - await page.keyboard.press('Enter'); - await expect( - graphPanel.getByRole('button', { name: '展开 Agent Graph' }), - ).toHaveAttribute('aria-expanded', 'false'); - await assertAxHealth(cdp, 'conversation/agent-graph-empty'); -}); - -test('toast and error states expose healthy live regions', async ({ window: page }) => { - const cdp = await page.context().newCDPSession(page); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('/graph history'); - await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.getByText('Graph 历史', { exact: true })).toBeVisible(); - await assertAxHealth(cdp, 'overlay/graph-history-toast'); - - await page.evaluate(async () => { - await window.maka.connections.setDefaultModel(null); - await window.maka.settings.updateClient({ workHub: { enabled: true } }); - }); - const failure = page.getByRole('alert'); - await expect(failure).toContainText('WorkHub 暂时无法启动'); - await expect(failure).toContainText('请检查当前 Runtime Host 的默认模型配置'); - await assertAxHealth(cdp, 'workhub/startup-error'); -}); - -test('a streaming answer exposes a healthy live conversation state', async ({ window: page }) => { - const cdp = await page.context().newCDPSession(page); - await enterMainFromSkipLink(page); - const composer = page.locator(COMPOSER_INPUT); - await tabTo(page, composer, 'streaming composer', 60); - await page.keyboard.insertText(FAKE_HOLD_OPEN_PROMPT); - const send = page.getByRole('button', { name: '发送' }); - // After the Tab walk, not before it: a tooltip-carrying Astryx Button is - // disabled via `aria-disabled`, so it stays focusable and `tabTo` would - // reach it either way. - await tabTo(page, send, 'streaming Send button', 20); - await awaitSendReady(page); - await page.keyboard.press('Enter'); - - await expect(page.locator('.maka-bubble-streaming')).toContainText( - 'Fake backend waiting', - { timeout: 20_000 }, - ); - await expect(page.getByRole('button', { name: '停止' })).toBeEnabled(); - await assertAxHealth(cdp, 'conversation/streaming'); - - const stop = page.getByRole('button', { name: '停止' }); - await tabTo(page, stop, 'streaming Stop button', 20); - await page.keyboard.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - await assertAxHealth(cdp, 'conversation/stopped'); -}); - -test('composer and workbar entry points expose named actionable controls', async ({ - window: page, -}) => { - const cdp = await page.context().newCDPSession(page); - await expect(page.getByRole('main')).toHaveCount(1); - await expect(page.getByRole('region', { name: '新任务对话' })).toBeVisible(); - await assertAxHealth(cdp, 'conversation/new-task'); - - await enterMainFromSkipLink(page); - const composer = page.locator(COMPOSER_INPUT); - const prompt = 'create a session for accessibility coverage'; - await tabTo(page, composer, 'new-task composer', 60); - await page.keyboard.insertText(prompt); - const send = page.getByRole('button', { name: '发送' }); - await tabTo(page, send, 'new-task Send button', 20); - await awaitSendReady(page); - await page.keyboard.press('Enter'); - await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible({ - timeout: 30_000, - }); - await expect(page.getByRole('main')).toHaveCount(1); - await expect(page.getByRole('region', { name: /对话:/ })).toBeVisible(); - await assertAxHealth(cdp, 'conversation/session'); - - await page.getByRole('button', { name: '展开任务工作栏' }).click(); - await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); - await assertAxHealth(cdp, 'workbar/launcher'); - - // Registry order, minus the Task face this shell retired. The launcher is - // the empty state, so it opens the first face and then goes away; every face - // after it is opened from the [+] menu, which is the only entry point once a - // face is on screen. The faces stay open behind one another — the strip is a - // `tablist` now, so each one has to keep reporting its own tab. - const workbarPanels = ['侧边对话', '变更', '终端', '浏览器', '生成文件', '追踪'] as const; - for (const [index, panel] of workbarPanels.entries()) { - if (index === 0) { - await page - .getByRole('list', { name: '打开工具' }) - .getByRole('button', { name: new RegExp(`^${panel}(?: |$)`) }) - .click(); - } else { - await page.getByRole('button', { name: '打开或关闭工作栏的面' }).first().click(); - const faceMenu = page.getByRole('menu'); - await expect(faceMenu).toBeVisible(); - await assertAxHealth(cdp, `workbar/face-menu/${panel}`); - await faceMenu.getByRole('menuitem', { name: panel, exact: true }).click(); - // The menu stays open on select, so that one pick can close a face as - // well as open one. Dismiss it before reading the panel underneath. - await page.keyboard.press('Escape'); - await expect(faceMenu).toBeHidden(); - } - const activeTab = page.getByRole('tab', { name: new RegExp(panel) }); - await expect(activeTab).toBeVisible(); - await expect(activeTab).toHaveAttribute('aria-selected', 'true'); - if (panel === '终端') { - await expect(page.getByRole('region', { name: '任务终端' })).toBeVisible(); - } else if (panel === '浏览器') { - await expect(page.getByRole('region', { name: '嵌入式浏览器' })).toBeVisible(); - } - await assertAxHealth(cdp, `workbar/${panel}`); - } -}); - -/** - * Restoring a draft is not a reason to move focus. The composer places the - * restored caret with a selection, and a selection inside a `contenteditable` - * focuses it whatever held focus before — so before the caret was held back, - * activating a session row took focus out from under the keyboard user who - * activated it. Asserted in a real browser because that is where the focus - * side effect lives; the unit harness models it and cannot observe it. - */ -test('activating a session row with an unsent draft keeps focus on the row', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - const prompt = 'session for the draft focus contract'; - await composer.fill(prompt); - await awaitSendReady(page); - await composer.press('Enter'); - await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible({ - timeout: 30_000, - }); - - await composer.click(); - // Plain text, no Skill token: this contract is about the caret restore, and a - // token redraw writes the same selection for a reason of its own. - await page.keyboard.insertText('an unsent draft'); - await expect(composer).toHaveText('an unsent draft'); - - await ensureSidebarExpanded(page); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await expect(composer).toHaveText(''); - - const sessionRow = sidebar.locator('[data-session-id]').first(); - await sessionRow.click(); - await expect(composer).toHaveText('an unsent draft'); - await expect(composer).not.toBeFocused(); - await expect(sessionRow.locator(':focus')).toHaveCount(1); -}); diff --git a/apps/desktop/e2e/goal-dialog-budget.spec.ts b/apps/desktop/e2e/goal-dialog-budget.spec.ts deleted file mode 100644 index 14b56668e0..0000000000 --- a/apps/desktop/e2e/goal-dialog-budget.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { test, expect, COMPOSER_INPUT } from './fixtures'; - -/** - * Arming a Goal starts unattended token spending, and the two budgets in this - * dialog are what stops it. A budget the form shows but does not send is - * therefore the one failure this dialog must not have — most sharply when the - * value is dropped rather than altered, because an absent token budget is not - * a smaller ceiling but no ceiling at all. - * - * The assertions read the Goal back from the Host rather than watching the - * bridge call, so they answer what was actually armed. - */ -test('an unsendable budget blocks Start instead of arming a different one', async ({ - window: page, -}) => { - // The + menu only offers a Goal for a Session that exists, so seed one and - // let its Turn settle first — a live Turn disables the entry too. - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('seed session'); - await composer.press('Enter'); - await expect(page.getByRole('log').getByText(/Fake backend received: seed session/)).toBeVisible(); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - const sessionId = await page.evaluate(async () => (await window.maka.sessions.list())[0]?.id); - expect(sessionId).toBeTruthy(); - const armedGoal = () => - page.evaluate(async (id: string) => await window.maka.goal.get(id), sessionId as string); - - await page.getByRole('button', { name: '添加上下文' }).click(); - await page.getByRole('menuitem', { name: '设定 Goal…' }).click(); - - const dialog = page.getByRole('dialog'); - await dialog.getByLabel(/达成条件/).fill('所有测试通过'); - const start = dialog.getByRole('button', { name: '开始' }); - await expect(start).toBeEnabled(); - - // Below the Host's own minimum. The field this replaced kept such text to - // itself and left the sent budget null, so Start stayed enabled and armed no - // ceiling at all. - await dialog.getByLabel(/Token 预算/).fill('500'); - await expect(start).toBeDisabled(); - await expect(dialog.getByText(/请填不小于 1000 的整数/)).toBeVisible(); - - await dialog.getByLabel(/Token 预算/).fill('5000'); - await expect(start).toBeEnabled(); - - // Above the Host's ceiling on turns; the same rule from the other side. - await dialog.getByLabel(/最多轮数/).fill('250'); - await expect(start).toBeDisabled(); - await expect(await armedGoal()).toBeNull(); - - await dialog.getByLabel(/最多轮数/).fill('25'); - await expect(start).toBeEnabled(); - await start.click(); - - await expect - .poll(async () => { - const goal = await armedGoal(); - return goal && { - condition: goal.condition, - maxIterations: goal.maxIterations, - tokenBudget: goal.tokenBudget, - }; - }) - .toEqual({ condition: '所有测试通过', maxIterations: 25, tokenBudget: 5000 }); - - // The Host read above proves persistence; this proves the same broadcast now - // reaches the provider-owned chat projection without AppShell reading it. - const goalContext = page - .getByRole('region', { name: '任务上下文' }) - .filter({ visible: true }); - await expect( - goalContext - .getByText(/目标 0 \/ 25 · .* · 0 \/ 5k/) - .filter({ visible: true }), - ).toBeVisible(); - await expect( - goalContext - .getByRole('button', { name: '暂停自主执行目标(已进行 0/25 轮)' }) - .filter({ visible: true }), - ).toBeVisible(); -}); diff --git a/apps/desktop/e2e/module-hub.spec.ts b/apps/desktop/e2e/module-hub.spec.ts deleted file mode 100644 index 91609cfcfd..0000000000 --- a/apps/desktop/e2e/module-hub.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { expect, test } from './fixtures'; - -test('Module Hub switches all four leaves and opens scheduled creation once', async ({ - window: page, -}) => { - const expand = page.getByRole('button', { name: '展开侧边栏' }); - if (await expand.isVisible()) await expand.click(); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - - await sidebar.getByRole('button', { name: '扩展', exact: true }).click(); - await expect(page.locator('[data-module="skills"]')).toBeVisible(); - const extensions = page.getByRole('navigation', { name: /扩展内容/ }); - await extensions.getByRole('button', { name: 'MCP', exact: true }).click(); - await expect( - extensions.getByRole('button', { name: 'MCP', exact: true }), - ).toHaveAttribute('aria-current', 'true'); - - await sidebar.getByRole('button', { name: /定时任务/ }).click(); - await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); - const automations = page.getByRole('navigation', { name: /定时任务内容/ }); - await automations.getByRole('button', { name: '每日回顾', exact: true }).click(); - await expect(page.locator('[data-module="daily-review"]')).toBeVisible(); - - await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); - const palette = page.getByRole('dialog', { name: '命令面板' }); - await expect(palette).toBeVisible(); - await palette.getByRole('option', { name: /新建定时任务/ }).click(); - - const createDialog = page.getByRole('dialog', { name: '新建定时任务' }); - await expect(createDialog).toBeVisible(); - await expect(createDialog).toHaveCount(1); - await expect(page.locator('[data-module="scheduled-tasks"]')).toBeVisible(); -}); diff --git a/apps/desktop/e2e/new-task-draft-target.spec.ts b/apps/desktop/e2e/new-task-draft-target.spec.ts deleted file mode 100644 index 866d109eb8..0000000000 --- a/apps/desktop/e2e/new-task-draft-target.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Locator, Page } from '@playwright/test'; -import { COMPOSER_INPUT, NEW_TASK_PROJECT_NAME, expect, test } from './fixtures'; - -/** - * #3408, in the real window: the new-task draft slot is keyed by (profile, - * host, project), and the workspace picker that changes the project part sits - * directly under the composer — so "type, then pick where it runs" re-keyed the - * slot mid-typing and swapped the text out for the new target's empty one. - * - * `chat-composer-region-draft-handoff.test.ts` pins the handoff at the - * component. This pins the wiring the user actually touches: that the picker is - * what re-keys the composer, and that the draft survives it. - */ -const DRAFT = 'draft written before choosing a project'; - -/** - * Read the composer only after the click's render has committed AND its passive - * effects have flushed. The draft swap runs in an effect after the picker's own - * re-render, so a read taken between the two sees the text still on screen and - * passes against broken code — which is exactly what an earlier version of this - * spec did. `newTaskTargetWindow` is shown for the same reason: a hidden - * window's compositor is throttled to ~1fps, which stretched that gap from - * 0.1ms to seconds and made every assertion here vacuous. - */ -async function settle(page: Page): Promise { - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); -} - -// Picking an item hides the menu, and DropdownMenu then swallows a reopen -// click for 50ms. -async function openPicker(page: Page, picker: Locator): Promise { - await expect(async () => { - await picker.click(); - await expect(page.getByRole('menuitem').first()).toBeVisible({ timeout: 500 }); - }).toPass({ timeout: 10_000 }); -} - -test('the new-task draft follows the Project chosen under the composer', async ({ - newTaskTargetWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - const picker = page.locator('button.maka-workspace-picker'); - - // Wait for the seeded Project to be the resolved target before typing: until - // the catalog settles the draft key is the unresolved one, whose handoff is a - // different path and was never broken. - await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME)); - - await composer.click(); - // Two keystrokes in one frame — no human rate — make ChatComposerInput - // rewrite the editor and reset the caret. - await page.keyboard.type(DRAFT, { delay: 20 }); - await expect(composer).toHaveText(DRAFT); - - await picker.click(); - await page.getByRole('menuitem', { name: '无项目', exact: true }).click(); - // The picker's label is the selected target, so this asserts the click moved - // the selection. Without it the draft assertion below would still pass if the - // menu item stopped selecting anything at all. - await expect(picker).toHaveAttribute('aria-label', /无项目/); - await settle(page); - await expect(composer).toHaveText(DRAFT); - - await openPicker(page, picker); - await page.getByRole('menuitem', { name: NEW_TASK_PROJECT_NAME, exact: true }).click(); - await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME)); - await settle(page); - await expect(composer).toHaveText(DRAFT); -}); - -test('a staged attachment survives the Project chosen under the composer', async ({ - newTaskTargetWindow: page, -}) => { - const picker = page.locator('button.maka-workspace-picker'); - const chip = page.locator('.maka-composer-attachment-token'); - await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME)); - - await page.locator('.maka-composer').first().evaluate((form) => { - const transfer = new DataTransfer(); - transfer.items.add(new File(['staged'], 'dropped-notes.txt', { type: 'text/plain' })); - form.dispatchEvent( - new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true }), - ); - }); - await expect(chip).toHaveCount(1); - - await picker.click(); - await page.getByRole('menuitem', { name: '无项目', exact: true }).click(); - await expect(picker).toHaveAttribute('aria-label', /无项目/); - await settle(page); - await expect(chip).toHaveCount(1); -}); diff --git a/apps/desktop/e2e/parent-session-deletion.spec.ts b/apps/desktop/e2e/parent-session-deletion.spec.ts deleted file mode 100644 index c42e4c88ff..0000000000 --- a/apps/desktop/e2e/parent-session-deletion.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - expect, - PARENT_REMOVAL_CHILD_NAME, - PARENT_REMOVAL_PARENT_NAME, - test, -} from './fixtures'; - -/** - * The route is two steps on purpose: the rail archives, Settings deletes. The - * rail has no delete at all — a mis-click there is one hover away from an - * irreversible loss — so a task must have been archived once before anything - * can remove it. This walks the whole route rather than calling the command, - * because the route is the thing that changed. - */ -test('deleting a parent task archives its linked subagent task', async ({ - parentRemovalWindow: page, -}) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const taskList = page.getByRole('navigation', { name: '任务列表' }); - const parentRow = taskList - .locator('[data-maka-contract="session-row"]') - .filter({ hasText: PARENT_REMOVAL_PARENT_NAME }); - - await expect(parentRow).toHaveCount(1); - await expect(taskList.getByText(PARENT_REMOVAL_CHILD_NAME, { exact: true })).toHaveCount(0); - - await parentRow.hover(); - await parentRow.getByRole('button', { name: '任务操作' }).click(); - // The rail's menu ends at 归档. Deleting is not one of the things a row can - // be asked to do here. - await expect(page.getByRole('menuitem', { name: '删除', exact: true })).toHaveCount(0); - await page.getByRole('menuitem', { name: '归档', exact: true }).click(); - await expect(parentRow).toHaveCount(0); - - await page.getByRole('button', { name: '设置', exact: true }).click(); - await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); - await page.getByRole('button', { name: '已归档任务', exact: true }).click(); - const archivedTasks = page.getByRole('main', { name: '设置内容' }); - - await archivedTasks - .getByRole('button', { name: `「${PARENT_REMOVAL_PARENT_NAME}」的更多操作` }) - .click(); - // 彻底删除, not 删除: Settings names the irreversible verb in full, which is - // the point of routing every deletion through a surface reached by archiving. - await page.getByRole('menuitem', { name: '彻底删除', exact: true }).click(); - const confirm = page.getByRole('alertdialog', { - name: `删除 "${PARENT_REMOVAL_PARENT_NAME}"`, - }); - await expect(confirm).toBeVisible(); - // The confirm warns that the linked subtask is kept and archived rather than - // destroyed, so the archived row that appears next is not a surprise. It names - // no count — the Host owns the exact number and reports it in the toast. - await expect(confirm.getByText(/子任务.*归档/)).toBeVisible(); - await confirm.getByRole('button', { name: '删除', exact: true }).click(); - - await expect(archivedTasks.getByText(PARENT_REMOVAL_CHILD_NAME, { exact: true })).toBeVisible(); - await expect(archivedTasks.getByText(/原父任务已删除/)).toBeVisible(); - await expect(archivedTasks.getByText(PARENT_REMOVAL_PARENT_NAME, { exact: true })).toHaveCount(0); -}); diff --git a/apps/desktop/e2e/send-message.spec.ts b/apps/desktop/e2e/send-message.spec.ts deleted file mode 100644 index 55fbc49611..0000000000 --- a/apps/desktop/e2e/send-message.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { test, expect, COMPOSER_INPUT } from './fixtures'; - -/** - * Enter commits a candidate in a CJK IME; nothing else may act on it. Both the - * composer's send and ChatComposerInput's trigger menu read Enter, and the - * component runs its menu handling before the `onKeyDown` we pass it — so the - * guard is a native capture on the composer root that takes the key away from - * React entirely. - */ -/** - * Core chat loop: type a message, send it, see the deterministic fake backend - * stream a reply back into the transcript. Depends on the E2E seam: the - * fixture's MAKA_E2E=1 forces sessions:create onto the fake backend, and the - * seeded 'e2e' connection clears onboarding so the composer is usable. - */ -test('Enter mid-IME commits the candidate, then an ordinary send streams a reply', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('中文草稿'); - await composer.evaluate((element) => { - element.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); - // `isComposing: false` on purpose: only the composition we track ourselves - // can stop this one, so a passing test can't be crediting the component's - // own `nativeEvent.isComposing` check. - element.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), - ); - element.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })); - }); - - // A leaked send is asynchronous, so `toHaveCount(0)` can pass before it - // lands, and a second send of the same text would hide it. Send something - // different and pin the total instead. - await composer.fill('中文草稿 已提交'); - await composer.press('Enter'); - await expect(page.getByRole('log').getByText(/Fake backend received: 中文草稿 已提交/)).toBeVisible(); - await expect(page.getByLabel('你发送的消息')).toHaveCount(1); - - // Settle before the ordinary send: an Enter during a streaming turn would - // become steering instead of a second message. - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - // #1433: the deleted first-run panel had its own input, and the spec that - // covered the handoff between the two asserted this accessible name. With - // one composer left, the name is what a screen-reader user has to find the - // send target by — assert it on the path that exercises it. - await expect(composer).toHaveAttribute('aria-label', '消息输入框'); - await composer.fill('hello e2e'); - await composer.press('Enter'); - - await expect(page.getByRole('log').getByText(/Fake backend received: hello e2e/)).toBeVisible(); -}); diff --git a/apps/desktop/e2e/session-rail-render-contract.spec.ts b/apps/desktop/e2e/session-rail-render-contract.spec.ts deleted file mode 100644 index 722209475e..0000000000 --- a/apps/desktop/e2e/session-rail-render-contract.spec.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { expect, type Page } from '@playwright/test'; -import { - ensureSidebarExpanded, - RAIL_RENDER_SESSION_COUNT, - test, -} from './fixtures.js'; - -/** - * Switching a session moves one row's selection. What it must not do is rewrite - * the rest of the rail. - * - * Deliberately a budget on the OUTCOME rather than an assertion about - * identities or `memo`. The rail's cost has had several independent causes — - * `setActiveId` changing identity every AppShell render, `Intl` formatters - * rebuilt per row, catalog refreshes replacing unchanged row objects — and each - * was invisible to the others. A DOM-write budget catches all of them and the - * ones not yet found, including anything that raises the number of commits a - * switch produces (#4109). - * - * The counter reads inline `style` writes on rail buttons because that is the - * dominant term: every Astryx button removes and re-adds its `anchor-name` per - * render, so one wasted rail render is two style writes per button plus the - * style recalculation they force. - * - * The assertion that carries the contract is `rowsTouched`, not the total. - * Attributing each write to its row makes the budget independent of how many - * rows the fixture seeds, and closes the hole a total-only budget leaves: a - * regression that re-renders the whole rail exactly ONCE stays under any total - * generous enough not to flake, but it cannot touch two rows. That is also the - * missing middle of the fix's own claim — identity is fixed so `memo` holds, and - * `memo` holding means untouched rows produce no DOM work at all. - * - * The last two assertions are the timing half, folded in from #4109's own rail - * budget rather than kept as a second spec: how much was rewritten does not say - * whether the user saw it. The selection must pass through exactly one row on - * its way to the clicked one, and the status badges — which belong to sessions - * whose state did not change — must not be torn down and rebuilt underneath it. - * - * `styleWrites > 0` is the counter's own liveness check. Every write counted - * here comes from an Astryx ref callback that is not wrapped in `useCallback`; - * if that upstream detail is ever memoised, both the healthy and the regressed - * reading collapse to zero and a one-sided budget would pass forever without - * ever failing again. - */ -const RAIL_ROWS_TOUCHED_BUDGET = 2; -/** The leaving row and the arriving row, two writes each, doubled for slack. */ -const RAIL_STYLE_WRITE_BUDGET = 8; - -interface RailCounters { - /** Cumulative since the last `resetRailCounters`; what the budgets read. */ - styleWrites: number; - rowIds: string[]; - rowRemounts: number; - /** - * The selected row after each batch of records, appended only when it moved. - * Counting `aria-current` writes instead would read a re-render that rewrites - * the same selection as the selection moving; this reads what the user sees. - */ - selectedRowIds: (string | null)[]; - /** - * Status badges added or removed. They belong to sessions whose state did not - * change, so rebuilding them is the visible half of a rail re-render: the - * badges flash. `rowRemounts` does not cover it — the row survives. - */ - statusNodeChanges: number; - /** Drained by every quiet poll, so it reports only the latest interval. */ - delta: number; -} - -type RailWindow = Window & { __railCounters: RailCounters }; - -/** - * Waits until the rail has been silent for ~300ms. - * - * A fixed `waitForTimeout` would be the only thing standing between a slow - * machine and a red run: `toHaveCount` proves the rows mounted, not that the - * commits behind them are done, and one late catalog refresh writes more than - * the whole budget. `retries` is 0 in `playwright.config.ts`, so that failure - * would land on an unrelated pull request. - */ -async function waitForRailQuiet(page: Page): Promise { - let quietPolls = 0; - await expect - .poll( - async () => { - const delta = await page.evaluate(() => { - const counters = (window as unknown as RailWindow).__railCounters; - const seen = counters.delta; - counters.delta = 0; - return seen; - }); - quietPolls = delta === 0 ? quietPolls + 1 : 0; - return quietPolls; - }, - { timeout: 15_000, intervals: [100] }, - ) - .toBeGreaterThanOrEqual(3); -} - -test('switching sessions does not rewrite the whole Session rail', async ({ - railRenderWindow: page, -}) => { - await ensureSidebarExpanded(page); - - const rows = page.locator('.maka-session-row'); - await expect(rows).toHaveCount(RAIL_RENDER_SESSION_COUNT); - - const target = page.locator('.maka-session-row button.astryx-side-nav-item', { - hasText: 'Rail row 3', - }); - const selected = page.locator('.maka-session-row button.astryx-side-nav-item.selected'); - await expect(target).toBeVisible(); - - await page.evaluate(() => { - const selectedRowId = (): string | null => - document - .querySelector('.maka-session-row button.astryx-side-nav-item.selected') - ?.closest('.maka-session-row') - ?.getAttribute('data-session-id') ?? null; - - const counters = { - styleWrites: 0, - rowIds: [] as string[], - rowRemounts: 0, - selectedRowIds: [selectedRowId()] as (string | null)[], - statusNodeChanges: 0, - delta: 0, - }; - (window as unknown as { __railCounters: typeof counters }).__railCounters = counters; - const observer = new MutationObserver((records) => { - for (const record of records) { - if (record.type === 'childList') { - for (const node of record.addedNodes) { - const element = node as Element; - if (element.nodeType !== 1) continue; - // A row that unmounts and remounts writes its `anchor-name` once - // on the way in, from a ref callback that runs AFTER insertion — - // so an attribute-only counter reads a whole rail remount as - // cheaper than a rail re-render. Count the remounts directly. - if (element.classList?.contains('maka-session-row')) counters.rowRemounts += 1; - } - for (const node of [...record.addedNodes, ...record.removedNodes]) { - const element = node as Element; - if (element.nodeType !== 1) continue; - if ( - element.matches?.('[data-session-status]') || - element.querySelector?.('[data-session-status]') - ) { - counters.statusNodeChanges += 1; - } - } - continue; - } - const row = (record.target as Element).closest?.('.maka-session-row'); - if (!row) continue; - counters.styleWrites += 1; - counters.delta += 1; - const rowId = row.getAttribute('data-session-id'); - if (rowId && !counters.rowIds.includes(rowId)) counters.rowIds.push(rowId); - } - const selected = selectedRowId(); - if (selected !== counters.selectedRowIds.at(-1)) counters.selectedRowIds.push(selected); - }); - observer.observe(document.body, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ['style'], - }); - (window as unknown as { __railObserver: MutationObserver }).__railObserver = observer; - }); - - // Settle first: the budget is about a switch, not about arriving. - await waitForRailQuiet(page); - await page.evaluate(() => { - const counters = (window as unknown as RailWindow).__railCounters; - counters.styleWrites = 0; - counters.rowIds = []; - counters.rowRemounts = 0; - counters.selectedRowIds = counters.selectedRowIds.slice(-1); - counters.statusNodeChanges = 0; - counters.delta = 0; - }); - - const targetId = await page - .locator('.maka-session-row', { hasText: 'Rail row 3' }) - .getAttribute('data-session-id'); - expect(targetId).toBeTruthy(); - - await target.click(); - await expect(selected).toHaveText(/Rail row 3/); - // Let the post-switch commit cascade finish before reading the counters. - await waitForRailQuiet(page); - - const counted = await page.evaluate(() => { - const scope = window as unknown as RailWindow & { __railObserver: MutationObserver }; - scope.__railObserver.disconnect(); - const { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges } = - scope.__railCounters; - return { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges }; - }); - - expect( - counted.rowIds.length, - `rail rows touched by one session switch, of ${RAIL_RENDER_SESSION_COUNT} (${counted.rowIds.join(', ')})`, - ).toBeLessThanOrEqual(RAIL_ROWS_TOUCHED_BUDGET); - expect(counted.rowRemounts, 'rail rows remounted by one session switch').toBe(0); - expect(counted.styleWrites, 'the style-write counter never fired').toBeGreaterThan(0); - expect(counted.styleWrites, 'rail inline-style writes for one session switch').toBeLessThanOrEqual( - RAIL_STYLE_WRITE_BUDGET, - ); - - // The timing half. A budget says how much was rewritten, not whether the user - // saw it happen: a switch that lands the selection on a third row and takes it - // back stays well inside every count above (#4109). - expect( - counted.selectedRowIds.slice(1), - 'rows the selection passed through during one switch', - ).toEqual([targetId]); - expect(counted.statusNodeChanges, 'status badges rebuilt by one session switch').toBe(0); -}); diff --git a/apps/desktop/e2e/skill-draft-lifecycle.spec.ts b/apps/desktop/e2e/skill-draft-lifecycle.spec.ts deleted file mode 100644 index 227a8bc2a7..0000000000 --- a/apps/desktop/e2e/skill-draft-lifecycle.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Page } from '@playwright/test'; -import { expect, test, COMPOSER_INPUT } from './fixtures'; - -/** - * Revision drafts, per session, with a Skill staged in them. - * - * A staged Skill is a `/skill:` chip inside the draft text, so every path - * here — begin edit, prepare the branch, fail the send, cancel back — moves it - * by moving the text. The point of these journeys is that nothing has to carry - * the Skill separately for that to hold. - * - * The Skill itself comes from the real catalog (the invocable-skills fixture - * plus the Skills module page), not a Desktop-only starter IPC. - */ -async function openInstalledWorkspaceSkill(page: Page): Promise { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await sidebar.getByRole('button', { name: '扩展' }).click(); - await expect(page.locator('[data-module="skills"]')).toBeVisible(); - await expect(page.getByText('Workspace Only', { exact: true })).toBeVisible(); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); -} - -async function seedEditableTurn(page: Page): Promise { - const firstSend = page.locator(COMPOSER_INPUT); - await firstSend.fill('original message'); - await firstSend.press('Enter'); - await expect(page.getByText(/Fake backend received: original message/)).toBeVisible(); -} - -/** Type the draft, then append the Skill chip — the order a user works in. */ -async function composeWithSkill(page: Page, text: string, name: RegExp): Promise { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(text); - await composer.click(); - await composer.pressSequentially(' /'); - const option = page.getByRole('listbox', { name: /技能/ }).getByRole('option', { name }); - await expect(option).toBeVisible(); - await option.click(); -} - -async function beginRevision(page: Page): Promise { - const userMessage = page.getByLabel('你发送的消息').first(); - await userMessage.hover(); - await userMessage.getByRole('button', { name: '编辑并重发' }).click(); - await expect(page.locator('[data-revision-notice="true"]')).toBeVisible(); -} - -async function failWorkspaceSkillRevision(page: Page): Promise { - const disabled = await page.evaluate(() => - window.maka.skills.setEnabled('workspace-only', false), - ); - expect(disabled.ok).toBe(true); - - const composer = page.locator(COMPOSER_INPUT); - await composer.press('Enter'); - await expect(page.getByText('Skill 调用失败,消息未发送')).toBeVisible(); - // The draft survives the rejection whole, and reads as the token rather than - // as a chip: the Skill was just disabled, so it is gone from the catalog the - // composer draws chips from. A chip here would promise a Skill that no longer - // resolves — the text is the honest rendering, and re-enabling it below sends. - await expect(composer).toContainText('edited with skill'); - await expect(composer).toContainText('/skill:workspace-only'); -} - -test('a successful revision retry clears both child and source drafts', async ({ - invocableSkillsWindow: page, -}) => { - await openInstalledWorkspaceSkill(page); - await seedEditableTurn(page); - await beginRevision(page); - await composeWithSkill(page, 'edited with skill', /Workspace Only/); - await failWorkspaceSkillRevision(page); - - const enabled = await page.evaluate(() => - window.maka.skills.setEnabled('workspace-only', true), - ); - expect(enabled.ok).toBe(true); - await page.locator(COMPOSER_INPUT).press('Enter'); - - await expect(page.locator('[data-revision-notice="true"]')).toHaveCount(0); - await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); - await page.getByRole('button', { name: '查看上一版本' }).click(); - await expect( - page.getByLabel('你发送的消息').getByText('original message', { exact: true }), - ).toBeVisible(); - await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); -}); - -test('cancelling a failed revision restores the complete pre-edit draft', async ({ - invocableSkillsWindow: page, -}) => { - await openInstalledWorkspaceSkill(page); - await seedEditableTurn(page); - - const composer = page.locator(COMPOSER_INPUT); - await composeWithSkill(page, 'previous unsent draft', /Project Only/); - await beginRevision(page); - await composeWithSkill(page, 'edited with skill', /Workspace Only/); - await failWorkspaceSkillRevision(page); - - await page.getByRole('button', { name: '取消' }).click(); - - await expect(page.locator('[data-revision-notice="true"]')).toHaveCount(0); - // Restored through a single controlled write, which rebuilds the editor from - // the serialized draft; the Skill comes back as a chip because the composer - // redraws it from that text, not because anything carried it separately. - await expect(composer).toContainText('previous unsent draft'); - await expect( - page.locator('[data-astryx-token-value="/skill:project-only"]'), - ).toContainText('Project Only'); -}); diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts deleted file mode 100644 index 69bad64557..0000000000 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - COMPOSER_INPUT, - ensureSidebarExpanded, - expect, - test, - waitForWorkHubReady, -} from './fixtures'; - -test('WorkHub rebuilds delegated execution feedback after navigating away and back', async ({ - window: page, -}) => { - const initialPrompt = '检查支付回调重复投递时的幂等性'; - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(initialPrompt); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - - const sessionName = await page.evaluate(async () => - (await window.maka.sessions.list())[0]?.name, - ); - expect(sessionName).toBeTruthy(); - await page.evaluate(async () => { - await window.maka.settings.updateClient({ workHub: { enabled: true } }); - }); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); - // The conversation is the Coordination Session transcript. An ordinary - // Session is a routing target and a status row, never a turn in WorkHub. - await waitForWorkHubReady(page, 1); - await expect(page.locator('.workhub-turn')).toHaveCount(0); - await expect(page.locator('.workhub-empty h2')).toHaveText('从这里继续所有工作'); - - const routedPrompt = '继续这个工作,补充重复投递测试点。'; - const workHubComposer = page.locator( - '.workhub-surface .maka-composer-editor [contenteditable="true"]', - ); - await workHubComposer.fill(routedPrompt); - await workHubComposer.press('Enter'); - const routedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); - await expect(routedTurn.locator('.workhub-submitted')).toBeVisible(); - await routedTurn.locator('.workhub-submitted > button').click(); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeHidden(); - - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); - await waitForWorkHubReady(page, 1); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); - await expect( - page.locator('.workhub-projected-turn .workhub-user-bubble > p', { - hasText: routedPrompt, - }), - ).toBeVisible(); - await expect( - page.locator('.workhub-projected-turn', { hasText: routedPrompt }) - .locator('.workhub-submitted-state'), - ).toHaveText('关联有效 · 已完成'); -}); - -test('WorkHub explicitly announces a newly created work item', async ({ window: page }) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('检查支付回调重复投递时的幂等性'); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - await page.evaluate(async () => { - await window.maka.settings.updateClient({ workHub: { enabled: true } }); - }); - await waitForWorkHubReady(page, 1); - - const prompt = '创建一个新的 Session,名为登录稳定性'; - const workHubComposer = page.locator( - '.workhub-surface .maka-composer-editor [contenteditable="true"]', - ); - await workHubComposer.fill(prompt); - await workHubComposer.press('Enter'); - - const createdTurn = page.locator('.workhub-turn', { hasText: prompt }); - await expect(createdTurn.locator('.workhub-submitted')).toContainText('已创建新工作:'); - await expect(createdTurn.locator('.workhub-submitted-session strong')).toHaveText('登录稳定性'); -}); - -test('WorkHub replaces the exact linked delegation across Sessions', async ({ - window: page, -}) => { - const sourceSessionName = '检查支付回调重复投递时的幂等性'; - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(sourceSessionName); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - await page.evaluate(async (name) => { - const sourceSession = (await window.maka.sessions.list())[0]; - if (!sourceSession) throw new Error('Source Session was not found'); - await window.maka.sessions.rename(sourceSession.id, name); - }, sourceSessionName); - await page.evaluate(async () => { - await window.maka.settings.updateClient({ workHub: { enabled: true } }); - }); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); - await page.evaluate(async () => { - await window.maka.sessions.create({ name: '登录稳定性' }); - }); - await waitForWorkHubReady(page, 2); - - const workHubComposer = page.locator( - '.workhub-surface .maka-composer-editor [contenteditable="true"]', - ); - await workHubComposer.fill('继续这个工作,补充重复投递测试点。'); - await workHubComposer.press('Enter'); - const continuedTurn = page.locator('.workhub-turn', { - hasText: '继续这个工作,补充重复投递测试点。', - }); - await expect( - continuedTurn.locator('.workhub-submitted-session strong'), - ).toHaveText(sourceSessionName); - await waitForWorkHubReady(page, 2); - - await workHubComposer.fill('不是这个,换成登录稳定性,补充刷新令牌失败判定。'); - await expect( - page.locator('.workhub-surface').getByRole('button', { name: '发送' }), - ).toBeEnabled(); - await workHubComposer.press('Enter'); - - const correctionTurn = page.locator('.workhub-turn', { - hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', - }); - await expect( - correctionTurn.locator('.workhub-submitted-session strong'), - ).toHaveText('登录稳定性'); - await expect(correctionTurn.locator('.workhub-error')).toHaveCount(0); - await expect( - continuedTurn.locator('.workhub-submitted-state'), - ).toHaveText('已被更正'); - await expect( - correctionTurn.locator('.workhub-submitted-state'), - ).toContainText( - /^关联有效 · (?:已接收|进行中|等待你|已完成|失败|已中止|正在恢复)$/u, - ); -}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 06a7759a64..fcb0e4a382 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -895,8 +895,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 147, - "nonTriviaTokens": 15588 + "importSpecifiers": 146, + "nonTriviaTokens": 15587 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts new file mode 100644 index 0000000000..44205dc37d --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import type { ComposerHandle } from '@maka/ui'; +import { retireRevisionDraft } from '../../renderer/features/conversation/index.js'; +import { + createAppShellRevisionActions, + type TurnRevisionDraft, +} from '../../renderer/app-shell-revision-actions.js'; + +const originalWindow = globalThis.window; + +afterEach(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); +}); + +function preparedDraft(): TurnRevisionDraft { + return { + sourceSessionId: 'source', + sourceTurnId: 'turn-1', + copyId: 'revision-copy', + copyPhase: 'started', + draftSessionId: 'child', + originalText: 'original message', + previousComposerText: 'previous unsent draft /skill:project-only', + }; +} + +test('a successful revision send clears the child and source draft owners', () => { + const cleared: string[] = []; + const committed: Array = []; + let copyCompleted = 0; + retireRevisionDraft( + preparedDraft(), + (key) => cleared.push(key), + () => { + copyCompleted += 1; + }, + () => committed.push(null), + ); + + assert.equal(copyCompleted, 1); + assert.deepEqual(cleared, ['child', 'source']); + assert.deepEqual(committed, [null]); +}); + +test('cancelling a prepared revision restores the complete pre-edit draft', async () => { + const draftRef = { current: preparedDraft() as TurnRevisionDraft | null }; + const calls: string[] = []; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + maka: { + sessions: { + abandonSessionCopy: async ( + sourceSessionId: string, + copyId: string, + ) => { + calls.push(`abandon:${sourceSessionId}:${copyId}`); + }, + }, + }, + }, + }); + const composer = { + setDraft: (key: string, text: string) => calls.push(`set:${key}:${text}`), + clearDraft: (key: string) => calls.push(`clear:${key}`), + setText: (text: string) => calls.push(`text:${text}`), + getText: () => '', + focus: () => calls.push('focus'), + } as unknown as ComposerHandle; + const actions = createAppShellRevisionActions({ + uiLocale: 'en', + activeIdRef: { current: 'child' }, + composerRef: { current: composer }, + messages: [], + hasPendingAttachments: () => false, + openSessionInChat: (sessionId) => calls.push(`open:${sessionId}`), + refreshMessages: async (sessionId) => { + calls.push(`messages:${sessionId}`); + return true; + }, + refreshSessions: async () => { + calls.push('sessions'); + return []; + }, + setMessages: () => undefined, + commitRevisionDraft: (draft) => { + draftRef.current = draft; + }, + revisionDraftRef: draftRef, + toastApi: { info: () => undefined, error: () => undefined }, + }); + + await actions.cancelRevisionDraft(); + + assert.equal(draftRef.current, null); + assert.deepEqual(calls, [ + 'abandon:source:revision-copy', + 'set:source:previous unsent draft /skill:project-only', + 'clear:child', + 'open:source', + 'messages:source', + 'sessions', + ]); +}); diff --git a/apps/desktop/src/main/__tests__/goal-dialog.test.ts b/apps/desktop/src/main/__tests__/goal-dialog.test.ts index ee233698c2..1ed2d81e00 100644 --- a/apps/desktop/src/main/__tests__/goal-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/goal-dialog.test.ts @@ -25,7 +25,10 @@ import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; import type { GoalArmOutcome } from '../../shared/goal-arm.js'; -import { GoalDialog } from '../../renderer/features/goals/testing.js'; +import { + GoalDialog, + type GoalArmInput, +} from '../../renderer/features/goals/testing.js'; const originalGlobals = { document: globalThis.document, @@ -135,8 +138,46 @@ test('keeps the Goal form editable after a deterministic rejection', async () => assert.equal(findButton(harness.document, 'Start').hasAttribute('disabled'), false); }); +test('blocks invalid budgets and arms the exact values the form shows', async () => { + const requests: unknown[] = []; + const harness = installGoalDialog(async (sessionId, request) => { + requests.push({ sessionId, request }); + return { kind: 'armed', goal: goalState() }; + }); + await harness.render('session-1'); + await setInputValue(harness.document, 'textarea', 'All tests pass'); + + const [maxIterations, tokenBudget] = harness.document.querySelectorAll('input'); + assert.ok(maxIterations); + assert.ok(tokenBudget); + + await setInputElementValue(tokenBudget, '500'); + assert.equal(findButton(harness.document, 'Start').hasAttribute('disabled'), true); + assert.match(harness.document.body.textContent, /at least 1,?000/i); + + await setInputElementValue(tokenBudget, '5000'); + assert.equal(findButton(harness.document, 'Start').hasAttribute('disabled'), false); + + await setInputElementValue(maxIterations, '250'); + assert.equal(findButton(harness.document, 'Start').hasAttribute('disabled'), true); + assert.match(harness.document.body.textContent, /from 1 to 200/i); + + await setInputElementValue(maxIterations, '25'); + await clickButton(harness.document, 'Start'); + + assert.deepEqual(requests, [{ + sessionId: 'session-1', + request: { + condition: 'All tests pass', + maxIterations: 25, + tokenBudget: 5_000, + }, + }]); + assert.equal(harness.closed, 1); +}); + function installGoalDialog( - arm: (sessionId: string) => Promise, + arm: (sessionId: string, request: GoalArmInput) => Promise, ) { const parsed = parseHTML('
'); const { document, window } = parsed; @@ -211,6 +252,13 @@ async function setInputValue( ): Promise { const input = document.querySelector(selector) as HTMLInputElement | null; assert.ok(input, `missing input: ${selector}`); + await setInputElementValue(input, value); +} + +async function setInputElementValue( + input: HTMLInputElement, + value: string, +): Promise { await act(async () => { input.value = value; const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6473dd7e7..d6eb45bc62 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -75,6 +75,7 @@ import { ChatMessageSurface } from './chat-message-surface'; import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; import { deriveTaskReadinessNotice, + retireRevisionDraft, isTaskSubmissionHardBlocked, resolveTaskReadinessModelTarget, transcriptReadingPosition, @@ -183,12 +184,8 @@ import { import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; import { createAppShellChatActions } from './app-shell-chat-actions'; import { createAppShellTurnActions } from './app-shell-turn-actions'; -import { - abandonTurnRevisionCopyAttempt, - completeTurnRevisionCopyAttempt, - createAppShellRevisionActions, - type TurnRevisionDraft, -} from './app-shell-revision-actions'; +import * as revisionActions from './app-shell-revision-actions'; +import type { TurnRevisionDraft } from './app-shell-revision-actions'; import { createAppShellSessionStartActions } from './app-shell-session-start-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; import { useStableActions } from './use-stable-actions'; @@ -793,12 +790,15 @@ function AppShellContent({ const source = sessions.find((session) => session.id === draft.sourceSessionId); const owner = sessions.find((session) => session.id === draft.draftSessionId); if (source && owner && !source.isArchived && !owner.isArchived) return; - composerRef.current?.clearDraft(draft.draftSessionId); - if (draft.sourceSessionId !== draft.draftSessionId) - composerRef.current?.clearDraft(draft.sourceSessionId); - if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); - else void abandonTurnRevisionCopyAttempt(draft); - commitRevisionDraft(null); + retireRevisionDraft( + draft, + (sessionId) => composerRef.current?.clearDraft(sessionId), + () => + draft.copyPhase === 'reserved' + ? revisionActions.completeTurnRevisionCopyAttempt(draft) + : void revisionActions.abandonTurnRevisionCopyAttempt(draft), + () => commitRevisionDraft(null), + ); }, [sessions, commitRevisionDraft]); const { @@ -1800,7 +1800,7 @@ function AppShellContent({ beginEditUserMessage, prepareRevisionSend, cancelRevisionDraft, - } = useStableActions(createAppShellRevisionActions, { + } = useStableActions(revisionActions.createAppShellRevisionActions, { uiLocale, activeIdRef, composerRef, @@ -2112,13 +2112,15 @@ function AppShellContent({ } if (ok !== false && revisionSend) { if (expectedRevisionDraft) { - completeTurnRevisionCopyAttempt(expectedRevisionDraft); - composerRef.current?.clearDraft(expectedRevisionDraft.draftSessionId); - if (expectedRevisionDraft.sourceSessionId !== expectedRevisionDraft.draftSessionId) { - composerRef.current?.clearDraft(expectedRevisionDraft.sourceSessionId); - } + retireRevisionDraft( + expectedRevisionDraft, + (sessionId) => composerRef.current?.clearDraft(sessionId), + () => revisionActions.completeTurnRevisionCopyAttempt(expectedRevisionDraft), + () => commitRevisionDraft(null), + ); + } else { + commitRevisionDraft(null); } - commitRevisionDraft(null); } return ok; } diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index e8f6b42289..cc82b0b11f 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -39,4 +39,5 @@ export { resolveTaskReadinessModelTarget, type TaskReadinessNotice, } from './model/task-readiness-notice.js'; +export { retireRevisionDraft } from './model/revision-draft-lifecycle.js'; export * from './model/session-ui-state.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/revision-draft-lifecycle.ts b/apps/desktop/src/renderer/features/conversation/model/revision-draft-lifecycle.ts new file mode 100644 index 0000000000..ddb2cd3a34 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/revision-draft-lifecycle.ts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function retireRevisionDraft( + draft: { sourceSessionId: string; draftSessionId: string }, + clearDraft: (sessionId: string) => void, + finishCopy: () => void, + clearRevision: () => void, +): void { + finishCopy(); + clearDraft(draft.draftSessionId); + if (draft.sourceSessionId !== draft.draftSessionId) { + clearDraft(draft.sourceSessionId); + } + clearRevision(); +} diff --git a/apps/desktop/src/renderer/features/goals/testing.ts b/apps/desktop/src/renderer/features/goals/testing.ts index 96e6cbd839..99b5134a8e 100644 --- a/apps/desktop/src/renderer/features/goals/testing.ts +++ b/apps/desktop/src/renderer/features/goals/testing.ts @@ -30,7 +30,7 @@ export { type GoalController, type UseGoalControllerInput, } from './controller/use-goal-controller.js'; -export type { GoalServices } from './ports.js'; +export type { GoalArmInput, GoalServices } from './ports.js'; const noopSubscription = (): (() => void) => () => undefined; diff --git a/apps/desktop/stories/composer-skill-draft.stories.tsx b/apps/desktop/stories/composer-skill-draft.stories.tsx index 123c10637a..3e9e31b8fd 100644 --- a/apps/desktop/stories/composer-skill-draft.stories.tsx +++ b/apps/desktop/stories/composer-skill-draft.stories.tsx @@ -148,3 +148,32 @@ export const StagedSkillsSurviveADraftScopeSwitch: Story = { await expect(wire).toContain('run it'); }, }; + +// Real path: a CJK IME owns Enter while committing a candidate. The native +// capture guard must keep that key away from both the composer's send handler +// and Astryx's trigger menu; the first ordinary Enter afterwards still sends. +export const ImeCommitDoesNotSend: Story = { + play: async ({ canvasElement }) => { + const composer = editor(canvasElement); + await userEvent.click(composer); + await userEvent.keyboard('中文草稿'); + + composer.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + composer.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + // Chromium reports false for the observed regression; the component's + // own composition lifecycle is the only guard this story credits. + isComposing: false, + }), + ); + composer.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })); + await expect(sent).not.toHaveBeenCalled(); + + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(sent).toHaveBeenCalledTimes(1)); + await expect(sent).toHaveBeenCalledWith('中文草稿'); + }, +}; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 032e326f80..b235dfc86c 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -2639,6 +2639,24 @@ export const HealthCenter: Story = { export const About: Story = { decorators: [withSettingsBridge], render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.findByText('本地开发版', { exact: true })).resolves.toBeTruthy(); + await expect(canvas.findByText('本地开发构建,不检查更新。')).resolves.toBeTruthy(); + await expect(canvas.queryByRole('button', { name: '检查更新' })).not.toBeInTheDocument(); + await expect( + canvas.findByRole('heading', { name: '支持' }), + ).resolves.toBeTruthy(); + await expect( + canvas.findByRole('button', { name: '复制诊断信息' }), + ).resolves.toBeEnabled(); + await expect(canvas.findByRole('link', { name: '报告问题' })).resolves.toBeTruthy(); + await expect( + canvas.findByRole('button', { name: '键盘快捷键' }), + ).resolves.toBeEnabled(); + const privacy = await canvas.findByRole('list', { name: '隐私承诺' }); + await expect(within(privacy).getAllByRole('listitem')).toHaveLength(3); + }, }; // Real path: the same page inside a packaged Nightly. Nightly publishes daily diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 9a5700d44d..1e75ca1089 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useRef, type ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, waitFor, within } from 'storybook/test'; import type { ProjectRecord } from '@maka/core/project'; @@ -92,6 +92,7 @@ function panelProps(input: { groups?: SessionListPanelProps['groups']; projectActions?: SessionListPanelProps['projectActions']; worktreeSessionIds?: SessionListPanelProps['worktreeSessionIds']; + onSelectSession?: SessionListPanelProps['onSelectSession']; }): SessionListPanelProps { return { selection: input.selection ?? { section: 'sessions' }, @@ -106,7 +107,7 @@ function panelProps(input: { ...(input.groups ? { groups: input.groups } : {}), ...(input.projectActions ? { projectActions: input.projectActions } : {}), ...(input.worktreeSessionIds ? { worktreeSessionIds: input.worktreeSessionIds } : {}), - onSelectSession: noop, + onSelectSession: input.onSelectSession ?? noop, onSelect: noop, onOpenSettings: noop, onNew: noop, @@ -237,6 +238,40 @@ const longTitleSessions = [ }), ]; +const renderBudgetSessions = Array.from({ length: 32 }, (_, index) => + makeSession({ + id: `render-budget-${index}`, + name: `Rail row ${index}`, + status: index % 5 === 0 ? 'running' : 'active', + lastMessageAt: NOW - index * 60_000, + }), +); + +function RenderBudgetRail() { + const [activeId, setActiveId] = useState('render-budget-0'); + const select = useCallback((sessionId: string) => setActiveId(sessionId), []); + return ( + + + + ); +} + +async function waitForRailMutationQuiet(counters: { delta: number }): Promise { + let quiet = 0; + await waitFor(() => { + quiet = counters.delta === 0 ? quiet + 1 : 0; + counters.delta = 0; + expect(quiet).toBeGreaterThanOrEqual(3); + }, { timeout: 10_000, interval: 100 }); +} + const liveRunAuthoritySessions: SessionSummary[] = [ { ...makeSession({ @@ -298,6 +333,93 @@ export const ConversationStates: Story = { ), }; +// Real path: switching between two ordinary Sessions in a populated rail. The +// controller unit test pins stable command identities; this real-layout story +// pins the resulting DOM budget, so a different source of whole-rail rewrites +// still fails without paying Electron startup cost. +export const SessionSwitchRenderBudget: Story = { + render: () => , + play: async ({ canvasElement }) => { + const counters = { + styleWrites: 0, + rowIds: new Set(), + rowRemounts: 0, + selectedRowIds: [] as (string | null)[], + statusNodeChanges: 0, + delta: 0, + }; + const selectedRowId = (): string | null => + canvasElement + .querySelector('.maka-session-row button.astryx-side-nav-item.selected') + ?.closest('.maka-session-row') + ?.getAttribute('data-session-id') ?? null; + counters.selectedRowIds.push(selectedRowId()); + const observer = new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'childList') { + for (const node of record.addedNodes) { + const element = node as Element; + if (element.nodeType === 1 && element.classList?.contains('maka-session-row')) { + counters.rowRemounts += 1; + } + } + for (const node of [...record.addedNodes, ...record.removedNodes]) { + const element = node as Element; + if ( + element.nodeType === 1 && + (element.matches?.('[data-session-status]') || + element.querySelector?.('[data-session-status]')) + ) { + counters.statusNodeChanges += 1; + } + } + continue; + } + const row = (record.target as Element).closest?.('.maka-session-row'); + if (!row) continue; + counters.styleWrites += 1; + counters.delta += 1; + const rowId = row.getAttribute('data-session-id'); + if (rowId) counters.rowIds.add(rowId); + } + const selected = selectedRowId(); + if (selected !== counters.selectedRowIds.at(-1)) counters.selectedRowIds.push(selected); + }); + observer.observe(canvasElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['style'], + }); + + await waitForRailMutationQuiet(counters); + counters.styleWrites = 0; + counters.rowIds.clear(); + counters.rowRemounts = 0; + counters.selectedRowIds = counters.selectedRowIds.slice(-1); + counters.statusNodeChanges = 0; + + const target = canvasElement.querySelector( + '.maka-session-row[data-session-id="render-budget-3"] button.astryx-side-nav-item', + ); + await expect(target).not.toBeNull(); + target!.click(); + await waitFor(() => expect(target!).toHaveClass('selected')); + await waitForRailMutationQuiet(counters); + observer.disconnect(); + + expect(counters.rowIds.size).toBeLessThanOrEqual(2); + expect(counters.rowRemounts).toBe(0); + expect(counters.styleWrites).toBeGreaterThan(0); + // Storybook's focus handoff invokes one extra ref pair compared with the + // Electron fixture. The stronger two-row budget above still rejects any + // whole-rail rewrite regardless of the fixture size. + expect(counters.styleWrites).toBeLessThanOrEqual(10); + expect(counters.selectedRowIds.slice(1)).toEqual(['render-budget-3']); + expect(counters.statusNodeChanges).toBe(0); + }, +}; + // Real path: the active task row's overflow menu after its semantic trigger is // opened. The menu is portaled outside the rail, so the play assertion reads // from the owning document rather than only the story canvas.