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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<void> {
|
||||
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,
|
||||
|
||||
+10
-1
@@ -4026,7 +4026,16 @@ export default function App() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', position: 'relative' }}>
|
||||
<div
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', position: 'relative' }}
|
||||
data-testid="active-document-pane"
|
||||
data-doc-id={activeDocId}
|
||||
data-doc-title={
|
||||
deliverables.find((d) => d.docId === activeDocId)?.title ||
|
||||
visibleDeliverables.find((d) => d.docId === activeDocId)?.title ||
|
||||
''
|
||||
}
|
||||
>
|
||||
{/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */}
|
||||
<SlimTabBar
|
||||
deliverables={visibleDeliverables}
|
||||
|
||||
@@ -277,6 +277,7 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 0 }}>
|
||||
<button
|
||||
className="dropdown-trigger"
|
||||
data-testid="doc-selector-trigger"
|
||||
onClick={() => {
|
||||
setShowDocMenu(!showDocMenu);
|
||||
setShowCategoryMenu(false);
|
||||
@@ -385,6 +386,8 @@ export const SlimTabBar: React.FC<SlimTabBarProps> = ({
|
||||
return (
|
||||
<button
|
||||
key={doc.docId}
|
||||
data-doc-id={doc.docId}
|
||||
data-doc-title={doc.title}
|
||||
onClick={() => {
|
||||
onSelectDoc(doc.docId);
|
||||
setShowDocMenu(false);
|
||||
|
||||
Reference in New Issue
Block a user