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 条 (含三分类分段切换与私密/共享识别) */}
= ({