/** * Full-system real-browser multi-user acceptance (FS-*). * * Hard rules: * - Real Chromium clicks/fills only. No page.route mocks; no API create/project/message/approve. * - Multi BrowserContext (never share storageState across roles). * - Each page: console + pageerror → console-*.jsonl * - Unmet conditions → FAIL (or explicit SKIP with reason). Never fake PASS. * - PI / squad waits: 15–20 min timeouts. * * Gate: RUN_UI_ACCEPTANCE=1 + ADMIN_USERNAME/ADMIN_PASSWORD + BASE_URL. */ import { test, expect, type Page, type Browser, type BrowserContext, type ConsoleMessage } 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 { currentProjectBannerName } from '../src/lib/currentProjectRestore'; import { assertWritableUiAcceptanceAssetsDir, resolveUiAcceptanceAssetsDir, } from '../src/lib/playwrightPhaseArtifacts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; const ASSETS = resolveUiAcceptanceAssetsDir( path.resolve(__dirname, '..'), process.env.UI_ACCEPTANCE_ASSETS_DIR || path.resolve(__dirname, '../../.doc/test-reports/assets/2026-09-26-full-system-browser-e2e'), ); const SCREENSHOTS = path.join(ASSETS, 'screenshots'); const RESULTS_FILE = path.join(ASSETS, 'ui-results.json'); const ACCOUNTS_FILE = path.join(ASSETS, 'retained-accounts.json'); const RUN_META_FILE = path.join(ASSETS, 'run-meta.json'); if (runAcceptance) { assertWritableUiAcceptanceAssetsDir(ASSETS); fs.mkdirSync(SCREENSHOTS, { recursive: true }); } type Verdict = 'PASS' | 'FAIL' | 'SKIP'; type CaseResult = { id: string; name: string; priority: 'P0' | 'P1' | 'P2'; input: string; expected: string; actual: string; verdict: Verdict; durationMs: number; console_errors: string[]; notes?: string; skip_reason?: string; }; const recorded: Record = {}; const consoleBuffers = new Map>(); const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; const suffix = `${Date.now().toString(36)}`; const PREFIX_USER = 'e2e26_'; const PREFIX_PROJ = 'E2E全量-20260926-'; const USERS = { author: { username: `${PREFIX_USER}author_${suffix}`, display: `E2E编制-${suffix}`, roleId: 'role-tech-lead' as const }, reviewer: { username: `${PREFIX_USER}reviewer_${suffix}`, display: `E2E审核-${suffix}`, roleId: 'role-engineer' as const }, approver: { username: `${PREFIX_USER}approver_${suffix}`, display: `E2E批准-${suffix}`, roleId: 'role-engineer' as const }, member: { username: `${PREFIX_USER}member_${suffix}`, display: `E2E成员-${suffix}`, roleId: 'role-engineer' as const }, guest: { username: `${PREFIX_USER}guest_${suffix}`, display: `E2E访客-${suffix}`, roleId: 'role-guest' as const }, }; const INIT_PASS = `Init-${suffix}9A`; const FINAL_PASS: Record = { author: `Auth-${suffix}9A`, reviewer: `Rev-${suffix}9A`, approver: `App-${suffix}9A`, member: `Mem-${suffix}9A`, guest: `Gst-${suffix}9A`, }; const PROJECT_NAME = `${PREFIX_PROJ}${suffix}`; /** Shared across serial tests */ let shared = { controlledDocId: '', controlledTitle: '', uploadedDocId: '', headed: false, display: process.env.DISPLAY || '', }; function fail(msg: string): never { throw new Error(msg); } function requireAdminCreds(): { username: string; password: string } { if (!ADMIN_USER || !ADMIN_PASS) { fail('ADMIN_USERNAME and ADMIN_PASSWORD are required (no credential fallbacks)'); } return { username: ADMIN_USER, password: ADMIN_PASS }; } function sanitizeUrl(raw: string): string { try { return new URL(raw, 'http://127.0.0.1').pathname; } catch { return raw.split('?')[0]?.replace(/https?:\/\/[^/]+/i, '') || raw; } } 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 record(r: CaseResult) { recorded[r.id] = r; saveResults(); } function recordSkip(id: string, name: string, priority: CaseResult['priority'], reason: string) { record({ id, name, priority, input: '(not run)', expected: '(n/a)', actual: '(skipped)', verdict: 'SKIP', durationMs: 0, console_errors: [], skip_reason: reason, }); } async function shot(page: Page, name: string) { await page.screenshot({ path: path.join(SCREENSHOTS, `${name}.png`), fullPage: true }).catch(() => undefined); } function attachConsole(page: Page, label: string) { const buf: Array<{ level: string; text: string; ts: string }> = []; consoleBuffers.set(label, buf); const sink = path.join(ASSETS, `console-${label}.jsonl`); const writeLine = (obj: object) => { fs.appendFileSync(sink, `${JSON.stringify(obj)}\n`, 'utf-8'); }; page.on('console', (msg: ConsoleMessage) => { const level = msg.type(); const text = msg.text(); const row = { level, text, ts: new Date().toISOString() }; buf.push(row); if (level === 'error' || level === 'warning') writeLine({ kind: 'console', ...row }); }); page.on('pageerror', (err) => { const row = { level: 'pageerror', text: String(err?.message || err), ts: new Date().toISOString() }; buf.push(row); writeLine({ kind: 'pageerror', ...row }); }); } function consoleErrorsFor(label: string): string[] { const buf = consoleBuffers.get(label) || []; return buf .filter((e) => e.level === 'error' || e.level === 'pageerror') .map((e) => `[${e.level}] ${e.text.slice(0, 240)}`); } async function newRolePage(browser: Browser, label: string): Promise<{ ctx: BrowserContext; page: Page }> { const ctx = await browser.newContext({ locale: 'zh-CN' }); const page = await ctx.newPage(); attachConsole(page, label); return { ctx, page }; } 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("进入医疗协同工作台")'); const force = page.getByPlaceholder('请输入初始密码'); if (await force.isVisible({ timeout: 2500 }).catch(() => false)) { fail(`account ${username} still requires first-login password change`); } 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 openAdminUsers(page: Page) { const btn = page.locator('button[title*="系统管理与准入治理"]'); await expect(btn).toBeVisible({ timeout: 15_000 }); await btn.click(); await expect(page.getByRole('button', { name: '用户与准入治理' })).toBeVisible({ timeout: 15_000 }); await page.getByRole('button', { name: '用户与准入治理' }).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); 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 roleSelect.selectOption(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 }); } async function toggleCreateProjectMember( projectModal: ReturnType, displayName: string, /** CreateProjectModal options: author|reviewer|approver|viewer (no editor). */ role?: 'author' | 'reviewer' | 'approver' | 'viewer', ) { const memberName = projectModal.getByText(displayName, { exact: true }).first(); await expect(memberName).toBeVisible({ timeout: 15_000 }); await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); if (!role) return; 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(role); } /** After create, promote a create-modal viewer to project_role=member (editable, no doc duty). */ async function promoteToProjectMember(page: Page, displayName: string) { // LeftRail: title="管理当前项目成员与职责分配" (avoid matching vault "保存" buttons). const openBtn = page.locator('button[title="管理当前项目成员与职责分配"]').first(); await expect(openBtn).toBeVisible({ timeout: 15_000 }); await openBtn.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]'); // Row is flex space-between with the display name + a role