Files
Agent-System/web/e2e/project143-regression.spec.ts
T

616 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, CaseResult> = {};
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<string, CaseResult>;
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<void> {
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<OverlaySample[]> {
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();
});
});