From ff4aaa9ce4702708f5e5705541e9d002bdb1e828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E9=80=8A?= <211125649+UncertaintyDeterminesYou4ndMe@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:13:02 +0800 Subject: [PATCH] test(desktop): merge same-fixture e2e assertions into coherent journeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #2390 (Electron E2E half; the Storybook matrix is a separate PR per the issue). Every e2e window fixture is function-scoped, so 69 tests paid 69 Electron launches — with most tests re-launching and re-seeding the exact state their file-mates had just built. Tests that share one fixture and compatible assertions now run as consecutive phases of one journey; every merge is annotated with the ordering constraint that made it safe (count pins run first, destructive phases run last, don't-ask-again phases end their window). Merged: ask-user-question 2->1, attachment 2->1, bot-onboarding 2->1, composer-mention-token 4->1, composer-skill-invocation 7->3, keyboard-help 2->1, mcp 5->2, new-messages-indicator 2->1 (also stops re-seeding the six-message transcript), providers 3->1, quote-companion 12->4, send-message 4->2, session-workbar 2->1, settings-projects 3->1, settings 6->2, skill-delete-scope 2->1. Kept separate deliberately: - storage-root-conflict (cold-start race is the subject), - permission-mode-surface (two different fixture scenarios), - the staged-Skills draft-restoration test (its contract is editor rebuilds on external value changes; concurrent session activity in a shared window perturbs exactly that — observed, not guessed), - quote-companion's batch-close (needs the confirmation dialog the numbered-tab journey suppresses via don't-ask-again). Also removes 11 window fixtures that no spec references (dead seeding code left behind by earlier test deletions): longTranscriptWindow, shortFinalTurnWindow, overflowingRailWindow, sidebarLongSessionsWindow, disclosureOutputWindow, staleSessionsWindow, gitReviewWindow, gitReviewLargeWindow, artifactPaneWindow, localeSwitchWindow, planRemindersWindow. 69 tests / 69 launches -> 34 tests / 34 launches. Local (4 workers): 57.9s -> 47.3/43.2/40.8s across three green rounds. CI runs a single worker, where launch count dominates wall time. --- apps/desktop/e2e/ask-user-question.spec.ts | 18 +- apps/desktop/e2e/attachment.spec.ts | 58 +- apps/desktop/e2e/bot-onboarding.spec.ts | 9 +- .../e2e/composer-mention-token.spec.ts | 150 ++-- .../e2e/composer-skill-invocation.spec.ts | 166 ++-- apps/desktop/e2e/fixtures.ts | 166 ---- apps/desktop/e2e/keyboard-help.spec.ts | 35 +- apps/desktop/e2e/mcp.spec.ts | 93 +- .../e2e/new-messages-indicator.spec.ts | 32 +- apps/desktop/e2e/providers.spec.ts | 65 +- apps/desktop/e2e/quote-companion.spec.ts | 807 ++++++++---------- apps/desktop/e2e/send-message.spec.ts | 66 +- apps/desktop/e2e/session-workbar.spec.ts | 32 +- apps/desktop/e2e/settings-projects.spec.ts | 53 +- apps/desktop/e2e/settings.spec.ts | 84 +- apps/desktop/e2e/skill-delete-scope.spec.ts | 30 +- 16 files changed, 778 insertions(+), 1086 deletions(-) diff --git a/apps/desktop/e2e/ask-user-question.spec.ts b/apps/desktop/e2e/ask-user-question.spec.ts index 33844a9610..c63dd07d9a 100644 --- a/apps/desktop/e2e/ask-user-question.spec.ts +++ b/apps/desktop/e2e/ask-user-question.spec.ts @@ -1,7 +1,13 @@ import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime'; import { test, expect, COMPOSER_INPUT } from './fixtures.js'; -test('rehydrates a prompt the surface never received live', async ({ window: page }) => { +// 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'); @@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag .first() .click(); - await expect(prompt).toBeVisible(); - await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible(); -}); - -test('answers three questions and continues the same fake-backend 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(); await expect(page.locator('.maka-composer')).toBeHidden(); await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible(); diff --git a/apps/desktop/e2e/attachment.spec.ts b/apps/desktop/e2e/attachment.spec.ts index b76fa824a4..86b57722b1 100644 --- a/apps/desktop/e2e/attachment.spec.ts +++ b/apps/desktop/e2e/attachment.spec.ts @@ -1,42 +1,12 @@ import { test, expect, COMPOSER_INPUT } from './fixtures'; -test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => { - const firstSend = page.locator(COMPOSER_INPUT); - await firstSend.fill('ime-paste-test'); - await firstSend.press('Enter'); - await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible(); - - const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]'); - const editable = composer.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 }); -}); - /** * 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', async ({ window: page }) => { +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); @@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window 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 index a88da25d02..f5d8944a0a 100644 --- a/apps/desktop/e2e/bot-onboarding.spec.ts +++ b/apps/desktop/e2e/bot-onboarding.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from './fixtures'; -test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => { +test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => { const settings = page.getByRole('main', { name: '设置内容' }); await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible(); @@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘 await dialog.getByRole('button', { name: '完成' }).click(); await expect(dialog).toBeHidden(); -}); - -test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => { - const settings = page.getByRole('main', { name: '设置内容' }); + // 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: '微信扫码登录' }); diff --git a/apps/desktop/e2e/composer-mention-token.spec.ts b/apps/desktop/e2e/composer-mention-token.spec.ts index d43a0ec48b..f0fcaf4e10 100644 --- a/apps/desktop/e2e/composer-mention-token.spec.ts +++ b/apps/desktop/e2e/composer-mention-token.spec.ts @@ -1,5 +1,28 @@ 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 @@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures'; * `contenteditable="false"` stretched it to the full line and pushed the * surrounding text onto separate rows. */ -test('a picked file mention becomes an inline token and sends as its path', async ({ +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.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.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: '工作区文件' }); @@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn expect(tokenWidth).toBeLessThan(lineWidth / 2); await composer.press('Enter'); - const bubble = page.getByLabel('你发送的消息').first(); + // 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); @@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn ).toBeVisible(); await page.reload(); - const reloadedBubble = page.getByLabel('你发送的消息').first(); + const reloadedBubble = page.getByLabel('你发送的消息').last(); await expect(reloadedBubble).toBeVisible(); await expect( reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'), @@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn '普通文本 @.maka/skills/agent-write/SKILL.md', ); }); - -/** - * 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. - */ -test('the trigger menu opens exactly on the boundaries we depend on', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - 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); -}); - -/** - * 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. - */ -test('Enter with an open, empty trigger menu withholds one send, not every send', 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.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible(); - await expect(page.getByLabel('你发送的消息')).toHaveCount(1); -}); - -/** - * 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. - */ -test('moving the caret off the query closes the trigger menu', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - 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.getByText('Fake backend received: 看一下 @agent')).toBeVisible(); - await expect(composer.locator('[data-astryx-token]')).toHaveCount(0); -}); diff --git a/apps/desktop/e2e/composer-skill-invocation.spec.ts b/apps/desktop/e2e/composer-skill-invocation.spec.ts index 914547e96e..0ead53af61 100644 --- a/apps/desktop/e2e/composer-skill-invocation.spec.ts +++ b/apps/desktop/e2e/composer-skill-invocation.spec.ts @@ -35,7 +35,13 @@ async function selectStarterSkill( await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); } -test('slash suggestions follow Runtime project discovery and host gating', async ({ +// 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); @@ -53,34 +59,7 @@ test('slash suggestions follow Runtime project discovery and host gating', async })).map((skill) => skill.name), ); expect(planNames).not.toContain('Agent Write'); -}); - -test('slash suggestions in a Deep Research session drop non-research Skills', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - const listbox = page.getByRole('listbox', { name: /技能/ }); - - 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'); -}); -test('open Skill suggestions follow current collaboration capabilities', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await expect(composer).toBeVisible(); await composer.fill('Open a session'); await composer.press('Enter'); await expect.poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length).toBe(1); @@ -95,7 +74,6 @@ test('open Skill suggestions follow current collaboration capabilities', async ( await expect.poll(() => listNames(session.id)).toContain('Agent Write'); await composer.fill('/'); - const listbox = page.getByRole('listbox', { name: /技能/ }); await expect(listbox).toContainText('Agent Write'); await expect @@ -107,56 +85,21 @@ test('open Skill suggestions follow current collaboration capabilities', async ( ); await expect.poll(() => listNames(session.id)).not.toContain('Agent Write'); await expect(listbox).not.toContainText('Agent Write'); -}); - -/** - * #1912: + → 选择技能 opens the SAME `/` menu the keyboard opens, by typing the - * trigger. There is no second Skill surface — the multi-select panel that used - * to live here is gone, and with it the transparent, product-owned popover that - * was the reported defect. - * - * The half that only a real window can show is the trigger boundary. - * `useTriggerMenu` recognizes `/` at a line start or after a space or newline, - * so + on a draft that ends in anything else has to insert the space itself. - * Get that wrong and + does nothing at all, silently, and only when a draft is - * present. The draft here ends in a chip, whose U+00A0 anchor is the character - * that looks like a space and is not one. - */ -test('the + Skills entry opens the `/` menu, before and after a chip', async ({ - window: page, -}) => { - await createStarterSkillAndReload(page); - - const composer = page.locator(COMPOSER_INPUT); - 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(); - await expect(listbox.getByRole('option', { name: /示例技能/ })).toBeVisible(); - await composer.press('Enter'); - await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); - await openFromPlus(); + await composer.fill('/'); await expect(listbox).toBeVisible(); + await expect(listbox).not.toContainText('Deep Research Only'); + await composer.fill(''); - // 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('/'); + // ⌘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'); }); /** @@ -228,23 +171,14 @@ test('staged Skills come back as chips after leaving and returning', async ({ ).toHaveText(['Project Only', 'Workspace Only']); }); -test('chip-only send renders a readable user message', async ({ window: page }) => { - await createStarterSkillAndReload(page); - await selectStarterSkill(page); - - const composer = page.locator(COMPOSER_INPUT); - 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('示例技能'); -}); - -test('a blocked Skill invocation keeps the complete composer draft', async ({ +// The starter-skill window, three phases in dependency order: the blocked +// 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'); @@ -266,4 +200,56 @@ test('a blocked Skill invocation keeps the complete composer draft', async ({ 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/fixtures.ts b/apps/desktop/e2e/fixtures.ts index da4c322354..4fba643608 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -302,23 +302,12 @@ export const test = base.extend<{ window: Page; firstRunWindow: Page; modelPickerLongWindow: Page; - longTranscriptWindow: Page; - shortFinalTurnWindow: Page; - overflowingRailWindow: Page; - sidebarLongSessionsWindow: Page; - disclosureOutputWindow: Page; sandboxBoundaryWindow: Page; readOnlyBoundaryWindow: Page; - staleSessionsWindow: Page; sessionWorkbarWindow: Page; - gitReviewWindow: Page; - gitReviewLargeWindow: Page; - artifactPaneWindow: Page; botSettingsWindow: Page; - localeSwitchWindow: Page; invocableSkillsWindow: Page; settingsProjectsWindow: Page; - planRemindersWindow: Page; oauthReloginWindow: Page; }>({ // Seeded: a pre-staged connection clears onboarding so the composer is ready. @@ -364,101 +353,6 @@ export const test = base.extend<{ use, ); }, - // Long transcript: boots the e2e-fixture `long-transcript` fixture, which - // seeds a 24-turn (~1300px each) session and opens it as the active - // session. Fixture mode seeds its own connections, so no connection is - // pre-staged here. Readiness = turns on screen and RENDERED BY THE REAL - // MARKDOWN PIPELINE: the session is open and above-viewport turns sit at - // their content-visibility placeholder size. Used by the scroll-geometry - // spec. - // - // `.maka-markdown-pending` is the Suspense fallback for the lazily imported - // markdown chunk, and the turn-size warm-up will not start while one is on - // screen. Handing the page over before that chunk lands charged the spec's - // settle budget for a module load: under 50x CPU throttling the fallback - // holds for ~9.6s of a ~19s cold start, most of the spec's 15s, for work - // that is boot rather than settling. - longTranscriptWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '[data-chat-scroll-container="true"]:has(.maka-turn):not(:has(.maka-markdown-pending))', - e2eFixtureScenario: 'long-transcript', - locale: 'zh', - }, - use, - ); - }, - // Short final turn: boots the e2e-fixture `short-final-turn` fixture — five - // tall turns and a one-line last turn — and opens it as the active session. - // Same readiness contract as `longTranscriptWindow` and for the same reason: - // the markdown chunk must have landed before the spec scrolls. Used by the - // prompt-rail spec to reach an end of the scroller that the rail's - // activation band never covers. - shortFinalTurnWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '[data-chat-scroll-container="true"]:has(.maka-turn):not(:has(.maka-markdown-pending))', - e2eFixtureScenario: 'short-final-turn', - locale: 'zh', - }, - use, - ); - }, - // Overflowing rail: boots the e2e-fixture `overflowing-rail` fixture — 90 - // short turns — and opens it as the active session. Same readiness contract - // as the two above. Used by the prompt-rail spec to exercise the rail once it - // is past its cap and scrolling independently of the transcript. - overflowingRailWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '[data-chat-scroll-container="true"]:has(.maka-turn):not(:has(.maka-markdown-pending))', - e2eFixtureScenario: 'overflowing-rail', - locale: 'zh', - }, - use, - ); - }, - // Long sidebar sessions: boots the e2e-fixture `sidebar-long-sessions` - // fixture, which seeds 60 active sessions and opens the newest one - // (`...-00`) with the sidebar expanded. Fixture mode seeds its own - // connections, so no connection is pre-staged here. Readiness = a session - // row on screen INSIDE AN EXPANDED SIDEBAR: the panel grid has mounted, the - // session list has loaded from IPC, and the footer sits below the - // constrained list row. Used by the sidebar-geometry and sidebar-navigation - // specs. - // - // The `[data-sidebar-state="expanded"]` part is load-bearing. The shell - // boots collapsed (the localStorage default), and `sidebarCollapsed: false` - // only lands later, from `applyE2eFixture` — a rAF plus two IPC round trips - // after mount. A bare session label does not gate on it: a collapsed - // sidebar keeps the whole list mounted at full width behind `opacity: 0` in - // a 0px grid column, which Playwright still reports as visible. Tests then - // started against a sidebar that was about to expand under them. - sidebarLongSessionsWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '[data-sidebar-state="expanded"] [data-session-id]', - e2eFixtureScenario: 'sidebar-long-sessions', - locale: 'zh', - }, - use, - ); - }, - disclosureOutputWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.astryx-chat-tool-calls [role="button"][aria-expanded="false"]', - e2eFixtureScenario: 'disclosure-output', - locale: 'zh', - }, - 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. @@ -479,17 +373,6 @@ export const test = base.extend<{ use, ); }, - // Stale sessions: boots the e2e-fixture `stale-sessions` fixture — one - // healthy session (zai-live, secret seeded) and one locked fake-backend session - // (opened active). Exercises the #1038 health-notice authority against real IPC - // (connection list, hasSecret probe, connectionLocked summaries). - // Readiness = turns on screen: the fake session is open. - staleSessionsWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '.maka-turn', e2eFixtureScenario: 'stale-sessions', 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) => { @@ -506,39 +389,6 @@ export const test = base.extend<{ use, ); }, - gitReviewWindow: async ({}, use) => { - await withE2eWindow( - { - seed: true, - readinessSelector: COMPOSER_INPUT, - locale: 'zh', - gitReviewExtraFiles: 0, - }, - use, - ); - }, - gitReviewLargeWindow: async ({}, use) => { - await withE2eWindow( - { - seed: true, - readinessSelector: COMPOSER_INPUT, - locale: 'zh', - gitReviewExtraFiles: 45, - }, - use, - ); - }, - artifactPaneWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.maka-artifact-pane', - e2eFixtureScenario: 'artifact-pane', - locale: 'zh', - }, - 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. @@ -548,11 +398,6 @@ export const test = base.extend<{ use, ); }, - // Keep this fixture unpinned so the Follow system assertion observes the - // actual host language while the legacy fixtures remain deterministic. - localeSwitchWindow: async ({}, use) => { - await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT }, use); - }, // Project + Maka-workspace Skills with one deliberately host-incompatible // entry. Proves `/` uses Runtime discovery/gating rather than management UI data. invocableSkillsWindow: async ({}, use) => { @@ -563,17 +408,6 @@ export const test = base.extend<{ invocableSkills: true, }, use); }, - planRemindersWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.maka-module-page-rows', - e2eFixtureScenario: 'plan-reminders', - locale: 'zh', - }, - use, - ); - }, oauthReloginWindow: async ({}, use) => { await withE2eWindow( { diff --git a/apps/desktop/e2e/keyboard-help.spec.ts b/apps/desktop/e2e/keyboard-help.spec.ts index ef5dbdeef5..f9ed0442e2 100644 --- a/apps/desktop/e2e/keyboard-help.spec.ts +++ b/apps/desktop/e2e/keyboard-help.spec.ts @@ -7,7 +7,7 @@ const MOD_SLASH = process.platform === 'darwin' ? 'Meta+/' : 'Control+/'; * with keycap chrome — not the stacked plain text the issue reported — and * every documented entry point must reach it. */ -test('the help modal opens from its entry points and keeps its styled layout', async ({ window: page }) => { +test('the help modal opens from its entry points, keeps its styled layout, and survives a blank UA-CH platform', async ({ window: page }) => { // Entry point 1: bare `?` with no input focused. The composer autofocuses, // so park focus somewhere non-typing first. await page.locator('body').click({ position: { x: 4, y: 200 } }); @@ -42,29 +42,29 @@ test('the help modal opens from its entry points and keeps its styled layout', a // The platform's own modifier is what the sheet documents — ⌘ on macOS, // Ctrl elsewhere. (This harness boots with healthy UA-CH; the blank-UA-CH - // environment is pinned by the dedicated test below.) + // environment is pinned by the reload phase below.) const firstCombo = page.locator('.maka-help-section dd').first(); await expect(firstCombo).toContainText(process.platform === 'darwin' ? '⌘' : 'Ctrl'); // The same combo toggles it closed again. await page.keyboard.press(MOD_SLASH); await expect(body).not.toBeVisible(); -}); -/** - * Guard for the `@astryxdesign/core` blank-UA-CH patch (patches/README.md). - * - * Electron bundles with a rewritten identity (the ad-hoc-signed dev app) - * ship `userAgentData.platform: ''`, and Astryx's probe read the blank as - * "not Apple" — mod hotkeys bound Ctrl on macOS and keycaps drew Ctrl. This - * harness boots with healthy UA-CH, so the broken environment is - * reconstructed explicitly: blank UA-CH plus a Mac `navigator.platform`, - * installed before the app boots. Patched Astryx must fall through the - * blank to navigator.platform and land on ⌘ — on every host OS, which is - * what makes this test discriminating in CI (unpatched, the blank decides - * "not Apple" and the keycap reads Ctrl). - */ -test('a blank UA-CH platform falls through to navigator.platform', async ({ window: page }) => { + /** + * Guard for the `@astryxdesign/core` blank-UA-CH patch (patches/README.md). + * + * Electron bundles with a rewritten identity (the ad-hoc-signed dev app) + * ship `userAgentData.platform: ''`, and Astryx's probe read the blank as + * "not Apple" — mod hotkeys bound Ctrl on macOS and keycaps drew Ctrl. This + * harness boots with healthy UA-CH, so the broken environment is + * reconstructed explicitly: blank UA-CH plus a Mac `navigator.platform`, + * installed before the reload below. Patched Astryx must fall through the + * blank to navigator.platform and land on ⌘ — on every host OS, which is + * what makes this phase discriminating in CI (unpatched, the blank decides + * "not Apple" and the keycap reads Ctrl). Same window: the init script + * takes effect on the reload, so the healthy-UA-CH assertions above and + * this broken-environment phase share one launch. + */ await page.context().addInitScript(() => { const original = (navigator as { userAgentData?: unknown }).userAgentData ?? {}; Object.defineProperty(Navigator.prototype, 'userAgentData', { @@ -82,7 +82,6 @@ test('a blank UA-CH platform falls through to navigator.platform', async ({ wind // With the fake Mac platform installed, the mod hotkey must bind ⌘ — so // Meta+/ opens the modal regardless of the host OS. await page.keyboard.press('Meta+/'); - const body = page.locator('dialog[open] .maka-help-body'); await expect(body).toBeVisible(); await expect(page.locator('.maka-help-section dd').first()).toContainText('⌘'); }); diff --git a/apps/desktop/e2e/mcp.spec.ts b/apps/desktop/e2e/mcp.spec.ts index cc0cda7cb7..d4701d22bc 100644 --- a/apps/desktop/e2e/mcp.spec.ts +++ b/apps/desktop/e2e/mcp.spec.ts @@ -6,7 +6,12 @@ const fixtureServer = path.resolve( '../../packages/mcp/dist/__fixtures__/stdio-server.js', ); -test('module navigation removes the hidden chat surface from layout and hit testing', async ({ window: page }) => { +// Layout journey over one Extensions window: hidden-chat hit testing, the +// compact editor, the centred-column width matrix, and description +// truncation are all final-state layout facts over the same seeded state — +// none of them needs its own Electron launch. The stdio lifecycle below +// keeps its own window: it mutates server state end to end. +test('MCP module layout: hidden chat, compact editor, centred column, and truncation', async ({ window: page }) => { await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('navigation', { name: '对话列表' }).getByRole('button', { name: '扩展', exact: true }).click(); await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); @@ -21,14 +26,49 @@ test('module navigation removes the hidden chat surface from layout and hit test return Boolean(target?.closest('.maka-chat-layout')); }), ).toBe(false); -}); -test('MCP module page keeps one centred column without horizontal overflow', async ({ window: page }) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('navigation', { name: '对话列表' }).getByRole('button', { name: '扩展', exact: true }).click(); await page.locator('.maka-module-hub-selector').getByRole('button', { name: 'MCP' }).click(); await expect(page.getByRole('toolbar', { name: 'MCP 浏览操作' })).toBeVisible(); + await page.setViewportSize({ width: 1164, height: 700 }); + + const slackRow = page.locator('[data-maka-contract="mcp-market-row"]').filter({ hasText: 'Slack' }); + await slackRow.getByRole('button', { name: '安装 Slack' }).click(); + await slackRow.getByRole('button', { name: '管理' }).click(); + + const editor = page.getByRole('dialog', { name: '编辑 slack' }); + await expect(editor).toBeVisible(); + await expect.poll(() => editor.evaluate((element) => ( + element.getAnimations().every((animation) => animation.playState === 'finished') + ))).toBe(true); + const overflow = await editor.evaluate((dialog) => { + const fields = dialog.querySelector('.maka-mcp-form-fields'); + if (!fields) throw new Error('Expected MCP editor fields'); + return { + dialog: dialog.scrollHeight - dialog.clientHeight, + fields: fields.scrollHeight - fields.clientHeight, + }; + }); + + expect(overflow.dialog, JSON.stringify(overflow)).toBeLessThanOrEqual(1); + expect(overflow.fields, JSON.stringify(overflow)).toBeLessThanOrEqual(1); + + const selectedTransportSpacing = await editor.getByRole('radio', { name: '本地 stdio' }).evaluate((radio) => { + const radioWrapper = radio.parentElement; + const icon = radioWrapper?.nextElementSibling; + if (!(radioWrapper instanceof HTMLElement) || !(icon instanceof SVGElement)) { + throw new Error('Expected selected transport radio and icon'); + } + return icon.getBoundingClientRect().left - radioWrapper.getBoundingClientRect().right; + }); + expect(selectedTransportSpacing).toBeGreaterThanOrEqual(4); + + // Leave the editor before the width matrices below. + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name: '编辑 slack' })).toBeHidden(); + + await expect(page.getByRole('toolbar', { name: 'MCP 浏览操作' })).toBeVisible(); + for (const width of [1440, 1280, 861, 860, 761]) { await page.setViewportSize({ width, height: 700 }); await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(width); @@ -63,12 +103,7 @@ test('MCP module page keeps one centred column without horizontal overflow', asy expect(geometry.rowsWidth, `${width}px: ${JSON.stringify(geometry)}`).toBeLessThanOrEqual(900); expect(geometry.centerDelta, `${width}px: ${JSON.stringify(geometry)}`).toBeLessThanOrEqual(1); } -}); -test('MCP server descriptions truncate with an ellipsis at 700px and 500px', async ({ window: page }) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('navigation', { name: '对话列表' }).getByRole('button', { name: '扩展', exact: true }).click(); - await page.locator('.maka-module-hub-selector').getByRole('button', { name: 'MCP' }).click(); const endpoint = `https://example.com/${'narrow-description-segment/'.repeat(12)}mcp`; await page.evaluate(async (url) => { @@ -117,44 +152,6 @@ test('MCP server descriptions truncate with an ellipsis at 700px and 500px', asy } }); -test('credentialed MCP editor fits a compact desktop viewport without incidental scrolling', async ({ window: page }) => { - await page.setViewportSize({ width: 1164, height: 700 }); - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('navigation', { name: '对话列表' }).getByRole('button', { name: '扩展', exact: true }).click(); - await page.locator('.maka-module-hub-selector').getByRole('button', { name: 'MCP' }).click(); - - const slackRow = page.locator('[data-maka-contract="mcp-market-row"]').filter({ hasText: 'Slack' }); - await slackRow.getByRole('button', { name: '安装 Slack' }).click(); - await slackRow.getByRole('button', { name: '管理' }).click(); - - const editor = page.getByRole('dialog', { name: '编辑 slack' }); - await expect(editor).toBeVisible(); - await expect.poll(() => editor.evaluate((element) => ( - element.getAnimations().every((animation) => animation.playState === 'finished') - ))).toBe(true); - const overflow = await editor.evaluate((dialog) => { - const fields = dialog.querySelector('.maka-mcp-form-fields'); - if (!fields) throw new Error('Expected MCP editor fields'); - return { - dialog: dialog.scrollHeight - dialog.clientHeight, - fields: fields.scrollHeight - fields.clientHeight, - }; - }); - - expect(overflow.dialog, JSON.stringify(overflow)).toBeLessThanOrEqual(1); - expect(overflow.fields, JSON.stringify(overflow)).toBeLessThanOrEqual(1); - - const selectedTransportSpacing = await editor.getByRole('radio', { name: '本地 stdio' }).evaluate((radio) => { - const radioWrapper = radio.parentElement; - const icon = radioWrapper?.nextElementSibling; - if (!(radioWrapper instanceof HTMLElement) || !(icon instanceof SVGElement)) { - throw new Error('Expected selected transport radio and icon'); - } - return icon.getBoundingClientRect().left - radioWrapper.getBoundingClientRect().right; - }); - expect(selectedTransportSpacing).toBeGreaterThanOrEqual(4); -}); - 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: '对话列表' }); diff --git a/apps/desktop/e2e/new-messages-indicator.spec.ts b/apps/desktop/e2e/new-messages-indicator.spec.ts index adb566d70e..1b8e03ad3f 100644 --- a/apps/desktop/e2e/new-messages-indicator.spec.ts +++ b/apps/desktop/e2e/new-messages-indicator.spec.ts @@ -100,7 +100,7 @@ async function growTranscriptOverflow(page: import('@playwright/test').Page) { throw new Error('transcript did not overflow past the 100px button threshold after 6 messages'); } -test('clears the "New messages" indicator once the user scrolls back to the bottom', async ({ window: page }) => { +test('clears the "New messages" indicator at the bottom and never leaks it into a new conversation', async ({ window: page }) => { // Locale-agnostic matchers: zh overrides ("跳到最新消息"/"滚动到底部") or // Astryx en ("New messages"/"Scroll to bottom") — see the file header. const newMessagesBtn = page.getByRole('button', { name: /New messages|跳到最新消息/ }); @@ -135,18 +135,28 @@ test('clears the "New messages" indicator once the user scrolls back to the bott // → auto-follow re-locks → the indicator must clear (#2205). await scrollToBottom(page); await expect(newMessagesBtn).toHaveCount(0); -}); - -test('does not leak the indicator into a new conversation', async ({ window: page }) => { - // Same locale-agnostic matchers as the first test (see file header). - const newMessagesBtn = page.getByRole('button', { name: /New messages|跳到最新消息/ }); - // Build the flagged state: a long transcript, scrolled up, then a new reply - // while unlocked. - await growTranscriptOverflow(page); + // Phase 2 (#2205's second half), reusing the transcript grown above instead + // of paying the six-message seed again: re-flag the conversation, then prove + // the indicator does not leak into a brand-new conversation. + // + // Every turn must be fully settled first — a send while one still streams + // becomes steering, and no fresh reply would arrive to flag. The transcript + // length is dynamic (growTranscriptOverflow stops at overflow), so settle + // means: as many settled-turn footers as sent messages. + await expect + .poll( + async () => { + const [settled, sent] = await Promise.all([ + page.getByRole('button', { name: '重新生成' }).count(), + page.getByLabel('你发送的消息').count(), + ]); + return sent > 0 && settled === sent; + }, + { timeout: 20_000 }, + ) + .toBe(true); await scrollToTop(page); - const composer = page.locator(COMPOSER_INPUT); - await expect(composer).toHaveAttribute('aria-label', '消息输入框'); await composer.fill('flag this conversation'); await composer.press('Enter'); await expect(page.getByText('Fake backend received: flag this conversation', { exact: true })).toBeAttached(); diff --git a/apps/desktop/e2e/providers.spec.ts b/apps/desktop/e2e/providers.spec.ts index 9818f1ee0d..91cf1c7c14 100644 --- a/apps/desktop/e2e/providers.spec.ts +++ b/apps/desktop/e2e/providers.spec.ts @@ -75,7 +75,11 @@ async function expectNoDialog(page: Page) { await expect(page.getByRole('dialog')).toHaveCount(0); } -test('Models collection header matches its collection', async ({ window: page }) => { +test('provider connections: header geometry, the canonical API-key journey, and two-field rows', async ({ window: page }) => { + // One window, three phases over one connection list. Phase 1 ends with the + // seeded connection deleted (an empty list), which is exactly the state the + // add-journey needs; the add-journey deletes what it created, which is the + // state the two-field relay phase needs. await openModelsPage(page); const panel = page.locator('[data-maka-contract="providers-panel"]'); await expect(panel.locator('ul')).toBeVisible(); @@ -105,21 +109,12 @@ test('Models collection header matches its collection', async ({ window: page }) await expect(panel.getByRole('heading', { name: '模型连接', exact: true })).toBeVisible(); await expect(panel.getByText('· 1', { exact: true })).toBeVisible(); - await page.evaluate(async () => { - await window.maka.connections.delete('e2e'); - }); - 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); -}); -// Canonical API-key add journey. Cerebras is the concrete stand-in only because -// it is the strongest exercise of the color-asset render contract (a real -// upstream mark that must stay untouched in BOTH light and dark themes); -// the assertions below validate the *flow and the colorAssetRenderContract -// mechanism*, not Cerebras's data — that lives in the registry contract tests. -test('adds a catalog provider through the canonical API-key setup page', async ({ window: page }) => { + // Canonical API-key add journey. Cerebras is the concrete stand-in only because + // it is the strongest exercise of the color-asset render contract (a real + // upstream mark that must stay untouched in BOTH light and dark themes); + // the assertions below validate the *flow and the colorAssetRenderContract + // mechanism*, not Cerebras's data — that lives in the registry contract tests. // 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 @@ -130,8 +125,7 @@ test('adds a catalog provider through the canonical API-key setup page', async ( 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(); + // Already on the models page — phase 1 landed here after its reload. const catalog = await openCatalog(page, { category: 'API', search: 'Cerebras' }); // A color brand asset renders as an untouched : no currentColor mask, @@ -309,26 +303,23 @@ test('adds a catalog provider through the canonical API-key setup page', async ( 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. -test('keeps each settled row to its own field on a provider that has two', async ({ window: page }) => { - await openModelsPage(page); + // 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 setup = providerSetup(page); - await setup.getByRole('textbox', { name: /服务地址/ }).fill('https://relay.example.com/v1'); - await setup.getByRole('textbox', { name: /API Key/ }).fill('e2e-relay-key'); - await setup.getByRole('textbox', { name: /默认模型/ }).fill('relay-model'); + 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 detail = connectionDetail(page); - await expect(detail).toBeVisible(); - const connectionSection = detail.getByRole('region', { name: '连接' }); + 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. @@ -372,6 +363,18 @@ test('keeps each settled row to its own field on a provider that has two', async 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 = { diff --git a/apps/desktop/e2e/quote-companion.spec.ts b/apps/desktop/e2e/quote-companion.spec.ts index 92c06b27a4..302d0db407 100644 --- a/apps/desktop/e2e/quote-companion.spec.ts +++ b/apps/desktop/e2e/quote-companion.spec.ts @@ -23,143 +23,12 @@ async function waitForSourceSessionToSettle(page: Page) { } /** - * Quote companion lifecycle: stage selection → side panel → remove one staged - * quote → fork with inherited permissions → send → closing the tab cleans up. - * Composer chrome only needs token *count* here; full quote text lives in the - * panel list (Token labels truncate and must not be the source of truth). + * 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('quote companion removes one staged quote, forks, answers, and cleans up on tab close', 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) => { - 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 stageReply(firstSourceReply); - await stageReply(secondSourceReply); - - 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); -}); -/** - * The selection affordance's timing contract. Selecting text is not the same - * as wanting to quote it — most selections mean "copy" or are a reading habit - * — so the layer is owed to a settled selection inside a turn, and only that. - */ -test('the quote layer waits for the selection to settle, stays closed after Escape, and ignores the composer', async ({ +test('the quote layer: settle timing, Escape, immediate hide, and scroll following', async ({ window: page, }) => { await page.setViewportSize({ width: 1400, height: 900 }); @@ -247,124 +116,13 @@ test('the quote layer waits for the selection to settle, stays closed after Esca await selectContents(composer); await page.waitForTimeout(500); await expect(quoteLayer).toBeHidden(); -}); - -/** - * Scrolling moves the selection, so it must move the layer. The layer used to - * be cleared on scroll because its position was a snapshot; deriving the - * position from the live selection instead is what makes following possible. - */ -test('the quote layer follows the selection while the transcript scrolls', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 700 }); - const composer = page.locator(COMPOSER_INPUT); - // Long enough that the selected turn can be scrolled clear of the scroller. - for (let i = 0; i < 14; 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 11/) - .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); - }); - - const quoteLayer = page.locator('.maka-quote-actions'); - await expect(quoteLayer).toBeVisible(); - - /** - * The whole measurement runs inside the page. Scrolling and reading across - * the driver interleaves round-trips with frames the renderer is still - * settling in, which pairs a fresh layer position with a stale selection one - * — the flake this test kept hitting. In here the scroll and both rects are - * one synchronous layout, and the wait for the layer to catch up is by frame. - * - * Writing `scrollTop` rather than sending a wheel: wheel delivery is a - * host-level detail (it landed nowhere on CI's Linux runner), while what this - * test is about is the `scroll` event the hook listens to, which a scrollTop - * write raises just the same. - */ - const follow = await page.evaluate(async () => { - let scroller = document.querySelector('.maka-chat-message-list')?.parentElement ?? null; - while (scroller && scroller.scrollHeight <= scroller.clientHeight) { - scroller = scroller.parentElement; - } - if (!scroller) throw new Error('no scrollable transcript ancestor'); - - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); - const selectionTop = () => - window.getSelection()?.getRangeAt(0).getBoundingClientRect().top ?? null; - const layerTop = () => - document.querySelector('.maka-quote-actions')?.getBoundingClientRect().top ?? null; - - const before = { selection: selectionTop(), layer: layerTop() }; - scroller.scrollTop -= 220; - const selectionShift = selectionTop()! - before.selection!; - - for (let frame = 0; frame < 120; frame += 1) { - await nextFrame(); - const now = layerTop(); - if (now !== null && Math.abs(now - before.layer! - selectionShift) <= 1) { - return { selectionShift, layerShift: now - before.layer! }; - } - } - return { selectionShift, layerShift: layerTop() === null ? null : layerTop()! - before.layer! }; - }); - - // A scroll that moved nothing would make the assertion below vacuous. - expect(follow.selectionShift).not.toBe(0); - // The layer tracks the selection rather than merely surviving the scroll. - // Sub-pixel tolerance, not exactness: the layer is positioned in CSS pixels - // and rounded to the device grid, so it lands within a pixel of the anchor. - expect(Math.abs(follow.layerShift! - follow.selectionShift)).toBeLessThanOrEqual(1); - - // Following stops at the edge of the scroller. Once the anchor leaves the - // visible band there is nothing to point at, and a bar clamped to the top of - // the window pointing at off-screen text is the noise this feature exists to - // avoid. `getBoundingClientRect()` still reports a full-size rect for an - // off-screen range, so this cannot be left to the zero-size guard. - await page.evaluate(() => { - let scroller = document.querySelector('.maka-chat-message-list')?.parentElement ?? null; - while (scroller && scroller.scrollHeight <= scroller.clientHeight) { - scroller = scroller.parentElement; - } - if (scroller) scroller.scrollTop = 0; - }); - await expect(quoteLayer).toBeHidden(); -}); -/** - * A new selection must not leave the layer standing on the previous one's - * anchor while the new one settles. Asserted within a frame: the settle timer - * would clear it ~350ms later anyway, so any auto-waiting assertion passes - * whether or not the code hides it up front. - */ -test('a new selection hides the layer immediately, not when the next one settles', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const composer = page.locator(COMPOSER_INPUT); - for (const label of ['hide first alpha', 'hide first beta']) { - await composer.fill(label); - await composer.press('Enter'); - await expect(page.getByText(new RegExp(`Fake backend received: ${label}`)).last()).toBeVisible(); - await waitForSourceSessionToSettle(page); - } + // 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 @@ -378,7 +136,7 @@ test('a new selection hides the layer immediately, not when the next one settles selection?.addRange(range); }); - await select(/Fake backend received: hide first alpha/); + await select(/Fake backend received: selection timing source/); await expect(page.locator('.maka-quote-actions')).toBeVisible(); const stillVisibleNextFrame = await page @@ -394,49 +152,191 @@ test('a new selection hides the layer immediately, not when the next one settles return !!document.querySelector('.maka-quote-actions'); }); expect(stillVisibleNextFrame).toBe(false); -}); -test('side conversations open as independent numbered tabs and confirm before discarding content', async ({ + // 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 conversations: every entry point, then the numbered-tab lifecycle', async ({ window: page, }) => { await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator(COMPOSER_INPUT); - await mainComposer.fill('side conversation source'); + 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 conversation source/)).toBeVisible(); + 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 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 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(() => - visiblePanel + 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(2); - await visiblePanel.locator(COMPOSER_INPUT).fill('first side draft'); + .toBe(1); - // 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( + // 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(3); - await visiblePanel.locator(COMPOSER_INPUT).fill('second side draft'); + .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); + + // Numbered tabs, independent drafts, and the don't-ask-again close. + await waitForSourceSessionToSettle(page); + + 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'); @@ -461,11 +361,11 @@ test('side conversations open as independent numbered tabs and confirm before di .map(({ permissionMode }) => permissionMode) .sort(), })); - }, sourceSession!.id), + }, sourceSession.id), ) .toEqual({ count: 3, - companions: [sourceSession!.permissionMode, sourceSession!.permissionMode].sort(), + companions: [sourceSession.permissionMode, sourceSession.permissionMode].sort(), }); await page.getByRole('button', { name: '关闭first side draft', exact: true }).click(); @@ -501,29 +401,151 @@ test('side conversations open as independent numbered tabs and confirm before di .toBe(1); }); -test('side chat inherits permissions and steers an active turn without losing the draft', async ({ +/** + * Side-chat behavior over one source conversation: the staged-quote + * lifecycle, permission inheritance + steering, failure classification and + * retry, and draft survival across collapse and navigation. + */ +test('side chat: staged quotes, steering and permissions, failures, and draft survival', async ({ window: page, }) => { await page.setViewportSize({ width: 1400, height: 900 }); const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side steering source'); + await mainComposer.fill('quote companion source one'); await mainComposer.press('Enter'); - await expect(page.getByText(/Fake backend received: side steering source/)).toBeVisible(); + + 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) => { + 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 stageReply(firstSourceReply); + await stageReply(secondSourceReply); + + 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); + + // Steering and permission inheritance on a fresh fork. + await waitForSourceSessionToSettle(page); + await openSideConversationFromLauncher(page); - const panel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - const sideComposer = panel.locator(COMPOSER_INPUT); + 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 = panel.locator('.maka-user-message'); + const sideUserMessages = steerPanel.locator('.maka-user-message'); await expect(sideUserMessages).toHaveCount(1); await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); - await expect(panel.getByRole('button', { name: '停止' })).toBeVisible(); - const steerButton = panel.getByRole('button', { name: '插入消息' }); + 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(); @@ -533,65 +555,65 @@ test('side chat inherits permissions and steers an active turn without losing th await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); await expect(sideUserMessages.nth(1)).toContainText('only answer the side request'); await expect( - panel.getByText(/Acknowledged steering: only answer the side request/), + 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 companionSession = ( + const steerCompanion = ( await page.evaluate(() => window.maka.sessions.list()) ).find(({ id }) => id !== sourceSession?.id); - expect(companionSession?.permissionMode).toBe(sourceSession?.permissionMode); - const permissionButton = panel.locator('.permissionModeIcon button').first(); + 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 === companionSession?.id)?.permissionMode; + return sessions.find(({ id }) => id === steerCompanion?.id)?.permissionMode; }) .toBe('bypass'); -}); -test('side chat classifies failures, survives collapse, retries, and closes during a failing turn', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side failure recovery source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: side failure recovery source/), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); + // Close the steering fork before the failure phases: it holds content, so + // answer the 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); - await openSideConversationFromLauncher(page); - const panel = page.locator('.maka-quote-companion'); - const sideComposer = panel.locator(COMPOSER_INPUT); + // Failure classification, collapse survival, and retry. const rightPanel = page.locator('[data-maka-contract="session-workbar-right"]'); + await waitForSourceSessionToSettle(page); - await sideComposer.fill('__e2e_error__:network'); - await sideComposer.press('Enter'); - await expect(panel.getByRole('button', { name: '停止' })).toBeVisible(); + 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(panel).toBeVisible(); - await expect(panel.locator('.maka-quote-companion-error')).toHaveText('网络错误'); - await expect(panel.getByRole('button', { name: '停止' })).toHaveCount(0); + await expect(failPanel).toBeVisible(); + await expect(failPanel.locator('.maka-quote-companion-error')).toHaveText('网络错误'); + await expect(failPanel.getByRole('button', { name: '停止' })).toHaveCount(0); - await sideComposer.fill('retry after deterministic network failure'); - await sideComposer.press('Enter'); - await expect(panel.locator('.maka-quote-companion-error')).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( - panel.getByText(/Fake backend received: retry after deterministic network failure/), + failPanel.getByText(/Fake backend received: retry after deterministic network failure/), ).toBeVisible(); - await sideComposer.fill('__e2e_error__:auth'); - await sideComposer.press('Enter'); + await failComposer.fill('__e2e_error__:auth'); + await failComposer.press('Enter'); const activeSideTab = page.locator( '.maka-workbar-tab[data-running][data-workbar-tab-id^="side-chat:"]', ); @@ -599,166 +621,47 @@ test('side chat classifies failures, survives collapse, retries, and closes duri 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(panel).toBeHidden(); + await expect(failPanel).toBeHidden(); await expect .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) .toBe(1); await page.waitForTimeout(350); await expect(page.getByText('鉴权失败')).toHaveCount(0); -}); -test('side conversation survives workbar collapse and launcher navigation with its draft', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator(COMPOSER_INPUT); - await mainComposer.fill('side conversation draft source'); - await mainComposer.press('Enter'); - await expect(page.getByText(/Fake backend received: side conversation draft source/)).toBeVisible(); + // Draft survival across collapse and launcher navigation. + await waitForSourceSessionToSettle(page); await openSideConversationFromLauncher(page); - const panel = page.locator('.maka-quote-companion'); - const companionComposer = panel.locator(COMPOSER_INPUT); - const rightPanel = page.locator('[data-maka-contract="session-workbar-right"]'); - await companionComposer.fill('draft survives panel navigation'); + 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(panel).toBeHidden(); + await expect(draftPanel).toBeHidden(); await page.getByRole('button', { name: '展开会话工作栏' }).click(); await expect(rightPanel).toBeVisible(); - await expect(panel).toBeVisible(); - await expect(companionComposer).toHaveText('draft survives panel navigation'); + 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(panel).toBeHidden(); + await expect(draftPanel).toBeHidden(); await page.getByRole('tab', { name: '侧边对话', exact: true }).click(); - await expect(panel).toBeVisible(); - await expect(companionComposer).toHaveText('draft survives panel navigation'); + await expect(draftPanel).toBeVisible(); + await expect(draftComposer).toHaveText('draft survives panel navigation'); await page.getByRole('button', { name: '关闭侧边对话', exact: true }).click(); - await expect(panel).toBeHidden(); + await expect(draftPanel).toBeHidden(); await expect(rightPanel).toBeHidden(); await expect .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) .toBe(1); }); -test('command palette opens side chat and focuses it when ready', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.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(); - await expect - .poll(async () => { - const [source] = await page.evaluate(() => window.maka.sessions.list()); - return source?.status; - }) - .not.toBe('running'); - - 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(); -}); - -test('slash suggestions expose Side and execute it without leaving command text', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('slash suggestion source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: slash suggestion source/), - ).toBeVisible(); - - 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); -}); - -test('/side opens a titled side chat and sends its prompt 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('slash side source'); - await mainComposer.press('Enter'); - await expect(page.getByText(/Fake backend received: slash side source/)).toBeVisible(); - await waitForSourceSessionToSettle(page); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - - 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 companion = page.locator('.maka-quote-companion'); - await expect( - companion.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); -}); - +// 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, }) => { diff --git a/apps/desktop/e2e/send-message.spec.ts b/apps/desktop/e2e/send-message.spec.ts index 10f483a161..e9697404c5 100644 --- a/apps/desktop/e2e/send-message.spec.ts +++ b/apps/desktop/e2e/send-message.spec.ts @@ -1,25 +1,6 @@ import { test, expect, COMPOSER_INPUT } from './fixtures'; import { FAKE_MERMAID_HOSTILE_PROMPT, FAKE_MERMAID_PROMPT } from '@maka/runtime'; -/** - * 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('send a message and see the fake backend stream a reply', async ({ window: page }) => { - const composer = page.locator(COMPOSER_INPUT); - // #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.getByText(/Fake backend received: hello e2e/)).toBeVisible(); -}); - /** * 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 @@ -27,7 +8,13 @@ test('send a message and see the fake backend stream a reply', async ({ window: * guard is a native capture on the composer root that takes the key away from * React entirely. */ -test('Enter mid-IME-composition commits the candidate instead of sending', async ({ +/** + * 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); @@ -50,9 +37,26 @@ test('Enter mid-IME-composition commits the candidate instead of sending', async await composer.press('Enter'); await expect(page.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.getByText(/Fake backend received: hello e2e/)).toBeVisible(); }); -test('renders a settled Mermaid fence as a diagram', async ({ window: page }) => { +// 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); @@ -181,17 +185,19 @@ test('renders a settled Mermaid fence as a diagram', async ({ window: page }) => await page.setViewportSize({ width: 340, height: 900 }); await expect(diagram.getByRole('button', { name: '全屏查看图表' })).toBeVisible(); await expect(diagram.getByRole('button', { name: '放大图表' })).toBeHidden(); -}); -test('keeps hostile Mermaid directives inert', async ({ window: page }) => { - const composer = page.locator(COMPOSER_INPUT); + // 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: '重新生成' })).toBeVisible(); - const diagram = page.locator('[data-maka-contract="mermaid"]').last(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-state', 'rendered'); - await expect(diagram.locator('.maka-mermaid-svg > svg')).toBeVisible(); - await expect(diagram.locator('script, foreignObject, a')).toHaveCount(0); - await expect(diagram.locator('[onclick], [onerror], [onload], [href^="javascript:"]')).toHaveCount(0); + 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 495495aac5..a01ed96ebe 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from './fixtures'; // 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', async ({ sessionWorkbarWindow: page }) => { +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"]', @@ -137,6 +137,18 @@ test('session tools share one user-controlled workbar', async ({ sessionWorkbarW await expect(rightWorkbar).toBeVisible(); // The width the drag above landed on, restored rather than reset. await expect(rightWorkbar).toHaveCSS('width', '511px'); + + // 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. + 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 rightWorkbar.getByRole('button', { name: '打开工作栏标签' }).click(); await page.getByRole('menuitem', { name: '文件' }).click(); await expect(page.getByText('暂无生成文件')).toBeVisible(); @@ -150,21 +162,3 @@ test('session tools share one user-controlled workbar', async ({ sessionWorkbarW await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); }); -test('the right workbar stays mounted across repeated collapse', async ({ - sessionWorkbarWindow: page, -}) => { - const workbar = page.locator('[data-maka-contract="session-workbar-right"]'); - await expect(workbar).toBeVisible(); - const collapseOnce = async () => { - await page.getByRole('button', { name: '收起会话工作栏' }).click(); - await expect(workbar).toHaveCount(1); - await expect(workbar).toBeHidden(); - await expect(workbar).toHaveAttribute('data-collapsed', 'true'); - }; - - await collapseOnce(); - await page.getByRole('button', { name: '展开会话工作栏' }).click(); - await expect(workbar).toBeVisible(); - await expect(workbar).toHaveCSS('width', '480px'); - await collapseOnce(); -}); diff --git a/apps/desktop/e2e/settings-projects.spec.ts b/apps/desktop/e2e/settings-projects.spec.ts index a7e1850aac..fcbb445097 100644 --- a/apps/desktop/e2e/settings-projects.spec.ts +++ b/apps/desktop/e2e/settings-projects.spec.ts @@ -8,7 +8,7 @@ import { expect, test } from './fixtures.js'; * 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 and moves the default between rows', async ({ +test('the projects page lists the catalog, moves the default, gates reveal, and renames in place', async ({ settingsProjectsWindow: page, }) => { await page @@ -74,17 +74,25 @@ test('the projects page lists the catalog and moves the default between rows', a await remaining.nth(stillEnabled[0]).click(); await expect(main.getByText('默认', { exact: true })).toHaveCount(1); } -}); -test('a project can be renamed in place from the row menu', async ({ - settingsProjectsWindow: page, -}) => { - await page - .getByRole('navigation', { name: /设置分组|Settings sections/ }) - .getByRole('button', { name: /项目|Projects/, exact: true }) - .click(); + // 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(); - const main = page.getByRole('main', { name: /设置内容|Settings content/ }); + // 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 @@ -110,28 +118,3 @@ test('a project can be renamed in place from the row menu', async ({ ) .toContain('astryx-renamed'); }); - -test('reveal is offered only for a project whose folder is still there', 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/ }); - 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); -}); diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index fa961dcf5a..7b26921638 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -11,39 +11,10 @@ function settingsNavigation(page: Page) { * classList.toggle). This exercises the settings open → navigate → mutate → * apply path without depending on pixel colors. */ -test('changing the theme in settings applies to the UI', async ({ window: page }) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('button', { name: '设置' }).click(); - await expect(page.getByLabel('设置内容')).toBeVisible(); - - await settingsNavigation(page).getByRole('button', { name: '外观', exact: true }).click(); - const lightTheme = page.getByRole('checkbox', { name: '浅色' }); - const darkTheme = page.getByRole('checkbox', { name: '深色' }); - await darkTheme.locator('..').click(); - await expect(darkTheme).toBeChecked(); - await expect(lightTheme).not.toBeChecked(); - - await expect.poll( - async () => page.evaluate(() => document.documentElement.classList.contains('dark')), - ).toBe(true); -}); - -test('settings back icon shares the navigation icon rail', async ({ window: page }) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('button', { name: '设置' }).click(); - - const iconCenterX = async (locator: ReturnType) => { - const box = await locator.boundingBox(); - expect(box).not.toBeNull(); - return box ? box.x + box.width / 2 : Number.NaN; - }; - const backIcon = page.getByRole('button', { name: '返回应用', exact: true }).locator('svg').first(); - const navIcon = settingsNavigation(page).getByRole('button', { name: '通用', exact: true }).locator('svg').first(); - - expect(Math.abs(await iconCenterX(backIcon) - await iconCenterX(navIcon))).toBeLessThanOrEqual(0.5); -}); - -test('subagent presets can be reviewed and edited in desktop settings', async ({ window: page }) => { +// 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]; @@ -115,9 +86,11 @@ test('subagent presets can be reviewed and edited in desktop settings', async ({ description: '快速阅读代码,并总结关键调用链。', enabled: false, }); -}); -test('deleting a subagent preset is reversible until the confirm is accepted', async ({ window: page }) => { + // 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]; @@ -137,11 +110,9 @@ test('deleting a subagent preset is reversible until the confirm is accepted', a }); }); - await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); - const settings = page.getByRole('main', { name: '设置内容' }); await settings.getByRole('button', { name: '配置“E2E 待删除”' }).click(); const deleteButton = settings.getByRole('button', { name: '删除', exact: true }); @@ -167,18 +138,18 @@ test('deleting a subagent preset is reversible until the confirm is accepted', a const current = await window.maka.settings.get(); return current.subagents.presets.length; })).toBe(0); -}); -test('a subagent preset can be created disabled and then enabled from its row', async ({ window: page }) => { + // 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 page.getByRole('button', { name: '设置' }).click(); await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); - const settings = page.getByRole('main', { name: '设置内容' }); // The create branch is a structurally different tree from the edit branch — // a typed id instead of a settled one, and no delete section — so it needs // its own journey rather than riding on the edit one. @@ -209,7 +180,11 @@ test('a subagent preset can be created disabled and then enabled from its row', })).toBe(true); }); -test('remote access prioritizes a configured channel that needs attention', async ({ window: page }) => { +// Appearance and channel surface in one window. The channel seed runs before +// settings opens (the shell snapshots the store on open); the icon-rail +// geometry, theme mutation, and attention-ordered channel list then share +// one settings shell. +test('settings shell: back-icon rail, theme application, and remote-access attention order', async ({ window: page }) => { const runtimeError = 'runtime-diagnostic-'.repeat(10); await page.evaluate(async (lastError) => { await window.maka.settings.update({ @@ -232,6 +207,31 @@ test('remote access prioritizes a configured channel that needs attention', asyn }, runtimeError); await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); + await expect(page.getByLabel('设置内容')).toBeVisible(); + + + const iconCenterX = async (locator: ReturnType) => { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + return box ? box.x + box.width / 2 : Number.NaN; + }; + const backIcon = page.getByRole('button', { name: '返回应用', exact: true }).locator('svg').first(); + const navIcon = settingsNavigation(page).getByRole('button', { name: '通用', exact: true }).locator('svg').first(); + + expect(Math.abs(await iconCenterX(backIcon) - await iconCenterX(navIcon))).toBeLessThanOrEqual(0.5); + + + await settingsNavigation(page).getByRole('button', { name: '外观', exact: true }).click(); + const lightTheme = page.getByRole('checkbox', { name: '浅色' }); + const darkTheme = page.getByRole('checkbox', { name: '深色' }); + await darkTheme.locator('..').click(); + await expect(darkTheme).toBeChecked(); + await expect(lightTheme).not.toBeChecked(); + + await expect.poll( + async () => page.evaluate(() => document.documentElement.classList.contains('dark')), + ).toBe(true); + const settings = page.getByRole('main', { name: '设置内容' }); await settingsNavigation(page).getByRole('button', { name: '远程接入' }).click(); diff --git a/apps/desktop/e2e/skill-delete-scope.spec.ts b/apps/desktop/e2e/skill-delete-scope.spec.ts index 66f492a5c0..719636f547 100644 --- a/apps/desktop/e2e/skill-delete-scope.spec.ts +++ b/apps/desktop/e2e/skill-delete-scope.spec.ts @@ -16,7 +16,7 @@ import { access } from 'node:fs/promises'; import path from 'node:path'; import { e2eHomeDir, test, expect } from './fixtures.js'; -test('deletes a user-scope skill from disk and drops it from the list', async ({ +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'); @@ -35,11 +35,20 @@ test('deletes a user-scope skill from disk and drops it from the list', async ({ // 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(); - const inspector = page.getByRole('complementary', { name: '技能详情' }); await inspector.getByRole('button', { name: '删除', exact: true }).click(); // Opening the confirmation alone must not touch disk. @@ -56,20 +65,3 @@ test('deletes a user-scope skill from disk and drops it from the list', async ({ await expect(page.locator('.maka-module-page-rows > li button:focus')).toHaveCount(1); }); -test('offers no delete for a project-scope skill', async ({ invocableSkillsWindow: page }) => { - // 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. - 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(); - - 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); -});