From 98751916da4086cfaac06e84c9700fff59da808e Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 16:58:08 +0000 Subject: [PATCH] Add Playwright E2E harness for the RDM audit workspace Why: U4 acceptance requires real-browser runs of the trace/audit workspace; the repo had only a manual .playwright.ts stub gated on PLAYWRIGHT_BASE_URL with no config, deps, or service wiring. Evidence: config/deps/spec staged; full run driven by Dev-side scripts/e2e.sh (seed-only smoke in progress in this task branch). Impact: dev-only additions (@playwright/test devDep, preview proxy segment, e2e/rdm-audit.playwright.ts); no production bundle changes. Refs: Dev task feature/rdm-e2e-acceptance; U4 in modules/documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md --- .gitignore | 3 + e2e/command-pairs.playwright.ts | 6 +- e2e/rdm-audit.playwright.ts | 116 ++++++++++++++++++++++++++++++++ package-lock.json | 46 +++++++++++++ package.json | 4 +- playwright.config.ts | 20 ++++++ vite.config.ts | 8 +++ 7 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 e2e/rdm-audit.playwright.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index d57d635..b4f9040 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist/ # mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库) .mimosa +# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e//frontend/) +test-results/ +playwright-report/ diff --git a/e2e/command-pairs.playwright.ts b/e2e/command-pairs.playwright.ts index 87d67be..44ddf7a 100644 --- a/e2e/command-pairs.playwright.ts +++ b/e2e/command-pairs.playwright.ts @@ -1,10 +1,12 @@ // 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go: // 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。 -// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。 +// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts +// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。) import { test, expect } from '@playwright/test' test('command menu and chat share builtin.help', async ({ page }) => { - test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run') + test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS, + 'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run') await page.goto(process.env.PLAYWRIGHT_BASE_URL!) await expect(page.getByText('帮助')).toBeVisible() }) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts new file mode 100644 index 0000000..172795c --- /dev/null +++ b/e2e/rdm-audit.playwright.ts @@ -0,0 +1,116 @@ +// 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('button[title^="追溯审计"]')).toBeVisible({ timeout: 15_000 }) +} + +async function openAudit(page: Page) { + // 左栏默认展开且展开态无体系工具入口:先收起(⌘B 按钮进入 icon 轨)。 + await page.locator('button[title="收起侧栏(⌘B)"]').click() + await page.locator('button[title^="追溯审计"]').click() + await expect(page.getByText('追溯审计').first()).toBeVisible() + await expect(page.locator('.rdm-audit-project')).toBeVisible() +} + +test.describe.serial('U4 项目追溯审计工作区', () => { + test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => { + await login(page) + await openAudit(page) + // 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。 + await expect(page.locator('.rdm-audit-card')).toHaveCount(9) + await expect(page.getByText(/截至水位 \d+/)).toBeVisible() + // 主项目应有切片与章节(种子 ingest 保证)。 + await expect(page.locator('.rdm-audit-card', { hasText: '切片' })).toContainText(/\d+/) + await expect(page.locator('.rdm-audit-card', { hasText: '章节' })).not.toContainText(/^0$/) + }) + + test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => { + await login(page) + await openAudit(page) + // 路径①:资料与文档(点击1)→ 选章节(点击2)→ 对象详情含最近事件(修改原因)。 + await page.getByRole('tab', { name: '资料与文档' }).click() + await expect(page.locator('.rdm-audit-tree-node')).not.toHaveCount(0) + await page.locator('.rdm-audit-tree-node').first().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(/来源依据(\d+)/)).toBeVisible() + await expect(page.getByText(/影响对象(\d+)/)).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(page.locator('.rdm-audit-card', { hasText: '切片' })).toContainText(/^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(page.locator('.rdm-audit-card', { hasText: '切片' })).toContainText(/[1-9]/) + await expect(page.locator('.rdm-audit-project')).toHaveValue( + await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '', + ) + }) +}) diff --git a/package-lock.json b/package-lock.json index 630fced..7033c71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@types/node": "^22.20.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -1212,6 +1213,22 @@ "win32" ] }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2749,6 +2766,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index 3943559..4351ded 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "test": "vitest run", - "preview": "vite preview" + "preview": "vite preview", + "e2e": "playwright test" }, "dependencies": { "docx-preview": "^0.4.1", @@ -21,6 +22,7 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@types/node": "^22.20.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a406231 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,20 @@ +// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。 +// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据); +// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。 +import { defineConfig } from '@playwright/test' + +const base = process.env.PLAYWRIGHT_BASE_URL || '' + +export default defineConfig({ + testDir: 'e2e', + testMatch: /.*(spec|playwright)\.ts$/, + timeout: 30_000, + retries: 0, + workers: 1, + reporter: [['list']], + use: { + baseURL: base, + locale: 'zh-CN', + trace: 'retain-on-failure', + }, +}) diff --git a/vite.config.ts b/vite.config.ts index bc3b2a0..fb4b47d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,5 +18,13 @@ export default defineConfig({ '/api': { target: 'http://127.0.0.1:8787', changeOrigin: false }, }, }, + // E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入 + // PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。 + preview: { + port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173), + proxy: { + '/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false }, + }, + }, test: { environment: 'node' }, })