Why: Owner decision merge_whole requires the parent "two trees" initiative's Frontend work (local-only95e1948, feature/rdm-two-trees-readable-audit: 814643f..95e1948) to ship together with the unified document render workspace branch. Evidence: - Conflict src/components/RdmAuditWorkspace.tsx (TreeList render): HEAD added a per-document "阅读与交付" open button inside the old two-level doc/section list;95e1948replaced that list with the recursive TreeNodeRow. Kept the recursive TreeNodeRow and moved the open button into it (wrapped in .rdm-audit-tree-row, onOpenDoc threaded through recursion, shown for kind === 'doc' at any depth; aria-label keeps "阅读与交付:<title>"). MaterialsTab/DocumentRenderWorkspace, saveBase64File and key={pid} from HEAD and readable presentation, paging, detail seq-guard, trace relation and timeline changes from95e1948merged cleanly. - Conflict src/styles.css (both appended at end of file): kept HEAD's render workspace / cockpit rules followed by 95e1948's 760px media query. - Semantic fix e2e/rdm-audit.playwright.ts: tree kind is now shown in business language, so the section filter matches '章节' instead of 'section'. - npm test: 42 files, 496 tests passed (incl. design-guard, rdmAuditPresentation, rdmAuditTree); npm run build: ok; node scripts/check-knip.mjs: 50 <= 51; npx playwright test --list: 78 tests in 17 files. Impact: RDM audit materials tab renders both project trees recursively with readable names and still opens any document in the render workspace; audit timeline and trace show readable records and retired/stale relations. Refs:95e1948(814643f,b52f965,c9b55eb,b921bef,505aba3,1ef68ab,ad503c9,987be74,95e1948); HEAD50d12ceCo-authored-by: Cursor <cursoragent@cursor.com>
147 lines
8.2 KiB
TypeScript
147 lines
8.2 KiB
TypeScript
// 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: '章节' }) }).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$/)
|
||
})
|
||
})
|