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);
-});