From 3f4c914dbf10a291245f5540de32edb896b17f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 2 Oct 2026 01:21:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95:=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=8D=A1=E5=88=86=E7=BB=84=E6=B4=BB=E7=BB=84=E8=B1=81=E5=85=8D?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B(live-group.spec)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/live-group.spec.ts | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/e2e/live-group.spec.ts diff --git a/tests/e2e/live-group.spec.ts b/tests/e2e/live-group.spec.ts new file mode 100644 index 0000000..5714f02 --- /dev/null +++ b/tests/e2e/live-group.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from 'playwright/test' + +//! UX-260701 工具卡分组「活组豁免」回归验证。 +//! 背景:同名工具 ≥2 张卡时组自动收起(防刷屏),但执行中(running/pending)的组 +//! 会被旧逻辑一并收掉——running 卡的强制展开被组 hidden(display:none)压制, +//! 用户看到的正是「正在执行的轮次整组只剩组头,展开不了」。 +//! 修复:groupHasLiveCard(含 running/pending/用户展开卡)→ 组不自动收、已收的自动复活; +//! isCardExpanded 用户展开记忆优先于组折叠;isGroupHidden 模板 class 豁免用户展开卡。 +//! +//! 事件注入路径:纯浏览器环境 ai-chat-event 走垫片 transformCallback 注册, +//! Playwright 经 __TAURI_INTERNALS__.callbacks 逐个投递事件驱动 handleEvent, +//! 仿真流式工具调用轮次(AiToolCallStarted/Completed),不动任何真实数据。 +//! 事件路由:注入事件不带 conversation_id,handleEvent 走「活跃会话」分支 +//! (事件直接落入当前视图的末条 assistant 消息),与真实流式一致。 + +/** 工具卡选择器(与 ToolCard.vue 模板对齐) */ +const TOOL_CARD = '.ai-tool-card' +/** 组隐藏卡选择器(display:none 的连坐卡) */ +const HIDDEN_CARD = '.ai-tool-card--group-hidden' + +/** 经垫片回调注入一条 AiChatEvent(投递给所有已注册的 ai-chat-event handler) */ +async function injectEvent(page: import('playwright/test').Page, event: Record) { + await page.evaluate((ev) => { + const internals = (window as unknown as { + __TAURI_INTERNALS__: { callbacks: Map void> } + }).__TAURI_INTERNALS__ + // payload 结构对齐 Tauri Event: { event, id, payload } + for (const cb of internals.callbacks.values()) { + cb({ event: 'ai-chat-event', id: 0, payload: ev }) + } + }, event) +} + +/** 等待可见工具卡数量达到 n(事件经 Vue 响应式渲染是异步的) */ +async function waitForVisibleCards(page: import('playwright/test').Page, n: number) { + await expect + .poll(async () => page.locator(TOOL_CARD).not(HIDDEN_CARD).count(), { timeout: 5000 }) + .toBe(n) +} + +test.beforeEach(async ({ page }) => { + // 打开 AI 聊天默认路由并等待挂载;垫片载入 conv-mock-1(自带 3 张 completed 历史卡: + // list_tasks/list_projects/count_tasks 各 1 张,互不同组、互不干扰) + await page.goto('/#ai-home') + await expect(page.locator('#app > *').first()).toBeVisible({ timeout: 20_000 }) + await waitForVisibleCards(page, 3) +}) + +test('基线:同工具 2 卡全 completed → 组收起(防刷屏设计不受修复影响)', async ({ page }) => { + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-r1', name: 'read_file', args: { path: 'a.txt' } }) + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-r2', name: 'read_file', args: { path: 'b.txt' } }) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-r1', result: 'content-a' }) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-r2', result: 'content-b' }) + // 全 completed → read_file 组(2 卡)默认收起:两张卡组隐藏,组头可见 + await expect + .poll(async () => page.locator(HIDDEN_CARD).count(), { timeout: 5000 }) + .toBe(2) +}) + +test('核心:组内有 running 卡 → 组不收起(活组豁免)', async ({ page }) => { + // 一张 read_file completed + 一张 running:组含活卡,不自动收起 + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-r1', name: 'read_file', args: { path: 'a.txt' } }) + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-r2', name: 'read_file', args: { path: 'b.txt' } }) + await waitForVisibleCards(page, 5) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-r1', result: 'content-a' }) + // r2 仍 running → 活组:0 张卡被组隐藏 + await expect + .poll(async () => page.locator(HIDDEN_CARD).count(), { timeout: 5000 }) + .toBe(0) + // 补强:r2 卡本体可见且非折叠态(running 强制展开) + const r2 = page.locator(TOOL_CARD).filter({ hasText: 'b.txt' }).first() + await expect(r2).toBeVisible() + await expect(r2).not.toHaveClass(/ai-tool-card--group-hidden/) +}) + +test('豁免解除:running 全部完成后 → 组恢复收起', async ({ page }) => { + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-r1', name: 'read_file', args: { path: 'a.txt' } }) + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-r2', name: 'read_file', args: { path: 'b.txt' } }) + await waitForVisibleCards(page, 5) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-r1', result: 'content-a' }) + await expect(page.locator(HIDDEN_CARD)).toHaveCount(0) + // r2 完成 → 组全 completed → 触发补齐 watch(签名变化)恢复收起 + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-r2', result: 'content-b' }) + await expect + .poll(async () => page.locator(HIDDEN_CARD).count(), { timeout: 5000 }) + .toBe(2) +}) + +test('用户展开记忆:点开的卡不被组收起连坐(hidden class 豁免)', async ({ page }) => { + // 建一个 2 卡全 completed 组(收起态)→ 点组头展开 → 点开其中一张卡 + // → 注入新组触发补齐 watch 重跑 → 用户点开的卡必须豁免组 hidden + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-g1', name: 'grep', args: { pattern: 'x' } }) + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-g2', name: 'grep', args: { pattern: 'y' } }) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-g1', result: '[]' }) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-g2', result: '[]' }) + await expect + .poll(async () => page.locator(HIDDEN_CARD).count(), { timeout: 5000 }) + .toBe(2) + // 组头点击展开(grep 组) + await page.locator('.ai-tool-group-header').filter({ hasText: 'grep' }).first().click() + await waitForVisibleCards(page, 5) + // 点开组内 tc-e2e-g1 卡的 header → userExpandedCards 记忆 + const g1 = page.locator(TOOL_CARD).filter({ hasText: 'pattern x' }).first() + await g1.locator('.ai-tool-header').first().click() + // 注入第二组触发组补齐 watch 签名变化重跑 + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-h1', name: 'list_ideas', args: {} }) + await injectEvent(page, { type: 'AiToolCallStarted', id: 'tc-e2e-h2', name: 'list_ideas', args: {} }) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-h1', result: '[]' }) + await injectEvent(page, { type: 'AiToolCallCompleted', id: 'tc-e2e-h2', result: '[]' }) + // g1 用户点开过 → 不被组 hidden 连坐;g2 未点 → 随组收起;h1/h2 全 completed 默认收 + await expect(g1).toBeVisible() + await expect(g1).not.toHaveClass(/ai-tool-card--group-hidden/) +})