diff --git a/e2e/document-renderer.playwright.ts b/e2e/document-renderer.playwright.ts index b8b4873..45d987e 100644 --- a/e2e/document-renderer.playwright.ts +++ b/e2e/document-renderer.playwright.ts @@ -1,199 +1,662 @@ -// 统一渲染 Task 11 运行验收:真实产品走通「资料与文档 → 阅读与交付」。 -// 驱动方:Dev 仓 scripts/e2e.sh(同 rdm-audit.playwright.ts 的环境契约); -// PLAYWRIGHT_BASE_URL 未设置时整组跳过。种子数据差异(是否有诊断、是否已有审阅提交) -// 以「可理解状态」分支断言,不伪造服务端内容。 -import { test, expect, type Page } from '@playwright/test' +// 统一渲染 §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 OWNER = process.env.E2E_OWNER || 'e2e-auditor' -const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026' -const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b' -const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目' -const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目' +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, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)') +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 }) -async function login(page: Page) { - await page.goto('/') - await expect(page.getByPlaceholder('由管理员分配')).toBeVisible() - await page.getByPlaceholder('由管理员分配').fill(OWNER) - await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD) - await page.getByRole('button', { name: '登录', exact: true }).click() - const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false) - if (gated) { - const pwInputs = page.locator('.lg-card input[type="password"]') - await pwInputs.nth(0).fill(PASSWORD) - await pwInputs.nth(1).fill(PASSWORD2) - await pwInputs.nth(2).fill(PASSWORD2) - await page.getByRole('button', { name: '设置新密码并进入' }).click() - } else { - await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) - await page.getByRole('button', { name: '登录', exact: true }).click() - } - await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_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) } -async function openDocument(page: Page) { - await page.locator('.tsb-tool', { hasText: '追溯审计' }).click() - await expect(page.locator('.rdm-audit-project')).toBeVisible() - await page.locator('.rdm-audit-project').selectOption({ label: MAIN }) +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.locator('.rdm-audit-tree-open').first() - await expect(open).toBeVisible({ timeout: 10_000 }) + 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.getByRole('tree', { name: '文档结构画布' })).toBeVisible({ timeout: 10_000 }) + 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}$`) }) }) -/** 失败文案必须可读:不得把原始 JSON 回执直出到主文案。 */ +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('{')).toBe(false) - expect(msg).not.toContain('"receipt"') + expect(msg.trim().startsWith('{'), `失败文案不得是原始 JSON:${msg}`).toBe(false) } } -test.describe.serial('统一渲染 Task 11 文档阅读与交付', () => { - test('结构画布与阅读视图共用选中与过滤,切换不丢', async ({ page }) => { - await login(page) - await openDocument(page) - const nodes = page.locator('.drw-canvas-node') - await expect(nodes.first()).toBeVisible() - const count = await nodes.count() - test.skip(count < 2, '种子文档无章节,跳过双视图选中断言') - const target = nodes.nth(1) - const title = (await target.getAttribute('title')) ?? '' - await target.click() - await expect(target).toHaveAttribute('aria-selected', 'true') - await page.getByLabel('过滤节点').fill(title.slice(0, 2)) - await page.getByRole('tab', { name: '阅读视图' }).click() - await expect(page.locator('.drw-reader')).toBeVisible() - await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText(title) - await expect(page.getByLabel('过滤节点')).toHaveValue(title.slice(0, 2)) - await page.getByRole('tab', { name: '结构画布' }).click() - await expect(page.locator(`.drw-canvas-node[aria-selected="true"]`)).toHaveAttribute('title', title) - }) +// ---- §11 ---- - test('结构画布可键盘导航并可滚动平移', async ({ page }) => { - await login(page) - await openDocument(page) - const root = page.locator('.drw-canvas-node').first() - await root.focus() - await page.keyboard.press('ArrowDown') - const selected = page.locator('.drw-canvas-node[aria-selected="true"]') - if (await page.locator('.drw-canvas-node').count() > 1) { - await expect(selected).toBeFocused() - await page.keyboard.press('ArrowLeft') - await expect(root).toHaveAttribute('aria-selected', 'true') - } - const canvas = page.getByRole('tree', { name: '文档结构画布' }) - const box = await canvas.boundingBox() - expect(box?.height ?? 0).toBeGreaterThan(100) - }) +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) - test('规则选择显示来源/版本/状态;不可用项给出原因;拒绝可读', async ({ page }) => { - await login(page) - await openDocument(page) - const rules = panel(page, '渲染规则') - await expect(rules.locator('.drw-profile').or(rules.locator('.drw-failure')).or(rules.getByText('没有可用的渲染规则')).first()) - .toBeVisible({ timeout: 10_000 }) - await expectReadableFailures(page) - for (const option of await rules.locator('.drw-profile').all()) { - await expect(option.locator('.drw-profile-source')).toHaveText(/^(文档规则|文档类型规则|模板规则|项目默认规则|公司默认规则)$/) - await expect(option.locator('.drw-profile-ver')).toHaveText(/^v.+/) - if (await option.locator('input[disabled]').count()) await expect(option.locator('.drw-profile-reason')).not.toBeEmpty() - } - if (await rules.locator('.drw-notice').count()) await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则') - }) + 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('生成预览:状态与诊断可见,点击诊断选中对应节点', async ({ page }) => { - await login(page) - await openDocument(page) - const generate = page.getByRole('button', { name: '生成预览', exact: true }) - await expect(generate).toBeVisible({ timeout: 10_000 }) - test.skip(await generate.isDisabled(), '无可预览规则') - await generate.click() - const preview = panel(page, '预览与提交') - await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 30_000 }) - await expectReadableFailures(page) - if (await preview.locator('.drw-op .drw-failure').count()) { - await expect(preview.getByRole('button', { name: '重试' })).toBeVisible() - return - } - const receipt = preview.locator('.drw-receipt') - await expect(receipt.locator('.ui-chip').first()).toHaveText(/^(已生成|生成中|排队中|生成失败|已取消|状态未知)$/) - // 诊断不在生成回执里:必须另行下载 diagnostics 后才出现(读取中 → 列表 / 无问题 / 可读失败)。 - await expect(receipt.getByText('诊断读取中')).toHaveCount(0, { timeout: 30_000 }) - await expect(receipt.locator('.drw-diags').or(receipt.getByText('没有诊断问题')).or(receipt.locator('.drw-failure')).first()).toBeVisible() - await expectReadableFailures(page) - // 错误/警告定位到节点:每个锚点都对应画布上带计数徽标的同名节点(提示级不计徽标)。 - const counted = receipt.locator('.drw-diag.sev-error button.drw-diag-node, .drw-diag.sev-warning button.drw-diag-node') - const countedTotal = await counted.count() - for (let i = 0; i < Math.min(countedTotal, 5); i++) { - const title = (await counted.nth(i).getAttribute('title')) ?? '' - expect(title).not.toBe('') - await expect(page.locator(`.drw-canvas-node[title="${title.replace(/"/g, '\\"')}"] .drw-badge`).first()).toBeVisible() - } - const anchors = receipt.locator('button.drw-diag-node') - if (await anchors.count()) { - const located = countedTotal ? counted.first() : anchors.first() - const title = (await located.getAttribute('title')) ?? '' - await located.click() - await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', title) - await page.getByRole('tab', { name: '阅读视图' }).click() - const section = page.locator('section.drw-reader-node.sel') - await expect(section).toHaveAttribute('id', /^drw-node-/) - await expect(section.locator('.drw-reader-title')).toHaveText(title) - if (countedTotal) await expect(section.locator('.drw-badge').first()).toBeVisible() - await expect(section.locator('a.drw-anchor')).toHaveAttribute('href', `#${await section.getAttribute('id')}`) - } - await expect(page.locator('.drw-delivery').first()).toBeVisible({ timeout: 15_000 }) - }) +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, ANNOTATION, REVISION_AFTER]) expect(pageText, `页面不得出现受限内容「${m}」`).not.toContain(m) + const passiveCount = s.net.length - test('交付记录可下载,文件名截断且 title 保留全名', async ({ page }) => { - await login(page) - await openDocument(page) - const download = page.locator('.drw-download').first() - test.skip(!(await download.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true).catch(() => false)), '暂无交付记录') - const file = page.waitForEvent('download') - await download.click() - const saved = await file - expect(saved.suggestedFilename()).toMatch(/\.(docx|md|json|txt)$/) - const name = panel(page, '交付记录').locator('.drw-filename').first() - await expect(name).toHaveAttribute('title', saved.suggestedFilename()) - }) + // 主动探测:以无权用户会话直调 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 }[] = [] + for (const [id, params] of probes) { + const r = await command(page, id, params) + 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 }) + 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`) + note(`宿主 A 会话列表 HTTP ${hostConv.status()}`) + expect.soft(hostConv.status() >= 400 || !(await hostConv.text()).includes('§11'), '宿主 A 会话不得可见').toBe(true) + note(`主动探测:${JSON.stringify(probeResults)}`) + await Promise.all(s.pending) - test('审阅:批注与修订分组,无法定位的单独成组', async ({ page }) => { - await login(page) - await openDocument(page) - const reviewPanel = panel(page, '审阅') - test.skip(!(await reviewPanel.count()), '服务未提供审阅能力') - await expect(reviewPanel.getByText('暂无审阅提交').or(reviewPanel.locator('.drw-annos')).or(reviewPanel.getByText('暂无批注')).first()) - .toBeVisible({ timeout: 10_000 }) - await expectReadableFailures(page) - await expect(reviewPanel).not.toContainText(' { + 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}`) }) + const scanned = path.join(EVID, 'network', 'item8-scan.json') + fs.mkdirSync(path.dirname(scanned), { recursive: true }) + fs.writeFileSync(scanned, JSON.stringify({ responses: s.net.length, passive: passiveCount, contentMarkers, idMarkers, leaks, probeResults }, null, 2)) + evidence.push(scanned) + expect(leaks, `受限内容泄露:${leaks.join(';')}`).toEqual([]) + await s.close() - test('窄屏与深色主题下保持可用', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await login(page) - await page.evaluate(() => { document.documentElement.dataset.theme = 'dark' }) - await openDocument(page) - const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth) - expect(overflow).toBeLessThanOrEqual(1) - await expect(page.getByRole('button', { name: '← 返回文档树' })).toBeVisible() - await page.getByRole('button', { name: '← 返回文档树' }).click() - await expect(page.locator('.rdm-audit-tree-open').first()).toBeVisible() - }) + // 反向: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('切换项目后不残留上一项目的文档内容', async ({ page }) => { - await login(page) - await openDocument(page) - await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED }) - await expect(page.locator('.drw')).toHaveCount(0) - await page.getByRole('tab', { name: '资料与文档' }).click() - await expect(page.getByText('暂无文档')).toBeVisible({ timeout: 10_000 }) - }) +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() + } + } })