From 301e1f401ee2d32994ec9568d7c394ecb081b34b Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 14:01:41 +0000 Subject: [PATCH] fix(e2e): stable doc selector and this-turn assistant waits Add doc-selector-trigger / active-document-pane data attrs; resolve assistant via virtualizer data-index siblings; UI-02/03 and open-by-id helpers stop relying on global text or first dropdown-trigger. Co-authored-by: Cursor --- web/e2e/document-revision-runtime.spec.ts | 165 +++++++++++++++------- web/src/App.tsx | 11 +- web/src/components/doc/SlimTabBar.tsx | 3 + 3 files changed, 124 insertions(+), 55 deletions(-) diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index 630cd2f3..47973b4f 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -222,27 +222,45 @@ async function adminCreateUser( } /** - * Squad wait: observe THIS turn's user bubble, then its following assistant sibling - * running → terminal. Never match a prior completed bubble globally. + * Locate the assistant bubble for THIS user turn. + * MessageBubble is nested under a virtualizer outer with data-index; siblings are those outers. */ -async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) { - const startedAt = Date.now(); +function turnAssistantLocator(page: Page, userPromptSnippet: string) { const userBubble = page .locator('[data-chat-role="user"]') .filter({ hasText: userPromptSnippet }) .last(); - await expect(userBubble).toBeVisible({ timeout: 30_000 }); + return { + userBubble, + assistant: userBubble.locator( + 'xpath=ancestor::*[@data-index][1]/following-sibling::*[@data-index][1]//*[@data-chat-role="assistant"]', + ), + }; +} - const assistant = userBubble.locator( - 'xpath=following-sibling::*[@data-chat-role="assistant"][1]', - ); +/** + * Wait for this-turn assistant terminal. Optionally require observing inflight first. + * UI-03 may skip inflight (already seen in UI-02) to avoid fast-complete race. + */ +async function waitForSquadCompletion( + page: Page, + userPromptSnippet: string, + timeoutMs = 420_000, + opts?: { requireInflight?: boolean }, +) { + const startedAt = Date.now(); + const requireInflight = opts?.requireInflight !== false; + const { userBubble, assistant } = turnAssistantLocator(page, userPromptSnippet); + await expect(userBubble).toBeVisible({ timeout: 30_000 }); await expect(assistant).toBeVisible({ timeout: 30_000 }); - await expect(assistant).toHaveAttribute( - 'data-chat-status', - /pending|streaming_status|auto_retrying/, - { timeout: 60_000 }, - ); + if (requireInflight) { + await expect(assistant).toHaveAttribute( + 'data-chat-status', + /pending|streaming_status|auto_retrying/, + { timeout: 60_000 }, + ); + } await expect(assistant).toHaveAttribute( 'data-chat-status', @@ -257,23 +275,32 @@ async function waitForSquadCompletion(page: Page, userPromptSnippet: string, tim return Date.now() - startedAt; } -async function openWorkflowDocByLabel(page: Page, label: RegExp) { - const trigger = page.locator('button.dropdown-trigger').first(); - if ((await trigger.count()) > 0) { - await trigger.click(); - const item = page - .locator('.dropdown-menu button, .dropdown-menu [role="menuitem"]') - .filter({ hasText: label }) - .first(); - if ((await item.count()) === 0) fail(`workflow slot not in SlimTabBar dropdown: ${label}`); - await item.click(); - return; +async function openWorkflowDocByLabel( + page: Page, + label: RegExp, +): Promise<{ title: string; docId: string }> { + const trigger = page.getByTestId('doc-selector-trigger'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.click(); + const item = page.locator('.dropdown-menu button[data-doc-id]').filter({ hasText: label }).first(); + if ((await item.count()) === 0) { + fail(`workflow slot not in doc-selector menu: ${label}`); } - const deck = page.locator('[data-testid="deck-drawer"], .deck-drawer').first(); - if ((await deck.count()) > 0) await deck.click(); - const card = page.locator('[data-doc-id^="doc_proj"]').filter({ hasText: label }).first(); - if ((await card.count()) === 0) fail(`no server-backed workflow card for ${label}`); - await card.click(); + const docId = (await item.getAttribute('data-doc-id')) || ''; + const title = (await item.getAttribute('data-doc-title')) || ''; + await item.click(); + if (!docId) fail(`selected menu item missing data-doc-id for ${label}`); + return { title: title || docId, docId }; +} + +async function openWorkflowDocById(page: Page, docId: string): Promise { + if (!docId) fail('openWorkflowDocById requires docId'); + const trigger = page.getByTestId('doc-selector-trigger'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.click(); + const item = page.locator(`.dropdown-menu button[data-doc-id="${docId}"]`).first(); + if ((await item.count()) === 0) fail(`doc-selector menu missing data-doc-id=${docId}`); + await item.click(); } async function sendChat(page: Page, text: string) { @@ -344,8 +371,10 @@ test.describe('document-revision-runtime UI @post-restart', () => { // Locate project by visible name. await page.getByText(st.projectName, { exact: false }).first().click(); await expect(page.getByText(st.projectName)).toBeVisible({ timeout: 20_000 }); - if (st.controlledDocTitle) { - await page.getByText(st.controlledDocTitle, { exact: false }).first().click(); + if (st.controlledDocId) { + await openWorkflowDocById(page, st.controlledDocId); + } else if (st.controlledDocTitle) { + await openWorkflowDocByLabel(page, new RegExp(st.controlledDocTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); const { v, r } = await readVersionBadge(page); @@ -375,8 +404,10 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { let page: Page; let logs: NetLog[] = []; let controlledTitle = ''; + let controlledDocId = ''; let versionBeforePending = ''; let secondDocTitle = ''; + let secondDocId = ''; test.beforeAll(async ({ browser }) => { context = await browser.newContext(); @@ -535,15 +566,20 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { if (await connErr.first().isVisible().catch(() => false)) { fail('UI-02 connection-error banner visible after tasks/runs — product FAIL'); } - // 运行态仅为额外 UI 断言,不能单独构成 PASS。 - const running = page.locator('text=正在思考').or(page.locator('text=生成中')); - await expect(running.first()).toBeVisible({ timeout: 60_000 }); + // 对本轮 user 对应 assistant 精确断言 inflight(禁止全局「生成中」)。 + const { assistant } = turnAssistantLocator(page, prompt.slice(0, 12)); + await expect(assistant).toBeVisible({ timeout: 30_000 }); + await expect(assistant).toHaveAttribute( + 'data-chat-status', + /pending|streaming_status|auto_retrying/, + { timeout: 60_000 }, + ); await shot(page, 'UI-02-squad-started.png'); recordPass({ id: 'UI-02', name: 'start real squad', input: prompt, - actual: `POST /api/v1/tasks/runs HTTP ${status}; running/thinking visible`, + actual: `POST /api/v1/tasks/runs HTTP ${status}; this-turn assistant inflight`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', @@ -553,24 +589,26 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { test('UI-03 wait first round; open controlled doc with V/r', async () => { const t0 = Date.now(); const promptSlice = '请基于本项目编制首轮'; - await waitForSquadCompletion(page, promptSlice); - // 精确打开工作流文档槽:SlimTabBar 下拉 / DeckDrawer,禁止全局 div 泛匹配。 - await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i); + // UI-02 已观测 inflight;此处只等同一 assistant 到 terminal,避免快速完成竞态。 + await waitForSquadCompletion(page, promptSlice, 420_000, { requireInflight: false }); + const selected = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i); + controlledDocId = selected.docId; + controlledTitle = selected.title; + if (!/^doc_proj\d+_/.test(controlledDocId)) { + fail(`UI-03: selected slot must be server doc_proj*, got ${controlledDocId}`); + } + const pane = page.getByTestId('active-document-pane'); + await expect(pane).toHaveAttribute('data-doc-id', controlledDocId, { timeout: 15_000 }); await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 60_000 }); await expect(page.getByTestId('doc-rev-template-hint')).toHaveCount(0); await expect(page.locator('text=版本账本未就绪')).toHaveCount(0); - const activeDoc = page.locator('[data-doc-id^="doc_proj"]').first(); - if ((await activeDoc.count()) === 0) fail('UI-03: active pane must be server doc_proj* not doc-p template'); const { v, r } = await readVersionBadge(page); - controlledTitle = (await page.locator('[data-testid="doc-rev-version"]').evaluate((el) => { - const root = el.closest('[data-doc-id]') || el.parentElement; - return root?.getAttribute('data-doc-id') || 'controlled'; - }).catch(() => 'controlled')) as string; await shot(page, '03-first-doc.png'); const st = readPhaseState(); writePhaseState({ ...st, controlledDocTitle: controlledTitle, + controlledDocId, versionText: v, revisionText: r, }); @@ -578,7 +616,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { id: 'UI-03', name: 'first controlled doc ledger', input: 'wait squad; open intake server doc slot', - actual: `toolbar ${v}/${r}; server doc visible`, + actual: `toolbar ${v}/${r}; docId=${controlledDocId}; title=${controlledTitle}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', @@ -796,15 +834,21 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { '请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。'; await sendChat(page, prompt); await waitForSquadCompletion(page, '系统设计输入'); - await openWorkflowDocByLabel(page, /设计输入|系统设计/); - secondDocTitle = '系统设计输入'; + const second = await openWorkflowDocByLabel(page, /设计输入|系统设计/); + secondDocTitle = second.title; + secondDocId = second.docId; + await expect(page.getByTestId('active-document-pane')).toHaveAttribute('data-doc-id', secondDocId); await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 45_000 }); const states: string[] = []; - for (const label of [controlledTitle, secondDocTitle]) { - if (!label) continue; - await openWorkflowDocByLabel(page, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + for (const target of [ + { id: controlledDocId, title: controlledTitle }, + { id: secondDocId, title: secondDocTitle }, + ]) { + if (!target.id) continue; + await openWorkflowDocById(page, target.id); + await expect(page.getByTestId('active-document-pane')).toHaveAttribute('data-doc-id', target.id); const { v, r } = await readVersionBadge(page); - states.push(`${label}:${v}/${r}`); + states.push(`${target.title}:${v}/${r}`); } // Page upload attachment const fileInput = page.locator('input[type="file"]'); @@ -857,14 +901,22 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { const t0 = Date.now(); logs = []; // Ensure A is on controlled doc - await page.getByText(controlledTitle || PROJECT_NAME, { exact: false }).first().click(); + if (controlledDocId) { + await openWorkflowDocById(page, controlledDocId); + } else { + await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i); + } await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); const ctxB = await page.context().browser()!.newContext(); const pageB = await ctxB.newPage(); await login(pageB, USER_B, FINAL_PASS_B); await pageB.getByText(PROJECT_NAME, { exact: false }).first().click(); - await pageB.getByText(controlledTitle || /需求/, { exact: false }).first().click(); + if (controlledDocId) { + await openWorkflowDocById(pageB, controlledDocId); + } else { + await openWorkflowDocByLabel(pageB, /用户初始需求|需求摸底|intake/i); + } await expect(pageB.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); // Both open same old revision — A saves first @@ -900,7 +952,11 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { await logoutViaUI(page); await login(page, USER_A, FINAL_PASS_A); await page.getByText(PROJECT_NAME, { exact: false }).first().click(); - await page.getByText(controlledTitle || /需求/, { exact: false }).first().click(); + if (controlledDocId) { + await openWorkflowDocById(page, controlledDocId); + } else { + await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i); + } await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); const again = await readVersionBadge(page); if (again.v !== v) fail(`version changed across re-login unexpectedly ${v}→${again.v}`); @@ -925,6 +981,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { ...st, projectName: PROJECT_NAME, controlledDocTitle: controlledTitle, + controlledDocId, versionText: v, revisionText: r, userA: USER_A, diff --git a/web/src/App.tsx b/web/src/App.tsx index 87ea8eb6..4e29cd22 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4026,7 +4026,16 @@ export default function App() {

) : ( -
+
d.docId === activeDocId)?.title || + visibleDeliverables.find((d) => d.docId === activeDocId)?.title || + '' + } + > {/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */} = ({