// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。 // 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on) // 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。 // 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致): // PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173) // E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员) // E2E_PROJECT_MAIN 已 ingest 的主项目名 // E2E_PROJECT_ISOLATED 零数据隔离项目名 import { test, expect, type Page } from '@playwright/test' 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 || '隔离检查项目' test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)') 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 gate = page.getByText('修改密码') const gated = await gate.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 }) } async function openAudit(page: Page) { // 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。 await page.locator('.tsb-tool', { hasText: '追溯审计' }).click() await expect(page.getByText('追溯审计').first()).toBeVisible() await expect(page.locator('.rdm-audit-project')).toBeVisible() } test.describe.serial('U4 项目追溯审计工作区', () => { // 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。 const card = (page: Page, label: string) => page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) }) test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => { await login(page) await openAudit(page) // 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。 await expect(page.locator('.rdm-audit-card')).toHaveCount(9) await expect(page.getByText(/截至水位 \d+/)).toBeVisible() // 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。 await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/) await expect(card(page, '章节')).toContainText(/[1-9]/) }) test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => { await login(page) await openAudit(page) // 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。 // docs 树是层级结构:第一层是文档,章节在展开后的第二层。 await page.getByRole('tab', { name: '资料与文档' }).click() await page.locator('.rdm-audit-tree-node').first().click() const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node') .filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first() await expect(sectionNode).toBeVisible() await sectionNode.click() await expect(page.locator('.rdm-audit-detail')).toBeVisible() await expect(page.getByText('最近事件')).toBeVisible() // 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态 //(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。 await page.getByRole('tab', { name: '关联追溯' }).click() await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0) await page.locator('.rdm-audit-pick button').first().click() await expect(page.getByText('← 返回')).toBeVisible() await expect( page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')), ).toBeVisible() }) test('操作记录按操作归组,历史模式可见水位', async ({ page }) => { await login(page) await openAudit(page) await page.getByRole('tab', { name: '操作记录' }).click() // 事件以单行或操作组呈现;种子项目必有 ingest 事件。 await expect( page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')), ).not.toHaveCount(0) // 历史模式:返回当前切换明确,无对象时输入提示可理解。 await page.getByRole('button', { name: '历史模式' }).click() await expect(page.getByText(/截至 seq \d+/)).toBeVisible() await page.getByRole('button', { name: '← 返回当前' }).click() await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible() }) test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => { await login(page) await openAudit(page) // 切到隔离项目:概览计数应为 0 且无残留「加载中…」。 await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED }) await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 }) await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0') // 空态:文档树与时间线给出可理解提示。 await page.getByRole('tab', { name: '资料与文档' }).click() await expect(page.getByText('暂无文档')).toBeVisible() await page.getByRole('tab', { name: '操作记录' }).click() await expect(page.getByText('暂无事件')).toBeVisible() // 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。 await page.getByRole('tab', { name: '概览' }).click() await page.locator('.rdm-audit-project').selectOption({ label: MAIN }) await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 }) await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/) await expect(page.locator('.rdm-audit-project')).toHaveValue( await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '', ) }) test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => { await login(page) await openAudit(page) const exportBox = page.locator('.rdm-audit-export') await expect(exportBox).toBeVisible() await exportBox.getByLabel('导出类型').selectOption('cgraph-docx') await exportBox.getByRole('button', { name: '生成', exact: true }).click() const draft = exportBox.getByRole('button', { name: '生成草稿' }) if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) { await draft.click() } await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 }) await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible() await expect(exportBox.getByText(/有效期/)).toBeVisible() const download = page.waitForEvent('download') await exportBox.getByRole('button', { name: '下载' }).click() const file = await download expect(file.suggestedFilename()).toMatch(/\.docx$/) }) })