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'; /** * 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, 'ui-acceptance-results.json'); const PHASE_STATE_FILE = path.join(ASSETS, 'phase-state.json'); /** Local-only secrets for post-restart login — never copied into results JSON. */ const PHASE_SECRETS_FILE = path.join(ASSETS, 'phase-secrets.local.json'); 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 }); } /** * Squad wait: MUST observe start (user bubble + running/thinking) before terminal. * failed status → FAIL. */ async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) { const startedAt = Date.now(); const userBubble = page.locator('div, article, li').filter({ hasText: userPromptSnippet }).first(); await expect(userBubble).toBeVisible({ timeout: 30_000 }); const running = page .locator('text=正在思考') .or(page.locator('text=生成中')) .or(page.locator('.agent-pulse-active')) .or(page.locator('text=执行中')) .or(page.locator('text=编排中')); await expect(running.first()).toBeVisible({ timeout: 60_000 }); const terminal = page .locator('text=已完成') .or(page.locator('text=待编制人确认')) .or(page.locator('text=失败')) .or(page.locator('[data-status="completed"]')) .or(page.locator('[data-status="pending_author_confirmation"]')) .or(page.locator('[data-status="failed"]')); await expect(terminal.first()).toBeVisible({ timeout: timeoutMs }); if (await page.locator('text=失败').or(page.locator('[data-status="failed"]')).first().isVisible().catch(() => false)) { fail('squad terminal status is failed'); } // Ensure running indicator has cleared. await expect(page.locator('text=正在思考,请稍候...')).toBeHidden({ timeout: 60_000 }).catch(() => undefined); return Date.now() - startedAt; } 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(); } /** 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 }; } async function captureOverlayFingerprint(page: Page, mode: 'pure' | 'manual' | 'all') { await page.locator('select[aria-label="变更显示模式"]').selectOption(mode); await page.waitForTimeout(400); const marked = page.locator('[data-overlay-kind]'); const target = mode === 'pure' ? page.locator('[data-overlay-kind], [class*="block"]').first() : marked.first(); if ((await target.count()) === 0) { fail(`UI overlay mode=${mode}: no block to sample computed style`); } return target.evaluate((el) => { const s = window.getComputedStyle(el as HTMLElement); return { backgroundColor: s.backgroundColor, boxShadow: s.boxShadow, className: (el as HTMLElement).className || '', overlayKind: (el as HTMLElement).getAttribute('data-overlay-kind') || '', }; }); } // ─── 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); // 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(); } 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 versionBeforePending = ''; let secondDocTitle = ''; 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(page.getByRole('banner').getByText(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 = '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; await sendChat(page, prompt); await expect(page.getByText(prompt.slice(0, 12))).toBeVisible({ timeout: 20_000 }); const running = page.locator('text=正在思考').or(page.locator('text=生成中')); await expect(running.first()).toBeVisible({ timeout: 60_000 }); await shot(page, 'UI-02-squad-started.png'); recordPass({ id: 'UI-02', name: 'start real squad', input: prompt, actual: 'running/thinking visible after user bubble', 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 = '请基于本项目编制首轮'; await waitForSquadCompletion(page, promptSlice); // Open a workflow doc tab that is no longer template-only (server doc_proj*). const tab = page.locator('[role="tab"], button, div').filter({ hasText: /需求|摸底|URS|用户/ }).first(); if ((await tab.count()) === 0) fail('no controlled deliverable tab after first squad round'); await tab.click(); 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); controlledTitle = (await tab.textContent())?.trim() || 'controlled'; await shot(page, '03-first-doc.png'); const st = readPhaseState(); writePhaseState({ ...st, controlledDocTitle: controlledTitle, versionText: v, revisionText: r, }); recordPass({ id: 'UI-03', name: 'first controlled doc ledger', input: 'wait squad; open deliverable tab', actual: `toolbar ${v}/${r}`, 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 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'); // Close if possible await page.keyboard.press('Escape').catch(() => undefined); recordPass({ id: 'UI-04', name: 'change report fields', input: 'click 变更报告', actual: 'risk/impact/open questions visible', 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 confirm = page.getByRole('button', { name: /确认提交/ }); if ((await confirm.count()) === 0) fail('confirm button missing for pending high-risk candidate'); 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 shot(page, 'UI-05a-confirmed.png'); recordPass({ id: 'UI-05a', name: 'confirm pending', input: 'click 确认提交', actual: `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); const prompt = '请再次按医疗器械临床试验与注册检验法规,重写电磁兼容与软件生存周期章节并给出条款级符合性判断(二轮高风险候选-拒绝用例)。'; await sendChat(page, prompt); 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 reject = page.getByRole('button', { name: /拒绝/ }); if ((await reject.count()) === 0) fail('reject button missing'); 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 shot(page, 'UI-05b-rejected.png'); recordPass({ id: 'UI-05b', name: 'reject pending', input: prompt, actual: `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 block = page.locator('[data-overlay-kind], p, h1, h2').first(); await block.dblclick({ timeout: 15_000 }); const editor = page.locator('textarea, [contenteditable="true"]').last(); await expect(editor).toBeVisible({ timeout: 10_000 }); const marker = `UI06-${suffix}`; await editor.fill(`人工修订段落 ${marker} 用于硬刷新验收。`); await page.getByRole('button', { name: /保存/ }).click(); 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'); const st = readPhaseState(); writePhaseState({ ...st, bodySnippet: marker, 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'); const key = (x: { backgroundColor: string; boxShadow: string; className: string }) => `${x.backgroundColor}|${x.boxShadow}|${x.className}`; if (key(pure) === key(manual) && key(manual) === key(all)) { fail(`overlay modes not visually distinct: pure=${JSON.stringify(pure)} manual=${JSON.stringify(manual)} all=${JSON.stringify(all)}`); } if (key(pure) === key(manual) || key(pure) === key(all) || key(manual) === key(all)) { // Require pairwise difference across the three captures. fail(`each overlay mode must differ: pure=${key(pure)} manual=${key(manual)} all=${key(all)}`); } recordPass({ id: 'UI-07', name: 'overlay pure/manual/all', input: 'switch three overlay modes; sample computed style', actual: `pure≠manual≠all`, 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 list = page.locator('text=/V\\d+/'); await expect(list.first()).toBeVisible({ timeout: 15_000 }); const n = await list.count(); if (n < 1) fail('version history list empty'); await page.getByRole('button', { name: '比较' }).click(); await shot(page, 'UI-10-history-compare.png'); recordPass({ id: 'UI-10', name: 'history + compare', input: 'open 版本历史 / 比较', actual: `history entries=${n}`, 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 = '请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。'; await sendChat(page, prompt); await waitForSquadCompletion(page, '系统设计输入'); const secondTab = page.locator('[role="tab"], button, div').filter({ hasText: /设计输入|系统设计/ }).first(); if ((await secondTab.count()) === 0) fail('second controlled document tab not found after chat'); await secondTab.click(); secondDocTitle = (await secondTab.textContent())?.trim() || ''; 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 page.getByText(label, { exact: false }).first().click(); const { v, r } = await readVersionBadge(page); states.push(`${label}:${v}/${r}`); } // Page upload attachment 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`); fs.writeFileSync(tmp, `attachment ${suffix}\n`, 'utf-8'); await fileInput.first().setInputFiles(tmp); await expect(page.getByText(`upload-${suffix}`).or(page.getByText('attachment'))).toBeVisible({ timeout: 30_000, }); await shot(page, 'UI-11-multi-doc.png'); recordPass({ id: 'UI-11', name: 'multi doc + upload', input: 'chat second doc; upload file', actual: states.join('; '), networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-12 user C (not a member) must not see project body', 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 leaked = await gp.getByText(PROJECT_NAME, { exact: false }).isVisible().catch(() => false); if (leaked) { await gp.getByText(PROJECT_NAME, { exact: false }).first().click(); const bodyLeak = await gp.getByText(/人工修订段落|UI06-/).isVisible().catch(() => false); if (bodyLeak) fail('unauthorized user C can read controlled body'); } await shot(gp, '12-unauthorized-access.png'); await ctx.close(); recordPass({ id: 'UI-12', name: 'unauthorized isolation', input: `login ${USER_C} (not member)`, actual: leaked ? 'project listed but body blocked' : 'project not visible', networkLogs: guestLogs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); test('UI-13 A/B dual context conflict on same revision', async () => { const t0 = Date.now(); logs = []; // Ensure A is on controlled doc await page.getByText(controlledTitle || PROJECT_NAME, { exact: false }).first().click(); 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(); await expect(pageB.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); // Both open same old revision — A saves first const blockA = page.locator('p, h2, [data-overlay-kind]').first(); await blockA.dblclick(); await page.locator('textarea, [contenteditable="true"]').last().fill(`A-win-${suffix}`); await page.getByRole('button', { name: /保存/ }).click(); await page.waitForTimeout(1000); const blockB = pageB.locator('p, h2, [data-overlay-kind]').first(); await blockB.dblclick(); await pageB.locator('textarea, [contenteditable="true"]').last().fill(`B-conflict-${suffix}`); await pageB.getByRole('button', { name: /保存/ }).click(); const conflict = pageB.getByText(/冲突|刷新后重做|版本冲突/); await expect(conflict.first()).toBeVisible({ timeout: 30_000 }); await shot(pageB, 'UI-13-conflict-B.png'); await ctxB.close(); recordPass({ id: 'UI-13', name: 'A/B concurrent edit conflict', input: 'A save then B save on same base revision', actual: 'B sees conflict / 刷新后重做', networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); 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(); await page.getByText(controlledTitle || /需求/, { exact: false }).first().click(); 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}`); 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}`, 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, versionText: v, revisionText: r, 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 = []; const uploadTab = page.getByText(`upload-${suffix}`).or(page.getByText(/attachment/)); if ((await uploadTab.count()) === 0) fail('upload tab missing (UI-11 must have uploaded via page)'); await uploadTab.first().click(); await expect(page.locator('text=版本账本未就绪')).toHaveCount(0); // Registered upload must mount real toolbar (doc-free), not orphan hint. 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: 'open page-uploaded attachment after createFreeform registration', actual: 'toolbar V visible; no doc-upload revision-state', networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', }); }); });