Files
Agent-System-Frontend/e2e/document-renderer.playwright.ts
T
weichenandCursor 520e7df58b Scan active probe bodies for restricted content in renderer spec
Why: Probes sent with page.request do not raise page response events, so
the §11-8 leak scan only covered passive page responses.

Evidence: Final run 20261016T065123Z item8-scan.json counted 18 responses,
all passive; probe bodies were checked only for rejection and payload.

Impact: Every probe and host API body is scanned for project A names, the
controlled and uncontrolled document titles and review markers.

Refs: unified renderer plan Task 13 §11
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 06:52:43 +00:00

669 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一渲染 §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 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<string, any> => { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')) } catch { return {} } }
const writeState = (patch: Record<string, unknown>) => fs.writeFileSync(STATE(), JSON.stringify({ ...readState(), ...patch }, null, 2))
let evidence: string[] = []
let notes: string[] = []
let openSessions = new Set<() => Promise<void>>()
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<void>[] = []
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<string>(r => setTimeout(() => r('<<body timeout>>'), 5_000))])
.catch(() => '<<no body>>')
.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<string, unknown>, 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)
}
}
// ---- §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-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')}`)
await expect(option.locator('.ui-chip').first()).toHaveText('已验证')
await expect(option.locator('.drw-profile-reason')).toContainText('未发布')
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()
await expectReadableFailures(page)
await shot(page, 'item5b-review-annotations')
await s.close()
})
test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
items(5)
const { submissionId, round } = 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 reviewerButtons = await reviewPanel.getByRole('button', { name: /通过|同意|批准|签署|驳回/ }).count()
expect.soft(reviewerButtons,
'GAP[Frontend] 审阅面板没有审核通过/驳回入口;rdm.review.doc.approve 是需会话来源锚点的正式写命令,只能经对话代理触发').toBeGreaterThan(0)
apiAssisted('rdm.review.doc.approve 以审核人会话 + 本人会话来源锚点调用')
const approve = await command(r.page, 'rdm.review.doc.approve', { projectId: A.pid, docId: A.doc, note: `§11 审核通过 ${RUN}` }, env('RBA_SRC_REVIEWER_A'))
expect(approve.body?.receipt?.state, `approve 回执:${approve.text.slice(0, 400)}`).toBe('committed')
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 approverButtons = await approverPanel.getByRole('button', { name: /批准|签署|定稿|通过/ }).filter({ hasNotText: '生成批准稿' }).count()
expect.soft(approverButtons,
'GAP[Frontend] 审阅面板没有批准/定稿(签署)入口;rdm.review.doc.final 需会话来源锚点,浏览器工作区无法完成批准').toBeGreaterThan(0)
apiAssisted('rdm.review.doc.final 以批准人会话 + 本人会话来源锚点调用')
const final = await command(a.page, 'rdm.review.doc.final', { projectId: A.pid, docId: A.doc, round, reason: `§11 批准 ${RUN}` }, env('RBA_SRC_APPROVER_A'))
expect(final.body?.receipt?.state, `final 回执:${final.text.slice(0, 400)}`).toBe('committed')
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)
expect.soft(await page.locator('.drw-download', { hasText: /完整包|全部|打包|ZIP|zip/ }).count(),
'GAP[RDM] rdm.render.download 只支持 docx/review-docx/markdown/manifest/diagnostics,无完整交付包(zip)种类;GAP[Frontend] 交付记录无「完整包」下载入口').toBeGreaterThan(0)
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) })
const packageButtons = await page.locator('.drw-download', { hasText: /完整包|全部|打包|ZIP|zip/ }).count()
expect.soft(packageButtons,
'GAP[RDM] rdm.render.download 只支持 docx/review-docx/markdown/manifest/diagnostics,无完整交付包(zip)种类;GAP[Frontend] 交付记录无「完整包」下载入口').toBeGreaterThan(0)
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<string, unknown>][] = [
['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<string, unknown>]] : []),
]
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 交付文件名恒为 <docId>.<ext>,产品不产生长文件名;长名称以 ${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()
}
}
})