Why: Browser acceptance (/tmp/renderer-browser-acceptance/20261017T061021Z) found §11-4 showed 25/25 diagnostics as 未定位节点 because the renderer nodeId is the canonical mapping id (pp.cover.document-number.value) while the workspace tree uses dn-… ids; RDM now adds treeNodeId to every diagnostic. §11-3 failed only because the e2e spec still expected 已验证/未发布 although all 44 DHF profiles are published. Evidence: npx tsc --noEmit exit 0; npx vitest run 39 files / 445 tests passed (4 new in test/document-render-workspace.test.ts covering treeNodeId, nodeId fallback, unknown ids and per-node counts); npm run build ok; node scripts/check-knip.mjs unused exports 51 <= 51; e2e/document-renderer.playwright.ts type-checks (tsc exit 0) and playwright test --list loads 13 tests. Real rdm.render.profiles payload in evidence/network/item3-author.jsonl: template rule state=published, publicationAllowed=true, no blockedReason, refusal controlled_requires_exact_profile. Impact: normalizeDiagnostics keeps treeNodeId; locateDiagnostic resolves treeNodeId first and falls back to nodeId, otherwise the diagnostic stays explicitly unlocated. Badges and the 有诊断问题 filter count by the located tree node. Clicking a located diagnostic selects the node and scrolls it into view in canvas or reader, also on a repeated click. Diagnostics loading keeps its single request guard. §11-3 now asserts 已发布, 可正式交付, an enabled option and no reason, and still asserts source, version, the no-fallback notice and refusal. Not run against a live backend or browser here. Refs: unified renderer plan Task 13 §11 items 3 and 4; spec 10 §11 item 3; summary.md root causes §A and §B. Co-authored-by: Cursor <cursoragent@cursor.com>
744 lines
45 KiB
TypeScript
744 lines
45 KiB
TypeScript
// 统一渲染 §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)
|
||
}
|
||
}
|
||
|
||
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()
|
||
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<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()
|
||
}
|
||
}
|
||
})
|