import { test, expect, type Page, type BrowserContext, type Response } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { BLOCK_EDITOR_DIALOG_LABEL, BLOCK_EDITOR_SAVE_NAME, } from '../src/lib/blockEditorDialog'; import { ANY_TERMINAL_STATUS_RE, evaluateSquadObservation, } from '../src/lib/squadCompletionObservation'; import { CATEGORY_TRIGGER_LABEL, requiredCategoryForDocOpen, } from '../src/lib/workflowCategoryNavigation'; import { DOC_DUTY_BADGE_TESTID, DOC_DUTY_SELECT_TESTID, evaluateReviewerDuty, } from '../src/lib/dutyCellAssertion'; import { currentProjectBannerName, visibleProjectEntry, } from '../src/lib/currentProjectRestore'; import { SHARED_PHASE_SECRETS_FILE, SHARED_PHASE_STATE_FILE, playwrightResultsFileName, } from '../src/lib/playwrightPhaseArtifacts'; import { nextDurableBodySnippet } from '../src/lib/durableBodySnippet'; /** * Document revision runtime — real UI acceptance (UI-01..UI-16). * * Hard rules: * - Real click/fill/upload only. No page.route / API / DB / mock / stub. * - Unmet conditions throw (FAIL). Never fallback PASS. * - Admin UI creates A/B accounts; no seed guest/123456. * - UI_PHASE=post-restart: only restore checks (host docker restart between runs). */ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; const uiPhase = (process.env.UI_PHASE || 'main').trim(); // main | post-restart const ASSETS = path.resolve( __dirname, '../../.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui', ); const SCREENSHOTS = path.join(ASSETS, 'screenshots'); const TRACES = path.join(ASSETS, 'traces'); const VIDEOS = path.join(ASSETS, 'videos'); const RESULTS_FILE = path.join(ASSETS, playwrightResultsFileName(uiPhase)); const PHASE_STATE_FILE = path.join(ASSETS, SHARED_PHASE_STATE_FILE); /** Local-only secrets for post-restart login — never copied into results JSON. */ const PHASE_SECRETS_FILE = path.join(ASSETS, SHARED_PHASE_SECRETS_FILE); for (const d of [SCREENSHOTS, TRACES, VIDEOS]) { fs.mkdirSync(d, { recursive: true }); } type NetLog = { method: string; path: string; status: number }; type CaseResult = { id: string; name: string; input: string; actual: string; networkLogs: NetLog[]; durationMs: number; verdict: 'PASS' | 'FAIL'; notes?: string; }; const recorded: Record = {}; function fail(msg: string): never { throw new Error(msg); } /** Strip credentials and query strings from evidence URLs. */ function sanitizeUrl(raw: string): string { try { const u = new URL(raw, 'http://127.0.0.1'); return u.pathname; } catch { return raw.split('?')[0]?.replace(/https?:\/\/[^/]+/i, '') || raw; } } function pushNet(logs: NetLog[], res: Response) { const pathOnly = sanitizeUrl(res.url()); if (!pathOnly.includes('/api/')) return; logs.push({ method: res.request().method(), path: pathOnly, status: res.status() }); } function saveResults() { const scrubbed = JSON.parse(JSON.stringify(recorded)) as Record; const blob = JSON.stringify(scrubbed, null, 2); if (/admin-secret|Bearer\s|password["']?\s*[:=]|token=/i.test(blob)) { fail('results must not contain credentials or tokens'); } fs.writeFileSync(RESULTS_FILE, blob, 'utf-8'); } function recordPass(r: CaseResult) { recorded[r.id] = { ...r, verdict: 'PASS' }; saveResults(); } async function shot(page: Page, name: string) { await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true }); } type PhaseState = { projectName: string; projectIdHint?: string; controlledDocTitle?: string; controlledDocId?: string; versionText?: string; revisionText?: string; bodySnippet?: string; userA: string; userB: string; userC: string; }; type PhaseSecrets = { passA: string; passB: string; passC: string; }; function writePhaseState(s: PhaseState) { fs.writeFileSync(PHASE_STATE_FILE, JSON.stringify(s, null, 2), 'utf-8'); } function writePhaseSecrets(s: PhaseSecrets) { fs.writeFileSync(PHASE_SECRETS_FILE, JSON.stringify(s, null, 2), 'utf-8'); } function readPhaseState(): PhaseState { if (!fs.existsSync(PHASE_STATE_FILE)) { fail(`missing phase-state.json for UI_PHASE=${uiPhase}; run main phase first`); } return JSON.parse(fs.readFileSync(PHASE_STATE_FILE, 'utf-8')) as PhaseState; } function readPhaseSecrets(): PhaseSecrets { if (process.env.UI_E2E_PASS_A) { return { passA: process.env.UI_E2E_PASS_A, passB: process.env.UI_E2E_PASS_B || '', passC: process.env.UI_E2E_PASS_C || '', }; } if (!fs.existsSync(PHASE_SECRETS_FILE)) { fail('missing phase-secrets.local.json (or UI_E2E_PASS_A) for post-restart login'); } return JSON.parse(fs.readFileSync(PHASE_SECRETS_FILE, 'utf-8')) as PhaseSecrets; } const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; /** Admin creds must come from env — never hardcode or fallback secrets into the repo/runtime. */ function requireAdminCreds(): { username: string; password: string } { if (!ADMIN_USER || !ADMIN_PASS) { fail( 'ADMIN_USERNAME and ADMIN_PASSWORD environment variables are required for UI acceptance (no credential fallbacks)', ); } return { username: ADMIN_USER, password: ADMIN_PASS }; } const suffix = `${Date.now().toString(36)}`; const USER_A = `uia_${suffix}`; const USER_B = `uib_${suffix}`; const USER_C = `uic_${suffix}`; const INIT_PASS = `Init-${suffix}9`; const FINAL_PASS_A = `PassA-${suffix}9`; const FINAL_PASS_B = `PassB-${suffix}9`; const FINAL_PASS_C = `PassC-${suffix}9`; const PROJECT_NAME = `UI验收-超声微创系统-${suffix}`; async function login(page: Page, username: string, password: string) { await page.goto('/'); await page.fill('input[placeholder*="系统用户名"]', username); await page.fill('input[placeholder*="登录密码"]', password); await page.click('button:has-text("进入医疗协同工作台")'); // First-login force change may appear for admin-created users. const force = page.getByPlaceholder('请输入初始密码'); if (await force.isVisible({ timeout: 2500 }).catch(() => false)) { fail(`account ${username} still requires first-login password change — call changePasswordFirstLogin`); } await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); } async function changePasswordFirstLogin(page: Page, oldPass: string, newPass: string) { await expect(page.getByPlaceholder('请输入初始密码')).toBeVisible({ timeout: 15_000 }); await page.fill('input[placeholder="请输入初始密码"]', oldPass); await page.fill('input[placeholder*="至少 6 位"]', newPass); await page.fill('input[placeholder="再次输入新密码"]', newPass); await page.getByRole('button', { name: /确认重置并进入系统/ }).click(); await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); } async function logoutViaUI(page: Page) { await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 }); const logout = page.getByRole('button', { name: '退出当前登录' }); await expect(logout).toBeVisible({ timeout: 10_000 }); await logout.click(); await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 }); } async function openAdminConsole(page: Page) { await shot(page, 'UI-00-admin-logged-in.png'); const btn = page.locator('button[title*="系统管理与准入治理"]'); if (!(await btn.isVisible({ timeout: 10_000 }).catch(() => false))) { const leftRailText = await page.locator('aside').innerText().catch(() => 'no aside'); fail(`admin button not visible. Left rail content: ${leftRailText}`); } await btn.click(); await expect(page.getByRole('button', { name: '开通受控员工账号' })).toBeVisible({ timeout: 15_000 }); } async function adminCreateUser( page: Page, username: string, realName: string, initPass: string, roleId: 'role-tech-lead' | 'role-engineer' | 'role-guest', ) { await page.getByRole('button', { name: '开通受控员工账号' }).click(); const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' }); await expect(form).toBeVisible({ timeout: 10_000 }); await form.locator('input[placeholder="如 li_si"]').fill(username); await form.locator('input[placeholder="如 李四"]').fill(realName); // 临时初始密码:label sibling input const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); if ((await passBox.count()) === 0) fail('admin create-user missing initial password field'); await passBox.fill(initPass); const roleSelect = form.locator('select'); await expect(roleSelect).toBeVisible({ timeout: 10_000 }); await roleSelect.selectOption(roleId); await expect(roleSelect).toHaveValue(roleId); await form.getByRole('button', { name: '确认开通账号' }).click(); await expect(page.getByText(new RegExp(`已成功开通|${realName}`)).first()).toBeVisible({ timeout: 15_000 }); await expect(form).toBeHidden({ timeout: 10_000 }); } /** * Locate the assistant bubble for THIS user turn. * MessageBubble is nested under a virtualizer outer with data-index; siblings are those outers. */ function turnAssistantLocator(page: Page, userPromptSnippet: string) { const userBubble = page .locator('[data-chat-role="user"]') .filter({ hasText: userPromptSnippet }) .last(); return { userBubble, assistant: userBubble.locator( 'xpath=ancestor::*[@data-index][1]/following-sibling::*[@data-index][1]//*[@data-chat-role="assistant"]', ), }; } /** * Wait for this-turn assistant terminal. * Inflight is optional: if the adjacent card is already a success terminal, accept it. */ async function waitForSquadCompletion( page: Page, userPromptSnippet: string, timeoutMs = 420_000, opts?: { requireInflight?: boolean }, ) { const startedAt = Date.now(); const requireInflight = opts?.requireInflight === true; const { userBubble, assistant } = turnAssistantLocator(page, userPromptSnippet); await expect(userBubble).toBeVisible({ timeout: 30_000 }); await expect(assistant).toBeVisible({ timeout: 30_000 }); const connErr = page.locator('text=连接异常'); const observe = async () => { const status = (await assistant.getAttribute('data-chat-status')) || ''; const hasVisibleError = await connErr.first().isVisible().catch(() => false); return evaluateSquadObservation( { userPromptMatched: true, assistantAdjacent: true, status, hasVisibleError, }, { requireInflight }, ); }; let verdict = await observe(); if (verdict.action === 'reject') { fail(`squad observation rejected: ${verdict.reason}`); } if (verdict.action === 'accept') { return Date.now() - startedAt; } await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, { timeout: timeoutMs, }); verdict = await observe(); if (verdict.action === 'reject') { fail(`squad observation rejected: ${verdict.reason}`); } if (verdict.action !== 'accept') { fail(`squad terminal not accepted: ${verdict.reason}`); } return Date.now() - startedAt; } async function selectDeliverableCategory( page: Page, category: 'workflow' | 'shared' | 'private', ) { const trigger = page.getByTestId('doc-category-trigger'); await expect(trigger).toBeVisible({ timeout: 15_000 }); await trigger.click(); const item = page.locator(`.dropdown-menu button[data-category="${category}"]`).first(); if ((await item.count()) === 0) fail(`category menu missing data-category=${category}`); await item.click(); await expect(trigger).toContainText(CATEGORY_TRIGGER_LABEL[category], { timeout: 10_000 }); } async function openWorkflowDocByLabel( page: Page, label: RegExp, ): Promise<{ title: string; docId: string }> { await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'workflow-label' })); 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 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 }; } /** 按已注册 doc_proj{n}_{taskKey} 打开,避开同标题模板槽与宽泛文案误命中。 */ async function openWorkflowDocByControlledTaskKey( page: Page, taskKey: string, ): Promise<{ title: string; docId: string }> { if (!/^[a-z][a-z0-9_]*$/.test(taskKey)) fail(`invalid controlled taskKey: ${taskKey}`); await selectDeliverableCategory( page, requiredCategoryForDocOpen({ kind: 'controlled-task-key', taskKey }), ); 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^="doc_proj"][data-doc-id$="_${taskKey}"]`) .first(); if ((await item.count()) === 0) { fail(`workflow menu missing registered doc_proj*_${taskKey}`); } const docId = (await item.getAttribute('data-doc-id')) || ''; const title = (await item.getAttribute('data-doc-title')) || ''; if (!new RegExp(`^doc_proj\\d+_${taskKey}$`).test(docId)) { fail(`expected doc_proj{n}_${taskKey}, got ${JSON.stringify(docId)}`); } await item.click(); return { title: title || docId, docId }; } async function openWorkflowDocById(page: Page, docId: string): Promise { if (!docId) fail('openWorkflowDocById requires docId'); await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'exact-id', 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 readReviewerIntakeDuty(page: Page, docId?: string): Promise { const select = docId ? page.locator(`[data-testid="${DOC_DUTY_SELECT_TESTID}"][data-doc-id="${docId}"]`) : page .locator('tr') .filter({ hasText: /用户初始需求|需求摸底/ }) .locator(`[data-testid="${DOC_DUTY_SELECT_TESTID}"]`); if ((await select.count()) > 0) { await expect(select).toBeVisible({ timeout: 10_000 }); const verdict = evaluateReviewerDuty({ source: 'select', value: await select.inputValue(), disabled: await select.isDisabled(), canAssignLead: (await select.getAttribute('data-can-assign')) === 'true', }); if (!verdict.ok || verdict.role !== 'reviewer') { fail(`reviewer B intake duty select must be reviewer: ${verdict.reason}`); } return 'reviewer'; } const badge = docId ? page.locator(`[data-testid="${DOC_DUTY_BADGE_TESTID}"][data-doc-id="${docId}"]`) : page .locator('tr') .filter({ hasText: /用户初始需求|需求摸底/ }) .locator(`[data-testid="${DOC_DUTY_BADGE_TESTID}"]`); if ((await badge.count()) === 0) { fail('intake duty cell missing select and badge'); } await expect(badge).toBeVisible({ timeout: 10_000 }); const verdict = evaluateReviewerDuty({ source: 'badge', role: (await badge.getAttribute('data-doc-role')) || '', }); if (!verdict.ok || verdict.role !== 'reviewer') { fail(`reviewer B intake duty badge must be reviewer: ${verdict.reason}`); } return 'reviewer'; } async function sendChat(page: Page, text: string) { const box = page.getByPlaceholder(/向项目助手提问/); await expect(box).toBeVisible({ timeout: 15_000 }); await box.fill(text); await page.getByRole('button', { name: /发送/ }).click(); } function waitForTasksRunsPost(page: Page) { return page.waitForResponse( (r) => { const pathOnly = sanitizeUrl(r.url()); return pathOnly.includes('/api/v1/tasks/runs') && r.request().method() === 'POST'; }, { timeout: 60_000 }, ); } function assertIntakeTaskRun(res: Response, expectedDocId: string, caseId: string) { const status = res.status(); if (status < 200 || status >= 300) { fail(`${caseId} tasks/runs must be 2xx, got HTTP ${status}`); } const body = res.request().postDataJSON() as { task_key?: string; doc_id?: string }; if (body.task_key !== 'intake') { fail(`${caseId} task_key must be intake, got ${JSON.stringify(body.task_key)}`); } if (!expectedDocId || body.doc_id !== expectedDocId) { fail(`${caseId} doc_id must be ${expectedDocId}, got ${JSON.stringify(body.doc_id)}`); } return body; } async function closeNamedDialog(page: Page, name: string, timeout = 10_000) { const dialog = page.getByRole('dialog', { name }); if ((await dialog.count()) === 0) return; await dialog.getByRole('button', { name: '关闭' }).click(); await expect(dialog).toHaveCount(0, { timeout }); } function blockEditorDialog(target: Page) { return target.getByRole('dialog', { name: BLOCK_EDITOR_DIALOG_LABEL }); } async function openFirstPaneEditorAndFill(target: Page, text: string) { const pane = target.getByTestId('active-document-pane'); const block = pane.getByTestId('document-block').first(); await block.dblclick({ timeout: 15_000 }); const dialog = blockEditorDialog(target); await expect(dialog).toBeVisible({ timeout: 10_000 }); const editor = dialog.locator('textarea, [contenteditable="true"]').first(); await expect(editor).toBeVisible({ timeout: 10_000 }); await editor.fill(text); return editor; } async function saveOpenEditor(target: Page) { const dialog = blockEditorDialog(target); await dialog.getByRole('button', { name: BLOCK_EDITOR_SAVE_NAME, exact: true }).click(); } async function editFirstPaneBlockAndSave(target: Page, text: string) { await openFirstPaneEditorAndFill(target, text); await saveOpenEditor(target); } async function readRevisionStateEtag(res: Response): Promise { const json = (await res.json().catch(() => ({}))) as { etag?: string }; if (!json.etag) fail(`revision-state missing etag: HTTP ${res.status()}`); return json.etag; } /** Assert zero revision-state 404 and zero requests for template doc-p* slots. */ function assertRevisionStateCleanForNewProject(logs: NetLog[]) { const hits = logs.filter((l) => l.path.includes('/revision-state')); const bad404 = hits.filter((l) => l.status === 404); if (bad404.length) { fail(`revision-state 404 is FAIL: ${JSON.stringify(bad404)}`); } // Match only hyphen template IDs (doc-p123-intake), NOT doc_proj* (underscore). const docPTemplateHits = hits.filter((l) => /\/documents\/doc-p\d+-[^/]+\/revision-state/.test(l.path)); if (docPTemplateHits.length !== 0) { fail(`new project template must issue 0 revision-state for doc-p*: got ${JSON.stringify(docPTemplateHits)}`); } const legacyHits = hits.filter((l) => /\/documents\/doc-\d{3}\/revision-state/.test(l.path)); if (legacyHits.length !== 0) { fail(`legacy local mock must not request revision-state: ${JSON.stringify(legacyHits)}`); } } async function readVersionBadge(page: Page): Promise<{ v: string; r: string }> { const v = (await page.locator('[data-testid="doc-rev-version"]').textContent())?.trim(); const r = (await page.locator('[data-testid="doc-rev-revision"]').textContent())?.trim(); if (!v || !r) fail('version/revision badge missing — document must be server-backed with ledger'); return { v, r }; } type OverlaySample = { backgroundColor: string; boxShadow: string; className: string; overlayKind: string; blockId: string; }; function overlayPaneKey(samples: OverlaySample[]): string { return samples .map((s) => `${s.blockId}:${s.backgroundColor}|${s.boxShadow}|${s.overlayKind}`) .join(';'); } function overlaySampleIsTransparent(s: OverlaySample): boolean { const bg = s.backgroundColor.replace(/\s+/g, ''); return (bg === 'transparent' || bg === 'rgba(0,0,0,0)') && (s.boxShadow === '' || s.boxShadow === 'none'); } function overlayPaneHasMark(samples: OverlaySample[]): boolean { return samples.some((s) => s.overlayKind !== '' || !overlaySampleIsTransparent(s)); } async function readOverlaySamples(page: Page): Promise { return page.getByTestId('active-document-pane').getByTestId('document-block').evaluateAll((els) => els.map((node) => { const el = node as HTMLElement; const s = window.getComputedStyle(el); return { backgroundColor: s.backgroundColor, boxShadow: s.boxShadow, className: el.className || '', overlayKind: el.getAttribute('data-overlay-kind') || '', blockId: el.getAttribute('data-block-id') || '', }; }), ); } /** Poll computed styles until two identical frames; do not sample the 200ms transition mid-frame. */ async function waitForOverlayStylesSettled(page: Page, mode: 'pure' | 'manual' | 'all') { let previous = ''; await expect .poll( async () => { const samples = await readOverlaySamples(page); if (samples.length === 0) return 'empty'; const key = overlayPaneKey(samples); const stable = previous !== '' && previous === key; previous = key; if (mode === 'pure') { return stable && !overlayPaneHasMark(samples) ? 'settled' : 'pending'; } const marked = samples.some((s) => s.overlayKind) && overlayPaneHasMark(samples); return stable && marked ? 'settled' : 'pending'; }, { timeout: 15_000 }, ) .toBe('settled'); } /** Sample every real pane block after overlay fetch + CSS transition settle. */ async function captureOverlayFingerprint(page: Page, mode: 'pure' | 'manual' | 'all') { const pane = page.getByTestId('active-document-pane'); const modeSelect = pane.locator('select[aria-label="变更显示模式"]'); const overlayResp = page.waitForResponse((r) => { if (r.request().method() !== 'GET') return false; const pathOnly = sanitizeUrl(r.url()); if (mode === 'pure') return pathOnly.includes('/revision-state'); if (mode === 'manual') return pathOnly.includes('/manual-overlay'); return pathOnly.includes('/diff') || pathOnly.includes('/manual-overlay'); }, { timeout: 20_000 }); await modeSelect.selectOption(mode); await expect(modeSelect).toHaveValue(mode); await overlayResp; const blocks = pane.getByTestId('document-block'); await expect(blocks.first()).toBeVisible({ timeout: 15_000 }); const marked = pane.locator('[data-testid="document-block"][data-overlay-kind]'); if (mode === 'pure') { await expect(marked).toHaveCount(0, { timeout: 15_000 }); } else { await expect(marked.first()).toBeVisible({ timeout: 15_000 }); } await waitForOverlayStylesSettled(page, mode); const samples = await readOverlaySamples(page); if (samples.length === 0) { fail(`UI overlay mode=${mode}: no document-block to sample computed style`); } if (mode !== 'pure' && !samples.some((s) => s.overlayKind)) { fail(`UI overlay mode=${mode}: no document-block overlay to sample computed style`); } return samples; } // ─── post-restart phase (host restarted docker between runs) ─── test.describe('document-revision-runtime UI @post-restart', () => { test.skip(!runAcceptance || uiPhase !== 'post-restart', 'UI_PHASE=post-restart only'); test('UI-15 restore after container restart (page login only)', async ({ page }) => { const t0 = Date.now(); const logs: NetLog[] = []; page.on('response', (res) => pushNet(logs, res)); const st = readPhaseState(); const secrets = readPhaseSecrets(); await login(page, st.userA, secrets.passA); await visibleProjectEntry(page, st.projectName).click(); await expect(currentProjectBannerName(page, st.projectName)).toBeVisible({ timeout: 20_000 }); 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); if (st.versionText && v !== st.versionText) fail(`version mismatch after restart: ${v} vs ${st.versionText}`); if (st.revisionText && r !== st.revisionText) fail(`revision mismatch after restart: ${r} vs ${st.revisionText}`); if (st.bodySnippet) { await expect(page.getByText(st.bodySnippet, { exact: false })).toBeVisible({ timeout: 15_000 }); } await shot(page, 'UI-15-post-restart.png'); recordPass({ id: 'UI-15', name: 'post-restart restore', input: `UI login as A; open ${st.projectName}`, actual: `version=${v} revision=${r}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); }); // ─── main phase ─── test.describe.serial('document-revision-runtime UI acceptance', () => { test.skip(!runAcceptance || uiPhase === 'post-restart', 'set RUN_UI_ACCEPTANCE=1 UI_PHASE=main'); let context: BrowserContext; let page: Page; let logs: NetLog[] = []; let controlledTitle = ''; let controlledDocId = ''; let versionBeforePending = ''; let secondDocTitle = ''; let secondDocId = ''; let uploadedDocId = ''; /** Latest server-durable body marker; UI-15-pre writes this as bodySnippet. */ let latestDurableBodySnippet = ''; test.beforeAll(async ({ browser }) => { context = await browser.newContext(); page = await context.newPage(); page.on('response', (res) => pushNet(logs, res)); }); test.afterAll(async () => { saveResults(); await context?.close(); }); test('UI-00 bootstrap: admin creates A/B/C via page', async () => { const t0 = Date.now(); logs = []; const admin = requireAdminCreds(); await page.goto('/'); await page.fill('input[placeholder*="系统用户名"]', admin.username); await page.fill('input[placeholder*="登录密码"]', admin.password); await page.click('button:has-text("进入医疗协同工作台")'); await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); await openAdminConsole(page); await adminCreateUser(page, USER_A, `编制人A-${suffix}`, INIT_PASS, 'role-tech-lead'); await adminCreateUser(page, USER_B, `协作者B-${suffix}`, INIT_PASS, 'role-engineer'); await adminCreateUser(page, USER_C, `无关人C-${suffix}`, INIT_PASS, 'role-guest'); // Close through the real top-right X, scoped to the outer fixed admin modal. const adminHeading = page.getByText('系统管理控制台', { exact: true }); const adminModal = adminHeading.locator( 'xpath=ancestor::div[contains(@style,"position: fixed")][1]', ); await adminModal.locator('button').first().click(); await expect(adminHeading).toBeHidden({ timeout: 10_000 }); await logoutViaUI(page); // A first login + password change await page.fill('input[placeholder*="系统用户名"]', USER_A); await page.fill('input[placeholder*="登录密码"]', INIT_PASS); await page.click('button:has-text("进入医疗协同工作台")'); await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_A); await logoutViaUI(page); await page.fill('input[placeholder*="系统用户名"]', USER_B); await page.fill('input[placeholder*="登录密码"]', INIT_PASS); await page.click('button:has-text("进入医疗协同工作台")'); await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_B); await logoutViaUI(page); await page.fill('input[placeholder*="系统用户名"]', USER_C); await page.fill('input[placeholder*="登录密码"]', INIT_PASS); await page.click('button:has-text("进入医疗协同工作台")'); await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_C); await logoutViaUI(page); writePhaseSecrets({ passA: FINAL_PASS_A, passB: FINAL_PASS_B, passC: FINAL_PASS_C }); writePhaseState({ projectName: PROJECT_NAME, userA: USER_A, userB: USER_B, userC: USER_C, }); recordPass({ id: 'UI-00', name: 'admin creates A/B/C', input: 'admin UI create users + first-login credential reset', actual: `users ${USER_A},${USER_B},${USER_C} ready`, networkLogs: logs.filter((l) => !/password|login/i.test(l.path)), durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-01 login A → page create project → template hint, no revision-state for doc-p', async () => { const t0 = Date.now(); await login(page, USER_A, FINAL_PASS_A); // Isolate network evidence to project-create window (exclude login-era noise). logs = []; await page.getByRole('button', { name: '新建立项会话' }).click(); const projectModalHeading = page.getByText('发起医疗器械新项目立项', { exact: true }); await expect(projectModalHeading).toBeVisible({ timeout: 30_000 }); const projectModal = projectModalHeading.locator( 'xpath=ancestor::div[contains(@style,"position: fixed")][1]', ); await projectModal.locator('input[placeholder*="无线便携式"]').fill(PROJECT_NAME); const desc = projectModal.locator('textarea'); if ((await desc.count()) === 0) fail('project form missing required description textarea'); await desc.fill('用于骨科微创手术的双频超声吸引与止血,需符合 GB 9706.1 与相关法规要求。'); // Select B through the visible member row, then assign a real project duty. const memberName = projectModal.getByText(`协作者B-${suffix}`, { exact: true }); await expect(memberName).toBeVisible({ timeout: 15_000 }); const memberToggle = memberName.locator( 'xpath=ancestor::div[contains(@style,"cursor: pointer")][1]', ); await memberToggle.click(); const memberContainer = memberName.locator( 'xpath=ancestor::div[contains(@style,"justify-content: space-between")][1]', ); const memberRole = memberContainer.locator('select'); await expect(memberRole).toBeEnabled(); await memberRole.selectOption('reviewer'); await expect(memberRole).toHaveValue('reviewer'); await projectModal.getByRole('button', { name: /确认立项/ }).click(); await expect(projectModalHeading).toBeHidden({ timeout: 15_000 }); await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); await expect(page.locator('aside')).toBeVisible(); // Template doc selected: must show non-error hint, never 账本未就绪 await expect(page.getByTestId('doc-rev-template-hint')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('doc-rev-template-hint')).toContainText('模板预览'); await expect(page.locator('text=版本账本未就绪')).toHaveCount(0); await shot(page, '01-project.png'); assertRevisionStateCleanForNewProject(logs); writePhaseState({ projectName: PROJECT_NAME, userA: USER_A, userB: USER_B, userC: USER_C, }); recordPass({ id: 'UI-01', name: 'create project workbench', input: `login A; create project ${PROJECT_NAME}; assign B`, actual: 'template hint visible; doc-p revision-state count=0; no revision-state 404', networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-02 send real squad task', async () => { const t0 = Date.now(); logs = []; const prompt = '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; // 产品契约:必须以 POST /api/v1/tasks/runs 的真实 HTTP 结果判定;禁止仅凭短暂「生成中」记 PASS。 const taskRunWait = page.waitForResponse( (r) => { const pathOnly = sanitizeUrl(r.url()); return pathOnly.includes('/api/v1/tasks/runs') && r.request().method() === 'POST'; }, { timeout: 60_000 }, ); await sendChat(page, prompt); await expect(page.getByText(prompt.slice(0, 12))).toBeVisible({ timeout: 20_000 }); const taskRes = await taskRunWait; const status = taskRes.status(); if (status === 409) { fail(`UI-02 tasks/runs 409 TASK_SCOPE_UNRESOLVED (or conflict) is FAIL; body must not be treated as started`); } if (status < 200 || status >= 300) { fail(`UI-02 tasks/runs must be 2xx (create usually 202 Accepted), got HTTP ${status}`); } const connErr = page.locator('text=连接异常'); if (await connErr.first().isVisible().catch(() => false)) { fail('UI-02 connection-error banner visible after tasks/runs — product FAIL'); } // 对本轮 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}; this-turn assistant inflight`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-03 wait first round; open controlled doc with V/r', async () => { const t0 = Date.now(); const promptSlice = '请基于本项目编制首轮'; // 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 { v, r } = await readVersionBadge(page); await shot(page, '03-first-doc.png'); const st = readPhaseState(); writePhaseState({ ...st, controlledDocTitle: controlledTitle, controlledDocId, versionText: v, revisionText: r, }); recordPass({ id: 'UI-03', name: 'first controlled doc ledger', input: 'wait squad; open intake server doc slot', actual: `toolbar ${v}/${r}; docId=${controlledDocId}; title=${controlledTitle}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-04 open change report with full fields', async () => { const t0 = Date.now(); logs = []; const reportBtn = page.getByRole('button', { name: /变更报告|待确认报告/ }); await expect(reportBtn).toBeEnabled({ timeout: 15_000 }); await reportBtn.click(); const dialog = page.getByRole('dialog', { name: '变更报告' }); await expect(dialog).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId('change-report-panel')).toBeVisible({ timeout: 20_000 }); const panel = page.locator('text=风险').or(page.locator('text=开放问题')).or(page.locator('text=项目影响')); await expect(panel.first()).toBeVisible({ timeout: 20_000 }); // Report list / body must be non-empty const bodyText = await page.locator('body').innerText(); if (!/风险|开放问题|摘要|影响/.test(bodyText)) fail('change report panel missing required fields'); await shot(page, '04-first-report.png'); await dialog.getByRole('button', { name: '关闭' }).click(); await expect(dialog).toHaveCount(0, { timeout: 10_000 }); await expect(page.getByTestId('change-report-panel')).toHaveCount(0); recordPass({ id: 'UI-04', name: 'change report fields', input: 'click 变更报告; close via 关闭', actual: 'risk/impact/open questions visible; dialog closed', networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-05 high-risk candidate #1 pending (version must not advance)', async () => { const t0 = Date.now(); logs = []; const { v: v0 } = await readVersionBadge(page); versionBeforePending = v0; const prompt = '请按 YY/T、GB 9706.1 与 NMPA 注册法规要求,大幅改写当前受控文档中的风险管理与电气安全章节,引用具体条款并重写适用性结论(二轮高风险候选-确认用例)。'; await sendChat(page, prompt); await waitForSquadCompletion(page, '二轮高风险候选-确认用例'); await expect(page.getByTestId('doc-rev-pending-badge')).toBeVisible({ timeout: 60_000 }); await expect(page.getByRole('button', { name: '待确认报告' })).toBeVisible(); const { v: vPending } = await readVersionBadge(page); if (vPending !== v0) fail(`pending must not advance version: was ${v0} now ${vPending}`); await shot(page, '05-second-report.png'); recordPass({ id: 'UI-05', name: 'high-risk pending #1', input: prompt, actual: `pending badge; version stayed ${vPending}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-05a confirm high-risk candidate → version advances', async () => { const t0 = Date.now(); logs = []; const pendingBtn = page.getByRole('button', { name: '待确认报告' }); await expect(pendingBtn).toBeVisible({ timeout: 15_000 }); await pendingBtn.click(); const reportPanel = page.getByTestId('change-report-panel'); await expect(reportPanel).toBeVisible({ timeout: 20_000 }); const riskEl = reportPanel.getByTestId('change-report-risk'); await expect(riskEl).toBeVisible({ timeout: 20_000 }); const risk = (await riskEl.innerText()).trim().toLowerCase(); if (risk !== 'high' && risk !== 'critical') { fail(`UI-05a pending report risk must be high (critical allowed); got ${JSON.stringify(risk)}`); } const actions = reportPanel.getByTestId('change-report-pending-actions'); await expect(actions).toBeVisible({ timeout: 15_000 }); const confirm = actions.getByRole('button', { name: '确认提交' }); await expect(confirm).toBeVisible({ timeout: 15_000 }); const decisionWait = page.waitForResponse( (r) => r.url().includes('/decision') && r.request().method() === 'POST', { timeout: 60_000 }, ); await confirm.click(); const decRes = await decisionWait; if (decRes.status() >= 400) fail(`decision confirm HTTP ${decRes.status()}`); const req = decRes.request().postDataJSON() as { confirmed?: boolean; decision?: string }; if (req.decision !== 'commit' || req.confirmed !== true) { fail(`decision body must be commit+confirmed: ${JSON.stringify(req)}`); } await expect(page.getByTestId('doc-rev-pending-badge')).toHaveCount(0, { timeout: 30_000 }); const { v } = await readVersionBadge(page); if (v === versionBeforePending) fail(`version must advance after confirm: still ${v}`); await closeNamedDialog(page, '变更报告'); await shot(page, 'UI-05a-confirmed.png'); recordPass({ id: 'UI-05a', name: 'confirm pending', input: 'click 待确认报告; assert pending actions; click 确认提交', actual: `risk=${risk}; decision ok; version ${v}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-05b second high-risk candidate → reject; version stays', async () => { const t0 = Date.now(); logs = []; const { v: vBefore } = await readVersionBadge(page); if (!controlledDocId) fail('UI-05b requires controlledDocId from UI-03'); const prompt = '请再次按医疗器械临床试验与注册检验法规,重写当前受控文档《用户初始需求收集表》中的电磁兼容与软件生存周期章节并给出条款级符合性判断(二轮高风险候选-拒绝用例)。'; const taskRunWait = waitForTasksRunsPost(page); await sendChat(page, prompt); const taskRes = await taskRunWait; const taskBody = assertIntakeTaskRun(taskRes, controlledDocId, 'UI-05b'); await waitForSquadCompletion(page, '二轮高风险候选-拒绝用例'); await expect(page.getByTestId('doc-rev-pending-badge')).toBeVisible({ timeout: 60_000 }); const { v: vPend } = await readVersionBadge(page); if (vPend !== vBefore) fail(`second pending must not advance version (${vBefore}→${vPend})`); const pendingBtn = page.getByRole('button', { name: '待确认报告' }); await expect(pendingBtn).toBeVisible({ timeout: 15_000 }); await pendingBtn.click(); const reportPanel = page.getByTestId('change-report-panel'); await expect(reportPanel).toBeVisible({ timeout: 20_000 }); const actions = reportPanel.getByTestId('change-report-pending-actions'); await expect(actions).toBeVisible({ timeout: 15_000 }); const reject = actions.getByRole('button', { name: '拒绝' }); await expect(reject).toBeVisible({ timeout: 15_000 }); const decisionWait = page.waitForResponse( (r) => r.url().includes('/decision') && r.request().method() === 'POST', { timeout: 60_000 }, ); await reject.click(); const decRes = await decisionWait; if (decRes.status() >= 400) fail(`decision reject HTTP ${decRes.status()}`); const req = decRes.request().postDataJSON() as { decision?: string }; if (req.decision !== 'reject') fail(`expected reject decision, got ${JSON.stringify(req)}`); await expect(page.getByTestId('doc-rev-pending-badge')).toHaveCount(0, { timeout: 30_000 }); const { v: vAfter } = await readVersionBadge(page); if (vAfter !== vBefore) fail(`reject must not advance version (${vBefore}→${vAfter})`); await closeNamedDialog(page, '变更报告'); await shot(page, 'UI-05b-rejected.png'); recordPass({ id: 'UI-05b', name: 'reject pending', input: `${prompt}; click 待确认报告; click 拒绝`, actual: `task_key=${taskBody.task_key} doc_id=${taskBody.doc_id}; rejected; version stayed ${vAfter}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-06 edit block → save → hard refresh persists r bump', async () => { const t0 = Date.now(); logs = []; const { r: r0 } = await readVersionBadge(page); const marker = `UI06-${suffix}`; await editFirstPaneBlockAndSave(page, `人工修订段落 ${marker} 用于硬刷新验收。`); await expect(page.locator('[data-testid="doc-rev-revision"]')).not.toHaveText(r0, { timeout: 30_000 }); const { r: r1 } = await readVersionBadge(page); await page.reload(); await expect(page.getByText(marker)).toBeVisible({ timeout: 30_000 }); await expect(page.locator('[data-testid="doc-rev-revision"]')).toContainText(r1.replace(/^r/, ''), { timeout: 30_000, }); await shot(page, 'UI-06-after-refresh.png'); latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, { kind: 'persisted', snippet: marker, }) || marker; const st = readPhaseState(); writePhaseState({ ...st, bodySnippet: latestDurableBodySnippet, revisionText: r1 }); recordPass({ id: 'UI-06', name: 'save + hard refresh', input: 'dblclick edit save reload', actual: `r ${r0}→${r1}; marker persisted`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-07/08/09 overlay modes differ in computed styles', async () => { const t0 = Date.now(); logs = []; const pure = await captureOverlayFingerprint(page, 'pure'); await shot(page, 'UI-07-pure.png'); const manual = await captureOverlayFingerprint(page, 'manual'); await shot(page, 'UI-08-manual.png'); const all = await captureOverlayFingerprint(page, 'all'); await shot(page, 'UI-09-all.png'); // Checklist: UI-07 无琥珀/蓝底;UI-08 人工改动琥珀标记;失败标准是「三 overlay 视觉无差异」。 // 同一 human hunk 在 manual/all 都是琥珀底,产品不保证两两不同,禁止换块制造差异。 const pureKey = overlayPaneKey(pure); const manualKey = overlayPaneKey(manual); const allKey = overlayPaneKey(all); if (overlayPaneHasMark(pure)) { fail(`UI-07 纯净阅读 must have no overlay decoration: ${JSON.stringify(pure)}`); } if (!overlayPaneHasMark(manual)) { fail(`UI-08 人工修改 must decorate changed blocks: ${JSON.stringify(manual)}`); } if (!overlayPaneHasMark(all)) { fail(`UI-09 全部变更 must show version/human decorations: ${JSON.stringify(all)}`); } if (pureKey === manualKey || pureKey === allKey) { fail(`pure must be visually distinct from manual/all: pure=${pureKey} manual=${manualKey} all=${allKey}`); } recordPass({ id: 'UI-07', name: 'overlay pure/manual/all', input: 'switch three overlay modes; sample pane document-block styles', actual: `pure unmarked; manual/all marked; manual===all allowed when hunks match`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', notes: JSON.stringify({ pure, manual, all }), }); recorded['UI-08'] = { ...recorded['UI-07'], id: 'UI-08', name: 'overlay manual' }; recorded['UI-09'] = { ...recorded['UI-07'], id: 'UI-09', name: 'overlay all' }; saveResults(); }); test('UI-10 version history + compare', async () => { const t0 = Date.now(); logs = []; await page.getByRole('button', { name: '版本历史' }).click(); const history = page.getByRole('dialog', { name: '版本历史' }); await expect(history).toBeVisible({ timeout: 15_000 }); const rows = history.locator('li').filter({ hasText: /V\d+/ }); await expect(rows.first()).toBeVisible({ timeout: 15_000 }); const n = await rows.count(); if (n < 2) fail(`version history must list at least two versions, got ${n}`); const vA = ((await rows.nth(0).locator('strong').innerText()) || '').trim(); const vB = ((await rows.nth(1).locator('strong').innerText()) || '').trim(); if (!vA || !vB || vA === vB) fail(`history A/B must be different versions, got ${vA} vs ${vB}`); await rows.nth(0).getByRole('button', { name: '选为 A' }).click(); await rows.nth(1).getByRole('button', { name: '选为 B' }).click(); await history.getByRole('button', { name: '比较 A/B' }).click(); const compare = page.getByRole('dialog', { name: '版本比较' }); await expect(compare).toBeVisible({ timeout: 20_000 }); // VersionHistoryDrawer onSelectCompare closes history before compare mounts. await expect(history).toHaveCount(0, { timeout: 10_000 }); await expect(compare.getByText(/\+\d+\s*\/\s*-\d+\s*\/\s*~\d+/)).toBeVisible({ timeout: 15_000 }); await shot(page, 'UI-10-history-compare.png'); await compare.getByRole('button', { name: '关闭', exact: true }).click(); await expect(compare).toHaveCount(0, { timeout: 10_000 }); await expect(page.getByRole('dialog', { name: '版本历史' })).toHaveCount(0); recordPass({ id: 'UI-10', name: 'history + compare', input: 'open 版本历史; pick two V as A/B; click 比较 A/B; history auto-closes; close compare', actual: `history entries=${n}; compared ${vA} vs ${vB}; history auto-closed; compare closed`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-11 chat second controlled doc + page upload attachment', async () => { const t0 = Date.now(); logs = []; const prompt = '请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。'; const taskRunWait = waitForTasksRunsPost(page); await sendChat(page, prompt); const taskRes = await taskRunWait; if (taskRes.status() !== 202) { fail(`UI-11 tasks/runs must be HTTP 202, got ${taskRes.status()}`); } const taskBody = taskRes.request().postDataJSON() as { task_key?: string }; if (taskBody.task_key !== 'prelim_design') { fail(`UI-11 task_key must be prelim_design, got ${JSON.stringify(taskBody.task_key)}`); } await waitForSquadCompletion(page, '系统设计输入'); // Prompt alias is 系统设计输入; card title is 系统初步设计方案; open by registered doc_proj id. const second = await openWorkflowDocByControlledTaskKey(page, 'prelim_design'); if (second.title !== '系统初步设计方案') { fail(`UI-11 prelim_design card title must be 系统初步设计方案, got ${JSON.stringify(second.title)}`); } 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 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(`${target.title}:${v}/${r}`); } // Page upload: chip draft → send → POST /api/v1/freeform-documents → doc-free pane. const fileInput = page.locator('input[type="file"]'); if ((await fileInput.count()) === 0) fail('no file input for page upload'); const tmp = path.join(ASSETS, `upload-${suffix}.txt`); const uploadName = `upload-${suffix}.txt`; fs.writeFileSync(tmp, `attachment ${suffix}\n`, 'utf-8'); await fileInput.first().setInputFiles(tmp); await expect( page.getByText(uploadName, { exact: false }).or(page.getByText(`upload-${suffix}`, { exact: false })), ).toBeVisible({ timeout: 15_000 }); const archivePrompt = '请将此附件归档为我的私密参考文档。'; const freeformWait = page.waitForResponse( (r) => { const pathOnly = sanitizeUrl(r.url()); return ( r.request().method() === 'POST' && pathOnly.includes('/api/v1/freeform-documents') && !pathOnly.includes('/from-message') ); }, { timeout: 60_000 }, ); const box = page.getByPlaceholder(/向项目助手提问/); await expect(box).toBeVisible({ timeout: 15_000 }); // 附件允许空文本;填归档句以便本轮 assistant 可定位并收口终态。 await box.fill(archivePrompt); await page.getByRole('button', { name: /发送/ }).click(); const freeformRes = await freeformWait; const freeformStatus = freeformRes.status(); if (freeformStatus < 200 || freeformStatus >= 300) { fail(`UI-11 POST /api/v1/freeform-documents must be 2xx, got HTTP ${freeformStatus}`); } const pane = page.getByTestId('active-document-pane'); await expect(pane).toHaveAttribute('data-doc-id', /^doc-free-/, { timeout: 45_000 }); uploadedDocId = (await pane.getAttribute('data-doc-id')) || ''; if (!uploadedDocId.startsWith('doc-free-')) { fail(`UI-11 upload must activate doc-free-* pane, got ${uploadedDocId}`); } if (uploadedDocId.includes('doc-upload')) { fail(`UI-11 must not leave doc-upload orphan active: ${uploadedDocId}`); } // 本轮助手可能直接 completed(归档快路径)。仍须:上传证据、相邻卡片、成功终态、无错误。 // inflight 只作可选观测,不得因错过 pending 而把 completed 判失败。 await waitForSquadCompletion(page, '请将此附件归档', 180_000); await shot(page, 'UI-11-multi-doc.png'); recordPass({ id: 'UI-11', name: 'multi doc + upload', input: 'chat second doc; upload file + send for freeform register', actual: `${states.join('; ')}; freeform HTTP ${freeformStatus}; uploadedDocId=${uploadedDocId}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-12 user C (not a member) must not see project', async () => { const t0 = Date.now(); const guestLogs: NetLog[] = []; const ctx = await page.context().browser()!.newContext(); const gp = await ctx.newPage(); gp.on('response', (res) => pushNet(guestLogs, res)); await login(gp, USER_C, FINAL_PASS_C); const listed = gp.getByText(PROJECT_NAME, { exact: false }); const n = await listed.count(); if (n !== 0) { fail(`non-member C must not see project ${PROJECT_NAME}; visible count=${n}`); } await shot(gp, '12-unauthorized-access.png'); await ctx.close(); recordPass({ id: 'UI-12', name: 'unauthorized isolation', input: `login ${USER_C} (not member)`, actual: `project ${PROJECT_NAME} not visible`, networkLogs: guestLogs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-13a reviewer B has no body-edit affordance', async () => { const t0 = Date.now(); logs = []; 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(); 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 }); await pageB.locator('[title*="法定权限矩阵"]').click(); await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toBeVisible({ timeout: 15_000 }); const duty = await readReviewerIntakeDuty(pageB, controlledDocId); await pageB.getByRole('button', { name: '关闭面板' }).click(); await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toHaveCount(0, { timeout: 10_000 }); await expect(pageB.getByText('双击任意段落就地编辑')).toHaveCount(0); const pane = pageB.getByTestId('active-document-pane'); await pane.getByTestId('document-block').first().dblclick({ timeout: 15_000 }); await expect(blockEditorDialog(pageB)).toHaveCount(0); await shot(pageB, 'UI-13a-reviewer-readonly.png'); await ctxB.close(); recordPass({ id: 'UI-13a', name: 'reviewer B no body edit', input: 'login reviewer B; open intake; rights matrix + dblclick', actual: `duty=${duty}; no author edit hint; no save editor`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-13b two author-A contexts conflict on same revision', async () => { const t0 = Date.now(); logs = []; const etagA1P = page.waitForResponse( (r) => r.url().includes('/revision-state') && r.request().method() === 'GET' && r.ok(), { timeout: 30_000 }, ); 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 a1Base = await readVersionBadge(page); const etagA1 = await readRevisionStateEtag(await etagA1P); const ctxA2 = await page.context().browser()!.newContext(); const pageA2 = await ctxA2.newPage(); await login(pageA2, USER_A, FINAL_PASS_A); await pageA2.getByText(PROJECT_NAME, { exact: false }).first().click(); const etagA2P = pageA2.waitForResponse( (r) => r.url().includes('/revision-state') && r.request().method() === 'GET' && r.ok(), { timeout: 30_000 }, ); if (controlledDocId) { await openWorkflowDocById(pageA2, controlledDocId); } else { await openWorkflowDocByLabel(pageA2, /用户初始需求|需求摸底|intake/i); } await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); const a2Base = await readVersionBadge(pageA2); const etagA2 = await readRevisionStateEtag(await etagA2P); if (a2Base.r !== a1Base.r) fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`); if (etagA2 !== etagA1) fail(`A2 must hydrate same etag as A1 before either save: ${etagA2} vs ${etagA1}`); const markerA1 = `A1-win-${suffix}`; const markerA2 = `A2-conflict-${suffix}`; const a2Editor = await openFirstPaneEditorAndFill(pageA2, `人工修订段落 ${markerA2}`); await expect(a2Editor).toBeVisible(); const patchA1 = page.waitForResponse( (r) => r.url().includes('/content') && r.request().method() === 'PATCH', { timeout: 30_000 }, ); await editFirstPaneBlockAndSave(page, `人工修订段落 ${markerA1}`); const a1Res = await patchA1; if (a1Res.status() < 200 || a1Res.status() >= 300) { fail(`A1 save must be 2xx, got HTTP ${a1Res.status()}`); } await expect(page.getByText(markerA1)).toBeVisible({ timeout: 30_000 }); await expect(a2Editor).toBeVisible(); const patchA2 = pageA2.waitForResponse( (r) => r.url().includes('/content') && r.request().method() === 'PATCH', { timeout: 30_000 }, ); await saveOpenEditor(pageA2); const a2Res = await patchA2; const a2Json = (await a2Res.json().catch(() => ({}))) as { code?: string }; if (a2Res.status() !== 409 && a2Json.code !== 'DOCUMENT_HEAD_CONFLICT') { fail(`A2 save must be DOCUMENT_HEAD_CONFLICT/409, got HTTP ${a2Res.status()} body=${JSON.stringify(a2Json)}`); } await expect(pageA2.getByText(/文档已被他人更新|刷新后重做|版本冲突/)).toBeVisible({ timeout: 30_000, }); await expect(pageA2.getByRole('button', { name: '刷新后重做' })).toBeVisible({ timeout: 10_000 }); await expect(a2Editor).toBeVisible(); const a2Kept = await a2Editor .inputValue() .catch(async () => (await a2Editor.innerText()) || ''); if (!a2Kept.includes(markerA2)) { fail(`A2 editor must keep unsaved text after conflict, got ${JSON.stringify(a2Kept)}`); } await expect(page.getByText(markerA1)).toBeVisible(); await expect(page.getByText(markerA2)).toHaveCount(0); await shot(pageA2, 'UI-13b-conflict-A2.png'); await ctxA2.close(); latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, { kind: 'persisted', snippet: markerA1, }) || markerA1; latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, { kind: 'conflict_draft', snippet: markerA2, }) || latestDurableBodySnippet; writePhaseState({ ...readPhaseState(), bodySnippet: latestDurableBodySnippet }); recordPass({ id: 'UI-13b', name: 'author A/A head conflict', input: 'both windows hydrate same r/etag; A2 editor open+filled before A1 save; then A2 save', actual: `base r=${a1Base.r} etag=${etagA1}; A1 kept ${markerA1}; A2 HTTP ${a2Res.status()} code=${a2Json.code || ''}; A2 editor retained`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); recorded['UI-13'] = { id: 'UI-13', name: 'split: 13a reviewer-readonly + 13b A/A conflict', input: 'UI-13a then UI-13b', actual: 'see UI-13a and UI-13b', networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', notes: 'B is project reviewer and must not be used as a body editor', }; saveResults(); }); test('UI-14 logout via UI then login again', async () => { const t0 = Date.now(); logs = []; const { v, r } = await readVersionBadge(page); await logoutViaUI(page); await login(page, USER_A, FINAL_PASS_A); await page.getByText(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: 30_000 }); const again = await readVersionBadge(page); if (again.v !== v) fail(`version changed across re-login unexpectedly ${v}→${again.v}`); if (again.r !== r) fail(`revision changed across re-login unexpectedly ${r}→${again.r}`); await shot(page, 'UI-14-relogin.png'); recordPass({ id: 'UI-14', name: 'UI logout + login', input: 'click 退出当前登录; login A', actual: `ledger ${again.v}/${again.r} unchanged`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-15 phase-main: write evidence then exit (host restarts docker before post-restart run)', async () => { const t0 = Date.now(); logs = []; const { v, r } = await readVersionBadge(page); const st = readPhaseState(); writePhaseState({ ...st, projectName: PROJECT_NAME, controlledDocTitle: controlledTitle, controlledDocId, versionText: v, revisionText: r, bodySnippet: latestDurableBodySnippet || st.bodySnippet, userA: USER_A, userB: USER_B, userC: USER_C, }); await shot(page, 'UI-15-pre-restart-evidence.png'); // Do NOT mark full UI-15 PASS here — only evidence for post-restart phase. recorded['UI-15-pre'] = { id: 'UI-15-pre', name: 'pre-restart evidence written', input: 'capture project/state; exit suite', actual: `state file written version=${v} revision=${r}`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', notes: 'Full UI-15 PASS only after UI_PHASE=post-restart run', }; saveResults(); }); test('UI-16 registered upload uses server doc-free ledger (no doc-upload orphan)', async () => { const t0 = Date.now(); logs = []; if (!uploadedDocId || !uploadedDocId.startsWith('doc-free-')) { fail(`UI-16 requires uploadedDocId from UI-11 (doc-free-*), got ${uploadedDocId || '(empty)'}`); } await selectDeliverableCategory(page, 'private'); await openWorkflowDocById(page, uploadedDocId); const pane = page.getByTestId('active-document-pane'); await expect(pane).toHaveAttribute('data-doc-id', uploadedDocId, { timeout: 15_000 }); await expect(pane).toHaveAttribute('data-doc-id', /^doc-free-/); const paneId = (await pane.getAttribute('data-doc-id')) || ''; if (paneId.startsWith('doc-upload')) { fail(`UI-16 must not open doc-upload orphan: ${paneId}`); } await expect(page.getByText('版本账本未就绪')).toHaveCount(0); await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); const orphanHits = logs.filter( (l) => l.path.includes('/revision-state') && /\/documents\/doc-upload-/.test(l.path), ); if (orphanHits.length) { fail(`must not request revision-state for doc-upload orphan: ${JSON.stringify(orphanHits)}`); } await shot(page, 'UI-16-registered-upload.png'); recordPass({ id: 'UI-16', name: 'registered upload server ledger', input: `category=private; open exact ${uploadedDocId}`, actual: `pane=${paneId}; toolbar V visible; no doc-upload revision-state`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); });