Files
Agent-System/web/e2e/document-revision-runtime.spec.ts
T
WeiCHEN 1dbdb95088 fix(e2e): click 确认推进 after intake 三权 before UI-11
Approved intake only unlocks the gate; prelim_design still 403 until
creator advances V0.1 → V0.5 via StageAdvanceBanner.
2026-09-23 00:17:08 +08:00

1742 lines
74 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';
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,
timeoutMs = 420_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 });
}
/**
* 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;
}
/** 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, 420_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 = [];
const etagA1P = page.waitForResponse(
(r) =>
r.url().includes('/revision-state') &&
r.request().method() === 'GET' &&
r.ok(),
{ timeout: 30_000 },
);
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 a1Base = await readVersionBadge(page);
const etagA1 = await readRevisionStateEtag(await etagA1P);
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();
const etagA2P = pageA2.waitForResponse(
(r) =>
r.url().includes('/revision-state') &&
r.request().method() === 'GET' &&
r.ok(),
{ timeout: 30_000 },
);
if (controlledDocId) {
await openWorkflowDocById(pageA2, controlledDocId);
} else {
await openWorkflowDocByLabel(pageA2, /用户初始需求|需求摸底|intake/i);
}
await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
const a2Base = await readVersionBadge(pageA2);
const etagA2 = await readRevisionStateEtag(await etagA2P);
if (a2Base.r !== a1Base.r) fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`);
if (etagA2 !== etagA1) fail(`A2 must hydrate same etag as A1 before either save: ${etagA2} vs ${etagA1}`);
const markerA1 = `A1-win-${suffix}`;
const markerA2 = `A2-conflict-${suffix}`;
const a2Editor = await openFirstPaneEditorAndFill(pageA2, `人工修订段落 ${markerA2}`);
await expect(a2Editor).toBeVisible();
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: `base r=${a1Base.r} etag=${etagA1}; 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',
});
});
});