Files
Agent-System/web/e2e/document-revision-runtime.spec.ts
T
WeiCHEN ecd8e94e24 fix(e2e): raise squad wait timeout to 10m for UI-03
Intake completion landed ~20s after the old 7m expect wall; keep claim-before-edit fix and avoid false pending failures.
2026-09-23 03:09:24 +08:00

1776 lines
76 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 BrowserContext, 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 {
ANY_TERMINAL_STATUS_RE,
evaluateSquadObservation,
} from '../src/lib/squadCompletionObservation';
import {
CATEGORY_TRIGGER_LABEL,
requiredCategoryForDocOpen,
} from '../src/lib/workflowCategoryNavigation';
import {
DOC_DUTY_BADGE_TESTID,
DOC_DUTY_SELECT_TESTID,
evaluateReviewerDuty,
} from '../src/lib/dutyCellAssertion';
import {
currentProjectBannerName,
visibleProjectEntry,
} from '../src/lib/currentProjectRestore';
import {
SHARED_PHASE_SECRETS_FILE,
SHARED_PHASE_STATE_FILE,
assertWritableUiAcceptanceAssetsDir,
playwrightResultsFileName,
resolveUiAcceptanceAssetsDir,
} from '../src/lib/playwrightPhaseArtifacts';
import { nextDurableBodySnippet } from '../src/lib/durableBodySnippet';
/**
* 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).
* - RESULTS_FILE / outputDir are UI_PHASE-aware; never overwrite main ledger or historical assets.
*/
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 = resolveUiAcceptanceAssetsDir(
path.resolve(__dirname, '..'),
process.env.UI_ACCEPTANCE_ASSETS_DIR,
);
const SCREENSHOTS = path.join(ASSETS, 'screenshots');
const TRACES = path.join(ASSETS, 'traces');
const VIDEOS = path.join(ASSETS, 'videos');
const RESULTS_FILE = path.join(ASSETS, playwrightResultsFileName(uiPhase));
const PHASE_STATE_FILE = path.join(ASSETS, SHARED_PHASE_STATE_FILE);
/** Local-only secrets for post-restart login — never copied into results JSON. */
const PHASE_SECRETS_FILE = path.join(ASSETS, SHARED_PHASE_SECRETS_FILE);
if (runAcceptance) {
assertWritableUiAcceptanceAssetsDir(ASSETS);
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["']?\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 });
}
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?.trim() || '';
const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || '';
/** Admin creds must come from env — never hardcode or fallback secrets into the repo/runtime. */
function requireAdminCreds(): { username: string; password: string } {
if (!ADMIN_USER || !ADMIN_PASS) {
fail(
'ADMIN_USERNAME and ADMIN_PASSWORD environment variables are required for UI acceptance (no credential fallbacks)',
);
}
return { username: ADMIN_USER, password: ADMIN_PASS };
}
const suffix = `${Date.now().toString(36)}`;
const USER_A = `uia_${suffix}`;
const USER_B = `uib_${suffix}`;
const USER_C = `uic_${suffix}`;
const USER_D = `uid_${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 FINAL_PASS_D = `PassD-${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 goToWorkflowDutyStep(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();
}
async function selectDutyMemberByName(
page: Page,
placeholder: '作者' | '审核' | '批准',
displayName: string,
) {
const sel = dutyRoleSelect(page, placeholder);
await expect(sel).toBeVisible({ timeout: 10_000 });
const option = sel.locator('option').filter({ hasText: displayName }).first();
if ((await option.count()) === 0) {
fail(`duty select ${placeholder} missing member ${displayName}`);
}
const value = await option.getAttribute('value');
if (!value) fail(`duty select ${placeholder} option ${displayName} has empty value`);
await sel.selectOption(value);
await expect(sel).toHaveValue(value);
}
/**
* Activate workflow with first intake doc under human approval + full 三权.
* Pass display names so UI-13 can rely on B as reviewer (not option-order lottery).
*/
async function activateProjectWorkflow(
page: Page,
duties: { author: string; reviewer: string; approver: string },
) {
await page.getByRole('button', { name: '配置工作流' }).click();
await goToWorkflowDutyStep(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();
await selectDutyMemberByName(page, '作者', duties.author);
await selectDutyMemberByName(page, '审核', duties.reviewer);
await selectDutyMemberByName(page, '批准', duties.approver);
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 });
// Close settings if still open.
const closeBtn = page.getByRole('button', { name: '关闭' });
if (await closeBtn.isVisible().catch(() => false)) await closeBtn.click();
}
async function toggleCreateProjectMember(
projectModal: ReturnType<Page['locator']>,
displayName: string,
role?: 'reviewer' | 'viewer' | 'editor',
) {
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);
await expect(memberRole).toHaveValue(role);
}
async function openAdminConsole(page: Page) {
await shot(page, 'UI-00-admin-logged-in.png');
const btn = page.locator('button[title*="系统管理与准入治理"]');
if (!(await btn.isVisible({ timeout: 10_000 }).catch(() => false))) {
const leftRailText = await page.locator('aside').innerText().catch(() => 'no aside');
fail(`admin button not visible. Left rail content: ${leftRailText}`);
}
await btn.click();
// Console defaults to 系统运行总览; user provisioning lives under 用户与准入治理.
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);
// 临时初始密码: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);
const roleSelect = form.locator('select');
await expect(roleSelect).toBeVisible({ timeout: 10_000 });
await roleSelect.selectOption(roleId);
await expect(roleSelect).toHaveValue(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 });
}
/**
* Locate the assistant bubble for THIS user turn.
* MessageBubble is nested under a virtualizer outer with data-index; siblings are those outers.
*/
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"]',
),
};
}
/**
* Wait for this-turn assistant terminal.
* Inflight is optional: if the adjacent card is already a success terminal, accept it.
*/
async function waitForSquadCompletion(
page: Page,
userPromptSnippet: string,
// Real intake often lands ~6–8m; 7m wall caused a false fail while server completed ~20s later.
timeoutMs = 600_000,
opts?: { requireInflight?: boolean },
) {
const startedAt = Date.now();
const requireInflight = opts?.requireInflight === true;
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 () => {
const status = (await assistant.getAttribute('data-chat-status')) || '';
const hasVisibleError = await connErr.first().isVisible().catch(() => false);
return evaluateSquadObservation(
{
userPromptMatched: true,
assistantAdjacent: true,
status,
hasVisibleError,
},
{ requireInflight },
);
};
let verdict = await observe();
if (verdict.action === 'reject') {
fail(`squad observation rejected: ${verdict.reason}`);
}
if (verdict.action === 'accept') {
return Date.now() - startedAt;
}
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: timeoutMs,
});
verdict = await observe();
if (verdict.action === 'reject') {
fail(`squad observation rejected: ${verdict.reason}`);
}
if (verdict.action !== 'accept') {
fail(`squad terminal not accepted: ${verdict.reason}`);
}
return Date.now() - startedAt;
}
async function ensureDocRailExpanded(page: Page) {
const expandDocRail = page.getByRole('button', { name: '展开受控文档栏' });
if (await expandDocRail.isVisible().catch(() => false)) {
await expandDocRail.click();
await expect(expandDocRail).toHaveCount(0, { timeout: 10_000 });
}
await expect(page.getByTestId('doc-category-trigger')).toBeVisible({ timeout: 15_000 });
}
async function selectDeliverableCategory(
page: Page,
category: 'workflow' | 'shared' | 'private',
) {
await ensureDocRailExpanded(page);
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 openWorkflowDocByLabel(
page: Page,
label: RegExp,
): Promise<{ title: string; docId: string }> {
await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'workflow-label' }));
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) {
fail(`workflow slot not in doc-selector menu: ${label}`);
}
const docId = (await item.getAttribute('data-doc-id')) || '';
const title = (await item.getAttribute('data-doc-title')) || '';
await item.click();
if (!docId) fail(`selected menu item missing data-doc-id for ${label}`);
return { title: title || docId, docId };
}
/** 按已注册 doc_proj{n}_{taskKey} 打开,避开同标题模板槽与宽泛文案误命中。 */
async function openWorkflowDocByControlledTaskKey(
page: Page,
taskKey: string,
): Promise<{ title: string; docId: string }> {
if (!/^[a-z][a-z0-9_]*$/.test(taskKey)) fail(`invalid controlled taskKey: ${taskKey}`);
await selectDeliverableCategory(
page,
requiredCategoryForDocOpen({ kind: 'controlled-task-key', taskKey }),
);
const trigger = page.getByTestId('doc-selector-trigger');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
// Prefer exact suffix; also accept bd_${taskKey} canonical keys from main.v2.
const item = page
.locator(
[
`.dropdown-menu button[data-doc-id^="doc_proj"][data-doc-id$="_bd_${taskKey}"]`,
`.dropdown-menu button[data-doc-id^="doc_proj"][data-doc-id$="_${taskKey}"]`,
].join(', '),
)
.first();
if ((await item.count()) === 0) {
fail(`workflow menu missing registered doc_proj*_${taskKey} (or bd_${taskKey})`);
}
const docId = (await item.getAttribute('data-doc-id')) || '';
const title = (await item.getAttribute('data-doc-title')) || '';
if (!new RegExp(`^doc_proj\\d+_(?:bd_)?${taskKey}$`).test(docId)) {
fail(`expected doc_proj{n}_(?:bd_)?${taskKey}, got ${JSON.stringify(docId)}`);
}
await item.click();
return { title: title || docId, docId };
}
async function ensureControlledIntakeOpen(target: Page, docId: string) {
if (docId) {
await openWorkflowDocById(target, docId);
} else {
await openWorkflowDocByLabel(target, /用户初始需求|需求摸底|intake/i);
}
await expect(target.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
}
/** Prelim/second docs often ship with author_id=0; without claim, currentDocRole is viewer and dblclick is a no-op. */
async function ensureAuthorClaimedForEdit(target: Page) {
const claim = target.getByRole('button', { name: /认领此文档/ });
if (await claim.isVisible().catch(() => false)) {
await claim.click();
await expect(claim).toHaveCount(0, { timeout: 15_000 });
}
await expect(target.getByText(/双击任意段落就地编辑/)).toBeVisible({ timeout: 30_000 });
}
/**
* V0.1 intake is blocking_when_enabled; prelim_design lives in V0.5 and is
* 403'd until intake completes 三权. Drive submit→review→approve via page only.
*/
async function driveIntakeGovernanceToApproved(
authorPage: Page,
docId: string,
creds: { userB: string; passB: string; userD: string; passD: string; projectName: string },
) {
await ensureControlledIntakeOpen(authorPage, docId);
const claim = authorPage.getByRole('button', { name: /认领此文档/ });
if (await claim.isVisible().catch(() => false)) {
await claim.click();
await expect(claim).toHaveCount(0, { timeout: 15_000 });
}
const submit = authorPage.getByRole('button', { name: /提交送审/ });
await expect(submit).toBeVisible({ timeout: 30_000 });
await submit.click();
await expect(authorPage.getByRole('button', { name: /提交送审/ })).toHaveCount(0, {
timeout: 30_000,
});
const browser = authorPage.context().browser();
if (!browser) fail('driveIntakeGovernanceToApproved requires browser');
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await login(pageB, creds.userB, creds.passB);
await pageB.getByText(creds.projectName, { exact: false }).first().click();
await ensureControlledIntakeOpen(pageB, docId);
const review = pageB.getByRole('button', { name: /审核通过/ });
await expect(review).toBeVisible({ timeout: 30_000 });
await review.click();
await expect(pageB.getByRole('button', { name: /审核通过/ })).toHaveCount(0, { timeout: 30_000 });
await ctxB.close();
const ctxD = await browser.newContext();
const pageD = await ctxD.newPage();
await login(pageD, creds.userD, creds.passD);
await pageD.getByText(creds.projectName, { exact: false }).first().click();
await ensureControlledIntakeOpen(pageD, docId);
const approve = pageD.getByRole('button', { name: /终审批准/ });
await expect(approve).toBeVisible({ timeout: 30_000 });
await approve.click();
await expect(pageD.getByRole('button', { name: /终审批准/ })).toHaveCount(0, { timeout: 60_000 });
await ctxD.close();
// Author session: reload so stage banner picks up approved intake, then advance to V0.5.
await authorPage.reload();
await expect(authorPage.getByText(creds.projectName, { exact: false }).first()).toBeVisible({
timeout: 30_000,
});
await ensureControlledIntakeOpen(authorPage, docId);
await expect(authorPage.getByText('必需文档已批准,可推进')).toBeVisible({ timeout: 90_000 });
const ack = authorPage.getByText(/确认已知悉后才能推进/);
if (await ack.isVisible().catch(() => false)) {
await authorPage.locator('label', { hasText: /确认已知悉后才能推进/ }).locator('input[type="checkbox"]').check();
}
const advanceBtn = authorPage.getByRole('button', { name: '确认推进' });
await expect(advanceBtn).toBeEnabled({ timeout: 30_000 });
await advanceBtn.click();
await expect(authorPage.getByText(/V0\.5|方案与可行性/)).toBeVisible({ timeout: 60_000 });
}
async function openWorkflowDocById(page: Page, docId: string): Promise<void> {
if (!docId) fail('openWorkflowDocById requires docId');
await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'exact-id', docId }));
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="${docId}"]`).first();
if ((await item.count()) === 0) fail(`doc-selector menu missing data-doc-id=${docId}`);
await item.click();
}
async function readReviewerIntakeDuty(page: Page, docId?: string): Promise<string> {
const select = docId
? page.locator(`[data-testid="${DOC_DUTY_SELECT_TESTID}"][data-doc-id="${docId}"]`)
: page
.locator('tr')
.filter({ hasText: /用户初始需求|需求摸底/ })
.locator(`[data-testid="${DOC_DUTY_SELECT_TESTID}"]`);
if ((await select.count()) > 0) {
await expect(select).toBeVisible({ timeout: 10_000 });
const verdict = evaluateReviewerDuty({
source: 'select',
value: await select.inputValue(),
disabled: await select.isDisabled(),
canAssignLead: (await select.getAttribute('data-can-assign')) === 'true',
});
if (!verdict.ok || verdict.role !== 'reviewer') {
fail(`reviewer B intake duty select must be reviewer: ${verdict.reason}`);
}
return 'reviewer';
}
const badge = docId
? page.locator(`[data-testid="${DOC_DUTY_BADGE_TESTID}"][data-doc-id="${docId}"]`)
: page
.locator('tr')
.filter({ hasText: /用户初始需求|需求摸底/ })
.locator(`[data-testid="${DOC_DUTY_BADGE_TESTID}"]`);
if ((await badge.count()) === 0) {
fail('intake duty cell missing select and badge');
}
await expect(badge).toBeVisible({ timeout: 10_000 });
const verdict = evaluateReviewerDuty({
source: 'badge',
role: (await badge.getAttribute('data-doc-role')) || '',
});
if (!verdict.ok || verdict.role !== 'reviewer') {
fail(`reviewer B intake duty badge must be reviewer: ${verdict.reason}`);
}
return 'reviewer';
}
async function ensureChatComposerIdle(page: Page, timeoutMs = 60_000) {
// Product fix: orphan streaming_status no longer locks send once runtime is idle.
// Still click stop if present, then wait for the send affordance.
const stopBtn = page.getByRole('button', { name: '停止当前生成' });
if (await stopBtn.isVisible().catch(() => false)) {
await stopBtn.click();
}
await expect(page.getByRole('button', { name: /发送 \(Enter\)/ })).toBeVisible({
timeout: timeoutMs,
});
}
async function sendChat(page: Page, text: string) {
const box = page.getByPlaceholder(/向项目助手提问/);
await expect(box).toBeVisible({ timeout: 15_000 });
await ensureChatComposerIdle(page);
await box.fill(text);
const sendBtn = page.getByRole('button', { name: /发送 \(Enter\)/ });
await expect(sendBtn).toBeEnabled({ timeout: 15_000 });
await sendBtn.click();
}
function waitForTasksRunsPost(page: Page) {
return page.waitForResponse(
(r) => {
const pathOnly = sanitizeUrl(r.url());
return pathOnly.includes('/api/v1/tasks/runs') && r.request().method() === 'POST';
},
{ timeout: 60_000 },
);
}
function assertIntakeTaskRun(res: Response, expectedDocId: string, caseId: string) {
const status = res.status();
if (status < 200 || status >= 300) {
fail(`${caseId} tasks/runs must be 2xx, got HTTP ${status}`);
}
const body = res.request().postDataJSON() as { task_key?: string; doc_id?: string };
// Server task_key is the doc_proj* suffix (e.g. bd_intake), not the bare template token.
const expectedKey = expectedDocId.replace(/^doc_proj\d+_/, '') || 'intake';
const keyOk =
body.task_key === expectedKey ||
body.task_key === 'intake' ||
(typeof body.task_key === 'string' && /(?:^|_)intake$/.test(body.task_key));
if (!keyOk) {
fail(
`${caseId} task_key must be intake-family (expected ${JSON.stringify(expectedKey)}), got ${JSON.stringify(body.task_key)}`,
);
}
if (!expectedDocId || body.doc_id !== expectedDocId) {
fail(`${caseId} doc_id must be ${expectedDocId}, got ${JSON.stringify(body.doc_id)}`);
}
return body;
}
async function closeNamedDialog(page: Page, name: string, timeout = 10_000) {
const dialog = page.getByRole('dialog', { name });
if ((await dialog.count()) === 0) return;
await dialog.getByRole('button', { name: '关闭' }).click();
await expect(dialog).toHaveCount(0, { timeout });
}
function blockEditorDialog(target: Page) {
return target.getByRole('dialog', { name: BLOCK_EDITOR_DIALOG_LABEL });
}
async function openFirstPaneEditorAndFill(target: Page, text: string) {
const pane = target.getByTestId('active-document-pane');
const block = pane.getByTestId('document-block').first();
await block.dblclick({ timeout: 15_000 });
const dialog = blockEditorDialog(target);
await expect(dialog).toBeVisible({ timeout: 10_000 });
const editor = dialog.locator('textarea, [contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 10_000 });
await editor.fill(text);
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 editFirstPaneBlockAndSave(target: Page, text: string) {
await openFirstPaneEditorAndFill(target, text);
await saveOpenEditor(target);
}
async function readRevisionStateEtag(res: Response): Promise<string> {
const json = (await res.json().catch(() => ({}))) as { etag?: string };
if (!json.etag) fail(`revision-state missing etag: HTTP ${res.status()}`);
return json.etag;
}
/** Fresh revision-state etag for a concrete doc_id (avoids racing unrelated poll responses). */
async function fetchRevisionStateEtag(target: Page, docId: string): Promise<string> {
const payload = await target.evaluate(async (id) => {
const headers: Record<string, string> = { Accept: 'application/json' };
try {
const raw = window.localStorage.getItem('smdf_auth_session');
if (raw) {
const session = JSON.parse(raw) as { token?: string };
if (session.token) {
headers.Authorization = `Bearer ${session.token}`;
}
}
} catch {
// ignore corrupt session; request will 401 and fail below
}
const res = await fetch(`/api/v1/documents/${encodeURIComponent(id)}/revision-state`, {
credentials: 'include',
headers,
});
const body = (await res.json().catch(() => ({}))) as { etag?: string; error?: string };
return { ok: res.ok, status: res.status, etag: body.etag || '', error: body.error || '' };
}, docId);
if (!payload.ok || !payload.etag) {
fail(
`revision-state fetch failed for ${docId}: HTTP ${payload.status} etag=${JSON.stringify(payload.etag)} error=${payload.error}`,
);
}
return payload.etag;
}
/** 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 };
}
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') || '',
};
}),
);
}
/** Poll computed styles until two identical frames; do not sample the 200ms transition mid-frame. */
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');
}
/** Sample every real pane block after overlay fetch + CSS transition settle. */
async function captureOverlayFingerprint(page: Page, mode: 'pure' | 'manual' | 'all') {
const pane = page.getByTestId('active-document-pane');
const modeSelect = pane.locator('select[aria-label="变更显示模式"]');
const overlayResp = page.waitForResponse((r) => {
if (r.request().method() !== 'GET') return false;
const pathOnly = sanitizeUrl(r.url());
if (mode === 'pure') return pathOnly.includes('/revision-state');
if (mode === 'manual') return pathOnly.includes('/manual-overlay');
return pathOnly.includes('/diff') || pathOnly.includes('/manual-overlay');
}, { timeout: 20_000 });
await modeSelect.selectOption(mode);
await expect(modeSelect).toHaveValue(mode);
await overlayResp;
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`);
}
if (mode !== 'pure' && !samples.some((s) => s.overlayKind)) {
fail(`UI overlay mode=${mode}: no document-block overlay to sample computed style`);
}
return samples;
}
// ─── 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);
await visibleProjectEntry(page, st.projectName).click();
await expect(currentProjectBannerName(page, st.projectName)).toBeVisible({ timeout: 20_000 });
if (st.controlledDocId) {
await openWorkflowDocById(page, st.controlledDocId);
} else if (st.controlledDocTitle) {
await openWorkflowDocByLabel(page, new RegExp(st.controlledDocTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
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 controlledDocId = '';
let versionBeforePending = '';
let secondDocTitle = '';
let secondDocId = '';
let uploadedDocId = '';
/** Latest server-durable body marker; UI-15-pre writes this as bodySnippet. */
let latestDurableBodySnippet = '';
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 = [];
const admin = requireAdminCreds();
await page.goto('/');
await page.fill('input[placeholder*="系统用户名"]', admin.username);
await page.fill('input[placeholder*="登录密码"]', admin.password);
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, 'role-tech-lead');
await adminCreateUser(page, USER_B, `协作者B-${suffix}`, INIT_PASS, 'role-engineer');
await adminCreateUser(page, USER_C, `无关人C-${suffix}`, INIT_PASS, 'role-guest');
await adminCreateUser(page, USER_D, `审批人D-${suffix}`, INIT_PASS, 'role-engineer');
// Full-page AdminConsoleView (not the old fixed modal): exit via 返回工作台.
await page.getByRole('button', { name: '返回工作台' }).click();
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 15_000 });
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);
await page.fill('input[placeholder*="系统用户名"]', USER_D);
await page.fill('input[placeholder*="登录密码"]', INIT_PASS);
await page.click('button:has-text("进入医疗协同工作台")');
await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_D);
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 credential reset',
actual: `users ${USER_A},${USER_B},${USER_C},${USER_D} ready`,
networkLogs: logs.filter((l) => !/password|login/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();
const projectModalHeading = page.getByText('发起医疗器械新项目立项', { exact: true });
await expect(projectModalHeading).toBeVisible({ timeout: 30_000 });
const projectModal = projectModalHeading.locator(
'xpath=ancestor::div[contains(@style,"position: fixed")][1]',
);
await projectModal.locator('input[placeholder*="无线便携式"]').fill(PROJECT_NAME);
const desc = projectModal.locator('textarea');
if ((await desc.count()) === 0) fail('project form missing required description textarea');
await desc.fill('用于骨科微创手术的双频超声吸引与止血,需符合 GB 9706.1 与相关法规要求。');
// B = reviewer; D = third member for workflow 三权 (C stays non-member for UI-12).
await toggleCreateProjectMember(projectModal, `协作者B-${suffix}`, 'reviewer');
await toggleCreateProjectMember(projectModal, `审批人D-${suffix}`, 'viewer');
await projectModal.getByRole('button', { name: /确认立项/ }).click();
await expect(projectModalHeading).toBeHidden({ timeout: 15_000 });
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
await expect(page.locator('aside')).toBeVisible();
// Right doc rail defaults collapsed; expand so template preview hint is in DOM.
const expandDocRail = page.getByRole('button', { name: '展开受控文档栏' });
if (await expandDocRail.isVisible().catch(() => false)) {
await expandDocRail.click();
}
// 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);
// Squad tasks require an activated workflow with ≥1 human-approval doc (product gate).
await activateProjectWorkflow(page, {
author: `编制人A-${suffix}`,
reviewer: `协作者B-${suffix}`,
approver: `审批人D-${suffix}`,
});
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+D; activate workflow`,
actual: 'template hint visible; workflow 运行中; 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 =
'请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。';
// 产品契约:必须以 POST /api/v1/tasks/runs 的真实 HTTP 结果判定;禁止仅凭短暂「生成中」记 PASS。
const taskRunWait = page.waitForResponse(
(r) => {
const pathOnly = sanitizeUrl(r.url());
return pathOnly.includes('/api/v1/tasks/runs') && r.request().method() === 'POST';
},
{ timeout: 60_000 },
);
await sendChat(page, prompt);
await expect(
page.locator('[data-chat-role="user"]').filter({ hasText: prompt.slice(0, 12) }).last(),
).toBeVisible({ timeout: 20_000 });
const taskRes = await taskRunWait;
const status = taskRes.status();
if (status === 409) {
fail(`UI-02 tasks/runs 409 TASK_SCOPE_UNRESOLVED (or conflict) is FAIL; body must not be treated as started`);
}
if (status < 200 || status >= 300) {
fail(`UI-02 tasks/runs must be 2xx (create usually 202 Accepted), got HTTP ${status}`);
}
const connErr = page.locator('text=连接异常');
if (await connErr.first().isVisible().catch(() => false)) {
fail('UI-02 connection-error banner visible after tasks/runs — product FAIL');
}
// 对本轮 user 对应 assistant 精确断言 inflight(禁止全局「生成中」)。
const { assistant } = turnAssistantLocator(page, prompt.slice(0, 12));
await expect(assistant).toBeVisible({ timeout: 30_000 });
await expect(assistant).toHaveAttribute(
'data-chat-status',
/pending|streaming_status|auto_retrying/,
{ timeout: 60_000 },
);
await shot(page, 'UI-02-squad-started.png');
recordPass({
id: 'UI-02',
name: 'start real squad',
input: prompt,
actual: `POST /api/v1/tasks/runs HTTP ${status}; this-turn assistant inflight`,
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 = '请基于本项目编制首轮';
// UI-02 已观测 inflight;此处只等同一 assistant 到 terminal,避免快速完成竞态。
await waitForSquadCompletion(page, promptSlice, 600_000, { requireInflight: false });
const selected = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i);
controlledDocId = selected.docId;
controlledTitle = selected.title;
if (!/^doc_proj\d+_/.test(controlledDocId)) {
fail(`UI-03: selected slot must be server doc_proj*, got ${controlledDocId}`);
}
const pane = page.getByTestId('active-document-pane');
await expect(pane).toHaveAttribute('data-doc-id', controlledDocId, { timeout: 15_000 });
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);
await shot(page, '03-first-doc.png');
const st = readPhaseState();
writePhaseState({
...st,
controlledDocTitle: controlledTitle,
controlledDocId,
versionText: v,
revisionText: r,
});
recordPass({
id: 'UI-03',
name: 'first controlled doc ledger',
input: 'wait squad; open intake server doc slot',
actual: `toolbar ${v}/${r}; docId=${controlledDocId}; title=${controlledTitle}`,
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 dialog = page.getByRole('dialog', { name: '变更报告' });
await expect(dialog).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId('change-report-panel')).toBeVisible({ timeout: 20_000 });
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');
await dialog.getByRole('button', { name: '关闭' }).click();
await expect(dialog).toHaveCount(0, { timeout: 10_000 });
await expect(page.getByTestId('change-report-panel')).toHaveCount(0);
recordPass({
id: 'UI-04',
name: 'change report fields',
input: 'click 变更报告; close via 关闭',
actual: 'risk/impact/open questions visible; dialog closed',
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 pendingBtn = page.getByRole('button', { name: '待确认报告' });
await expect(pendingBtn).toBeVisible({ timeout: 15_000 });
await pendingBtn.click();
const reportPanel = page.getByTestId('change-report-panel');
await expect(reportPanel).toBeVisible({ timeout: 20_000 });
const riskEl = reportPanel.getByTestId('change-report-risk');
await expect(riskEl).toBeVisible({ timeout: 20_000 });
const risk = (await riskEl.innerText()).trim().toLowerCase();
if (risk !== 'high' && risk !== 'critical') {
fail(`UI-05a pending report risk must be high (critical allowed); got ${JSON.stringify(risk)}`);
}
const actions = reportPanel.getByTestId('change-report-pending-actions');
await expect(actions).toBeVisible({ timeout: 15_000 });
const confirm = actions.getByRole('button', { name: '确认提交' });
await expect(confirm).toBeVisible({ timeout: 15_000 });
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 closeNamedDialog(page, '变更报告');
await shot(page, 'UI-05a-confirmed.png');
recordPass({
id: 'UI-05a',
name: 'confirm pending',
input: 'click 待确认报告; assert pending actions; click 确认提交',
actual: `risk=${risk}; 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);
if (!controlledDocId) fail('UI-05b requires controlledDocId from UI-03');
const prompt =
'请再次按医疗器械临床试验与注册检验法规,重写当前受控文档《用户初始需求收集表》中的电磁兼容与软件生存周期章节并给出条款级符合性判断(二轮高风险候选-拒绝用例)。';
const taskRunWait = waitForTasksRunsPost(page);
await sendChat(page, prompt);
const taskRes = await taskRunWait;
const taskBody = assertIntakeTaskRun(taskRes, controlledDocId, 'UI-05b');
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 pendingBtn = page.getByRole('button', { name: '待确认报告' });
await expect(pendingBtn).toBeVisible({ timeout: 15_000 });
await pendingBtn.click();
const reportPanel = page.getByTestId('change-report-panel');
await expect(reportPanel).toBeVisible({ timeout: 20_000 });
const actions = reportPanel.getByTestId('change-report-pending-actions');
await expect(actions).toBeVisible({ timeout: 15_000 });
const reject = actions.getByRole('button', { name: '拒绝' });
await expect(reject).toBeVisible({ timeout: 15_000 });
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 closeNamedDialog(page, '变更报告');
await shot(page, 'UI-05b-rejected.png');
recordPass({
id: 'UI-05b',
name: 'reject pending',
input: `${prompt}; click 待确认报告; click 拒绝`,
actual: `task_key=${taskBody.task_key} doc_id=${taskBody.doc_id}; 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 marker = `UI06-${suffix}`;
await editFirstPaneBlockAndSave(page, `人工修订段落 ${marker} 用于硬刷新验收。`);
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');
latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, {
kind: 'persisted',
snippet: marker,
}) || marker;
const st = readPhaseState();
writePhaseState({ ...st, bodySnippet: latestDurableBodySnippet, 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');
// Checklist: UI-07 无琥珀/蓝底;UI-08 人工改动琥珀标记;失败标准是「三 overlay 视觉无差异」。
// 同一 human hunk 在 manual/all 都是琥珀底,产品不保证两两不同,禁止换块制造差异。
const pureKey = overlayPaneKey(pure);
const manualKey = overlayPaneKey(manual);
const allKey = overlayPaneKey(all);
if (overlayPaneHasMark(pure)) {
fail(`UI-07 纯净阅读 must have no overlay decoration: ${JSON.stringify(pure)}`);
}
if (!overlayPaneHasMark(manual)) {
fail(`UI-08 人工修改 must decorate changed blocks: ${JSON.stringify(manual)}`);
}
if (!overlayPaneHasMark(all)) {
fail(`UI-09 全部变更 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}`);
}
recordPass({
id: 'UI-07',
name: 'overlay pure/manual/all',
input: 'switch three overlay modes; sample pane document-block styles',
actual: `pure unmarked; manual/all marked; manual===all allowed when hunks match`,
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 history = page.getByRole('dialog', { name: '版本历史' });
await expect(history).toBeVisible({ timeout: 15_000 });
const rows = history.locator('li').filter({ hasText: /V\d+/ });
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
const n = await rows.count();
if (n < 2) fail(`version history must list at least two versions, got ${n}`);
const vA = ((await rows.nth(0).locator('strong').innerText()) || '').trim();
const vB = ((await rows.nth(1).locator('strong').innerText()) || '').trim();
if (!vA || !vB || vA === vB) fail(`history A/B must be different versions, got ${vA} vs ${vB}`);
await rows.nth(0).getByRole('button', { name: '选为 A' }).click();
await rows.nth(1).getByRole('button', { name: '选为 B' }).click();
await history.getByRole('button', { name: '比较 A/B' }).click();
const compare = page.getByRole('dialog', { name: '版本比较' });
await expect(compare).toBeVisible({ timeout: 20_000 });
// VersionHistoryDrawer onSelectCompare closes history before compare mounts.
await expect(history).toHaveCount(0, { timeout: 10_000 });
await expect(compare.getByText(/\+\d+\s*\/\s*-\d+\s*\/\s*~\d+/)).toBeVisible({ timeout: 15_000 });
await shot(page, 'UI-10-history-compare.png');
await compare.getByRole('button', { name: '关闭', exact: true }).click();
await expect(compare).toHaveCount(0, { timeout: 10_000 });
await expect(page.getByRole('dialog', { name: '版本历史' })).toHaveCount(0);
recordPass({
id: 'UI-10',
name: 'history + compare',
input: 'open 版本历史; pick two V as A/B; click 比较 A/B; history auto-closes; close compare',
actual: `history entries=${n}; compared ${vA} vs ${vB}; history auto-closed; compare closed`,
networkLogs: logs,
durationMs: Date.now() - t0,
verdict: 'PASS',
});
});
test('UI-11 chat second controlled doc + page upload attachment', async () => {
const t0 = Date.now();
logs = [];
// prelim_design is V0.5; V0.1 intake must finish 三权 or tasks/runs returns 403.
if (!controlledDocId) fail('UI-11 requires controlledDocId from UI-03');
await driveIntakeGovernanceToApproved(page, controlledDocId, {
userB: USER_B,
passB: FINAL_PASS_B,
userD: USER_D,
passD: FINAL_PASS_D,
projectName: PROJECT_NAME,
});
const prompt =
'请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。';
const taskRunWait = waitForTasksRunsPost(page);
await sendChat(page, prompt);
const taskRes = await taskRunWait;
if (taskRes.status() !== 202) {
fail(`UI-11 tasks/runs must be HTTP 202, got ${taskRes.status()}`);
}
const taskBody = taskRes.request().postDataJSON() as { task_key?: string };
if (taskBody.task_key !== 'prelim_design' && taskBody.task_key !== 'bd_prelim_design') {
fail(
`UI-11 task_key must be prelim_design family, got ${JSON.stringify(taskBody.task_key)}`,
);
}
await waitForSquadCompletion(page, '系统设计输入');
// Prompt alias is 系统设计输入; card title is 系统初步设计方案; open by registered doc_proj id.
const second = await openWorkflowDocByControlledTaskKey(page, 'prelim_design');
if (second.title !== '系统初步设计方案') {
fail(`UI-11 prelim_design card title must be 系统初步设计方案, got ${JSON.stringify(second.title)}`);
}
secondDocTitle = second.title;
secondDocId = second.docId;
await expect(page.getByTestId('active-document-pane')).toHaveAttribute('data-doc-id', secondDocId);
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 45_000 });
const states: string[] = [];
for (const target of [
{ id: controlledDocId, title: controlledTitle },
{ id: secondDocId, title: secondDocTitle },
]) {
if (!target.id) continue;
await openWorkflowDocById(page, target.id);
await expect(page.getByTestId('active-document-pane')).toHaveAttribute('data-doc-id', target.id);
const { v, r } = await readVersionBadge(page);
states.push(`${target.title}:${v}/${r}`);
}
// Page upload: chip draft → send → POST /api/v1/freeform-documents → doc-free pane.
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`);
const uploadName = `upload-${suffix}.txt`;
fs.writeFileSync(tmp, `attachment ${suffix}\n`, 'utf-8');
await fileInput.first().setInputFiles(tmp);
await expect(
page.getByText(uploadName, { exact: false }).or(page.getByText(`upload-${suffix}`, { exact: false })),
).toBeVisible({ timeout: 15_000 });
const archivePrompt = '请将此附件归档为我的私密参考文档。';
const freeformWait = page.waitForResponse(
(r) => {
const pathOnly = sanitizeUrl(r.url());
return (
r.request().method() === 'POST' &&
pathOnly.includes('/api/v1/freeform-documents') &&
!pathOnly.includes('/from-message')
);
},
{ timeout: 60_000 },
);
const box = page.getByPlaceholder(/向项目助手提问/);
await expect(box).toBeVisible({ timeout: 15_000 });
// 附件允许空文本;填归档句以便本轮 assistant 可定位并收口终态。
await box.fill(archivePrompt);
await page.getByRole('button', { name: /发送/ }).click();
const freeformRes = await freeformWait;
const freeformStatus = freeformRes.status();
if (freeformStatus < 200 || freeformStatus >= 300) {
fail(`UI-11 POST /api/v1/freeform-documents must be 2xx, got HTTP ${freeformStatus}`);
}
const pane = page.getByTestId('active-document-pane');
await expect(pane).toHaveAttribute('data-doc-id', /^doc-free-/, { timeout: 45_000 });
uploadedDocId = (await pane.getAttribute('data-doc-id')) || '';
if (!uploadedDocId.startsWith('doc-free-')) {
fail(`UI-11 upload must activate doc-free-* pane, got ${uploadedDocId}`);
}
if (uploadedDocId.includes('doc-upload')) {
fail(`UI-11 must not leave doc-upload orphan active: ${uploadedDocId}`);
}
// 本轮助手可能直接 completed(归档快路径)。仍须:上传证据、相邻卡片、成功终态、无错误。
// inflight 只作可选观测,不得因错过 pending 而把 completed 判失败。
await waitForSquadCompletion(page, '请将此附件归档', 180_000);
await shot(page, 'UI-11-multi-doc.png');
recordPass({
id: 'UI-11',
name: 'multi doc + upload',
input: 'chat second doc; upload file + send for freeform register',
actual: `${states.join('; ')}; freeform HTTP ${freeformStatus}; uploadedDocId=${uploadedDocId}`,
networkLogs: logs,
durationMs: Date.now() - t0,
verdict: 'PASS',
});
});
test('UI-12 user C (not a member) must not see project', 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 listed = gp.getByText(PROJECT_NAME, { exact: false });
const n = await listed.count();
if (n !== 0) {
fail(`non-member C must not see project ${PROJECT_NAME}; visible count=${n}`);
}
await shot(gp, '12-unauthorized-access.png');
await ctx.close();
recordPass({
id: 'UI-12',
name: 'unauthorized isolation',
input: `login ${USER_C} (not member)`,
actual: `project ${PROJECT_NAME} not visible`,
networkLogs: guestLogs,
durationMs: Date.now() - t0,
verdict: 'PASS',
});
});
test('UI-13a reviewer B has no body-edit affordance', async () => {
const t0 = Date.now();
logs = [];
if (controlledDocId) {
await openWorkflowDocById(page, controlledDocId);
} else {
await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i);
}
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();
if (controlledDocId) {
await openWorkflowDocById(pageB, controlledDocId);
} else {
await openWorkflowDocByLabel(pageB, /用户初始需求|需求摸底|intake/i);
}
await expect(pageB.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
await pageB.locator('[title*="法定权限矩阵"]').click();
await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toBeVisible({ timeout: 15_000 });
const duty = await readReviewerIntakeDuty(pageB, controlledDocId);
await pageB.getByRole('button', { name: '关闭面板' }).click();
await expect(pageB.getByText('当前账号与项目职责权限矩阵')).toHaveCount(0, { timeout: 10_000 });
await expect(pageB.getByText('双击任意段落就地编辑')).toHaveCount(0);
const pane = pageB.getByTestId('active-document-pane');
await pane.getByTestId('document-block').first().dblclick({ timeout: 15_000 });
await expect(blockEditorDialog(pageB)).toHaveCount(0);
await shot(pageB, 'UI-13a-reviewer-readonly.png');
await ctxB.close();
recordPass({
id: 'UI-13a',
name: 'reviewer B no body edit',
input: 'login reviewer B; open intake; rights matrix + dblclick',
actual: `duty=${duty}; no author edit hint; no save editor`,
networkLogs: logs,
durationMs: Date.now() - t0,
verdict: 'PASS',
});
});
test('UI-13b two author-A contexts conflict on same revision', async () => {
const t0 = Date.now();
logs = [];
// Intake is locked 已生效受控 after UI-11 三权; conflict must use drafting prelim.
// Prelim ships author_id=0 → resolveCurrentDocRole is viewer until 认领此文档.
if (!secondDocId) fail('UI-13b requires secondDocId from UI-11 (prelim drafting)');
const conflictDocId = secondDocId;
await openWorkflowDocById(page, conflictDocId);
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 });
await ensureAuthorClaimedForEdit(page);
const a1Base = await readVersionBadge(page);
const etagA1 = await fetchRevisionStateEtag(page, conflictDocId);
const ctxA2 = await page.context().browser()!.newContext();
const pageA2 = await ctxA2.newPage();
await login(pageA2, USER_A, FINAL_PASS_A);
await pageA2.getByText(PROJECT_NAME, { exact: false }).first().click();
await openWorkflowDocById(pageA2, conflictDocId);
await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
// Same user is now author after A1 claim; wait for edit affordance (not claim button).
await expect(pageA2.getByText(/双击任意段落就地编辑/)).toBeVisible({ timeout: 30_000 });
const a2Base = await readVersionBadge(pageA2);
const etagA2 = await fetchRevisionStateEtag(pageA2, conflictDocId);
if (a2Base.r !== a1Base.r) {
fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`);
}
const markerA1 = `A1-win-${suffix}`;
const markerA2 = `A2-conflict-${suffix}`;
const a2Editor = await openFirstPaneEditorAndFill(pageA2, `人工修订段落 ${markerA2}`);
await expect(a2Editor).toBeVisible();
// A1 already on conflict doc; refresh etag then save while A2 keeps an open draft.
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 });
const etagA1Fresh = await fetchRevisionStateEtag(page, conflictDocId);
if (etagA2 !== etagA1Fresh && etagA2 !== etagA1) {
fail(`A2/A1 etag diverged before A1 save: A2=${etagA2} A1fresh=${etagA1Fresh} A1open=${etagA1}`);
}
const patchA1 = page.waitForResponse(
(r) => r.url().includes('/content') && r.request().method() === 'PATCH',
{ timeout: 30_000 },
);
await editFirstPaneBlockAndSave(page, `人工修订段落 ${markerA1}`);
const a1Res = await patchA1;
if (a1Res.status() < 200 || a1Res.status() >= 300) {
fail(`A1 save must be 2xx, got HTTP ${a1Res.status()}`);
}
await expect(page.getByText(markerA1)).toBeVisible({ timeout: 30_000 });
await expect(a2Editor).toBeVisible();
const patchA2 = pageA2.waitForResponse(
(r) => r.url().includes('/content') && r.request().method() === 'PATCH',
{ timeout: 30_000 },
);
await saveOpenEditor(pageA2);
const a2Res = await patchA2;
const a2Json = (await a2Res.json().catch(() => ({}))) as { code?: string };
if (a2Res.status() !== 409 && a2Json.code !== 'DOCUMENT_HEAD_CONFLICT') {
fail(`A2 save must be DOCUMENT_HEAD_CONFLICT/409, got HTTP ${a2Res.status()} body=${JSON.stringify(a2Json)}`);
}
await expect(pageA2.getByText(/文档已被他人更新|刷新后重做|版本冲突/)).toBeVisible({
timeout: 30_000,
});
await expect(pageA2.getByRole('button', { name: '刷新后重做' })).toBeVisible({ timeout: 10_000 });
await expect(a2Editor).toBeVisible();
const a2Kept = await a2Editor
.inputValue()
.catch(async () => (await a2Editor.innerText()) || '');
if (!a2Kept.includes(markerA2)) {
fail(`A2 editor must keep unsaved text after conflict, got ${JSON.stringify(a2Kept)}`);
}
await expect(page.getByText(markerA1)).toBeVisible();
await expect(page.getByText(markerA2)).toHaveCount(0);
await shot(pageA2, 'UI-13b-conflict-A2.png');
await ctxA2.close();
latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, {
kind: 'persisted',
snippet: markerA1,
}) || markerA1;
latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, {
kind: 'conflict_draft',
snippet: markerA2,
}) || latestDurableBodySnippet;
writePhaseState({ ...readPhaseState(), bodySnippet: latestDurableBodySnippet });
recordPass({
id: 'UI-13b',
name: 'author A/A head conflict',
input: 'both windows hydrate same r/etag; A2 editor open+filled before A1 save; then A2 save',
actual: `doc=${conflictDocId}; base r=${a1Base.r} etag=${etagA1Fresh}; A1 kept ${markerA1}; A2 HTTP ${a2Res.status()} code=${a2Json.code || ''}; A2 editor retained`,
networkLogs: logs,
durationMs: Date.now() - t0,
verdict: 'PASS',
});
recorded['UI-13'] = {
id: 'UI-13',
name: 'split: 13a reviewer-readonly + 13b A/A conflict',
input: 'UI-13a then UI-13b',
actual: 'see UI-13a and UI-13b',
networkLogs: logs,
durationMs: Date.now() - t0,
verdict: 'PASS',
notes: 'B is project reviewer and must not be used as a body editor',
};
saveResults();
});
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();
if (controlledDocId) {
await openWorkflowDocById(page, controlledDocId);
} else {
await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i);
}
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}`);
if (again.r !== r) fail(`revision changed across re-login unexpectedly ${r}→${again.r}`);
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} unchanged`,
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,
controlledDocId,
versionText: v,
revisionText: r,
bodySnippet: latestDurableBodySnippet || st.bodySnippet,
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 = [];
if (!uploadedDocId || !uploadedDocId.startsWith('doc-free-')) {
fail(`UI-16 requires uploadedDocId from UI-11 (doc-free-*), got ${uploadedDocId || '(empty)'}`);
}
await selectDeliverableCategory(page, 'private');
await openWorkflowDocById(page, uploadedDocId);
const pane = page.getByTestId('active-document-pane');
await expect(pane).toHaveAttribute('data-doc-id', uploadedDocId, { timeout: 15_000 });
await expect(pane).toHaveAttribute('data-doc-id', /^doc-free-/);
const paneId = (await pane.getAttribute('data-doc-id')) || '';
if (paneId.startsWith('doc-upload')) {
fail(`UI-16 must not open doc-upload orphan: ${paneId}`);
}
await expect(page.getByText('版本账本未就绪')).toHaveCount(0);
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: `category=private; open exact ${uploadedDocId}`,
actual: `pane=${paneId}; toolbar V visible; no doc-upload revision-state`,
networkLogs: logs,
durationMs: Date.now() - t0,
verdict: 'PASS',
});
});
});