Files
Agent-System/web/e2e/document-revision-runtime.spec.ts
T
weichenandCursor 59b6db74bb test(e2e): tighten UI-01 zero revision-state 404 assertions
Match only hyphenated doc-p template paths, reset logs after login, and
expect registered uploads to use server doc-free ledgers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 11:12:03 +00:00

886 lines
37 KiB
TypeScript

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<string, CaseResult> = {};
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<string, CaseResult>;
const blob = JSON.stringify(scrubbed, null, 2);
if (/admin-secret|Bearer\s|password|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 || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASSWORD || 'admin-secret-password-123';
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 page.locator('button[title*="系统管理与准入治理"]').click();
await expect(page.getByText('开通受控员工账号')).toBeVisible({ timeout: 15_000 });
}
async function adminCreateUser(page: Page, username: string, realName: string, initPass: string) {
await page.getByText('开通受控员工账号').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);
await form.getByRole('button', { name: '确认开通账号' }).click();
await expect(page.getByText(new RegExp(`已成功开通|${realName}`))).toBeVisible({ timeout: 15_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 = [];
await page.goto('/');
await page.fill('input[placeholder*="系统用户名"]', ADMIN_USER);
await page.fill('input[placeholder*="登录密码"]', ADMIN_PASS);
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);
await adminCreateUser(page, USER_B, `协作者B-${suffix}`, INIT_PASS);
await adminCreateUser(page, USER_C, `无关人C-${suffix}`, INIT_PASS);
// Close admin modal
await page.keyboard.press('Escape');
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 password change',
actual: `users ${USER_A},${USER_B},${USER_C} ready`,
networkLogs: logs.filter((l) => !/password/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();
await expect(page.getByText('发起医疗器械新项目立项')).toBeVisible();
await page.fill('input[placeholder*="无线便携式"]', PROJECT_NAME);
const desc = page.locator('textarea').first();
if ((await desc.count()) === 0) fail('project form missing required description textarea');
await desc.fill('用于骨科微创手术的双频超声吸引与止血,需符合 GB 9706.1 与相关法规要求。');
// Select B as member
const memberRow = page.locator('div').filter({ hasText: `协作者B-${suffix}` }).first();
if ((await memberRow.count()) === 0) fail('member B not listed in create-project form');
await memberRow.click();
await page.getByRole('button', { name: /确认立项/ }).click();
await expect(page.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',
});
});
});