1654 lines
63 KiB
TypeScript
1654 lines
63 KiB
TypeScript
/**
|
||
* Full-system real-browser multi-user acceptance (FS-*).
|
||
*
|
||
* Hard rules:
|
||
* - Real Chromium clicks/fills only. No page.route mocks; no API create/project/message/approve.
|
||
* - Multi BrowserContext (never share storageState across roles).
|
||
* - Each page: console + pageerror → console-*.jsonl
|
||
* - Unmet conditions → FAIL (or explicit SKIP with reason). Never fake PASS.
|
||
* - PI / squad waits: 15–20 min timeouts.
|
||
*
|
||
* Gate: RUN_UI_ACCEPTANCE=1 + ADMIN_USERNAME/ADMIN_PASSWORD + BASE_URL.
|
||
*/
|
||
import { test, expect, type Page, type Browser, type BrowserContext, type ConsoleMessage } from '@playwright/test';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import {
|
||
BLOCK_EDITOR_DIALOG_LABEL,
|
||
BLOCK_EDITOR_SAVE_NAME,
|
||
} from '../src/lib/blockEditorDialog';
|
||
import {
|
||
ANY_TERMINAL_STATUS_RE,
|
||
evaluateSquadObservation,
|
||
} from '../src/lib/squadCompletionObservation';
|
||
import {
|
||
CATEGORY_TRIGGER_LABEL,
|
||
requiredCategoryForDocOpen,
|
||
} from '../src/lib/workflowCategoryNavigation';
|
||
import { currentProjectBannerName } from '../src/lib/currentProjectRestore';
|
||
import {
|
||
assertWritableUiAcceptanceAssetsDir,
|
||
resolveUiAcceptanceAssetsDir,
|
||
} from '../src/lib/playwrightPhaseArtifacts';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1';
|
||
|
||
const ASSETS = resolveUiAcceptanceAssetsDir(
|
||
path.resolve(__dirname, '..'),
|
||
process.env.UI_ACCEPTANCE_ASSETS_DIR ||
|
||
path.resolve(__dirname, '../../.doc/test-reports/assets/2026-09-26-full-system-browser-e2e'),
|
||
);
|
||
const SCREENSHOTS = path.join(ASSETS, 'screenshots');
|
||
const RESULTS_FILE = path.join(ASSETS, 'ui-results.json');
|
||
const ACCOUNTS_FILE = path.join(ASSETS, 'retained-accounts.json');
|
||
const RUN_META_FILE = path.join(ASSETS, 'run-meta.json');
|
||
|
||
if (runAcceptance) {
|
||
assertWritableUiAcceptanceAssetsDir(ASSETS);
|
||
fs.mkdirSync(SCREENSHOTS, { recursive: true });
|
||
}
|
||
|
||
type Verdict = 'PASS' | 'FAIL' | 'SKIP';
|
||
type CaseResult = {
|
||
id: string;
|
||
name: string;
|
||
priority: 'P0' | 'P1' | 'P2';
|
||
input: string;
|
||
expected: string;
|
||
actual: string;
|
||
verdict: Verdict;
|
||
durationMs: number;
|
||
console_errors: string[];
|
||
notes?: string;
|
||
skip_reason?: string;
|
||
};
|
||
|
||
const recorded: Record<string, CaseResult> = {};
|
||
const consoleBuffers = new Map<string, Array<{ level: string; text: string; ts: string }>>();
|
||
|
||
const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || '';
|
||
const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || '';
|
||
const suffix = `${Date.now().toString(36)}`;
|
||
const PREFIX_USER = 'e2e26_';
|
||
const PREFIX_PROJ = 'E2E全量-20260926-';
|
||
|
||
const USERS = {
|
||
author: { username: `${PREFIX_USER}author_${suffix}`, display: `E2E编制-${suffix}`, roleId: 'role-tech-lead' as const },
|
||
reviewer: { username: `${PREFIX_USER}reviewer_${suffix}`, display: `E2E审核-${suffix}`, roleId: 'role-engineer' as const },
|
||
approver: { username: `${PREFIX_USER}approver_${suffix}`, display: `E2E批准-${suffix}`, roleId: 'role-engineer' as const },
|
||
member: { username: `${PREFIX_USER}member_${suffix}`, display: `E2E成员-${suffix}`, roleId: 'role-engineer' as const },
|
||
guest: { username: `${PREFIX_USER}guest_${suffix}`, display: `E2E访客-${suffix}`, roleId: 'role-guest' as const },
|
||
};
|
||
const INIT_PASS = `Init-${suffix}9A`;
|
||
const FINAL_PASS: Record<keyof typeof USERS, string> = {
|
||
author: `Auth-${suffix}9A`,
|
||
reviewer: `Rev-${suffix}9A`,
|
||
approver: `App-${suffix}9A`,
|
||
member: `Mem-${suffix}9A`,
|
||
guest: `Gst-${suffix}9A`,
|
||
};
|
||
const PROJECT_NAME = `${PREFIX_PROJ}${suffix}`;
|
||
|
||
/** Shared across serial tests */
|
||
let shared = {
|
||
controlledDocId: '',
|
||
controlledTitle: '',
|
||
uploadedDocId: '',
|
||
headed: false,
|
||
display: process.env.DISPLAY || '',
|
||
};
|
||
|
||
function fail(msg: string): never {
|
||
throw new Error(msg);
|
||
}
|
||
|
||
function requireAdminCreds(): { username: string; password: string } {
|
||
if (!ADMIN_USER || !ADMIN_PASS) {
|
||
fail('ADMIN_USERNAME and ADMIN_PASSWORD are required (no credential fallbacks)');
|
||
}
|
||
return { username: ADMIN_USER, password: ADMIN_PASS };
|
||
}
|
||
|
||
function sanitizeUrl(raw: string): string {
|
||
try {
|
||
return new URL(raw, 'http://127.0.0.1').pathname;
|
||
} catch {
|
||
return raw.split('?')[0]?.replace(/https?:\/\/[^/]+/i, '') || raw;
|
||
}
|
||
}
|
||
|
||
function saveResults() {
|
||
const scrubbed = JSON.parse(JSON.stringify(recorded)) as Record<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 record(r: CaseResult) {
|
||
recorded[r.id] = r;
|
||
saveResults();
|
||
}
|
||
|
||
function recordSkip(id: string, name: string, priority: CaseResult['priority'], reason: string) {
|
||
record({
|
||
id,
|
||
name,
|
||
priority,
|
||
input: '(not run)',
|
||
expected: '(n/a)',
|
||
actual: '(skipped)',
|
||
verdict: 'SKIP',
|
||
durationMs: 0,
|
||
console_errors: [],
|
||
skip_reason: reason,
|
||
});
|
||
}
|
||
|
||
async function shot(page: Page, name: string) {
|
||
await page.screenshot({ path: path.join(SCREENSHOTS, `${name}.png`), fullPage: true }).catch(() => undefined);
|
||
}
|
||
|
||
function attachConsole(page: Page, label: string) {
|
||
const buf: Array<{ level: string; text: string; ts: string }> = [];
|
||
consoleBuffers.set(label, buf);
|
||
const sink = path.join(ASSETS, `console-${label}.jsonl`);
|
||
const writeLine = (obj: object) => {
|
||
fs.appendFileSync(sink, `${JSON.stringify(obj)}\n`, 'utf-8');
|
||
};
|
||
page.on('console', (msg: ConsoleMessage) => {
|
||
const level = msg.type();
|
||
const text = msg.text();
|
||
const row = { level, text, ts: new Date().toISOString() };
|
||
buf.push(row);
|
||
if (level === 'error' || level === 'warning') writeLine({ kind: 'console', ...row });
|
||
});
|
||
page.on('pageerror', (err) => {
|
||
const row = { level: 'pageerror', text: String(err?.message || err), ts: new Date().toISOString() };
|
||
buf.push(row);
|
||
writeLine({ kind: 'pageerror', ...row });
|
||
});
|
||
}
|
||
|
||
function consoleErrorsFor(label: string): string[] {
|
||
const buf = consoleBuffers.get(label) || [];
|
||
return buf
|
||
.filter((e) => e.level === 'error' || e.level === 'pageerror')
|
||
.map((e) => `[${e.level}] ${e.text.slice(0, 240)}`);
|
||
}
|
||
|
||
async function newRolePage(browser: Browser, label: string): Promise<{ ctx: BrowserContext; page: Page }> {
|
||
const ctx = await browser.newContext({ locale: 'zh-CN' });
|
||
const page = await ctx.newPage();
|
||
attachConsole(page, label);
|
||
return { ctx, page };
|
||
}
|
||
|
||
async function login(page: Page, username: string, password: string) {
|
||
await page.goto('/');
|
||
await page.fill('input[placeholder*="系统用户名"]', username);
|
||
await page.fill('input[placeholder*="登录密码"]', password);
|
||
await page.click('button:has-text("进入医疗协同工作台")');
|
||
const force = page.getByPlaceholder('请输入初始密码');
|
||
if (await force.isVisible({ timeout: 2500 }).catch(() => false)) {
|
||
fail(`account ${username} still requires first-login password change`);
|
||
}
|
||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||
}
|
||
|
||
async function changePasswordFirstLogin(page: Page, oldPass: string, newPass: string) {
|
||
await expect(page.getByPlaceholder('请输入初始密码')).toBeVisible({ timeout: 15_000 });
|
||
await page.fill('input[placeholder="请输入初始密码"]', oldPass);
|
||
await page.fill('input[placeholder*="至少 6 位"]', newPass);
|
||
await page.fill('input[placeholder="再次输入新密码"]', newPass);
|
||
await page.getByRole('button', { name: /确认重置并进入系统/ }).click();
|
||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||
}
|
||
|
||
async function logoutViaUI(page: Page) {
|
||
await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 });
|
||
const logout = page.getByRole('button', { name: '退出当前登录' });
|
||
await expect(logout).toBeVisible({ timeout: 10_000 });
|
||
await logout.click();
|
||
await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 });
|
||
}
|
||
|
||
async function openAdminUsers(page: Page) {
|
||
const btn = page.locator('button[title*="系统管理与准入治理"]');
|
||
await expect(btn).toBeVisible({ timeout: 15_000 });
|
||
await btn.click();
|
||
await expect(page.getByRole('button', { name: '用户与准入治理' })).toBeVisible({ timeout: 15_000 });
|
||
await page.getByRole('button', { name: '用户与准入治理' }).click();
|
||
await expect(page.getByRole('button', { name: '开通受控员工账号' })).toBeVisible({ timeout: 15_000 });
|
||
}
|
||
|
||
async function adminCreateUser(
|
||
page: Page,
|
||
username: string,
|
||
realName: string,
|
||
initPass: string,
|
||
roleId: 'role-tech-lead' | 'role-engineer' | 'role-guest',
|
||
) {
|
||
await page.getByRole('button', { name: '开通受控员工账号' }).click();
|
||
const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' });
|
||
await expect(form).toBeVisible({ timeout: 10_000 });
|
||
await form.locator('input[placeholder="如 li_si"]').fill(username);
|
||
await form.locator('input[placeholder="如 李四"]').fill(realName);
|
||
const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]');
|
||
if ((await passBox.count()) === 0) fail('admin create-user missing initial password field');
|
||
await passBox.fill(initPass);
|
||
const roleSelect = form.locator('select');
|
||
await roleSelect.selectOption(roleId);
|
||
await form.getByRole('button', { name: '确认开通账号' }).click();
|
||
await expect(page.getByText(new RegExp(`已成功开通|${realName}`)).first()).toBeVisible({ timeout: 15_000 });
|
||
await expect(form).toBeHidden({ timeout: 10_000 });
|
||
}
|
||
|
||
async function toggleCreateProjectMember(
|
||
projectModal: ReturnType<Page['locator']>,
|
||
displayName: string,
|
||
/** CreateProjectModal options: author|reviewer|approver|viewer (no editor). */
|
||
role?: 'author' | 'reviewer' | 'approver' | 'viewer',
|
||
) {
|
||
const memberName = projectModal.getByText(displayName, { exact: true }).first();
|
||
await expect(memberName).toBeVisible({ timeout: 15_000 });
|
||
await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click();
|
||
if (!role) return;
|
||
const memberContainer = memberName.locator(
|
||
'xpath=ancestor::div[contains(@style,"justify-content: space-between")][1]',
|
||
);
|
||
const memberRole = memberContainer.locator('select');
|
||
await expect(memberRole).toBeEnabled();
|
||
await memberRole.selectOption(role);
|
||
}
|
||
|
||
/** After create, promote a create-modal viewer to project_role=member (editable, no doc duty). */
|
||
async function promoteToProjectMember(page: Page, displayName: string) {
|
||
// LeftRail: title="管理当前项目成员与职责分配" (avoid matching vault "保存" buttons).
|
||
const openBtn = page.locator('button[title="管理当前项目成员与职责分配"]').first();
|
||
await expect(openBtn).toBeVisible({ timeout: 15_000 });
|
||
await openBtn.click();
|
||
const heading = page.getByText('项目成员与职责权限管理', { exact: true });
|
||
await expect(heading).toBeVisible({ timeout: 20_000 });
|
||
const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]');
|
||
// Row is flex space-between with the display name + a role <select>.
|
||
const nameEl = modal.getByText(displayName, { exact: true }).first();
|
||
await expect(nameEl).toBeVisible({ timeout: 15_000 });
|
||
const row = nameEl.locator(
|
||
'xpath=ancestor::div[contains(@style,"justify-content: space-between")][1]',
|
||
);
|
||
const roleSelect = row.locator('select').first();
|
||
await expect(roleSelect).toBeVisible({ timeout: 15_000 });
|
||
const opts = await roleSelect.locator('option').evaluateAll((nodes) =>
|
||
nodes.map((n) => ({
|
||
value: (n as HTMLOptionElement).value,
|
||
text: (n.textContent || '').trim(),
|
||
})),
|
||
);
|
||
const memberOpt = opts.find((o) => o.value === 'member' || /^项目成员/.test(o.text));
|
||
if (!memberOpt) {
|
||
fail(`promoteToProjectMember: no member option in ${JSON.stringify(opts)}`);
|
||
}
|
||
// onChange persists immediately — no separate save; do NOT match vault「保存」.
|
||
await roleSelect.selectOption(memberOpt.value);
|
||
await expect(roleSelect).toHaveValue('member');
|
||
await expect(row.getByText('项目成员 (Member)', { exact: true })).toBeVisible({ timeout: 15_000 });
|
||
await modal.getByRole('button', { name: '完成并关闭' }).click();
|
||
await expect(heading).toBeHidden({ timeout: 15_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);
|
||
}
|
||
|
||
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 });
|
||
const closeBtn = page.getByRole('button', { name: '关闭' });
|
||
if (await closeBtn.isVisible().catch(() => false)) await closeBtn.click();
|
||
}
|
||
|
||
async function ensureChatComposerIdle(page: Page, timeoutMs = 60_000) {
|
||
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 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"]',
|
||
),
|
||
};
|
||
}
|
||
|
||
async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 1_200_000) {
|
||
const startedAt = Date.now();
|
||
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: false },
|
||
);
|
||
};
|
||
let verdict = await observe();
|
||
if (verdict.action === 'reject') fail(`squad observation rejected: ${verdict.reason}`);
|
||
if (verdict.action === 'accept') return Date.now() - startedAt;
|
||
|
||
// Mid-wait heal: if steps look done but status stuck pending, reload to pick history task_status.
|
||
const healDeadline = Date.now() + Math.min(timeoutMs, 480_000);
|
||
while (Date.now() < healDeadline) {
|
||
try {
|
||
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
|
||
timeout: 60_000,
|
||
});
|
||
break;
|
||
} catch {
|
||
const status = (await assistant.getAttribute('data-chat-status')) || '';
|
||
const body = await page.locator('body').innerText().catch(() => '');
|
||
const phasesDone =
|
||
/质量门禁已完成|质量.*100|简报已完成|组建小队已完成/.test(body) ||
|
||
(await page.getByText(/质量门禁已完成|任务已完成/).count()) > 0;
|
||
if (status === 'pending' || status === 'streaming_status') {
|
||
if (phasesDone || Date.now() - startedAt > 240_000) {
|
||
await page.reload();
|
||
await expect(page.getByText(PROJECT_NAME, { exact: false }).first()).toBeVisible({
|
||
timeout: 30_000,
|
||
});
|
||
await openProject(page);
|
||
const again = turnAssistantLocator(page, userPromptSnippet);
|
||
await expect(again.assistant).toBeVisible({ timeout: 30_000 });
|
||
const healed = (await again.assistant.getAttribute('data-chat-status')) || '';
|
||
if (ANY_TERMINAL_STATUS_RE.test(healed)) {
|
||
return Date.now() - startedAt;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const finalAssistant = turnAssistantLocator(page, userPromptSnippet).assistant;
|
||
await expect(finalAssistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
|
||
timeout: Math.max(30_000, timeoutMs - (Date.now() - startedAt)),
|
||
});
|
||
verdict = await observe();
|
||
// Re-bind observe to final assistant
|
||
const status = (await finalAssistant.getAttribute('data-chat-status')) || '';
|
||
const hasVisibleError = await connErr.first().isVisible().catch(() => false);
|
||
verdict = evaluateSquadObservation(
|
||
{ userPromptMatched: true, assistantAdjacent: true, status, hasVisibleError },
|
||
{ requireInflight: false },
|
||
);
|
||
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 waitForAssistantReply(page: Page, userSnippet: string, timeoutMs = 180_000) {
|
||
const { userBubble, assistant } = turnAssistantLocator(page, userSnippet);
|
||
await expect(userBubble).toBeVisible({ timeout: 20_000 });
|
||
await expect(assistant).toBeVisible({ timeout: timeoutMs });
|
||
// Casual chat may not set squad status attrs; wait for non-empty body.
|
||
await expect
|
||
.poll(async () => ((await assistant.innerText().catch(() => '')) || '').trim().length, {
|
||
timeout: timeoutMs,
|
||
})
|
||
.toBeGreaterThan(0);
|
||
}
|
||
|
||
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 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) {
|
||
await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'workflow-label' }));
|
||
const trigger = page.getByTestId('doc-selector-trigger');
|
||
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 };
|
||
}
|
||
|
||
async function openWorkflowDocById(page: Page, docId: string) {
|
||
await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'exact-id', docId }));
|
||
const trigger = page.getByTestId('doc-selector-trigger');
|
||
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 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 });
|
||
}
|
||
|
||
function blockEditorDialog(target: Page) {
|
||
return target.getByRole('dialog', { name: BLOCK_EDITOR_DIALOG_LABEL });
|
||
}
|
||
|
||
async function editFirstPaneBlockAndSave(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);
|
||
await dialog.getByRole('button', { name: BLOCK_EDITOR_SAVE_NAME, exact: true }).click();
|
||
}
|
||
|
||
async function openProject(page: Page) {
|
||
await page.getByText(PROJECT_NAME, { exact: false }).first().click();
|
||
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
|
||
}
|
||
|
||
async function ensureIntakeOpen(page: Page) {
|
||
if (shared.controlledDocId) {
|
||
await openWorkflowDocById(page, shared.controlledDocId);
|
||
} else {
|
||
const sel = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i);
|
||
shared.controlledDocId = sel.docId;
|
||
shared.controlledTitle = sel.title;
|
||
}
|
||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 60_000 });
|
||
}
|
||
|
||
test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the real dev stack');
|
||
|
||
test.describe.serial('full-system browser multi-user E2E', () => {
|
||
// Per-test wall must exceed waitForSquadCompletion (20m) + overhead; config default is 15m.
|
||
test.describe.configure({ timeout: 2_100_000 });
|
||
|
||
test.beforeAll(() => {
|
||
fs.writeFileSync(
|
||
RUN_META_FILE,
|
||
JSON.stringify(
|
||
{
|
||
startedAt: new Date().toISOString(),
|
||
baseURL: process.env.BASE_URL || 'http://127.0.0.1:8080',
|
||
display: process.env.DISPLAY || '',
|
||
headedHint: process.env.HEADED === '1',
|
||
projectName: PROJECT_NAME,
|
||
users: Object.fromEntries(
|
||
Object.entries(USERS).map(([k, v]) => [k, { username: v.username, display: v.display }]),
|
||
),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
'utf-8',
|
||
);
|
||
});
|
||
|
||
test.afterAll(() => {
|
||
fs.writeFileSync(
|
||
ACCOUNTS_FILE,
|
||
JSON.stringify(
|
||
{
|
||
note: 'Passwords intentionally omitted. Accounts retained after E2E by design.',
|
||
projectName: PROJECT_NAME,
|
||
usernames: Object.values(USERS).map((u) => u.username),
|
||
roles: Object.fromEntries(Object.entries(USERS).map(([k, v]) => [k, v.username])),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
'utf-8',
|
||
);
|
||
saveResults();
|
||
});
|
||
|
||
test('FS-IAM-01 admin creates multi-role users via UI', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'admin-iam';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
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 openAdminUsers(page);
|
||
await shot(page, 'FS-IAM-01-admin-users');
|
||
for (const key of ['author', 'reviewer', 'approver', 'member', 'guest'] as const) {
|
||
const u = USERS[key];
|
||
await adminCreateUser(page, u.username, u.display, INIT_PASS, u.roleId);
|
||
}
|
||
await page.getByRole('button', { name: '返回工作台' }).click();
|
||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 15_000 });
|
||
await shot(page, 'FS-IAM-01-done');
|
||
record({
|
||
id: 'FS-IAM-01',
|
||
name: 'admin creates multi-role users',
|
||
priority: 'P0',
|
||
input: 'admin console → 开通受控员工账号 ×5',
|
||
expected: 'author/reviewer/approver/member/guest created',
|
||
actual: Object.values(USERS)
|
||
.map((u) => u.username)
|
||
.join(','),
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-IAM-01-FAIL');
|
||
record({
|
||
id: 'FS-IAM-01',
|
||
name: 'admin creates multi-role users',
|
||
priority: 'P0',
|
||
input: 'admin create users',
|
||
expected: '5 users',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-IAM-02 first-login password change for all e2e users', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'iam-firstlogin';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
for (const key of ['author', 'reviewer', 'approver', 'member', 'guest'] as const) {
|
||
const u = USERS[key];
|
||
await page.goto('/');
|
||
await page.fill('input[placeholder*="系统用户名"]', u.username);
|
||
await page.fill('input[placeholder*="登录密码"]', INIT_PASS);
|
||
await page.click('button:has-text("进入医疗协同工作台")');
|
||
await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS[key]);
|
||
await logoutViaUI(page);
|
||
}
|
||
await shot(page, 'FS-IAM-02-done');
|
||
record({
|
||
id: 'FS-IAM-02',
|
||
name: 'first-login password change',
|
||
priority: 'P0',
|
||
input: 'each e2e26_* account first login',
|
||
expected: 'all reach workbench after reset',
|
||
actual: '5/5 password changes OK',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-IAM-02-FAIL');
|
||
record({
|
||
id: 'FS-IAM-02',
|
||
name: 'first-login password change',
|
||
priority: 'P0',
|
||
input: 'first login',
|
||
expected: 'password reset',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-PROJ-01 create project + add members with roles', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'proj-create';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await page.getByRole('button', { name: '新建立项会话' }).click();
|
||
const heading = page.getByText('发起医疗器械新项目立项', { exact: true });
|
||
await expect(heading).toBeVisible({ timeout: 30_000 });
|
||
const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]');
|
||
await modal.locator('input[placeholder*="无线便携式"]').fill(PROJECT_NAME);
|
||
const desc = modal.locator('textarea');
|
||
if ((await desc.count()) === 0) fail('project form missing description');
|
||
await desc.fill('全系统浏览器 E2E:骨科超声吸引止血,覆盖多人协作与审签链路。');
|
||
await toggleCreateProjectMember(modal, USERS.reviewer.display, 'reviewer');
|
||
await toggleCreateProjectMember(modal, USERS.approver.display, 'approver');
|
||
// Plain collaborator: create-modal has no "member" option — select (defaults viewer) then promote.
|
||
await toggleCreateProjectMember(modal, USERS.member.display);
|
||
const guestVisible = await modal.getByText(USERS.guest.display, { exact: true }).count();
|
||
if (guestVisible > 0) {
|
||
await toggleCreateProjectMember(modal, USERS.guest.display, 'viewer');
|
||
}
|
||
await shot(page, 'FS-PROJ-01-before-confirm');
|
||
await modal.getByRole('button', { name: /确认立项/ }).click();
|
||
await expect(heading).toBeHidden({ timeout: 15_000 });
|
||
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
|
||
try {
|
||
await promoteToProjectMember(page, USERS.member.display);
|
||
} catch (promoteErr) {
|
||
record({
|
||
id: 'FS-PROJ-01b',
|
||
name: 'promote member role',
|
||
priority: 'P0',
|
||
input: `promote ${USERS.member.display} to project member`,
|
||
expected: 'project_role=member',
|
||
actual: String(promoteErr),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
notes: 'create-modal 仅有 author/reviewer/approver/viewer;成员级可编辑需立项后改派',
|
||
});
|
||
throw promoteErr;
|
||
}
|
||
await shot(page, 'FS-PROJ-01-created');
|
||
record({
|
||
id: 'FS-PROJ-01',
|
||
name: 'create project + members',
|
||
priority: 'P0',
|
||
input: `project=${PROJECT_NAME}; reviewer/approver duties + member promote(+guest)`,
|
||
expected: 'banner shows project; member is project_role=member',
|
||
actual: `created; guestListed=${guestVisible > 0}; member promoted`,
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
if (guestVisible === 0) {
|
||
recordSkip('FS-IAM-GUEST-LIST', 'guest in project member list', 'P1', 'guest display not shown in create-project member picker');
|
||
}
|
||
} catch (e) {
|
||
await shot(page, 'FS-PROJ-01-FAIL');
|
||
record({
|
||
id: 'FS-PROJ-01',
|
||
name: 'create project + members',
|
||
priority: 'P0',
|
||
input: 'create project',
|
||
expected: 'project created',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-PROJ-02 activate workflow with author/reviewer/approver duties', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'proj-wf';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
await activateProjectWorkflow(page, {
|
||
author: USERS.author.display,
|
||
reviewer: USERS.reviewer.display,
|
||
approver: USERS.approver.display,
|
||
});
|
||
await shot(page, 'FS-PROJ-02-activated');
|
||
record({
|
||
id: 'FS-PROJ-02',
|
||
name: 'activate workflow duties',
|
||
priority: 'P0',
|
||
input: '配置工作流 → 纳入审批 → 三权指派 → 确认激活',
|
||
expected: '运行中',
|
||
actual: 'workflow activated',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-PROJ-02-FAIL');
|
||
record({
|
||
id: 'FS-PROJ-02',
|
||
name: 'activate workflow duties',
|
||
priority: 'P0',
|
||
input: 'activate workflow',
|
||
expected: '运行中',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-CHAT-01 casual multi-turn dialogue', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'chat-casual';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
const prompt = 'E2E闲聊:请用一句话确认项目通道可用,不要生成受控文档。';
|
||
await sendChat(page, prompt);
|
||
await waitForAssistantReply(page, 'E2E闲聊', 180_000);
|
||
await shot(page, 'FS-CHAT-01-reply');
|
||
record({
|
||
id: 'FS-CHAT-01',
|
||
name: 'casual dialogue',
|
||
priority: 'P0',
|
||
input: prompt,
|
||
expected: 'assistant reply visible',
|
||
actual: 'assistant bubble non-empty',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-CHAT-01-FAIL');
|
||
record({
|
||
id: 'FS-CHAT-01',
|
||
name: 'casual dialogue',
|
||
priority: 'P0',
|
||
input: 'casual chat',
|
||
expected: 'reply',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
notes: '可能无响应或连接异常——见截图与 console',
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-SQUAD-WF-01 generate workflow intake document (real PI)', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'squad-wf';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
const prompt = '请编制用户初始需求收集表';
|
||
await sendChat(page, prompt);
|
||
const elapsed = await waitForSquadCompletion(page, '请编制用户初始需求收集表', 1_200_000);
|
||
const selected = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i);
|
||
shared.controlledDocId = selected.docId;
|
||
shared.controlledTitle = selected.title;
|
||
if (!/^doc_proj\d+_/.test(shared.controlledDocId)) {
|
||
fail(`expected doc_proj* intake, got ${shared.controlledDocId}`);
|
||
}
|
||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 60_000 });
|
||
await shot(page, 'FS-SQUAD-WF-01-doc');
|
||
record({
|
||
id: 'FS-SQUAD-WF-01',
|
||
name: 'squad workflow doc generation',
|
||
priority: 'P0',
|
||
input: prompt,
|
||
expected: 'squad terminal + server doc_proj intake with V/r',
|
||
actual: `docId=${shared.controlledDocId}; title=${shared.controlledTitle}; squadMs=${elapsed}`,
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-SQUAD-WF-01-FAIL');
|
||
record({
|
||
id: 'FS-SQUAD-WF-01',
|
||
name: 'squad workflow doc generation',
|
||
priority: 'P0',
|
||
input: '请编制用户初始需求收集表',
|
||
expected: 'PI squad completes',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
notes: '真实 PI 超时/失败记 FAIL,不伪装 PASS',
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-DOC-REV-01 author edit controlled doc', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'doc-edit';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
if (!shared.controlledDocId) fail('requires FS-SQUAD-WF-01');
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
await ensureAuthorClaimedForEdit(page);
|
||
const marker = `E2E修订-${suffix}`;
|
||
await editFirstPaneBlockAndSave(page, `人工修订段落 ${marker} 用于审签链路。`);
|
||
await expect(page.getByText(marker)).toBeVisible({ timeout: 30_000 });
|
||
await shot(page, 'FS-DOC-REV-01-edited');
|
||
record({
|
||
id: 'FS-DOC-REV-01',
|
||
name: 'author edit',
|
||
priority: 'P0',
|
||
input: `edit block with ${marker}`,
|
||
expected: 'marker persisted in pane',
|
||
actual: `edited ${shared.controlledDocId}`,
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-DOC-REV-01-FAIL');
|
||
record({
|
||
id: 'FS-DOC-REV-01',
|
||
name: 'author edit',
|
||
priority: 'P0',
|
||
input: 'edit',
|
||
expected: 'save ok',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
// NONAUTH while intake is still drafting (before submit/approve chain).
|
||
test('FS-NONAUTH-01 non-author member can edit after claim', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'nonauth-edit';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.member.username, FINAL_PASS.member);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
const claim = page.getByRole('button', { name: /认领此文档/ });
|
||
if (await claim.isVisible().catch(() => false)) {
|
||
await claim.click();
|
||
await expect(claim).toHaveCount(0, { timeout: 15_000 });
|
||
}
|
||
const editHint = page.getByText(/双击任意段落就地编辑/);
|
||
if (!(await editHint.isVisible({ timeout: 10_000 }).catch(() => false))) {
|
||
record({
|
||
id: 'FS-NONAUTH-01',
|
||
name: 'non-author edit',
|
||
priority: 'P0',
|
||
input: 'member open intake',
|
||
expected: 'can claim/edit when drafting',
|
||
actual: 'edit affordance absent',
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
notes: 'drafting 态应允许成员认领编辑(deny_only_submit)',
|
||
});
|
||
fail('non-author member missing edit affordance while drafting');
|
||
}
|
||
const marker = `E2E成员编-${suffix}`;
|
||
await editFirstPaneBlockAndSave(page, `成员可编辑段落 ${marker}`);
|
||
await expect(page.getByText(marker)).toBeVisible({ timeout: 30_000 });
|
||
await shot(page, 'FS-NONAUTH-01-edited');
|
||
record({
|
||
id: 'FS-NONAUTH-01',
|
||
name: 'non-author edit',
|
||
priority: 'P0',
|
||
input: `member edit ${marker}`,
|
||
expected: 'save succeeds',
|
||
actual: 'member edit OK',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-NONAUTH-01-FAIL');
|
||
if (!recorded['FS-NONAUTH-01']) {
|
||
record({
|
||
id: 'FS-NONAUTH-01',
|
||
name: 'non-author edit',
|
||
priority: 'P0',
|
||
input: 'member edit',
|
||
expected: 'edit ok',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
}
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-NONAUTH-02 non-author submit gated (duty)', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'nonauth-submit';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.member.username, FINAL_PASS.member);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
const submit = page.getByRole('button', { name: /提交送审/ });
|
||
const waiting = page.getByText(/等待编制负责人/);
|
||
const submitCount = await submit.count();
|
||
const waitingVisible = await waiting.isVisible().catch(() => false);
|
||
await shot(page, 'FS-NONAUTH-02-gate');
|
||
if (submitCount === 0 || waitingVisible) {
|
||
record({
|
||
id: 'FS-NONAUTH-02',
|
||
name: 'non-author submit gate',
|
||
priority: 'P0',
|
||
input: 'member views gov action bar',
|
||
expected: 'no submit OR waiting-author hint',
|
||
actual: `submitCount=${submitCount}; waiting=${waitingVisible}`,
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
return;
|
||
}
|
||
await submit.first().click();
|
||
const denied = page.getByText(/无权限|职责|DOC_DUTY|禁止|仅编制/);
|
||
await expect(denied.first()).toBeVisible({ timeout: 20_000 });
|
||
record({
|
||
id: 'FS-NONAUTH-02',
|
||
name: 'non-author submit gate',
|
||
priority: 'P0',
|
||
input: 'member click submit',
|
||
expected: 'denied',
|
||
actual: 'denied UI shown',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-NONAUTH-02-FAIL');
|
||
record({
|
||
id: 'FS-NONAUTH-02',
|
||
name: 'non-author submit gate',
|
||
priority: 'P0',
|
||
input: 'submit gate',
|
||
expected: 'denied',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-DOC-REV-02 author submit for review', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'doc-submit';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
const submit = page.getByRole('button', { name: /提交送审/ });
|
||
await expect(submit).toBeVisible({ timeout: 30_000 });
|
||
await submit.click();
|
||
await expect(page.getByRole('button', { name: /提交送审/ })).toHaveCount(0, { timeout: 30_000 });
|
||
await shot(page, 'FS-DOC-REV-02-submitted');
|
||
record({
|
||
id: 'FS-DOC-REV-02',
|
||
name: 'submit for review',
|
||
priority: 'P0',
|
||
input: 'click 提交送审',
|
||
expected: 'submit button disappears (reviewing)',
|
||
actual: 'submitted',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-DOC-REV-02-FAIL');
|
||
record({
|
||
id: 'FS-DOC-REV-02',
|
||
name: 'submit for review',
|
||
priority: 'P0',
|
||
input: 'submit',
|
||
expected: 'reviewing',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-DOC-REV-03 reviewer rejects with reason', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'doc-reject';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.reviewer.username, FINAL_PASS.reviewer);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
const rejectBtn = page.getByRole('button', { name: /驳回修改/ });
|
||
await expect(rejectBtn).toBeVisible({ timeout: 30_000 });
|
||
await rejectBtn.click();
|
||
const reason = page.getByPlaceholder(/说明驳回修改的具体意见/);
|
||
await expect(reason).toBeVisible({ timeout: 15_000 });
|
||
await reason.fill('E2E驳回:缺少电气安全章节条款引用,退回编制补充。');
|
||
await page.getByRole('button', { name: /确认驳回退回编制/ }).click();
|
||
await expect(page.getByRole('button', { name: /驳回修改/ })).toHaveCount(0, { timeout: 30_000 });
|
||
await shot(page, 'FS-DOC-REV-03-rejected');
|
||
record({
|
||
id: 'FS-DOC-REV-03',
|
||
name: 'reviewer reject',
|
||
priority: 'P0',
|
||
input: '驳回修改 + 法定原因',
|
||
expected: 'back to drafting',
|
||
actual: 'reject confirmed',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-DOC-REV-03-FAIL');
|
||
record({
|
||
id: 'FS-DOC-REV-03',
|
||
name: 'reviewer reject',
|
||
priority: 'P0',
|
||
input: 'reject',
|
||
expected: 'drafting',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-DOC-REV-04 author fix and resubmit', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'doc-resubmit';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
await ensureAuthorClaimedForEdit(page);
|
||
const marker = `E2E再送-${suffix}`;
|
||
await editFirstPaneBlockAndSave(page, `补充电气安全条款后的修订 ${marker}`);
|
||
const submit = page.getByRole('button', { name: /提交送审/ });
|
||
await expect(submit).toBeVisible({ timeout: 30_000 });
|
||
await submit.click();
|
||
await expect(page.getByRole('button', { name: /提交送审/ })).toHaveCount(0, { timeout: 30_000 });
|
||
await shot(page, 'FS-DOC-REV-04-resubmitted');
|
||
record({
|
||
id: 'FS-DOC-REV-04',
|
||
name: 'fix and resubmit',
|
||
priority: 'P0',
|
||
input: `edit ${marker} + 提交送审`,
|
||
expected: 'back to reviewing',
|
||
actual: 'resubmitted',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-DOC-REV-04-FAIL');
|
||
record({
|
||
id: 'FS-DOC-REV-04',
|
||
name: 'fix and resubmit',
|
||
priority: 'P0',
|
||
input: 'resubmit',
|
||
expected: 'reviewing',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-DOC-REV-05 reviewer pass', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'doc-pass';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.reviewer.username, FINAL_PASS.reviewer);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
const pass = page.getByRole('button', { name: /审核通过/ });
|
||
await expect(pass).toBeVisible({ timeout: 30_000 });
|
||
await pass.click();
|
||
await expect(page.getByRole('button', { name: /审核通过/ })).toHaveCount(0, { timeout: 30_000 });
|
||
await shot(page, 'FS-DOC-REV-05-passed');
|
||
record({
|
||
id: 'FS-DOC-REV-05',
|
||
name: 'reviewer pass',
|
||
priority: 'P0',
|
||
input: '审核通过',
|
||
expected: 'awaiting approve',
|
||
actual: 'passed review',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-DOC-REV-05-FAIL');
|
||
record({
|
||
id: 'FS-DOC-REV-05',
|
||
name: 'reviewer pass',
|
||
priority: 'P0',
|
||
input: 'pass',
|
||
expected: 'approving',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-DOC-REV-06 approver final approve', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'doc-approve';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.approver.username, FINAL_PASS.approver);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
const approve = page.getByRole('button', { name: /终审批准/ });
|
||
await expect(approve).toBeVisible({ timeout: 30_000 });
|
||
await approve.click();
|
||
await expect(page.getByRole('button', { name: /终审批准/ })).toHaveCount(0, { timeout: 60_000 });
|
||
await shot(page, 'FS-DOC-REV-06-approved');
|
||
record({
|
||
id: 'FS-DOC-REV-06',
|
||
name: 'approver approve',
|
||
priority: 'P0',
|
||
input: '终审批准',
|
||
expected: 'approved / controlled',
|
||
actual: 'approved',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-DOC-REV-06-FAIL');
|
||
record({
|
||
id: 'FS-DOC-REV-06',
|
||
name: 'approver approve',
|
||
priority: 'P0',
|
||
input: 'approve',
|
||
expected: 'approved',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-DOC-REV-07 cascade re-review if UI reachable', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'doc-cascade';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
// After approve, body edit may be locked or trigger re-review. Probe UI affordances.
|
||
const claim = page.getByRole('button', { name: /认领此文档/ });
|
||
const editHint = page.getByText(/双击任意段落就地编辑/);
|
||
const reOpen = page.getByRole('button', { name: /重新打开|启动变更|发起变更/ });
|
||
const canClaim = await claim.isVisible().catch(() => false);
|
||
const canEdit = await editHint.isVisible().catch(() => false);
|
||
const canReopen = await reOpen.isVisible().catch(() => false);
|
||
await shot(page, 'FS-DOC-REV-07-probe');
|
||
if (!canClaim && !canEdit && !canReopen) {
|
||
record({
|
||
id: 'FS-DOC-REV-07',
|
||
name: 'cascade re-review',
|
||
priority: 'P0',
|
||
input: 'probe post-approve edit/reopen',
|
||
expected: 're-review path if UI exposes',
|
||
actual: 'no reopen/edit affordance after approve',
|
||
verdict: 'SKIP',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
skip_reason: '批准后 UI 未暴露可触发级联重审的入口(本轮可达性不足)',
|
||
});
|
||
return;
|
||
}
|
||
if (canClaim) await claim.click();
|
||
if (canReopen) await reOpen.click();
|
||
if (canEdit || (await editHint.isVisible().catch(() => false))) {
|
||
await editFirstPaneBlockAndSave(page, `级联重审探测修订 ${suffix}`);
|
||
const submit = page.getByRole('button', { name: /提交送审/ });
|
||
if (await submit.isVisible().catch(() => false)) {
|
||
await submit.click();
|
||
await expect(submit).toHaveCount(0, { timeout: 30_000 });
|
||
}
|
||
}
|
||
await shot(page, 'FS-DOC-REV-07-done');
|
||
record({
|
||
id: 'FS-DOC-REV-07',
|
||
name: 'cascade re-review',
|
||
priority: 'P0',
|
||
input: `claim=${canClaim} edit=${canEdit} reopen=${canReopen}`,
|
||
expected: 're-enter review path',
|
||
actual: 'cascade path exercised',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-DOC-REV-07-FAIL');
|
||
record({
|
||
id: 'FS-DOC-REV-07',
|
||
name: 'cascade re-review',
|
||
priority: 'P0',
|
||
input: 'cascade',
|
||
expected: 're-review or SKIP',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-ASSIST-01 assistant-style task prompt', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'assist';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
const prompt =
|
||
'请作为项目助手,用条目列出本项目下一步 3 个建议(不要启动小队工作流文档生成)。E2E助手任务标记。';
|
||
await sendChat(page, prompt);
|
||
await waitForAssistantReply(page, 'E2E助手任务标记', 300_000);
|
||
await shot(page, 'FS-ASSIST-01-reply');
|
||
record({
|
||
id: 'FS-ASSIST-01',
|
||
name: 'assistant task',
|
||
priority: 'P1',
|
||
input: prompt,
|
||
expected: 'assistant reply without requiring squad doc',
|
||
actual: 'assistant reply visible',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-ASSIST-01-FAIL');
|
||
record({
|
||
id: 'FS-ASSIST-01',
|
||
name: 'assistant task',
|
||
priority: 'P1',
|
||
input: 'assistant prompt',
|
||
expected: 'reply',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
// P1: do not hard-fail entire suite serial chain — rethrow still stops serial; record FAIL and continue by not throwing?
|
||
// User asked FAIL not fake PASS; serial describe will stop. Rethrow.
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-SQUAD-FREE-01 complex free / adhoc task', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'squad-free';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
const prompt =
|
||
'帮我做一份掌上超声与进口竞品的横向对标分析报告,覆盖法规注册、工程架构与商务定价三个视角,要详尽成体系。E2E自由任务标记。';
|
||
await sendChat(page, prompt);
|
||
// May route to squad adhoc or upgrade card — wait for terminal or upgrade UI.
|
||
const upgrade = page.getByText(/自由任务|升级|特设小队/);
|
||
const userVisible = page.locator('[data-chat-role="user"]').filter({ hasText: 'E2E自由任务标记' });
|
||
await expect(userVisible).toBeVisible({ timeout: 20_000 });
|
||
try {
|
||
await waitForSquadCompletion(page, 'E2E自由任务标记', 1_200_000);
|
||
} catch {
|
||
// Accept upgrade/confirm card as observed free-task routing.
|
||
await expect(upgrade.first()).toBeVisible({ timeout: 60_000 });
|
||
}
|
||
await shot(page, 'FS-SQUAD-FREE-01');
|
||
record({
|
||
id: 'FS-SQUAD-FREE-01',
|
||
name: 'squad free/adhoc task',
|
||
priority: 'P1',
|
||
input: prompt,
|
||
expected: 'squad terminal or free-task upgrade UI',
|
||
actual: 'free-task path observed',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-SQUAD-FREE-01-FAIL');
|
||
record({
|
||
id: 'FS-SQUAD-FREE-01',
|
||
name: 'squad free/adhoc task',
|
||
priority: 'P1',
|
||
input: 'free task',
|
||
expected: 'route/complete',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-UPLOAD-01 upload attachment → freeform register', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'upload';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
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, `E2E upload body ${suffix}\n临床需求摘要占位\n`, 'utf-8');
|
||
await fileInput.first().setInputFiles(tmp);
|
||
await expect(page.getByText(uploadName, { exact: false })).toBeVisible({ timeout: 15_000 });
|
||
const archivePrompt = '请将此附件归档为我的私密参考文档。E2E上传标记。';
|
||
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 },
|
||
);
|
||
await sendChat(page, archivePrompt);
|
||
const freeformRes = await freeformWait;
|
||
if (freeformRes.status() < 200 || freeformRes.status() >= 300) {
|
||
fail(`freeform POST HTTP ${freeformRes.status()}`);
|
||
}
|
||
const pane = page.getByTestId('active-document-pane');
|
||
await expect(pane).toHaveAttribute('data-doc-id', /^doc-free-/, { timeout: 45_000 });
|
||
shared.uploadedDocId = (await pane.getAttribute('data-doc-id')) || '';
|
||
await waitForSquadCompletion(page, 'E2E上传标记', 180_000).catch(() => undefined);
|
||
await shot(page, 'FS-UPLOAD-01');
|
||
record({
|
||
id: 'FS-UPLOAD-01',
|
||
name: 'upload + freeform register',
|
||
priority: 'P1',
|
||
input: uploadName,
|
||
expected: 'doc-free-* pane',
|
||
actual: `uploadedDocId=${shared.uploadedDocId}`,
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-UPLOAD-01-FAIL');
|
||
record({
|
||
id: 'FS-UPLOAD-01',
|
||
name: 'upload + freeform',
|
||
priority: 'P1',
|
||
input: 'upload',
|
||
expected: 'doc-free',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-KB-01 admin KB console readonly check', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'kb-admin';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
const admin = requireAdminCreds();
|
||
await login(page, admin.username, admin.password);
|
||
await page.locator('button[title*="系统管理与准入治理"]').click();
|
||
await expect(page.getByRole('button', { name: '知识库连接' })).toBeVisible({ timeout: 15_000 });
|
||
await page.getByRole('button', { name: '知识库连接' }).click();
|
||
await shot(page, 'FS-KB-01');
|
||
const body = await page.locator('body').innerText();
|
||
if (!/知识库|KB|连接|就绪|向量/i.test(body)) {
|
||
fail('KB section body missing expected keywords');
|
||
}
|
||
record({
|
||
id: 'FS-KB-01',
|
||
name: 'KB admin readonly',
|
||
priority: 'P1',
|
||
input: 'admin → 知识库连接',
|
||
expected: 'section renders with KB status cues',
|
||
actual: 'KB section visible',
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-KB-01-FAIL');
|
||
record({
|
||
id: 'FS-KB-01',
|
||
name: 'KB admin readonly',
|
||
priority: 'P1',
|
||
input: 'KB',
|
||
expected: 'visible',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-CTX-01 context injection observation', async ({ browser }) => {
|
||
const t0 = Date.now();
|
||
const label = 'ctx';
|
||
const { ctx, page } = await newRolePage(browser, label);
|
||
try {
|
||
await login(page, USERS.author.username, FINAL_PASS.author);
|
||
await openProject(page);
|
||
await ensureIntakeOpen(page);
|
||
const conflict = page.getByText(/冲突|上下文|已注入|工作区|workspace|dialogue/i);
|
||
const ctxChip = page.locator('[data-testid*="context"], [data-testid*="ctx"]');
|
||
const seenConflict = await conflict.first().isVisible().catch(() => false);
|
||
const seenChip = (await ctxChip.count()) > 0;
|
||
await shot(page, 'FS-CTX-01');
|
||
// Observation case: PASS if any UI cue; else SKIP with note to check workspace disk offline.
|
||
if (!seenConflict && !seenChip) {
|
||
record({
|
||
id: 'FS-CTX-01',
|
||
name: 'context injection observation',
|
||
priority: 'P1',
|
||
input: 'open doc + scan UI for context cues',
|
||
expected: 'conflict card or context chip OR disk evidence',
|
||
actual: 'no obvious UI cue in viewport',
|
||
verdict: 'SKIP',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
skip_reason: 'UI 未显示冲突卡/上下文 chip;需人工核对 data/dev workspace context/dialogue 落盘',
|
||
});
|
||
return;
|
||
}
|
||
record({
|
||
id: 'FS-CTX-01',
|
||
name: 'context injection observation',
|
||
priority: 'P1',
|
||
input: 'observe UI',
|
||
expected: 'context cue',
|
||
actual: `conflict=${seenConflict}; chip=${seenChip}`,
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
} catch (e) {
|
||
await shot(page, 'FS-CTX-01-FAIL');
|
||
record({
|
||
id: 'FS-CTX-01',
|
||
name: 'context observation',
|
||
priority: 'P1',
|
||
input: 'ctx',
|
||
expected: 'observe',
|
||
actual: String(e),
|
||
verdict: 'FAIL',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: consoleErrorsFor(label),
|
||
});
|
||
throw e;
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
|
||
test('FS-CONSOLE-01 aggregate console errors across roles', async () => {
|
||
const t0 = Date.now();
|
||
const all: string[] = [];
|
||
for (const [label, buf] of consoleBuffers.entries()) {
|
||
for (const row of buf) {
|
||
if (row.level === 'error' || row.level === 'pageerror') {
|
||
all.push(`${label}: [${row.level}] ${row.text.slice(0, 200)}`);
|
||
}
|
||
}
|
||
}
|
||
const summaryPath = path.join(ASSETS, 'console-errors-summary.json');
|
||
fs.writeFileSync(summaryPath, JSON.stringify({ count: all.length, errors: all }, null, 2), 'utf-8');
|
||
// Console noise alone is not FAIL unless paired with silent UI hang (recorded in notes elsewhere).
|
||
record({
|
||
id: 'FS-CONSOLE-01',
|
||
name: 'console error aggregate',
|
||
priority: 'P2',
|
||
input: 'all page console/pageerror buffers',
|
||
expected: 'summary written; severe silent failures already FAIL in cases',
|
||
actual: `errorCount=${all.length}; summary=${summaryPath}`,
|
||
verdict: 'PASS',
|
||
durationMs: Date.now() - t0,
|
||
console_errors: all.slice(0, 50),
|
||
notes: all.length ? '存在 console error/pageerror,详见 console-*.jsonl' : '无 error/pageerror',
|
||
});
|
||
});
|
||
});
|