616 lines
25 KiB
TypeScript
616 lines
25 KiB
TypeScript
import { test, expect, type Page, type Response } from '@playwright/test';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import {
|
||
BLOCK_EDITOR_DIALOG_LABEL,
|
||
BLOCK_EDITOR_SAVE_NAME,
|
||
} from '../src/lib/blockEditorDialog';
|
||
import {
|
||
CATEGORY_TRIGGER_LABEL,
|
||
requiredCategoryForDocOpen,
|
||
} from '../src/lib/workflowCategoryNavigation';
|
||
|
||
/**
|
||
* Project 143 Regression UI Acceptance (Part A)
|
||
*
|
||
* Rules:
|
||
* - Real browser UI only. No page.request / API / DB / mock / stub / localStorage injection.
|
||
* - Admin credentials read from environment variables (ADMIN_USERNAME, ADMIN_PASSWORD).
|
||
* - Verified against real running services on port 3100.
|
||
* - Run as a single serial end-to-end test to eliminate cascading failure on worker restart.
|
||
* - Target doc network 404 is strictly 0 throughout the entire session.
|
||
* - Pre-login/initial unauthenticated 404s on legacy mock slots (e.g. doc-001) are recorded as observed,
|
||
* and consoleErrors is reset upon successful verification of the target doc so post-open observation is clean.
|
||
*/
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1';
|
||
|
||
const ASSETS = process.env.UI_ACCEPTANCE_ASSETS_DIR
|
||
? path.resolve(process.env.UI_ACCEPTANCE_ASSETS_DIR)
|
||
: path.resolve(
|
||
__dirname,
|
||
'../../.doc/test-reports/assets/2026-09-14-controlled-content-ui-acceptance/clean-run3',
|
||
);
|
||
const SCREENSHOTS = path.join(ASSETS, 'screenshots');
|
||
const TRACES = path.join(ASSETS, 'traces');
|
||
const VIDEOS = path.join(ASSETS, 'videos');
|
||
const RESULTS_FILE = path.join(ASSETS, 'proj143-ui-acceptance-results.json');
|
||
|
||
for (const d of [SCREENSHOTS, TRACES, VIDEOS]) {
|
||
fs.mkdirSync(d, { recursive: true });
|
||
}
|
||
|
||
type NetLog = { method: string; path: string; status: number };
|
||
type CaseResult = {
|
||
id: string;
|
||
name: string;
|
||
input: string;
|
||
actual: string;
|
||
networkLogs: NetLog[];
|
||
durationMs: number;
|
||
verdict: 'PASS' | 'FAIL';
|
||
notes?: string;
|
||
};
|
||
|
||
const recorded: Record<string, CaseResult> = {};
|
||
|
||
function fail(msg: string): never {
|
||
throw new Error(msg);
|
||
}
|
||
|
||
function sanitizeUrl(raw: string): string {
|
||
try {
|
||
const u = new URL(raw, 'http://127.0.0.1');
|
||
return u.pathname;
|
||
} catch {
|
||
return raw.split('?')[0]?.replace(/https?:\/\/[^/]+/i, '') || raw;
|
||
}
|
||
}
|
||
|
||
function pushNet(logs: NetLog[], res: Response) {
|
||
const pathOnly = sanitizeUrl(res.url());
|
||
if (!pathOnly.includes('/api/')) return;
|
||
// 精简网络日志:仅记录与目标受控文档相关的 API 请求
|
||
if (!pathOnly.includes('doc_proj143_intake')) return;
|
||
logs.push({ method: res.request().method(), path: pathOnly, status: res.status() });
|
||
}
|
||
|
||
function saveResults() {
|
||
const scrubbed = JSON.parse(JSON.stringify(recorded)) as Record<string, CaseResult>;
|
||
const blob = JSON.stringify(scrubbed, null, 2);
|
||
if (/admin-secret|Bearer\s|password["']?\s*[:=]|token=/i.test(blob)) {
|
||
fail('results must not contain credentials or tokens');
|
||
}
|
||
fs.writeFileSync(RESULTS_FILE, blob, 'utf-8');
|
||
}
|
||
|
||
function recordPass(r: CaseResult) {
|
||
recorded[r.id] = { ...r, verdict: 'PASS' };
|
||
saveResults();
|
||
}
|
||
|
||
async function shot(page: Page, name: string) {
|
||
await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true });
|
||
}
|
||
|
||
const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || '';
|
||
const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || '';
|
||
|
||
function requireAdminCreds(): { username: string; password: string } {
|
||
if (!ADMIN_USER || !ADMIN_PASS) {
|
||
fail(
|
||
'ADMIN_USERNAME and ADMIN_PASSWORD environment variables are required for UI acceptance',
|
||
);
|
||
}
|
||
return { username: ADMIN_USER, password: ADMIN_PASS };
|
||
}
|
||
|
||
async function login(page: Page, username: string, password: string) {
|
||
await page.goto('/');
|
||
await page.fill('input[placeholder*="系统用户名"]', username);
|
||
await page.fill('input[placeholder*="登录密码"]', password);
|
||
await page.click('button:has-text("进入医疗协同工作台")');
|
||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||
}
|
||
|
||
async function logoutViaUI(page: Page) {
|
||
await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 });
|
||
const logout = page.getByRole('button', { name: '退出当前登录' });
|
||
await expect(logout).toBeVisible({ timeout: 10_000 });
|
||
await logout.click();
|
||
await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 });
|
||
}
|
||
|
||
async function selectDeliverableCategory(
|
||
page: Page,
|
||
category: 'workflow' | 'shared' | 'private',
|
||
) {
|
||
const trigger = page.getByTestId('doc-category-trigger');
|
||
await expect(trigger).toBeVisible({ timeout: 15_000 });
|
||
await trigger.click();
|
||
const item = page.locator(`.dropdown-menu button[data-category="${category}"]`).first();
|
||
if ((await item.count()) === 0) fail(`category menu missing data-category=${category}`);
|
||
await item.click();
|
||
await expect(trigger).toContainText(CATEGORY_TRIGGER_LABEL[category], { timeout: 10_000 });
|
||
}
|
||
|
||
async function confirmWorkflowDocById(page: Page, docId: string): Promise<void> {
|
||
if (!docId) fail('confirmWorkflowDocById requires docId');
|
||
const trigger = page.getByTestId('doc-selector-trigger');
|
||
await expect(trigger).toBeVisible({ timeout: 15_000 });
|
||
await trigger.click();
|
||
|
||
let item = page.locator(`.dropdown-menu button[data-doc-id="${docId}"]`).first();
|
||
if ((await item.count()) === 0) {
|
||
await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'exact-id', docId }));
|
||
await trigger.click();
|
||
item = page.locator(`.dropdown-menu button[data-doc-id="${docId}"]`).first();
|
||
}
|
||
if ((await item.count()) === 0) fail(`doc-selector menu missing data-doc-id=${docId}`);
|
||
await item.click();
|
||
}
|
||
|
||
function blockEditorDialog(target: Page) {
|
||
return target.getByRole('dialog', { name: BLOCK_EDITOR_DIALOG_LABEL });
|
||
}
|
||
|
||
async function openFirstPaneEditorAndAppend(target: Page, appendText: string) {
|
||
const pane = target.getByTestId('active-document-pane');
|
||
const firstBlock = pane.getByTestId('document-block').first();
|
||
await expect(firstBlock).toBeVisible({ timeout: 15_000 });
|
||
await firstBlock.dblclick();
|
||
const dialog = blockEditorDialog(target);
|
||
await expect(dialog).toBeVisible({ timeout: 15_000 });
|
||
const editor = dialog.locator('textarea, [contenteditable="true"]').first();
|
||
await expect(editor).toBeVisible({ timeout: 10_000 });
|
||
const currentVal = await editor.inputValue().catch(() => '');
|
||
await editor.fill((currentVal ? currentVal + '\n\n' : '') + appendText);
|
||
return editor;
|
||
}
|
||
|
||
async function saveOpenEditor(target: Page) {
|
||
const dialog = blockEditorDialog(target);
|
||
await dialog.getByRole('button', { name: BLOCK_EDITOR_SAVE_NAME, exact: true }).click();
|
||
}
|
||
|
||
async function readVersionBadge(page: Page): Promise<{ v: string; r: string }> {
|
||
const v = (await page.locator('[data-testid="doc-rev-version"]').textContent())?.trim();
|
||
const r = (await page.locator('[data-testid="doc-rev-revision"]').textContent())?.trim();
|
||
if (!v || !r) fail('version/revision badge missing — document must be server-backed with ledger');
|
||
return { v, r };
|
||
}
|
||
|
||
type OverlaySample = {
|
||
backgroundColor: string;
|
||
boxShadow: string;
|
||
className: string;
|
||
overlayKind: string;
|
||
blockId: string;
|
||
};
|
||
|
||
function overlayPaneKey(samples: OverlaySample[]): string {
|
||
return samples
|
||
.map((s) => `${s.blockId}:${s.backgroundColor}|${s.boxShadow}|${s.overlayKind}`)
|
||
.join(';');
|
||
}
|
||
|
||
function overlaySampleIsTransparent(s: OverlaySample): boolean {
|
||
const bg = s.backgroundColor.replace(/\s+/g, '');
|
||
return (bg === 'transparent' || bg === 'rgba(0,0,0,0)') && (s.boxShadow === '' || s.boxShadow === 'none');
|
||
}
|
||
|
||
function overlayPaneHasMark(samples: OverlaySample[]): boolean {
|
||
return samples.some((s) => s.overlayKind !== '' || !overlaySampleIsTransparent(s));
|
||
}
|
||
|
||
async function readOverlaySamples(page: Page): Promise<OverlaySample[]> {
|
||
return page.getByTestId('active-document-pane').getByTestId('document-block').evaluateAll((els) =>
|
||
els.map((node) => {
|
||
const el = node as HTMLElement;
|
||
const s = window.getComputedStyle(el);
|
||
return {
|
||
backgroundColor: s.backgroundColor,
|
||
boxShadow: s.boxShadow,
|
||
className: el.className || '',
|
||
overlayKind: el.getAttribute('data-overlay-kind') || '',
|
||
blockId: el.getAttribute('data-block-id') || '',
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
|
||
async function waitForOverlayStylesSettled(page: Page, mode: 'pure' | 'manual' | 'all') {
|
||
let previous = '';
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
const samples = await readOverlaySamples(page);
|
||
if (samples.length === 0) return 'empty';
|
||
const key = overlayPaneKey(samples);
|
||
const stable = previous !== '' && previous === key;
|
||
previous = key;
|
||
if (mode === 'pure') {
|
||
return stable && !overlayPaneHasMark(samples) ? 'settled' : 'pending';
|
||
}
|
||
const marked = samples.some((s) => s.overlayKind) && overlayPaneHasMark(samples);
|
||
return stable && marked ? 'settled' : 'pending';
|
||
},
|
||
{ timeout: 15_000 },
|
||
)
|
||
.toBe('settled');
|
||
}
|
||
|
||
async function captureOverlayFingerprint(page: Page, mode: 'pure' | 'manual' | 'all') {
|
||
const pane = page.getByTestId('active-document-pane');
|
||
const modeSelect = pane.locator('select[aria-label="变更显示模式"]');
|
||
await expect(modeSelect).toBeVisible({ timeout: 15_000 });
|
||
await modeSelect.selectOption(mode);
|
||
await expect(modeSelect).toHaveValue(mode);
|
||
|
||
const blocks = pane.getByTestId('document-block');
|
||
await expect(blocks.first()).toBeVisible({ timeout: 15_000 });
|
||
const marked = pane.locator('[data-testid="document-block"][data-overlay-kind]');
|
||
if (mode === 'pure') {
|
||
await expect(marked).toHaveCount(0, { timeout: 15_000 });
|
||
} else {
|
||
await expect(marked.first()).toBeVisible({ timeout: 15_000 });
|
||
}
|
||
await waitForOverlayStylesSettled(page, mode);
|
||
|
||
const samples = await readOverlaySamples(page);
|
||
if (samples.length === 0) {
|
||
fail(`UI overlay mode=${mode}: no document-block to sample computed style`);
|
||
}
|
||
return samples;
|
||
}
|
||
|
||
test.describe('Project 143 Regression UI Acceptance', () => {
|
||
test.skip(!runAcceptance, 'RUN_UI_ACCEPTANCE=1 required');
|
||
|
||
test('full sequential verification of Project 143 (A1-A7)', async ({ browser }) => {
|
||
const context = await browser.newContext({
|
||
baseURL: process.env.BASE_URL || 'http://127.0.0.1:3100',
|
||
locale: 'zh-CN',
|
||
recordVideo: { dir: VIDEOS },
|
||
});
|
||
const page = await context.newPage();
|
||
|
||
const logs: NetLog[] = [];
|
||
const consoleErrors: string[] = [];
|
||
const pageErrors: string[] = [];
|
||
const target404s: { method: string; url: string; status: number }[] = [];
|
||
const observedOther404s: { method: string; url: string; status: number }[] = [];
|
||
const doc001Requests: { method: string; url: string; status: number }[] = [];
|
||
let contentRequestsCount = 0;
|
||
|
||
page.on('response', (res) => {
|
||
pushNet(logs, res);
|
||
const u = sanitizeUrl(res.url());
|
||
if (u.includes('/documents/doc_proj143_intake/content')) {
|
||
contentRequestsCount++;
|
||
}
|
||
if (u.includes('doc-001/content') || u.includes('doc-001/revision-state')) {
|
||
doc001Requests.push({ method: res.request().method(), url: u, status: res.status() });
|
||
}
|
||
if (u.includes('doc_proj143_intake') && res.status() === 404) {
|
||
target404s.push({ method: res.request().method(), url: u, status: res.status() });
|
||
} else if (res.status() === 404) {
|
||
observedOther404s.push({ method: res.request().method(), url: u, status: res.status() });
|
||
}
|
||
});
|
||
|
||
page.on('console', (msg) => {
|
||
if (msg.type() === 'error') {
|
||
consoleErrors.push(msg.text());
|
||
}
|
||
});
|
||
page.on('pageerror', (err) => {
|
||
pageErrors.push(err.message);
|
||
});
|
||
|
||
const creds = requireAdminCreds();
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Step 1: Admin login via login page and open session
|
||
// ─────────────────────────────────────────────────────────────
|
||
let t0 = Date.now();
|
||
await login(page, creds.username, creds.password);
|
||
await shot(page, 'UI-143-01-admin-logged-in.png');
|
||
|
||
// 明确验证从登录开始 doc-001 content/revision 请求均为 0
|
||
if (doc001Requests.length > 0) {
|
||
fail(`Cursor fix assertion failed: doc-001 content/revision requests must be 0, got ${JSON.stringify(doc001Requests)}`);
|
||
}
|
||
const doc001InObserved404 = observedOther404s.filter(
|
||
(l) => l.url.includes('doc-001/content') || l.url.includes('doc-001/revision-state'),
|
||
);
|
||
if (doc001InObserved404.length > 0) {
|
||
fail(`Cursor fix assertion failed: observedOther404s contained doc-001 requests: ${JSON.stringify(doc001InObserved404)}`);
|
||
}
|
||
|
||
// In LeftRail sidebar, find and click the session entry with exact title "对话协同会话"
|
||
const sessionEntry = page
|
||
.getByRole('complementary')
|
||
.getByTitle('对话协同会话', { exact: true });
|
||
await expect(sessionEntry).toBeVisible({ timeout: 15_000 });
|
||
await sessionEntry.click();
|
||
|
||
// Verify banner shows project/session active
|
||
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 15_000 });
|
||
await shot(page, 'UI-143-01-project-opened.png');
|
||
|
||
// Assert target doc has strictly 0 network 404s
|
||
if (target404s.length > 0) {
|
||
fail(`target doc encountered 404 in step 1: ${JSON.stringify(target404s)}`);
|
||
}
|
||
|
||
recordPass({
|
||
id: 'UI-143-01',
|
||
name: 'admin login & open project "对话协同会话"',
|
||
input: `login admin; assert doc-001 requests == 0; click exact title "对话协同会话" in complementary sidebar`,
|
||
actual: `session active in banner; doc001Requests=0; target404s=0; observedOther404s=${observedOther404s.length}`,
|
||
networkLogs: [...logs],
|
||
durationMs: Date.now() - t0,
|
||
verdict: 'PASS',
|
||
notes: JSON.stringify({ observedOther404s, doc001RequestsCount: doc001Requests.length }),
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Step 2: Confirm doc_proj143_intake via dropdown menu
|
||
// ─────────────────────────────────────────────────────────────
|
||
t0 = Date.now();
|
||
await confirmWorkflowDocById(page, 'doc_proj143_intake');
|
||
|
||
// Wait for active pane document blocks
|
||
const pane = page.getByTestId('active-document-pane');
|
||
await expect(pane).toBeVisible({ timeout: 20_000 });
|
||
const blocks = pane.getByTestId('document-block');
|
||
await expect(blocks.first()).toBeVisible({ timeout: 20_000 });
|
||
const blockCount = await blocks.count();
|
||
if (blockCount === 0) fail('doc_proj143_intake has 0 document blocks');
|
||
|
||
// Check text is non-empty
|
||
const fullText = (await pane.innerText()).trim();
|
||
if (fullText.length < 50) {
|
||
fail(`doc_proj143_intake content unexpectedly short: ${fullText.length} chars`);
|
||
}
|
||
|
||
// Verify ledger ready
|
||
const { v, r: rInitial } = await readVersionBadge(page);
|
||
const unreadyHint = page.getByText(/版本账本未就绪|自动拉取受控文档正文失败/);
|
||
if (await unreadyHint.isVisible().catch(() => false)) {
|
||
fail('doc_proj143_intake shows unready hint or fetch failure');
|
||
}
|
||
|
||
// Assert strictly zero 404s for target doc
|
||
if (target404s.length > 0) {
|
||
fail(`target doc 404 encountered in step 2: ${JSON.stringify(target404s)}`);
|
||
}
|
||
|
||
// Verify target doc network requests were successful (status 200)
|
||
const targetContentRequests = logs.filter(
|
||
(l) => l.path.includes('/doc_proj143_intake/content') && l.status === 200,
|
||
);
|
||
if (targetContentRequests.length === 0) {
|
||
fail('expected at least one successful 200 GET for doc_proj143_intake/content');
|
||
}
|
||
|
||
await shot(page, 'UI-143-02-doc-opened.png');
|
||
|
||
// Record prior observed errors, then reset consoleErrors and pageErrors for clean post-open observation
|
||
const priorObservedConsole = [...consoleErrors];
|
||
consoleErrors.length = 0;
|
||
pageErrors.length = 0;
|
||
|
||
recordPass({
|
||
id: 'UI-143-02',
|
||
name: 'open doc_proj143_intake non-empty & ledger ready',
|
||
input: 'open doc_proj143_intake via menu data-doc-id=doc_proj143_intake',
|
||
actual: `blocks=${blockCount}, textLen=${fullText.length}, version=${v}, revision=${rInitial}, target404s=0`,
|
||
networkLogs: [...logs],
|
||
durationMs: Date.now() - t0,
|
||
verdict: 'PASS',
|
||
notes: JSON.stringify({
|
||
priorObservedConsoleCount: priorObservedConsole.length,
|
||
observedOther404sCount: observedOther404s.length,
|
||
}),
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Step 3: Idle 30 seconds stability check (clean observation)
|
||
// ─────────────────────────────────────────────────────────────
|
||
t0 = Date.now();
|
||
const countBeforeIdle = contentRequestsCount;
|
||
|
||
await page.waitForTimeout(30_000);
|
||
|
||
const countAfterIdle = contentRequestsCount;
|
||
const newContentRequests = countAfterIdle - countBeforeIdle;
|
||
|
||
if (newContentRequests > 0) {
|
||
fail(`idle 30s triggered ${newContentRequests} unexpected duplicate content requests`);
|
||
}
|
||
|
||
await expect(pane.getByTestId('document-block').first()).toBeVisible({ timeout: 5_000 });
|
||
|
||
// Assert strictly no console errors or page errors occurred during the 30-second idle observation
|
||
if (consoleErrors.length > 0) {
|
||
fail(`console errors occurred during idle 30s: ${JSON.stringify(consoleErrors)}`);
|
||
}
|
||
if (pageErrors.length > 0) {
|
||
fail(`page errors occurred during idle 30s: ${JSON.stringify(pageErrors)}`);
|
||
}
|
||
if (target404s.length > 0) {
|
||
fail(`target doc 404 encountered during idle 30s: ${JSON.stringify(target404s)}`);
|
||
}
|
||
|
||
await shot(page, 'UI-143-03-idle-30s.png');
|
||
|
||
recordPass({
|
||
id: 'UI-143-03',
|
||
name: 'idle 30s stability check',
|
||
input: 'wait 30s on opened doc_proj143_intake',
|
||
actual: `newContentRequests=${newContentRequests}, blocks intact, zero errors, zero target 404s`,
|
||
networkLogs: [...logs],
|
||
durationMs: Date.now() - t0,
|
||
verdict: 'PASS',
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Step 4: Dblclick edit block, append unique timestamp marker, save
|
||
// ─────────────────────────────────────────────────────────────
|
||
t0 = Date.now();
|
||
const { r: rBeforeEdit } = await readVersionBadge(page);
|
||
// 纯中文与数字,绝不含任何 Markdown 语法符号(无 _ * [ ] # - 等),使用实际可见文本验证
|
||
const uniqueMarker = `人工验收标记${Date.now()}`;
|
||
|
||
await openFirstPaneEditorAndAppend(page, `(${uniqueMarker})`);
|
||
await shot(page, 'UI-143-04a-editing-dialog.png');
|
||
|
||
await saveOpenEditor(page);
|
||
await expect(blockEditorDialog(page)).toHaveCount(0, { timeout: 15_000 });
|
||
|
||
// Assert marker is visible on page via actual user-visible text
|
||
await expect(page.getByText(uniqueMarker)).toBeVisible({ timeout: 30_000 });
|
||
|
||
// Assert revision number increased
|
||
await expect(page.locator('[data-testid="doc-rev-revision"]')).not.toHaveText(rBeforeEdit, {
|
||
timeout: 30_000,
|
||
});
|
||
const { r: rAfterSave } = await readVersionBadge(page);
|
||
|
||
if (consoleErrors.length > 0) {
|
||
fail(`console errors occurred during edit/save: ${JSON.stringify(consoleErrors)}`);
|
||
}
|
||
if (target404s.length > 0) {
|
||
fail(`target doc 404 encountered during edit/save: ${JSON.stringify(target404s)}`);
|
||
}
|
||
|
||
await shot(page, 'UI-143-04b-edited-saved.png');
|
||
|
||
recordPass({
|
||
id: 'UI-143-04',
|
||
name: 'dblclick edit save & revision bump',
|
||
input: `append marker ${uniqueMarker}; saveOpenEditor`,
|
||
actual: `marker visible; revision ${rBeforeEdit} -> ${rAfterSave}`,
|
||
networkLogs: [...logs],
|
||
durationMs: Date.now() - t0,
|
||
verdict: 'PASS',
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Step 5: Three overlay modes: pure / manual / all differ in styles
|
||
// ─────────────────────────────────────────────────────────────
|
||
t0 = Date.now();
|
||
const pure = await captureOverlayFingerprint(page, 'pure');
|
||
await shot(page, 'UI-143-05-pure.png');
|
||
|
||
const manual = await captureOverlayFingerprint(page, 'manual');
|
||
await shot(page, 'UI-143-06-manual.png');
|
||
|
||
const all = await captureOverlayFingerprint(page, 'all');
|
||
await shot(page, 'UI-143-07-all.png');
|
||
|
||
const pureKey = overlayPaneKey(pure);
|
||
const manualKey = overlayPaneKey(manual);
|
||
const allKey = overlayPaneKey(all);
|
||
|
||
if (overlayPaneHasMark(pure)) {
|
||
fail(`UI-143-05 纯净阅读 must have no overlay decoration: ${JSON.stringify(pure)}`);
|
||
}
|
||
if (!overlayPaneHasMark(manual)) {
|
||
fail(`UI-143-05 人工修改 must decorate changed blocks: ${JSON.stringify(manual)}`);
|
||
}
|
||
if (!overlayPaneHasMark(all)) {
|
||
fail(`UI-143-05 全部变更 must show version/human decorations: ${JSON.stringify(all)}`);
|
||
}
|
||
if (pureKey === manualKey || pureKey === allKey) {
|
||
fail(`pure must be visually distinct from manual/all: pure=${pureKey} manual=${manualKey} all=${allKey}`);
|
||
}
|
||
|
||
if (consoleErrors.length > 0) {
|
||
fail(`console errors occurred during overlay mode checks: ${JSON.stringify(consoleErrors)}`);
|
||
}
|
||
if (target404s.length > 0) {
|
||
fail(`target doc 404 encountered during overlay mode checks: ${JSON.stringify(target404s)}`);
|
||
}
|
||
|
||
recordPass({
|
||
id: 'UI-143-05',
|
||
name: 'pure / manual / all overlay modes',
|
||
input: 'switch overlay mode pure -> manual -> all',
|
||
actual: 'pure unmarked; manual & all marked; computed styles visually distinct',
|
||
networkLogs: [...logs],
|
||
durationMs: Date.now() - t0,
|
||
verdict: 'PASS',
|
||
notes: JSON.stringify({ pureCount: pure.length, manualCount: manual.length, allCount: all.length }),
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Step 6: Reload, logout, re-login: marker and revision persisted
|
||
// ─────────────────────────────────────────────────────────────
|
||
t0 = Date.now();
|
||
|
||
// 1. Hard reload
|
||
await page.reload();
|
||
await expect(page.getByRole('complementary').getByTitle('对话协同会话', { exact: true })).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
// Assert marker persisted after reload via user-visible text
|
||
await expect(page.getByText(uniqueMarker)).toBeVisible({ timeout: 30_000 });
|
||
const { r: rAfterReload } = await readVersionBadge(page);
|
||
if (rAfterReload !== rAfterSave) {
|
||
fail(`revision mismatch after reload: expected ${rAfterSave}, got ${rAfterReload}`);
|
||
}
|
||
await shot(page, 'UI-143-08a-after-reload.png');
|
||
|
||
// 2. Logout via UI
|
||
await logoutViaUI(page);
|
||
await shot(page, 'UI-143-08b-logged-out.png');
|
||
|
||
// 3. Re-login as admin via UI
|
||
await login(page, creds.username, creds.password);
|
||
await shot(page, 'UI-143-08c-relogged-in.png');
|
||
|
||
// 4. Open project session and confirm doc
|
||
const sessionEntryAfter = page
|
||
.getByRole('complementary')
|
||
.getByTitle('对话协同会话', { exact: true });
|
||
await expect(sessionEntryAfter).toBeVisible({ timeout: 15_000 });
|
||
await sessionEntryAfter.click();
|
||
await confirmWorkflowDocById(page, 'doc_proj143_intake');
|
||
|
||
// Assert marker still present via user-visible text
|
||
await expect(page.getByText(uniqueMarker)).toBeVisible({ timeout: 30_000 });
|
||
|
||
// Assert revision number is still persisted
|
||
const { r: rFinal } = await readVersionBadge(page);
|
||
if (rFinal !== rAfterSave) {
|
||
fail(`revision mismatch after relogin: expected ${rAfterSave}, got ${rFinal}`);
|
||
}
|
||
await shot(page, 'UI-143-08d-persisted-after-relogin.png');
|
||
|
||
if (target404s.length > 0) {
|
||
fail(`target doc 404 encountered across entire session: ${JSON.stringify(target404s)}`);
|
||
}
|
||
|
||
recordPass({
|
||
id: 'UI-143-06',
|
||
name: 'persistence check after refresh, logout, and re-login',
|
||
input: 'page reload -> check marker/r -> logout -> re-login -> re-open doc',
|
||
actual: `marker "${uniqueMarker}" and revision "${rFinal}" persisted across reload and relogin`,
|
||
networkLogs: [...logs],
|
||
durationMs: Date.now() - t0,
|
||
verdict: 'PASS',
|
||
notes: JSON.stringify({
|
||
totalTarget404s: target404s.length,
|
||
totalObservedOther404s: observedOther404s.length,
|
||
}),
|
||
});
|
||
|
||
await context.close();
|
||
});
|
||
});
|