// 统一渲染 §11 浏览器验收:真实产品(RDM 二进制 auth on + dist preview)走通九项。 // 驱动方:/tmp/renderer-browser-acceptance/browser-acceptance.sh(种子写 $RBA_OUT/seed.env); // 未提供 PLAYWRIGHT_BASE_URL 或 RBA_OUT 时整组跳过。 // 产品缺能力处用 expect.soft('GAP[仓] …') 记失败并继续取证;浏览器做不到、只能以本人会话 // 直调命令的步骤记为 API_ASSISTED。跨用例状态写 $RBA_OUT/state.json(失败会重启 worker)。 import { test, expect, type Browser, type Locator, type Page } from '@playwright/test' import fs from 'node:fs' import path from 'node:path' const env = (k: string) => process.env[k] ?? '' const OUT = env('RBA_OUT') const EVID = OUT ? path.join(OUT, 'evidence') : '' const RUN = path.basename(OUT || 'run') const PW = env('RBA_PASSWORD') const U = { admin: env('RBA_ADMIN'), author: env('RBA_AUTHOR'), reviewer: env('RBA_REVIEWER'), approver: env('RBA_APPROVER'), outsider: env('RBA_OUTSIDER') } const A = { name: env('RBA_PROJECT_A'), pid: env('RBA_PID_A'), host: env('RBA_HOST_A'), doc: env('RBA_DOC_A'), title: env('RBA_DOC_A_TITLE') } const B = { name: env('RBA_PROJECT_B'), pid: env('RBA_PID_B'), host: env('RBA_HOST_B'), doc: env('RBA_DOC_B') } const UNCTRL = { doc: env('RBA_UNCTRL_DOC'), title: env('RBA_UNCTRL_TITLE') } const GENERAL = { id: env('RBA_GENERAL_PROFILE'), version: env('RBA_GENERAL_VERSION') } const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}` const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标` const RANGE_ANNOTATION = `§11 范围批注 ${RUN}:「目标」需可量化` const REVISION_AFTER = `§11 修订 ${RUN}:目标改为三项可量化指标` test.skip(!process.env.PLAYWRIGHT_BASE_URL || !OUT, 'set PLAYWRIGHT_BASE_URL and RBA_OUT (browser-acceptance.sh drives this suite)') test.describe.configure({ mode: 'default', timeout: 240_000 }) // ---- 证据与跨用例状态 ---- const STATE = () => path.join(OUT, 'state.json') const readState = (): Record => { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')) } catch { return {} } } const writeState = (patch: Record) => fs.writeFileSync(STATE(), JSON.stringify({ ...readState(), ...patch }, null, 2)) let evidence: string[] = [] let notes: string[] = [] let openSessions = new Set<() => Promise>() const note = (s: string) => { notes.push(s) } test.beforeEach(() => { evidence = []; notes = []; openSessions = new Set() }) test.afterEach(async ({}, info) => { for (const close of [...openSessions]) await close().catch(() => {}) const items = info.annotations.filter(a => a.type === 'item').map(a => a.description) const dir = path.join(EVID, 'results') fs.mkdirSync(dir, { recursive: true }) const errors = info.errors.map(e => (e.message ?? String(e)).replace(/\u001b\[[0-9;]*m/g, '').slice(0, 1500)) fs.writeFileSync(path.join(dir, `${info.testId}.json`), JSON.stringify({ title: info.title, items, status: info.status, errors, evidence, notes, apiAssisted: info.annotations.filter(a => a.type === 'API_ASSISTED').map(a => a.description), durationMs: info.duration, }, null, 2)) }) const items = (...ids: number[]) => { for (const id of ids) test.info().annotations.push({ type: 'item', description: String(id) }) } const apiAssisted = (what: string) => test.info().annotations.push({ type: 'API_ASSISTED', description: what }) async function shot(page: Page, name: string, target?: Locator) { const file = path.join(EVID, 'screens', `${name}.png`) fs.mkdirSync(path.dirname(file), { recursive: true }) if (target) await target.screenshot({ path: file }) else await page.screenshot({ path: file, fullPage: false }) evidence.push(file) } interface NetEntry { url: string; method: string; status: number; request: string; body: string } /** 每个会话一个上下文:主题经真实持久化入口 localStorage('as.ui') 注入;记录全部 /api 响应体。 */ async function session(browser: Browser, owner: string, tag: string, opts: { theme?: 'light' | 'dark'; width?: number; height?: number } = {}) { const ctx = await browser.newContext({ viewport: { width: opts.width ?? 1440, height: opts.height ?? 900 }, acceptDownloads: true, locale: 'zh-CN' }) if (opts.theme) { await ctx.addInitScript(theme => { const prev = (() => { try { return JSON.parse(localStorage.getItem('as.ui') || '{}') } catch { return {} } })() localStorage.setItem('as.ui', JSON.stringify({ ...prev, theme })) }, opts.theme) } const page = await ctx.newPage() const net: NetEntry[] = [] const pending: Promise[] = [] page.on('response', res => { const url = res.url() if (!url.includes('/api/') || (res.headers()['content-type'] ?? '').includes('event-stream')) return pending.push(Promise.race([res.text(), new Promise(r => setTimeout(() => r('<>'), 5_000))]) .catch(() => '<>') .then(body => { net.push({ url, method: res.request().method(), status: res.status(), request: res.request().postData() ?? '', body }) })) }) const close = async () => { openSessions.delete(close) await Promise.all(pending) const file = path.join(EVID, 'network', `${tag}.jsonl`) fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, net.map(n => JSON.stringify(n)).join('\n') + '\n') evidence.push(file) await ctx.close() } openSessions.add(close) await login(page, owner) return { page, net, pending, close } } async function login(page: Page, owner: string) { await page.goto('/') await page.getByPlaceholder('由管理员分配').fill(owner) await page.getByPlaceholder('初始密码或常用密码').fill(PW) await page.getByRole('button', { name: '登录', exact: true }).click() await page.locator('.tsb-tool', { hasText: '追溯审计' }).waitFor({ state: 'attached', timeout: 20_000 }) } /** 窄屏下侧栏收进「上下文」面板:先经工作台导航打开,再点审计入口。 */ async function clickAuditEntry(page: Page) { const entry = page.locator('.tsb-tool', { hasText: '追溯审计' }) if (!(await entry.isVisible())) await page.getByRole('button', { name: '打开上下文' }).click() await entry.click() } async function openAudit(page: Page, projectName: string) { await clickAuditEntry(page) await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 }) await page.locator('.rdm-audit-project').selectOption({ label: projectName }) } async function openDoc(page: Page, title: string) { await page.getByRole('tab', { name: '资料与文档' }).click() const open = page.getByRole('button', { name: `阅读与交付:${title}`, exact: true }) for (let i = 0; i < 5 && !(await open.isVisible().catch(() => false)); i++) { const more = page.locator('.rdm-audit-more') if (await more.isVisible().catch(() => false)) await more.click() else await page.waitForTimeout(500) } await open.click() await expect(page.locator('.drw')).toBeVisible() await expect(page.locator('.drw-canvas-node').first()).toBeVisible({ timeout: 20_000 }) } const panel = (page: Page, label: string) => page.locator('.drw-panel').filter({ has: page.locator('.ui-seclabel', { hasText: new RegExp(`^${label}$`) }) }) const deliveryOf = (page: Page, mode: '预览' | '审阅' | '批准') => page.locator('.drw-delivery').filter({ has: page.locator('.ui-chip', { hasText: new RegExp(`^${mode}$`) }) }).first() /** 以本人浏览器会话直调命令(与前端同一 /api/commands 通道、同一 cookie)。 */ async function command(page: Page, id: string, params: Record, source?: string) { const [conversationId, anchorMessageId] = (source ?? '').split(' ') const res = await page.request.post(`/api/commands/${id}`, { data: { params, ...(source ? { entrance: 'api', conversationId, anchorMessageId } : {}) }, }) const text = await res.text() let body: any = null try { body = JSON.parse(text) } catch { /* 非 JSON 原样保留 */ } const file = path.join(EVID, 'api', `${test.info().testId}-${id}.json`) fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, JSON.stringify({ id, params, status: res.status(), body: body ?? text }, null, 2)) evidence.push(file) return { status: res.status(), text, body } } async function download(page: Page, delivery: Locator, label: string) { const button = delivery.getByRole('button', { name: label, exact: true }) await expect(button, `下载入口「${label}」`).toBeVisible() const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 30_000 }), button.click()]) const name = dl.suggestedFilename() const file = path.join(EVID, 'downloads', name) fs.mkdirSync(path.dirname(file), { recursive: true }) await dl.saveAs(file) evidence.push(file) return { name, file, size: fs.statSync(file).size } } async function expectReadableFailures(page: Page) { for (const msg of await page.locator('.drw-failure-msg').allTextContents()) { expect(msg.trim().startsWith('{'), `失败文案不得是原始 JSON:${msg}`).toBe(false) } } const parseJson = (text: string): any => { try { return JSON.parse(text) } catch { return null } } /** * 工作区整文决定的取证:先有同会话的已提交来源消息(POST /messages 响应),命令再以它为顶层锚点分发, * params 不含来源字段,入口为页面动作;回执必须 committed。 */ async function expectCommittedDecision(s: { net: NetEntry[] }, command: string, label: string) { const cmdUrl = `/api/commands/${command}` await expect.poll(() => s.net.some(n => n.url.includes(cmdUrl)), { message: `应分发 ${command}`, timeout: 15_000 }).toBe(true) const dispatch = s.net.filter(n => n.url.includes(cmdUrl)).at(-1)! const body = parseJson(dispatch.request) expect(body.params, '命令参数不携带来源字段').not.toHaveProperty('anchorMessageId') expect(body.entrance, '工作区动作入口').toBe('button') const findSource = () => s.net.find(n => n.method === 'POST' && n.url.includes(`/api/conversations/${body.conversationId}/messages`) && parseJson(n.body)?.message?.id === body.anchorMessageId) await expect.poll(() => findSource() !== undefined, { message: '锚点必须是同会话已提交的来源消息', timeout: 15_000 }).toBe(true) expect(parseJson(findSource()!.request)?.text, '来源消息写明中文审签决定').toContain(`审签决定:${label}`) expect(parseJson(dispatch.body)?.receipt?.state, `${command} 回执 committed`).toBe('committed') } // ---- §11 ---- test('§11-1 文档树 → 结构画布与阅读视图(共用选中与过滤)', async ({ browser }) => { items(1) const s = await session(browser, U.author, 'item1-author') const { page } = s await openAudit(page, A.name) await page.getByRole('tab', { name: '资料与文档' }).click() const docRow = page.locator('.rdm-audit-tree-row').filter({ hasText: A.title }) await expect(docRow).toBeVisible({ timeout: 15_000 }) await docRow.locator('.rdm-audit-tree-node').click() await expect(page.locator('.rdm-audit-tree-children').first()).toBeVisible({ timeout: 15_000 }) await shot(page, 'item1-doc-tree') await openDoc(page, A.title) const nodes = page.locator('.drw-canvas-node') const total = await nodes.count() note(`结构画布节点数 ${total}`) expect(total, '受控文档应展开为多节点结构').toBeGreaterThan(10) const blank = (await page.locator('.drw-canvas-title').allTextContents()).filter(t => !t.trim()).length expect(blank, '画布不得出现无标题的空白节点').toBe(0) await shot(page, 'item1-canvas') const target = page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first() await target.click() await expect(target).toHaveAttribute('aria-selected', 'true') await page.getByLabel('过滤节点').fill('目标') await page.getByRole('tab', { name: '阅读视图' }).click() await expect(page.locator('.drw-reader')).toBeVisible() await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText('项目内容、目标及意义') await expect(page.getByLabel('过滤节点')).toHaveValue('目标') await shot(page, 'item1-reader-filtered') await page.getByLabel('过滤节点').fill('') await page.getByRole('tab', { name: '结构画布' }).click() await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', '项目内容、目标及意义') // 键盘导航 await page.locator('.drw-canvas-node[aria-selected="true"]').focus() await page.keyboard.press('ArrowDown') await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toBeFocused() await s.close() }) test('§11-2b 规则绑定:项目设置权限可绑定与解绑(非受控文档)', async ({ browser }) => { items(2) const s = await session(browser, U.admin, 'item2b-admin') const { page } = s await openAudit(page, A.name) await openDoc(page, UNCTRL.title) const binding = panel(page, '规则绑定') await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 }) await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check() await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click() await expect(panel(page, '规则绑定').getByText('绑定规则已完成')).toBeVisible({ timeout: 30_000 }) await expectReadableFailures(page) await expect(panel(page, '渲染规则').locator('.drw-profile-source', { hasText: '文档规则' }), '绑定后规则列表刷新出文档规则').toBeVisible({ timeout: 15_000 }) await shot(page, 'item2b-bound', binding) await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '解绑', exact: true }).click() await expect(panel(page, '规则绑定').getByText('解绑规则已完成')).toBeVisible({ timeout: 30_000 }) await expectReadableFailures(page) await s.close() }) test('§11-3b 受控文档拒绝绑定通用规则,失败以可读文案显示', async ({ browser }) => { items(3) const s = await session(browser, U.admin, 'item3b-admin') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const binding = panel(page, '规则绑定') await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 }) await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check() await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click() await expect(binding.locator('.drw-failure-msg')).toContainText('受控文档', { timeout: 30_000 }) await expect(binding.locator('.drw-failure-msg')).not.toContainText('controlled_requires_exact_profile') await shot(page, 'item3b-controlled-refusal', binding) await s.close() }) test('§11-3 受控文档:规则来源、版本与不回退说明', async ({ browser }) => { items(3) const s = await session(browser, U.author, 'item3-author') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const rules = panel(page, '渲染规则') const option = rules.locator('.drw-profile').first() await expect(option).toBeVisible({ timeout: 15_000 }) await expect(option.locator('.drw-profile-source')).toHaveText('模板规则') await expect(option.locator('.drw-profile-id')).toHaveText(TEMPLATE_PROFILE) await expect(option.locator('.drw-profile-ver')).toHaveText(`v${env('RBA_TEMPLATE_VERSION')}`) // 44 个 DHF 专用规则均已发布(state=published、publicationAllowed=true):状态为已发布,可正式交付,无未发布原因。 await expect(option.locator('.ui-chip').first()).toHaveText('已发布') await expect(option.locator('.ui-chip', { hasText: '可正式交付' })).toBeVisible() await expect(option.locator('input[type="radio"]')).toBeEnabled() await expect(option.locator('.drw-profile-reason')).toHaveCount(0) await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则') await expect(rules.locator('.drw-failure-msg')).toContainText('不得回退到项目或公司默认规则') await expectReadableFailures(page) await shot(page, 'item3-rules-controlled', rules) await s.close() }) test('§11-2 专用规则与公司通用规则可选', async ({ browser }) => { items(2) const s = await session(browser, U.author, 'item2-author') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const rules = panel(page, '渲染规则') await expect(rules.locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 }) const sources = await rules.locator('.drw-profile-source').allTextContents() note(`受控文档可选规则来源:${sources.join('、')}`) expect(sources.some(x => x === '模板规则' || x === '文档类型规则' || x === '文档规则'), '应提供专用(模板/文档类型)规则').toBe(true) await shot(page, 'item2-controlled-rules', rules) await page.getByRole('button', { name: '← 返回文档树' }).click() await openDoc(page, UNCTRL.title) const rulesU = panel(page, '渲染规则') await expect(rulesU.locator('.drw-profile').or(rulesU.locator('.drw-failure')).or(rulesU.getByText('没有可用的渲染规则')).first()).toBeVisible({ timeout: 15_000 }) const uSources = await rulesU.locator('.drw-profile-source').allTextContents() note(`非受控文档可选规则来源:${uSources.join('、') || '(无)'};拒绝:${(await rulesU.locator('.drw-failure-msg').allTextContents()).join(' | ')}`) await shot(page, 'item2-uncontrolled-rules', rulesU) expect.soft(uSources.includes('公司默认规则'), `GAP[RDM] 已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version} 不出现在非受控文档的规则列表:rdm.render.profiles 只读 rdm_render_profile_bindings,而产品无任何写绑定命令(organization-default/project-default/document-type 绑定不可达)`).toBe(true) expect.soft(sources.length + uSources.length >= 2 && uSources.length >= 1, 'GAP[RDM] 浏览器内无法在同一文档上于专用规则与公司通用规则之间选择(专用规则只到 validated;通用规则无绑定)').toBe(true) await s.close() }) test('§11-4 生成预览并把诊断定位到节点', async ({ browser }) => { items(4) const s = await session(browser, U.author, 'item4-author') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const generate = page.getByRole('button', { name: '生成预览', exact: true }) await expect(generate).toBeEnabled({ timeout: 15_000 }) await generate.click() const preview = panel(page, '预览与提交') await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 90_000 }) await expectReadableFailures(page) await shot(page, 'item4-preview-receipt') await expect(preview.locator('.drw-op .drw-failure'), '预览不应失败').toHaveCount(0) const receipt = preview.locator('.drw-receipt') await expect(receipt.locator('.ui-chip').first()).toHaveText('已生成', { timeout: 60_000 }) await expect(receipt.getByText('诊断读取中')).toHaveCount(0, { timeout: 60_000 }) await expect(receipt.locator('.drw-diags').or(receipt.getByText('没有诊断问题')).or(receipt.locator('.drw-failure')).first()).toBeVisible() await expectReadableFailures(page) const summary = await receipt.locator('.drw-muted').first().textContent() const located = receipt.locator('button.drw-diag-node') const locatedCount = await located.count() const unlocated = await receipt.locator('.drw-diag-node.unlocated').count() note(`诊断摘要:${summary};可定位 ${locatedCount} 条(首屏),未定位 ${unlocated} 条`) await shot(page, 'item4-diagnostics', preview) await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 }) expect.soft(locatedCount, 'GAP[Renderer] 渲染器生产代码从不产出 warning/info 诊断、也不填 Diagnostic.NodeID(仅失败时产出无节点的 error);GAP[RDM] 受控 DHF 实例 172 个节点全为空/未开始,预览 diagnostics 为 null——无可定位到节点的诊断').toBeGreaterThan(0) if (!locatedCount) { await s.close(); return } const first = located.first() const title = (await first.getAttribute('title')) ?? '' await first.click() await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', title) const counted = receipt.locator('.drw-diag.sev-error button.drw-diag-node, .drw-diag.sev-warning button.drw-diag-node') if (await counted.count()) { const t = (await counted.first().getAttribute('title')) ?? '' await expect(page.locator(`.drw-canvas-node[title="${t.replace(/"/g, '\\"')}"] .drw-badge`).first()).toBeVisible() } await shot(page, 'item4-diagnostic-located-canvas') await page.getByRole('tab', { name: '阅读视图' }).click() const section = page.locator('section.drw-reader-node.sel') await expect(section.locator('.drw-reader-title')).toHaveText(title) await expect(section.locator('a.drw-anchor')).toHaveAttribute('href', `#${await section.getAttribute('id')}`) await shot(page, 'item4-diagnostic-located-reader') await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 }) await s.close() }) test('§11-5a 编制人提交审阅', async ({ browser }) => { items(5) const s = await session(browser, U.author, 'item5a-author') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const submit = page.getByRole('button', { name: '提交审阅', exact: true }) await expect(submit).toBeVisible({ timeout: 15_000 }) await shot(page, 'item5a-submit-state', panel(page, '预览与提交')) const uiSubmit = await submit.isEnabled() note(`浏览器「提交审阅」可用:${uiSubmit};原因:${await submit.getAttribute('title')}`) expect.soft(uiSubmit, 'GAP[RDM] 受控文档的专用模板规则只能经 rdm.render.validate 到 validated,产品无发布路径(company.* 为保留前缀,candidate.publish 不绑定作用域),rdm.render.submit 要求 published → 浏览器无法提交审阅').toBe(true) let submissionId = '' let round = 0 if (uiSubmit) { await submit.click() await expect(panel(page, '预览与提交').getByText('提交审阅已完成')).toBeVisible({ timeout: 60_000 }) } else { apiAssisted(`rdm.render.submit 以编制人会话显式指定已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version}`) const r = await command(page, 'rdm.render.submit', { projectId: A.pid, docId: A.doc, operationId: `rba-submit-${RUN}`, profileId: GENERAL.id, profileVersion: GENERAL.version, }) note(`API 提交回执:${r.text.slice(0, 300)}`) expect.soft(r.body?.receipt?.state, `BLOCKER[RDM] rdm.render.submit 被拒(${r.body?.receipt?.reason ?? r.status}):trustedRenderActor → LookupAccountDirectoryOwner(purpose=document-duties) 需要 RDM 注册 AccountDirectoryScopeResolver,而 RDM d63f1db 从未调用 RegisterAccountDirectoryScopeResolver,Backend 恒返回 ErrAccountForbidden;submit/annotate/revise 在真实产品中全部不可用`).toBe('committed') } const status = await command(page, 'rdm.review.doc.status', { projectId: A.pid, docId: A.doc }) submissionId = status.body?.payload?.submission?.submissionId ?? '' round = status.body?.payload?.submission?.round ?? 0 writeState({ submissionId, round }) expect(submissionId, '提交后 rdm.review.doc.status 返回提交编号').not.toBe('') await page.reload() await openAudit(page, A.name) await openDoc(page, A.title) await expect(panel(page, '审阅').locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 15_000 }) await shot(page, 'item5a-submitted') await s.close() }) test('§11-5b 审核人添加批注与修订', async ({ browser }) => { items(5) const { submissionId } = readState() const s = await session(browser, U.reviewer, 'item5b-reviewer') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const review = panel(page, '审阅') if (!submissionId) { await expect(review.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 }) await shot(page, 'item5b-reviewer-no-submission') expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核人无法添加批注与修订').not.toBe('') } await expect(review.locator('.drw-mono'), '审核人在新会话中看到编制人的提交').toHaveAttribute('title', submissionId, { timeout: 20_000 }) await page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first().click() await expect(review.locator('.drw-composer-node')).toHaveText('项目内容、目标及意义') await review.getByLabel('批注内容').fill(ANNOTATION) await review.getByRole('button', { name: '添加批注' }).click() await expect(review.locator('.drw-anno-text', { hasText: ANNOTATION })).toBeVisible({ timeout: 30_000 }) await review.getByLabel('审阅动作').selectOption('revise') await review.getByLabel('修订类型').selectOption('replace') await review.getByLabel('修订前').fill('项目目标') await review.getByLabel('修订后').fill(REVISION_AFTER) await review.getByRole('button', { name: '提交修订' }).click() await expect(review.locator('.drw-diff-add', { hasText: REVISION_AFTER })).toBeVisible({ timeout: 30_000 }) await expect(review.locator('.drw-anno-group .drw-anno-node').first()).toHaveText('项目内容、目标及意义') await expect(page.locator('.drw-canvas-node[title="项目内容、目标及意义"] .drw-badge.review').first()).toBeVisible() // 范围批注(06 §2):阅读视图选中节点正文「目标」(码点 5..7),载荷带偏移与摘录,不带 textHash/range。 await page.getByRole('tab', { name: '阅读视图' }).click() const nodeText = page.locator('section.drw-reader-node.sel [data-anchor-node]') await expect(nodeText).toHaveText('项目内容、目标及意义') await nodeText.evaluate(el => { const range = document.createRange() range.setStart(el.firstChild!, 5) range.setEnd(el.firstChild!, 7) const sel = window.getSelection()! sel.removeAllRanges() sel.addRange(range) el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) }) await expect(review.locator('.drw-composer-quote')).toHaveText('「目标」') await review.getByLabel('批注内容').fill(RANGE_ANNOTATION) await review.getByRole('button', { name: '添加批注' }).click() const annotateCalls = () => s.net.filter(n => n.url.includes('/api/commands/rdm.render.review.annotate') && parseJson(n.request)?.params?.text === RANGE_ANNOTATION) await expect.poll(() => annotateCalls().length, { message: '应分发范围批注', timeout: 30_000 }).toBeGreaterThan(0) const rangeCall = annotateCalls().at(-1)! const rangeParams = parseJson(rangeCall.request)?.params expect(rangeParams, '范围批注载荷').toMatchObject({ textStart: 5, textEnd: 7, quote: '目标' }) expect(rangeParams).not.toHaveProperty('textHash') expect(rangeParams).not.toHaveProperty('range') note(`范围批注回执:${rangeCall.body.slice(0, 300)}`) const rangeItem = review.locator('.drw-anno', { hasText: RANGE_ANNOTATION }) const anchored = await rangeItem.locator('.drw-anno-quote').waitFor({ timeout: 30_000 }).then(() => true, () => false) if (anchored) await expect(rangeItem.locator('.drw-anno-quote')).toHaveText('目标') expect.soft(anchored, `GAP[RDM] 范围批注未按所选文字保存(回执:${rangeCall.body.slice(0, 200)})`).toBe(true) await page.getByRole('tab', { name: '结构画布' }).click() await expectReadableFailures(page) await shot(page, 'item5b-review-annotations') await s.close() }) test('§11-5c 审核通过与批准(签署)', async ({ browser }) => { items(5) const { submissionId } = readState() const r = await session(browser, U.reviewer, 'item5c-reviewer') await openAudit(r.page, A.name) await openDoc(r.page, A.title) const reviewPanel = panel(r.page, '审阅') if (!submissionId) { await expect(reviewPanel.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 }) expect.soft(await r.page.getByRole('button', { name: /审核通过|驳回|批准|签署|定稿/ }).count(), 'GAP[Frontend] 文档工作区没有审核通过/驳回/批准入口(rdm.review.doc.approve/final 需会话来源锚点,只能经对话代理)').toBeGreaterThan(0) await shot(r.page, 'item5c-reviewer-no-submission', reviewPanel) expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核与批准签署无从进行').not.toBe('') } await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 }) // 整文审签只走工作区入口:审核人待审时必须出现「审核通过」;成功与可读失败都必须可见。 const decisionPanel = panel(r.page, '整文审签') const uiApprove = decisionPanel.getByRole('button', { name: '审核通过', exact: true }) await expect(uiApprove, 'GAP[Frontend] 审核人在待审状态下工作区没有「审核通过」入口').toBeVisible({ timeout: 20_000 }) await uiApprove.click() const done = decisionPanel.getByText('整文审核通过已完成') const failed = decisionPanel.locator('.drw-failure-msg') await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 }) expect.soft(await done.count(), `审核通过应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1) await expectReadableFailures(r.page) await expectCommittedDecision(r, 'rdm.review.doc.approve', '审核通过') await shot(r.page, 'item5c-reviewer-approved', decisionPanel) await r.close() const a = await session(browser, U.approver, 'item5c-approver') await openAudit(a.page, A.name) await openDoc(a.page, A.title) const approverPanel = panel(a.page, '审阅') await expect(approverPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 }) // 终批入口只在批准人且全员已审核通过时出现;载荷绑定界面所示轮次。 const finalPanel = panel(a.page, '整文审签') const uiFinal = finalPanel.getByRole('button', { name: '终批', exact: true }) await expect(uiFinal, 'GAP[Frontend] 批准人在全员审核通过后工作区没有「终批」入口').toBeVisible({ timeout: 20_000 }) await uiFinal.click() const finalDone = finalPanel.getByText('整文终批已完成') const finalFailed = finalPanel.locator('.drw-failure-msg') await expect(finalDone.or(finalFailed).first()).toBeVisible({ timeout: 60_000 }) expect.soft(await finalDone.count(), `终批应成功:${(await finalFailed.allTextContents()).join(' | ')}`).toBe(1) await expectReadableFailures(a.page) await expectCommittedDecision(a, 'rdm.review.doc.final', '终批') await a.page.reload() await openAudit(a.page, A.name) await openDoc(a.page, A.title) const decisions = panel(a.page, '审阅').locator('.drw-decision') await expect(decisions.first()).toBeVisible({ timeout: 20_000 }) const decisionTexts = await decisions.allTextContents() note(`签署记录:${decisionTexts.join(' | ')}`) expect(decisionTexts.length, '审阅面板显示审核与批准两条签署').toBeGreaterThanOrEqual(2) await shot(a.page, 'item5c-decisions', panel(a.page, '审阅')) await a.close() }) test('§11-6 下载 Markdown、批准稿、审阅留痕稿与完整包', async ({ browser }) => { items(6) const { submissionId } = readState() if (!submissionId) { // 无渲染提交:只能取证编制人预览交付的 Markdown/DOCX/清单下载,批准稿与留痕稿记为阻断 const s = await session(browser, U.author, 'item6-author-preview') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const preview = deliveryOf(page, '预览') await expect(preview).toBeVisible({ timeout: 20_000 }) const md = await download(page, preview, 'Markdown') expect(md.name).toMatch(/\.md$/) const docx = await download(page, preview, '预览 DOCX') expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK') const manifest = await download(page, preview, '清单(manifest)') note(`预览交付下载:${[md, docx, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`) writeState({ downloads: [md, docx, manifest].map(f => f.name) }) await shot(page, 'item6-preview-downloads', panel(page, '交付记录')) expect.soft(await deliveryOf(page, '批准').count(), 'BLOCKER[RDM] 无渲染提交 → 无批准交付,批准稿不可下载').toBeGreaterThan(0) expect.soft(await page.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count(), 'BLOCKER[RDM] 无渲染提交 → 无审阅交付,审阅留痕稿不可下载').toBeGreaterThan(0) const pkg = await download(page, preview, '完整交付包(ZIP)') expect(pkg.name).toMatch(/\.zip$/) expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK') await s.close() return } const s = await session(browser, U.approver, 'item6-approver') const { page } = s await openAudit(page, A.name) await openDoc(page, A.title) const reviewPanel = panel(page, '审阅') await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 }) const ok = panel(page, '预览与提交') await reviewPanel.getByRole('button', { name: '生成批准稿' }).click() await expect(ok.getByText('生成批准稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 }) await expectReadableFailures(page) await expect(ok.locator('.drw-op .drw-failure'), '生成批准稿不应失败').toHaveCount(0) await reviewPanel.getByRole('button', { name: '生成留痕稿' }).click() await expect(ok.getByText('生成留痕稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 }) await expect(ok.locator('.drw-op .drw-failure'), '生成留痕稿不应失败').toHaveCount(0) const approved = deliveryOf(page, '批准') const reviewed = deliveryOf(page, '审阅') await expect(approved).toBeVisible({ timeout: 20_000 }) await expect(reviewed).toBeVisible({ timeout: 20_000 }) await shot(page, 'item6-deliveries', panel(page, '交付记录')) const kinds = { approved: await approved.locator('.drw-download').allTextContents(), review: await reviewed.locator('.drw-download').allTextContents() } note(`批准交付可下载:${kinds.approved.join('、')};审阅交付可下载:${kinds.review.join('、')}`) writeState({ deliveryKinds: kinds }) const md = await download(page, approved, 'Markdown') expect(md.name).toMatch(/\.md$/) expect(fs.readFileSync(md.file, 'utf8').length).toBeGreaterThan(50) const docx = await download(page, approved, '批准稿 DOCX') expect(docx.name).toMatch(/\.docx$/) expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK') const traceHolder = (await reviewed.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count()) ? reviewed : approved const trace = await download(page, traceHolder, '审阅留痕 DOCX') expect(trace.name).toMatch(/\.docx$/) expect(fs.readFileSync(trace.file).subarray(0, 2).toString()).toBe('PK') const manifest = await download(page, approved, '清单(manifest)') note(`下载:${[md, docx, trace, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`) // 长文件名:显示截断,title 保留完整文件名(§11-9 一并取证) const lastName = panel(page, '交付记录').locator('.drw-filename').first() await expect(lastName).toHaveAttribute('title', manifest.name) const shown = (await lastName.textContent()) ?? '' note(`文件名显示「${shown}」,完整「${manifest.name}」`) if (manifest.name.length > 36) expect(shown).toContain('…') await shot(page, 'item6-downloaded-longname', panel(page, '交付记录')) writeState({ downloads: [md, docx, trace, manifest].map(f => f.name) }) // 完整包:rdm.render.download kind=package,服务端确定性 ZIP;与单件下载同一鉴权路径。 const pkg = await download(page, approved, '完整交付包(ZIP)') expect(pkg.name).toMatch(/\.zip$/) expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK') note(`完整交付包:${pkg.name}(${pkg.size} B)`) writeState({ downloads: [md, docx, trace, manifest, pkg].map(f => f.name) }) await s.close() }) test('§11-7 追溯网显示来源、规则、修订、签署与交付物', async ({ browser }) => { items(7) const s = await session(browser, U.admin, 'item7-admin') const { page } = s await openAudit(page, A.name) await page.getByRole('tab', { name: '关联追溯' }).click() const pick = page.locator('.rdm-audit-pick button', { hasText: /^项目内容、目标及意义$/ }).first() await expect(pick).toBeVisible({ timeout: 60_000 }) await pick.click() await expect(page.locator('.rdm-audit-trace-head')).toBeVisible() await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 20_000 }) await shot(page, 'item7-trace-section') await Promise.all(s.pending) const relationBodies = s.net.filter(n => n.url.includes('rdm.inspect.relations')).map(n => n.body).join('\n') const text = (await page.locator('.rdm-audit-body').textContent()) ?? '' note(`关联追溯页面文本(截断):${text.slice(0, 300)}`) const has = (re: RegExp) => re.test(text) || re.test(relationBodies) expect.soft(has(/source|来源依据([1-9]/), 'GAP[RDM] 追溯网未给出该章节的来源依据(rdm.inspect.relations 只含通用网/合规边)').toBe(true) expect.soft(has(/profile|渲染规则|org\.general|company\.dhf/), 'GAP[RDM] 追溯网不含渲染规则(profile)节点或边').toBe(true) expect.soft(has(/revision|修订/), 'GAP[RDM] 追溯网不含审阅修订').toBe(true) expect.soft(has(/decision|approve|final|签署|批准/), 'GAP[RDM] 追溯网不含审核/批准签署').toBe(true) expect.soft(has(/delivery|交付|deliveryId/), 'GAP[RDM] 追溯网不含渲染交付物(preview/review/approved delivery)').toBe(true) // 补充取证:操作记录是否留下渲染与签署事件(不计入追溯网判定) await page.getByRole('button', { name: '← 返回' }).click() await page.getByRole('tab', { name: '操作记录' }).click() await page.waitForTimeout(2_000) await shot(page, 'item7-timeline') const timeline = (await page.locator('.rdm-audit-body').textContent()) ?? '' note(`操作记录含 render:${/render/.test(timeline)};含 review:${/review/.test(timeline)}`) await s.close() }) test('§11-8 无权用户的页面与网络响应不含受限内容', async ({ browser }) => { items(8) const st = readState() const s = await session(browser, U.outsider, 'item8-outsider') const { page } = s await clickAuditEntry(page) await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 }) const options = await page.locator('.rdm-audit-project option').allTextContents() note(`无权用户项目列表:${options.join('、')}`) expect(options).not.toContain(A.name) expect(options).toContain(B.name) await page.getByRole('tab', { name: '资料与文档' }).click() await expect(page.locator('.rdm-audit-tree-row').first()).toBeVisible({ timeout: 15_000 }) await page.getByRole('tab', { name: '关联追溯' }).click() await page.waitForTimeout(1_500) await page.getByRole('tab', { name: '操作记录' }).click() await page.waitForTimeout(1_500) await shot(page, 'item8-outsider-audit') const pageText = (await page.locator('body').textContent()) ?? '' for (const m of [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER]) expect(pageText, `页面不得出现受限内容「${m}」`).not.toContain(m) const passiveCount = s.net.length // 主动探测:以无权用户会话直调 A 的读取与下载命令(请求中自带的 id 不算泄露) const probes: [string, Record][] = [ ['rdm.inspect.summary', { projectId: A.pid }], ['rdm.inspect.tree', { projectId: A.pid, kind: 'docs' }], ['rdm.inspect.tree', { projectId: A.pid, kind: 'docs', parent: A.doc }], ['rdm.inspect.relations', { projectId: A.pid, kind: 'doc', id: A.doc, depth: 1 }], ['rdm.render.profiles', { projectId: A.pid, docId: A.doc }], ['rdm.render.deliveries', { projectId: A.pid, docId: A.doc, limit: 20 }], ['rdm.review.doc.status', { projectId: A.pid, docId: A.doc }], ...(st.submissionId ? [['rdm.render.review.view', { projectId: A.pid, submissionId: st.submissionId }] as [string, Record]] : []), ] const probeResults: { id: string; status: number; state: string; hasPayload: boolean }[] = [] const probeBodies: { url: string; body: string }[] = [] for (const [id, params] of probes) { const r = await command(page, id, params) probeBodies.push({ url: `probe:${id}`, body: r.text }) const state = r.body?.receipt?.state ?? '' const payload = r.body?.payload const hasPayload = payload !== undefined && payload !== null && !(typeof payload === 'object' && Object.keys(payload).length === 0) probeResults.push({ id, status: r.status, state, hasPayload }) expect.soft(r.status >= 400 || state === 'rejected', `${id} 对无权用户应被拒绝:HTTP ${r.status} ${r.text.slice(0, 200)}`).toBe(true) expect.soft(hasPayload, `${id} 不得返回受限载荷`).toBe(false) } for (const kind of ['markdown', 'docx', 'manifest']) { const r = await command(page, 'rdm.render.download', { projectId: A.pid, deliveryId: 'probe', kind }) probeBodies.push({ url: `probe:rdm.render.download:${kind}`, body: r.text }) probeResults.push({ id: `rdm.render.download:${kind}`, status: r.status, state: r.body?.receipt?.state ?? '', hasPayload: !!r.body?.payload }) expect.soft(!r.body?.payload?.dataBase64, 'rdm.render.download 不得返回文件内容').toBe(true) } const hostProjects = await page.request.get('/api/projects') const hostText = await hostProjects.text() expect.soft(hostText.includes(A.host), '宿主项目列表不得含项目 A').toBe(false) const hostConv = await page.request.get(`/api/projects/${A.host}/conversations`) const hostConvText = await hostConv.text() probeBodies.push({ url: '/api/projects', body: hostText }, { url: `/api/projects/${A.host}/conversations`, body: hostConvText }) note(`宿主 A 会话列表 HTTP ${hostConv.status()}`) expect.soft(hostConv.status() >= 400 || !hostConvText.includes('§11'), '宿主 A 会话不得可见').toBe(true) note(`主动探测:${JSON.stringify(probeResults)}`) await Promise.all(s.pending) // 全部网络响应体扫描:内容标记任何响应都不得出现;A 的标识只在被动浏览响应中检查 const contentMarkers = [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER, ...(st.downloads ?? [])] const idMarkers = [A.pid, A.host, st.submissionId].filter(Boolean) const leaks: string[] = [] s.net.forEach((n, i) => { for (const m of contentMarkers) if (m && n.body.includes(m)) leaks.push(`${n.url} 含「${m}」`) if (i < passiveCount) for (const m of idMarkers) if (n.body.includes(m)) leaks.push(`${n.url} 含标识 ${m}`) }) for (const p of probeBodies) for (const m of contentMarkers) if (m && p.body.includes(m)) leaks.push(`${p.url} 含「${m}」`) const scanned = path.join(EVID, 'network', 'item8-scan.json') fs.mkdirSync(path.dirname(scanned), { recursive: true }) fs.writeFileSync(scanned, JSON.stringify({ pageResponses: s.net.length, passive: passiveCount, probeResponses: probeBodies.length, contentMarkers, idMarkers, leaks, probeResults }, null, 2)) evidence.push(scanned) expect(leaks, `受限内容泄露:${leaks.join(';')}`).toEqual([]) await s.close() // 反向:A 的审核人不是 B 成员,看不到 B const r = await session(browser, U.reviewer, 'item8-reviewer-vs-B') await clickAuditEntry(r.page) await expect(r.page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 }) expect(await r.page.locator('.rdm-audit-project option').allTextContents()).not.toContain(B.name) const bProbe = await command(r.page, 'rdm.render.profiles', { projectId: B.pid, docId: B.doc }) expect.soft(bProbe.status >= 400 || bProbe.body?.receipt?.state === 'rejected', `审核人读 B 应被拒绝:${bProbe.text.slice(0, 200)}`).toBe(true) await r.close() }) test('§11-9 浅色/深色/窄屏/长文档/长文件名可用', async ({ browser }) => { items(9) for (const theme of ['light', 'dark'] as const) { for (const [w, h, tag] of [[1440, 900, 'wide'], [390, 844, 'narrow']] as const) { const s = await session(browser, U.author, `item9-${theme}-${tag}`, { theme, width: w, height: h }) const { page } = s await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe(theme) await openAudit(page, A.name) await openDoc(page, A.title) await expect(panel(page, '渲染规则').locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 }) const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth) note(`${theme}/${tag}:.drw 横向溢出 ${overflow}px`) expect.soft(overflow, `${theme}/${tag} 工作区不得横向溢出`).toBeLessThanOrEqual(1) const title = page.locator('.drw-title') await expect(title).toHaveAttribute('title', A.title) const clipped = await title.evaluate(el => el.scrollWidth > el.clientWidth) note(`${theme}/${tag}:${A.title.length} 字长标题截断显示 ${clipped}`) expect(clipped, '长标题在页头截断显示(完整标题保留在 title)').toBe(true) expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') const bodyOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) expect.soft(bodyOverflow, `${theme}/${tag} 页面不得横向滚动`).toBeLessThanOrEqual(1) await shot(page, `item9-${theme}-${tag}-canvas`) if (tag === 'wide') { const delivery = page.locator('.drw-delivery').first() await expect(delivery, '长文件名取证需要至少一条交付记录').toBeVisible({ timeout: 15_000 }) const file = await download(page, delivery, 'Markdown') const name = panel(page, '交付记录').locator('.drw-filename').first() await expect(name).toHaveAttribute('title', file.name) const shown = (await name.textContent()) ?? '' note(`${theme}:下载文件名「${file.name}」(${file.name.length} 字)显示为「${shown}」——RDM 交付文件名恒为 .,产品不产生长文件名;长名称以 ${A.title.length} 字文档标题检验`) expect(shown.length).toBeLessThanOrEqual(36) await shot(page, `item9-${theme}-filename`, panel(page, '交付记录')) } await page.getByRole('tab', { name: '阅读视图' }).click() const sections = await page.locator('section.drw-reader-node').count() expect(sections, '长文档阅读视图完整列出节点').toBeGreaterThan(10) await page.locator('section.drw-reader-node').last().scrollIntoViewIfNeeded() await expect(page.locator('section.drw-reader-node').last()).toBeInViewport() await shot(page, `item9-${theme}-${tag}-reader-end`) await page.locator('.drw-side').scrollIntoViewIfNeeded() await shot(page, `item9-${theme}-${tag}-side`, page.locator('.drw-side')) await page.getByRole('button', { name: '← 返回文档树' }).click() await expect(page.getByRole('button', { name: `阅读与交付:${A.title}`, exact: true })).toBeVisible() await s.close() } } })