import { test, expect, type Page, 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 { CATEGORY_TRIGGER_LABEL, requiredCategoryForDocOpen, } from '../src/lib/workflowCategoryNavigation'; /** * Project 143 Regression UI Acceptance (Part A) * * Rules: * - Real browser UI only. No page.request / API / DB / mock / stub / localStorage injection. * - Admin credentials read from environment variables (ADMIN_USERNAME, ADMIN_PASSWORD). * - Verified against real running services on port 3100. * - Run as a single serial end-to-end test to eliminate cascading failure on worker restart. * - Target doc network 404 is strictly 0 throughout the entire session. * - Pre-login/initial unauthenticated 404s on legacy mock slots (e.g. doc-001) are recorded as observed, * and consoleErrors is reset upon successful verification of the target doc so post-open observation is clean. */ 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-controlled-content-ui-acceptance/clean-run3', ); const SCREENSHOTS = path.join(ASSETS, 'screenshots'); const TRACES = path.join(ASSETS, 'traces'); const VIDEOS = path.join(ASSETS, 'videos'); const RESULTS_FILE = path.join(ASSETS, 'proj143-ui-acceptance-results.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); } 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; // 精简网络日志:仅记录与目标受控文档相关的 API 请求 if (!pathOnly.includes('doc_proj143_intake')) 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 }); } const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; function requireAdminCreds(): { username: string; password: string } { if (!ADMIN_USER || !ADMIN_PASS) { fail( 'ADMIN_USERNAME and ADMIN_PASSWORD environment variables are required for UI acceptance', ); } return { username: ADMIN_USER, password: ADMIN_PASS }; } 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 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 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 confirmWorkflowDocById(page: Page, docId: string): Promise { if (!docId) fail('confirmWorkflowDocById requires docId'); const trigger = page.getByTestId('doc-selector-trigger'); await expect(trigger).toBeVisible({ timeout: 15_000 }); await trigger.click(); let item = page.locator(`.dropdown-menu button[data-doc-id="${docId}"]`).first(); if ((await item.count()) === 0) { await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'exact-id', docId })); await trigger.click(); 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(); } function blockEditorDialog(target: Page) { return target.getByRole('dialog', { name: BLOCK_EDITOR_DIALOG_LABEL }); } async function openFirstPaneEditorAndAppend(target: Page, appendText: string) { const pane = target.getByTestId('active-document-pane'); const firstBlock = pane.getByTestId('document-block').first(); await expect(firstBlock).toBeVisible({ timeout: 15_000 }); await firstBlock.dblclick(); const dialog = blockEditorDialog(target); await expect(dialog).toBeVisible({ timeout: 15_000 }); const editor = dialog.locator('textarea, [contenteditable="true"]').first(); await expect(editor).toBeVisible({ timeout: 10_000 }); const currentVal = await editor.inputValue().catch(() => ''); await editor.fill((currentVal ? currentVal + '\n\n' : '') + appendText); 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 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') || '', }; }), ); } 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'); } async function captureOverlayFingerprint(page: Page, mode: 'pure' | 'manual' | 'all') { const pane = page.getByTestId('active-document-pane'); const modeSelect = pane.locator('select[aria-label="变更显示模式"]'); await expect(modeSelect).toBeVisible({ timeout: 15_000 }); await modeSelect.selectOption(mode); await expect(modeSelect).toHaveValue(mode); 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`); } return samples; } test.describe('Project 143 Regression UI Acceptance', () => { test.skip(!runAcceptance, 'RUN_UI_ACCEPTANCE=1 required'); test('full sequential verification of Project 143 (A1-A7)', async ({ browser }) => { const context = await browser.newContext({ baseURL: process.env.BASE_URL || 'http://127.0.0.1:3100', locale: 'zh-CN', recordVideo: { dir: VIDEOS }, }); const page = await context.newPage(); const logs: NetLog[] = []; const consoleErrors: string[] = []; const pageErrors: string[] = []; const target404s: { method: string; url: string; status: number }[] = []; const observedOther404s: { method: string; url: string; status: number }[] = []; const doc001Requests: { method: string; url: string; status: number }[] = []; let contentRequestsCount = 0; page.on('response', (res) => { pushNet(logs, res); const u = sanitizeUrl(res.url()); if (u.includes('/documents/doc_proj143_intake/content')) { contentRequestsCount++; } if (u.includes('doc-001/content') || u.includes('doc-001/revision-state')) { doc001Requests.push({ method: res.request().method(), url: u, status: res.status() }); } if (u.includes('doc_proj143_intake') && res.status() === 404) { target404s.push({ method: res.request().method(), url: u, status: res.status() }); } else if (res.status() === 404) { observedOther404s.push({ method: res.request().method(), url: u, status: res.status() }); } }); page.on('console', (msg) => { if (msg.type() === 'error') { consoleErrors.push(msg.text()); } }); page.on('pageerror', (err) => { pageErrors.push(err.message); }); const creds = requireAdminCreds(); // ───────────────────────────────────────────────────────────── // Step 1: Admin login via login page and open session // ───────────────────────────────────────────────────────────── let t0 = Date.now(); await login(page, creds.username, creds.password); await shot(page, 'UI-143-01-admin-logged-in.png'); // 明确验证从登录开始 doc-001 content/revision 请求均为 0 if (doc001Requests.length > 0) { fail(`Cursor fix assertion failed: doc-001 content/revision requests must be 0, got ${JSON.stringify(doc001Requests)}`); } const doc001InObserved404 = observedOther404s.filter( (l) => l.url.includes('doc-001/content') || l.url.includes('doc-001/revision-state'), ); if (doc001InObserved404.length > 0) { fail(`Cursor fix assertion failed: observedOther404s contained doc-001 requests: ${JSON.stringify(doc001InObserved404)}`); } // In LeftRail sidebar, find and click the session entry with exact title "对话协同会话" const sessionEntry = page .getByRole('complementary') .getByTitle('对话协同会话', { exact: true }); await expect(sessionEntry).toBeVisible({ timeout: 15_000 }); await sessionEntry.click(); // Verify banner shows project/session active await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 15_000 }); await shot(page, 'UI-143-01-project-opened.png'); // Assert target doc has strictly 0 network 404s if (target404s.length > 0) { fail(`target doc encountered 404 in step 1: ${JSON.stringify(target404s)}`); } recordPass({ id: 'UI-143-01', name: 'admin login & open project "对话协同会话"', input: `login admin; assert doc-001 requests == 0; click exact title "对话协同会话" in complementary sidebar`, actual: `session active in banner; doc001Requests=0; target404s=0; observedOther404s=${observedOther404s.length}`, networkLogs: [...logs], durationMs: Date.now() - t0, verdict: 'PASS', notes: JSON.stringify({ observedOther404s, doc001RequestsCount: doc001Requests.length }), }); // ───────────────────────────────────────────────────────────── // Step 2: Confirm doc_proj143_intake via dropdown menu // ───────────────────────────────────────────────────────────── t0 = Date.now(); await confirmWorkflowDocById(page, 'doc_proj143_intake'); // Wait for active pane document blocks const pane = page.getByTestId('active-document-pane'); await expect(pane).toBeVisible({ timeout: 20_000 }); const blocks = pane.getByTestId('document-block'); await expect(blocks.first()).toBeVisible({ timeout: 20_000 }); const blockCount = await blocks.count(); if (blockCount === 0) fail('doc_proj143_intake has 0 document blocks'); // Check text is non-empty const fullText = (await pane.innerText()).trim(); if (fullText.length < 50) { fail(`doc_proj143_intake content unexpectedly short: ${fullText.length} chars`); } // Verify ledger ready const { v, r: rInitial } = await readVersionBadge(page); const unreadyHint = page.getByText(/版本账本未就绪|自动拉取受控文档正文失败/); if (await unreadyHint.isVisible().catch(() => false)) { fail('doc_proj143_intake shows unready hint or fetch failure'); } // Assert strictly zero 404s for target doc if (target404s.length > 0) { fail(`target doc 404 encountered in step 2: ${JSON.stringify(target404s)}`); } // Verify target doc network requests were successful (status 200) const targetContentRequests = logs.filter( (l) => l.path.includes('/doc_proj143_intake/content') && l.status === 200, ); if (targetContentRequests.length === 0) { fail('expected at least one successful 200 GET for doc_proj143_intake/content'); } await shot(page, 'UI-143-02-doc-opened.png'); // Record prior observed errors, then reset consoleErrors and pageErrors for clean post-open observation const priorObservedConsole = [...consoleErrors]; consoleErrors.length = 0; pageErrors.length = 0; recordPass({ id: 'UI-143-02', name: 'open doc_proj143_intake non-empty & ledger ready', input: 'open doc_proj143_intake via menu data-doc-id=doc_proj143_intake', actual: `blocks=${blockCount}, textLen=${fullText.length}, version=${v}, revision=${rInitial}, target404s=0`, networkLogs: [...logs], durationMs: Date.now() - t0, verdict: 'PASS', notes: JSON.stringify({ priorObservedConsoleCount: priorObservedConsole.length, observedOther404sCount: observedOther404s.length, }), }); // ───────────────────────────────────────────────────────────── // Step 3: Idle 30 seconds stability check (clean observation) // ───────────────────────────────────────────────────────────── t0 = Date.now(); const countBeforeIdle = contentRequestsCount; await page.waitForTimeout(30_000); const countAfterIdle = contentRequestsCount; const newContentRequests = countAfterIdle - countBeforeIdle; if (newContentRequests > 0) { fail(`idle 30s triggered ${newContentRequests} unexpected duplicate content requests`); } await expect(pane.getByTestId('document-block').first()).toBeVisible({ timeout: 5_000 }); // Assert strictly no console errors or page errors occurred during the 30-second idle observation if (consoleErrors.length > 0) { fail(`console errors occurred during idle 30s: ${JSON.stringify(consoleErrors)}`); } if (pageErrors.length > 0) { fail(`page errors occurred during idle 30s: ${JSON.stringify(pageErrors)}`); } if (target404s.length > 0) { fail(`target doc 404 encountered during idle 30s: ${JSON.stringify(target404s)}`); } await shot(page, 'UI-143-03-idle-30s.png'); recordPass({ id: 'UI-143-03', name: 'idle 30s stability check', input: 'wait 30s on opened doc_proj143_intake', actual: `newContentRequests=${newContentRequests}, blocks intact, zero errors, zero target 404s`, networkLogs: [...logs], durationMs: Date.now() - t0, verdict: 'PASS', }); // ───────────────────────────────────────────────────────────── // Step 4: Dblclick edit block, append unique timestamp marker, save // ───────────────────────────────────────────────────────────── t0 = Date.now(); const { r: rBeforeEdit } = await readVersionBadge(page); // 纯中文与数字,绝不含任何 Markdown 语法符号(无 _ * [ ] # - 等),使用实际可见文本验证 const uniqueMarker = `人工验收标记${Date.now()}`; await openFirstPaneEditorAndAppend(page, `(${uniqueMarker})`); await shot(page, 'UI-143-04a-editing-dialog.png'); await saveOpenEditor(page); await expect(blockEditorDialog(page)).toHaveCount(0, { timeout: 15_000 }); // Assert marker is visible on page via actual user-visible text await expect(page.getByText(uniqueMarker)).toBeVisible({ timeout: 30_000 }); // Assert revision number increased await expect(page.locator('[data-testid="doc-rev-revision"]')).not.toHaveText(rBeforeEdit, { timeout: 30_000, }); const { r: rAfterSave } = await readVersionBadge(page); if (consoleErrors.length > 0) { fail(`console errors occurred during edit/save: ${JSON.stringify(consoleErrors)}`); } if (target404s.length > 0) { fail(`target doc 404 encountered during edit/save: ${JSON.stringify(target404s)}`); } await shot(page, 'UI-143-04b-edited-saved.png'); recordPass({ id: 'UI-143-04', name: 'dblclick edit save & revision bump', input: `append marker ${uniqueMarker}; saveOpenEditor`, actual: `marker visible; revision ${rBeforeEdit} -> ${rAfterSave}`, networkLogs: [...logs], durationMs: Date.now() - t0, verdict: 'PASS', }); // ───────────────────────────────────────────────────────────── // Step 5: Three overlay modes: pure / manual / all differ in styles // ───────────────────────────────────────────────────────────── t0 = Date.now(); const pure = await captureOverlayFingerprint(page, 'pure'); await shot(page, 'UI-143-05-pure.png'); const manual = await captureOverlayFingerprint(page, 'manual'); await shot(page, 'UI-143-06-manual.png'); const all = await captureOverlayFingerprint(page, 'all'); await shot(page, 'UI-143-07-all.png'); const pureKey = overlayPaneKey(pure); const manualKey = overlayPaneKey(manual); const allKey = overlayPaneKey(all); if (overlayPaneHasMark(pure)) { fail(`UI-143-05 纯净阅读 must have no overlay decoration: ${JSON.stringify(pure)}`); } if (!overlayPaneHasMark(manual)) { fail(`UI-143-05 人工修改 must decorate changed blocks: ${JSON.stringify(manual)}`); } if (!overlayPaneHasMark(all)) { fail(`UI-143-05 全部变更 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}`); } if (consoleErrors.length > 0) { fail(`console errors occurred during overlay mode checks: ${JSON.stringify(consoleErrors)}`); } if (target404s.length > 0) { fail(`target doc 404 encountered during overlay mode checks: ${JSON.stringify(target404s)}`); } recordPass({ id: 'UI-143-05', name: 'pure / manual / all overlay modes', input: 'switch overlay mode pure -> manual -> all', actual: 'pure unmarked; manual & all marked; computed styles visually distinct', networkLogs: [...logs], durationMs: Date.now() - t0, verdict: 'PASS', notes: JSON.stringify({ pureCount: pure.length, manualCount: manual.length, allCount: all.length }), }); // ───────────────────────────────────────────────────────────── // Step 6: Reload, logout, re-login: marker and revision persisted // ───────────────────────────────────────────────────────────── t0 = Date.now(); // 1. Hard reload await page.reload(); await expect(page.getByRole('complementary').getByTitle('对话协同会话', { exact: true })).toBeVisible({ timeout: 20_000, }); // Assert marker persisted after reload via user-visible text await expect(page.getByText(uniqueMarker)).toBeVisible({ timeout: 30_000 }); const { r: rAfterReload } = await readVersionBadge(page); if (rAfterReload !== rAfterSave) { fail(`revision mismatch after reload: expected ${rAfterSave}, got ${rAfterReload}`); } await shot(page, 'UI-143-08a-after-reload.png'); // 2. Logout via UI await logoutViaUI(page); await shot(page, 'UI-143-08b-logged-out.png'); // 3. Re-login as admin via UI await login(page, creds.username, creds.password); await shot(page, 'UI-143-08c-relogged-in.png'); // 4. Open project session and confirm doc const sessionEntryAfter = page .getByRole('complementary') .getByTitle('对话协同会话', { exact: true }); await expect(sessionEntryAfter).toBeVisible({ timeout: 15_000 }); await sessionEntryAfter.click(); await confirmWorkflowDocById(page, 'doc_proj143_intake'); // Assert marker still present via user-visible text await expect(page.getByText(uniqueMarker)).toBeVisible({ timeout: 30_000 }); // Assert revision number is still persisted const { r: rFinal } = await readVersionBadge(page); if (rFinal !== rAfterSave) { fail(`revision mismatch after relogin: expected ${rAfterSave}, got ${rFinal}`); } await shot(page, 'UI-143-08d-persisted-after-relogin.png'); if (target404s.length > 0) { fail(`target doc 404 encountered across entire session: ${JSON.stringify(target404s)}`); } recordPass({ id: 'UI-143-06', name: 'persistence check after refresh, logout, and re-login', input: 'page reload -> check marker/r -> logout -> re-login -> re-open doc', actual: `marker "${uniqueMarker}" and revision "${rFinal}" persisted across reload and relogin`, networkLogs: [...logs], durationMs: Date.now() - t0, verdict: 'PASS', notes: JSON.stringify({ totalTarget404s: target404s.length, totalObservedOther404s: observedOther404s.length, }), }); await context.close(); }); });