225 lines
13 KiB
TypeScript
225 lines
13 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$/)
|
||
})
|
||
})
|
||
|
||
// 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()
|
||
})
|
||
})
|