Files
Agent-System-Frontend/e2e/rdm-audit.playwright.ts

225 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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$/)
})
})
// W10 审计工作台(rdm.audit.operations + rdm.trace.coverage/pending-recheck)。
// 需真实组合:RDM 二进制含 W8b/W5 命令面(SVC_REAL_RDM_BIN 指向该构建)且 serve 以 auth on 运行;
// e2e.sh 默认从 RDM origin/main 构建,未必含这些命令——缺 SVC_REAL_RDM_BIN 时整组跳过,不伪造结果。
const OPS_ROUTE = '**/api/commands/rdm.audit.operations'
async function openOperations(page: Page) {
await login(page)
await openAudit(page)
await page.getByRole('tab', { name: '审计记录' }).click()
await expect(page.getByText('正在读取审计记录…')).toHaveCount(0, { timeout: 15_000 })
}
/** 改写请求体的 params(命令总线请求体为 { params, ... })。 */
const withParams = (raw: string | null, patch: Record<string, unknown>) => {
const body = JSON.parse(raw || '{}') as { params?: Record<string, unknown> }
return JSON.stringify({ ...body, params: { ...(body.params || {}), ...patch } })
}
test.describe.serial('W10 审计工作台:审计记录', () => {
test.skip(!process.env.SVC_REAL_RDM_BIN, '需真实 RDM 二进制(SVC_REAL_RDM_BIN,含 rdm.audit.operations / rdm.trace.*)与 auth-on 组合环境')
test('列表呈现业务摘要与受控覆盖,行展开见字段差异与来源链', async ({ page }) => {
await openOperations(page)
await expect(page.getByText('受控追溯覆盖').or(page.getByText(/受控覆盖统计暂不可用/))).toBeVisible()
const rows = page.locator('.rdm-audit-op')
await expect(rows.first()).toBeVisible()
await expect(page.getByText(/已显示 \d+ \/ 共 \d+ 条 · 截至水位 \d+/)).toBeVisible()
await rows.first().locator('.mk-col-trigger').click()
await expect(rows.first().getByText('字段差异')).toBeVisible()
await expect(rows.first().getByText('来源与确认链')).toBeVisible()
await expect(rows.first().getByText('影响(当时与当前)')).toBeVisible()
})
test('游标翻页:每页一条时加载更多追加下一条', async ({ page }) => {
await page.route(OPS_ROUTE, route => route.continue({ postData: withParams(route.request().postData(), { limit: 1 }) }))
await openOperations(page)
const rows = page.locator('.rdm-audit-op')
await expect(rows).toHaveCount(1)
const meta = await page.getByText(/已显示 \d+ \/ 共 \d+ 条/).textContent()
const total = Number(/共 (\d+) 条/.exec(meta || '')?.[1] || 0)
test.skip(total < 2, `种子项目仅 ${total} 条审计记录,无法验证翻页`)
await page.getByRole('button', { name: '加载更多记录' }).click()
await expect(rows).toHaveCount(2)
})
test('游标失效:续页报明确错误,可从第一页重新查询', async ({ page }) => {
await page.route(OPS_ROUTE, async route => {
const body = JSON.parse(route.request().postData() || '{}') as { params?: { cursor?: string } }
if (body.params?.cursor) {
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ receipt: { state: 'rejected', error: '操作记录游标已失效:游标绑定主体不符——请从首页重新查询' } }) })
return
}
const res = await route.fetch({ postData: withParams(route.request().postData(), { limit: 1 }) })
const json = await res.json() as { payload?: Record<string, unknown> } & Record<string, unknown>
const pageBody = (json.payload ?? json) as Record<string, unknown>
pageBody.hasMore = true
pageBody.nextCursor = 'stale-cursor'
await route.fulfill({ response: res, json })
})
await openOperations(page)
await page.getByRole('button', { name: '加载更多记录' }).click()
await expect(page.getByText(/翻页位置已失效/)).toBeVisible()
await expect(page.getByRole('button', { name: '加载更多记录' })).toHaveCount(0)
await page.getByRole('button', { name: '从第一页重新查询' }).click()
await expect(page.getByText(/翻页位置已失效/)).toHaveCount(0)
await expect(page.locator('.rdm-audit-op')).toHaveCount(1)
})
test('无审计授权:首页错误态可读', async ({ page }) => {
await page.route(OPS_ROUTE, route => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ receipt: { state: 'rejected', error: '权限拒绝:无项目审计授权' } }) }))
await openOperations(page)
await expect(page.getByText(/你没有该项目的审计授权/)).toBeVisible()
await expect(page.getByText('原始回执')).toBeVisible()
})
})