测试: 工具卡分组活组豁免回归用例(live-group.spec)
This commit is contained in:
@@ -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<string, unknown>) {
|
||||||
|
await page.evaluate((ev) => {
|
||||||
|
const internals = (window as unknown as {
|
||||||
|
__TAURI_INTERNALS__: { callbacks: Map<number, (data: unknown) => void> }
|
||||||
|
}).__TAURI_INTERNALS__
|
||||||
|
// payload 结构对齐 Tauri Event<T>: { 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/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user