import { test, expect, type Page, type Browser } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { GOVERNANCE_BLOCKED_HINT } from '../src/lib/blockedGovernanceCopy'; import { currentProjectBannerName } from '../src/lib/currentProjectRestore'; import { ANY_TERMINAL_STATUS_RE, evaluateSquadObservation, } from '../src/lib/squadCompletionObservation'; import { CATEGORY_TRIGGER_LABEL, requiredCategoryForDocOpen, } from '../src/lib/workflowCategoryNavigation'; /** * M1 live: ledger-first GET. Projection missing must not become 404/409. * Project 143 is read-only. Do not terminate or PUT workflow-plan. */ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; const ASSETS = process.env.UI_ACCEPTANCE_ASSETS_DIR ? path.resolve(process.env.UI_ACCEPTANCE_ASSETS_DIR) : path.resolve( __dirname, '../../.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live', ); const SCREENSHOTS = path.join(ASSETS, 'screenshots'); fs.mkdirSync(SCREENSHOTS, { recursive: true }); const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080'; const TARGET_DOC = 'doc_proj143_intake'; const suffix = `${Date.now().toString(36)}`; const USER_B = `m1b_${suffix}`; const USER_C = `m1c_${suffix}`; const USER_D = `m1d_${suffix}`; const INIT_PASS = `Init-${suffix}9`; const FINAL_PASS_B = `PassB-${suffix}9`; const FINAL_PASS_C = `PassC-${suffix}9`; const FINAL_PASS_D = `PassD-${suffix}9`; const PROJECT_NAME = `M1活验-${suffix}`; test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the rebuilt dev stack'); const state: { newDocId?: string; newMarkdown?: string; outsiderReady?: boolean; } = {}; 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("进入医疗协同工作台")'); 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 sessionToken(page: Page): Promise { const token = await page.evaluate(() => { try { const raw = window.localStorage.getItem('smdf_auth_session'); if (!raw) return ''; const session = JSON.parse(raw) as { token?: string }; return session.token || ''; } catch { return ''; } }); if (!token) { throw new Error('login did not persist smdf_auth_session.token'); } return token; } async function getContent(page: Page, docId: string, token?: string) { const headers = token ? { Authorization: `Bearer ${token}` } : {}; const res = await page.request.get(`${BASE}/api/v1/docgov/documents/${docId}/content`, { headers, }); let body: { markdown?: string; consistency?: string; error_code?: string } = {}; try { body = (await res.json()) as typeof body; } catch { /* ignore */ } return { status: res.status(), body }; } function expectClosed404(status: number, body: { error_code?: string }, label: string) { expect(status, `${label} ${JSON.stringify(body)}`).toBe(404); expect(body.error_code, label).toBe('DOCUMENT_NOT_FOUND'); } async function goToDutyStep(page: Page) { const approval = page.locator('label', { hasText: '纳入人工审批' }).first(); const selectHint = page.getByText('选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。'); await expect(selectHint.or(approval)).toBeVisible({ timeout: 20_000 }); if (await approval.isVisible().catch(() => false)) { return; } await page.getByRole('button', { name: /下一步/ }).click(); await expect(approval).toBeVisible({ timeout: 20_000 }); } function dutyRoleSelect(page: Page, placeholder: '作者' | '审核' | '批准') { return page .locator('select') .filter({ has: page.getByRole('option', { name: placeholder, exact: true }) }) .first(); } 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"]', ), }; } async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) { 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 () => evaluateSquadObservation( { userPromptMatched: true, assistantAdjacent: true, status: (await assistant.getAttribute('data-chat-status')) || '', hasVisibleError: await connErr.first().isVisible().catch(() => false), }, { requireInflight: false }, ); let verdict = await observe(); if (verdict.action === 'reject') { throw new Error(`squad observation rejected: ${verdict.reason}`); } if (verdict.action === 'accept') { return; } await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, { timeout: timeoutMs, }); verdict = await observe(); if (verdict.action !== 'accept') { throw new Error(`squad terminal not accepted: ${verdict.reason}`); } } async function openWorkflowDocByLabel(page: Page, label: RegExp) { const category = requiredCategoryForDocOpen({ kind: 'workflow-label' }); const catTrigger = page.getByTestId('doc-category-trigger'); await expect(catTrigger).toBeVisible({ timeout: 15_000 }); await catTrigger.click(); const catItem = page.locator(`.dropdown-menu button[data-category="${category}"]`).first(); await catItem.click(); await expect(catTrigger).toContainText(CATEGORY_TRIGGER_LABEL[category], { timeout: 10_000 }); 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) { throw new Error(`workflow slot not in doc-selector menu: ${label}`); } const docId = (await item.getAttribute('data-doc-id')) || ''; await item.click(); if (!docId) { throw new Error(`selected menu item missing data-doc-id for ${label}`); } return docId; } test.describe.serial('controlled document reload M1 live', () => { test('project 143 content is 200 with consistency; anonymous is 401; unknown is 404', async ({ browser, }) => { if (!ADMIN_USER || !ADMIN_PASS) { throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); } const page = await browser.newPage(); const contentHits: { status: number; consistency?: string; error?: string }[] = []; page.on('response', async (res) => { const u = res.url(); if (!u.includes(`/docgov/documents/${TARGET_DOC}/content`)) return; let consistency: string | undefined; let error: string | undefined; try { const json = (await res.json()) as { consistency?: string; error_code?: string }; consistency = json.consistency; error = json.error_code; } catch { /* ignore non-json */ } contentHits.push({ status: res.status(), consistency, error }); }); await login(page, ADMIN_USER, ADMIN_PASS); await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click({ force: true }); await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 }); await page.screenshot({ path: path.join(SCREENSHOTS, '01-project-143.png'), fullPage: true }); const token = await sessionToken(page); const authed = await getContent(page, TARGET_DOC, token); expect(authed.status, `content ${JSON.stringify(authed.body)}`).toBe(200); expect(authed.body.markdown, 'ledger markdown required').toBeTruthy(); expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authed.body.consistency); expect(authed.body.error_code ?? '').not.toMatch(/PROJECTION/); await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); await page.reload(); await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 }); const afterReload = await getContent(page, TARGET_DOC, token); expect(afterReload.status).toBe(200); expect(afterReload.body.markdown).toBeTruthy(); await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); await page.screenshot({ path: path.join(SCREENSHOTS, '02-after-reload.png'), fullPage: true }); await logoutViaUI(page); await login(page, ADMIN_USER, ADMIN_PASS); await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click({ force: true }); await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 }); const afterRelogin = await getContent(page, TARGET_DOC, await sessionToken(page)); expect(afterRelogin.status).toBe(200); expect(afterRelogin.body.markdown).toBeTruthy(); await page.screenshot({ path: path.join(SCREENSHOTS, '03-after-relogin.png'), fullPage: true }); const anon = await getContent(page, TARGET_DOC); expect(anon.status).toBe(401); const missing = await getContent(page, 'doc_does_not_exist_m1', await sessionToken(page)); expectClosed404(missing.status, missing.body, 'unknown doc'); const projection409 = contentHits.filter( (h) => h.status === 409 || String(h.error || '').includes('PROJECTION'), ); expect(projection409, JSON.stringify(contentHits)).toHaveLength(0); await page.close(); }); test('non-member GET of project 143 is the same 404 as unknown', async ({ browser }) => { if (!ADMIN_USER || !ADMIN_PASS) { throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); } const admin = await browser.newPage(); await login(admin, ADMIN_USER, ADMIN_PASS); await admin.locator('button[title*="系统管理与准入治理"]').click(); for (const [user, name, role] of [ [USER_B, `审核协作者-${suffix}`, 'role-engineer'], [USER_C, `批准协作者-${suffix}`, 'role-engineer'], [USER_D, `局外人-${suffix}`, 'role-guest'], ] as const) { await admin.getByRole('button', { name: '开通受控员工账号' }).click(); const form = admin.locator('form').filter({ hasText: '用户名 (系统账号)' }); await form.locator('input[placeholder="如 li_si"]').fill(user); await form.locator('input[placeholder="如 李四"]').fill(name); const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); await passBox.fill(INIT_PASS); await form.locator('select').selectOption(role); await form.getByRole('button', { name: '确认开通账号' }).click(); await expect(form).toBeHidden({ timeout: 15_000 }); } const adminHeading = admin.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 admin.close(); const outsider = await browser.newPage(); await outsider.goto('/'); await outsider.fill('input[placeholder*="系统用户名"]', USER_D); await outsider.fill('input[placeholder*="登录密码"]', INIT_PASS); await outsider.click('button:has-text("进入医疗协同工作台")'); await changePasswordFirstLogin(outsider, INIT_PASS, FINAL_PASS_D); const token = await sessionToken(outsider); const closed = await getContent(outsider, TARGET_DOC, token); expectClosed404(closed.status, closed.body, 'non-member 143'); const unknown = await getContent(outsider, 'doc_does_not_exist_m1', token); expectClosed404(unknown.status, unknown.body, 'outsider unknown'); await outsider.screenshot({ path: path.join(SCREENSHOTS, '04-outsider-143.png'), fullPage: true }); await outsider.close(); state.outsiderReady = true; }); test('new project PI compile then reload keeps ledger markdown', async ({ browser }) => { if (!ADMIN_USER || !ADMIN_PASS) { throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); } const reviewer = await browser.newPage(); await reviewer.goto('/'); await reviewer.fill('input[placeholder*="系统用户名"]', USER_B); await reviewer.fill('input[placeholder*="登录密码"]', INIT_PASS); await reviewer.click('button:has-text("进入医疗协同工作台")'); await changePasswordFirstLogin(reviewer, INIT_PASS, FINAL_PASS_B); await reviewer.close(); const page = await browser.newPage(); const runCreates: { status: number; body: string }[] = []; page.on('response', async (res) => { if (res.url().includes('/api/v1/tasks/runs') && res.request().method() === 'POST') { runCreates.push({ status: res.status(), body: await res.text().catch(() => '') }); } }); await login(page, ADMIN_USER, ADMIN_PASS); await page.locator('button[title*="系统管理与准入治理"]').click(); await expect(page.getByRole('button', { name: '开通受控员工账号' })).toBeVisible({ timeout: 15_000 }); 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 page.getByRole('button', { name: '新建立项会话' }).click(); const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); await expect(heading).toBeVisible({ timeout: 20_000 }); const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); await modal.locator('input[placeholder*="无线便携式"]').fill(PROJECT_NAME); await modal.locator('textarea').fill('M1 活验:新建项目编制一条受控文档后刷新。'); for (const name of [`审核协作者-${suffix}`, `批准协作者-${suffix}`]) { const memberName = modal.getByText(name, { exact: true }); if (await memberName.isVisible({ timeout: 5_000 }).catch(() => false)) { await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); } } await modal.getByRole('button', { name: /确认立项/ }).click(); await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: path.join(SCREENSHOTS, '05-new-project.png'), fullPage: true }); const generatePrompt = '请编制用户初始需求收集表'; const input = page.getByPlaceholder(/向项目助手提问/); await input.fill(generatePrompt); await page.getByRole('button', { name: /发送/ }).click(); await expect(page.getByText(generatePrompt)).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(GOVERNANCE_BLOCKED_HINT)).toBeVisible({ timeout: 45_000 }); const blocked = runCreates.find((row) => row.status === 202 && row.body.includes('blocked_pending_governance')); if (!blocked) { throw new Error(`expected 202 blocked run before activation, got ${JSON.stringify(runCreates)}`); } await page.getByRole('button', { name: '配置工作流' }).click(); await goToDutyStep(page); const toggles = page.locator('label', { hasText: '纳入人工审批' }); const toggleCount = await toggles.count(); for (let i = 1; i < toggleCount; i += 1) { const box = toggles.nth(i).locator('input[type="checkbox"]'); if (await box.isChecked()) await box.uncheck(); } const firstToggle = toggles.first().locator('input[type="checkbox"]'); if (!(await firstToggle.isChecked())) await firstToggle.check(); const author = dutyRoleSelect(page, '作者'); await expect(author).toBeVisible({ timeout: 10_000 }); const values = ( await author.locator('option').evaluateAll((nodes) => nodes.map((n) => (n as HTMLOptionElement).value)) ).filter(Boolean); if (values.length < 3) { throw new Error(`need 3 members for full duties, got ${values.length}`); } await author.selectOption(values[0]); await dutyRoleSelect(page, '审核').selectOption(values[1]); await dutyRoleSelect(page, '批准').selectOption(values[2]); await page.getByRole('button', { name: /校验并预览/ }).click(); await expect(page.getByText('激活预览')).toBeVisible({ timeout: 20_000 }); await page.getByRole('button', { name: '确认激活' }).click(); await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 }); await page.screenshot({ path: path.join(SCREENSHOTS, '06-activated.png'), fullPage: true }); const compilePrompt = '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; const taskRunWait = page.waitForResponse( (r) => { try { return new URL(r.url()).pathname.includes('/api/v1/tasks/runs') && r.request().method() === 'POST'; } catch { return false; } }, { timeout: 60_000 }, ); await input.fill(compilePrompt); await page.getByRole('button', { name: /发送/ }).click(); const taskRes = await taskRunWait; if (taskRes.status() < 200 || taskRes.status() >= 300) { throw new Error(`compile tasks/runs HTTP ${taskRes.status()}`); } await waitForSquadCompletion(page, '请基于本项目编制首轮', 420_000); const docId = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i); if (!/^doc_proj\d+_/.test(docId)) { throw new Error(`expected server doc_proj* slot, got ${docId}`); } state.newDocId = docId; const token = await sessionToken(page); const first = await getContent(page, docId, token); expect(first.status, JSON.stringify(first.body)).toBe(200); expect(first.body.markdown).toBeTruthy(); expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(first.body.consistency); expect(first.body.error_code ?? '').not.toMatch(/PROJECTION/); state.newMarkdown = String(first.body.markdown); await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); await page.screenshot({ path: path.join(SCREENSHOTS, '07-new-doc.png'), fullPage: true }); await page.reload(); await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); const afterReload = await getContent(page, docId, token); expect(afterReload.status).toBe(200); expect(afterReload.body.markdown).toBe(state.newMarkdown); await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); await page.screenshot({ path: path.join(SCREENSHOTS, '08-new-doc-reload.png'), fullPage: true }); await logoutViaUI(page); await login(page, ADMIN_USER, ADMIN_PASS); await page.getByRole('complementary').getByTitle(PROJECT_NAME, { exact: true }).click({ force: true }); await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); const afterRelogin = await getContent(page, docId, await sessionToken(page)); expect(afterRelogin.status).toBe(200); expect(afterRelogin.body.markdown).toBe(state.newMarkdown); await page.screenshot({ path: path.join(SCREENSHOTS, '09-new-doc-relogin.png'), fullPage: true }); await page.close(); }); test('cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND', async ({ browser }: { browser: Browser }) => { if (!state.newDocId) { throw new Error('new project document id missing; compile test did not leave a ledger'); } const outsider = await browser.newPage(); await login(outsider, USER_D, FINAL_PASS_D); const tokenD = await sessionToken(outsider); const cross143 = await getContent(outsider, TARGET_DOC, tokenD); expectClosed404(cross143.status, cross143.body, 'D vs 143'); const crossNew = await getContent(outsider, state.newDocId, tokenD); expectClosed404(crossNew.status, crossNew.body, 'D vs new project doc'); await outsider.close(); const reviewer = await browser.newPage(); await login(reviewer, USER_B, FINAL_PASS_B); const tokenB = await sessionToken(reviewer); const otherStage = state.newDocId.replace(/_[a-z0-9]+$/i, '_design_control'); const wrongDuty = await getContent(reviewer, otherStage, tokenB); expectClosed404(wrongDuty.status, wrongDuty.body, 'B vs wrong stage-duty'); const stillClosed143 = await getContent(reviewer, TARGET_DOC, tokenB); expectClosed404(stillClosed143.status, stillClosed143.body, 'B vs 143'); await reviewer.screenshot({ path: path.join(SCREENSHOTS, '10-fail-closed.png'), fullPage: true }); await reviewer.close(); }); });