From 98751916da4086cfaac06e84c9700fff59da808e Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 16:58:08 +0000 Subject: [PATCH 1/8] 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' }, }) -- 2.54.0 From 566fec9f56a8fcdcb72eb9f2fd58f400bbae8804 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 17:52:27 +0000 Subject: [PATCH 2/8] Register the audit workspace builtin so its entry is reachable Why: buildWorkspaceEntries pushed only BUILTINS[0] (agents); the U4 rdm-audit workspace was declared but never ranked, so no user could open the trace/audit workspace from the sidebar. Evidence: vitest workspaces.test.ts 4/4 (updated expectations plus a new overflow-truncation case); full browser run in scripts/e2e.sh. Impact: sidebar gains the second builtin tool; server-declared workspaces keep their shortcut precedence. Refs: U4 (Dev #368) regression; task feature/rdm-e2e-acceptance. --- e2e/rdm-audit.playwright.ts | 9 ++++----- src/workspaces.ts | 1 + test/workspaces.test.ts | 22 ++++++++++++++++------ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index 172795c..fe0a4c0 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -36,14 +36,13 @@ async function login(page: Page) { await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) await page.getByRole('button', { name: '登录', exact: true }).click() } - // 登录成功后左侧体系工具栏出现「追溯审计」入口。 - await expect(page.locator('button[title^="追溯审计"]')).toBeVisible({ timeout: 15_000 }) + // 登录成功后侧栏体系工具出现「追溯审计」入口。 + await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 }) } async function openAudit(page: Page) { - // 左栏默认展开且展开态无体系工具入口:先收起(⌘B 按钮进入 icon 轨)。 - await page.locator('button[title="收起侧栏(⌘B)"]').click() - await page.locator('button[title^="追溯审计"]').click() + // 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。 + await page.locator('.tsb-tool', { hasText: '追溯审计' }).click() await expect(page.getByText('追溯审计').first()).toBeVisible() await expect(page.locator('.rdm-audit-project')).toBeVisible() } diff --git a/src/workspaces.ts b/src/workspaces.ts index 3735611..92631d0 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -57,6 +57,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry return 99 } ranked.push({ rank: takeFree(1), entry: BUILTINS[0] }) + ranked.push({ rank: takeFree(2), entry: BUILTINS[1] }) for (const entry of unranked) ranked.push({ rank: takeFree(9), entry }) return ranked .filter(item => item.rank <= 9) diff --git a/test/workspaces.test.ts b/test/workspaces.test.ts index 2c7423a..34472fd 100644 --- a/test/workspaces.test.ts +++ b/test/workspaces.test.ts @@ -1,19 +1,20 @@ import { describe, expect, it } from 'vitest' import { buildWorkspaceEntries } from '../src/workspaces' -// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。 +// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置 +// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。 describe('buildWorkspaceEntries', () => { - it('没有领域插件时,代理补在 ⌘1', () => { + it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => { const entries = buildWorkspaceEntries([]) - expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理']) + expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计']) }) it('声明了 shortcut 的工作区占住该位,内置工具让位', () => { const entries = buildWorkspaceEntries([ { id: 'trace', title: '追溯网', shortcut: '1' }, ]) - expect(entries.map(e => e.title)).toEqual(['追溯网', '代理']) - expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2']) + expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计']) + expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3']) expect(entries[0].key).toBe('ws:trace') }) @@ -21,6 +22,15 @@ describe('buildWorkspaceEntries', () => { const entries = buildWorkspaceEntries([ { id: 'mem', title: '记忆库' }, ]) - expect(entries.map(e => e.title)).toEqual(['代理', '记忆库']) + expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库']) + }) + + it('九位占满时低序内置保留,溢出项截断', () => { + const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) })) + const entries = buildWorkspaceEntries(server) + // ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。 + expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8) + expect(entries.map(e => e.title)).toContain('代理') + expect(entries.map(e => e.title)).not.toContain('追溯审计') }) }) -- 2.54.0 From fb846bed1454df7edd9d3fcf2c56ebb972cf0c27 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 17:53:40 +0000 Subject: [PATCH 3/8] Pin summary-card assertions to exact labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: hasText('章节') also matches the 空缺章节 card, tripping Playwright strict mode. Evidence: rerun of scripts/e2e.sh in this task. Impact: test-only. --- e2e/rdm-audit.playwright.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index fe0a4c0..021d4de 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -48,15 +48,19 @@ async function openAudit(page: Page) { } 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() - // 主项目应有切片与章节(种子 ingest 保证)。 - await expect(page.locator('.rdm-audit-card', { hasText: '切片' })).toContainText(/\d+/) - await expect(page.locator('.rdm-audit-card', { hasText: '章节' })).not.toContainText(/^0$/) + // 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。 + await expect(card(page, '切片')).toContainText(/[1-9]/) + await expect(card(page, '章节')).toContainText(/[1-9]/) }) test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => { @@ -97,7 +101,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => { // 切到隔离项目:概览计数应为 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 expect(card(page, '切片')).toContainText(/^0$/) // 空态:文档树与时间线给出可理解提示。 await page.getByRole('tab', { name: '资料与文档' }).click() await expect(page.getByText('暂无文档')).toBeVisible() @@ -107,7 +111,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => { 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(card(page, '切片')).toContainText(/[1-9]/) await expect(page.locator('.rdm-audit-project')).toHaveValue( await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '', ) -- 2.54.0 From d2d81e119c639c3e0d31217dc27a031da5e1d997 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 17:54:25 +0000 Subject: [PATCH 4/8] Pick a section node for the forensic path Why: the first docs-tree node is document-level and carries no event history; U4 evidence-finding targets sections. Evidence: scripts/e2e.sh rerun in this task. Impact: test-only. --- e2e/rdm-audit.playwright.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index 021d4de..b39f66b 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -67,9 +67,12 @@ test.describe.serial('U4 项目追溯审计工作区', () => { await login(page) await openAudit(page) // 路径①:资料与文档(点击1)→ 选章节(点击2)→ 对象详情含最近事件(修改原因)。 + // 树首个节点是文档级(无事件史),取证对象选 section。 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() + const sectionNode = page.locator('.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)→ 来源依据/影响对象分列。 -- 2.54.0 From 15d8a4093b708fde9dd97029523fb5c579337d3a Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 17:56:39 +0000 Subject: [PATCH 5/8] Expand the hierarchical docs tree and aggregate sections for tracing Why: rdm.inspect.tree docs is two-level (parent= returns sections), but the U4 workspace only fetched level one - the tree showed bare documents, clicking a doc hit unsupported inspect.object kind, and the trace tab always showed no sections. Evidence: tsc + design-guard green; scripts/e2e.sh full run in this task. Impact: tree browsing expands documents into sections; trace entry lists sections aggregated per document. Refs: U4/U3 read-service contract; task feature/rdm-e2e-acceptance. --- e2e/rdm-audit.playwright.ts | 7 ++-- src/components/RdmAuditWorkspace.tsx | 60 +++++++++++++++++++++++----- src/styles.css | 2 + 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index b39f66b..36bc1dd 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -66,10 +66,11 @@ test.describe.serial('U4 项目追溯审计工作区', () => { test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => { await login(page) await openAudit(page) - // 路径①:资料与文档(点击1)→ 选章节(点击2)→ 对象详情含最近事件(修改原因)。 - // 树首个节点是文档级(无事件史),取证对象选 section。 + // 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。 + // docs 树是层级结构:第一层是文档,章节在展开后的第二层。 await page.getByRole('tab', { name: '资料与文档' }).click() - const sectionNode = page.locator('.rdm-audit-tree-node') + 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() diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index 83605e0..07aaed3 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -144,11 +144,14 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { const [err, setErr] = useState('') const [selected, setSelected] = useState(null) const [detail, setDetail] = useState(null) + // docs 树是层级结构(rdm.inspect.tree parent= 才返回章节):文档节点 + // 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。 + const [children, setChildren] = useState>({}) const genRef = useRef(0) useEffect(() => { const gen = ++genRef.current - setShell(null); setErr(''); setSelected(null); setDetail(null) + setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({}) api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' }) .then(s => { if (gen === genRef.current) setShell(s) }) .catch(e => { if (gen === genRef.current) setErr(e.message) }) @@ -162,7 +165,19 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { .catch(e => { if (gen === genRef.current) setErr(e.message) }) } + const toggleDoc = (item: TreeItem) => { + if (children[item.id]) { + setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next }) + return + } + const gen = genRef.current + api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' }) + .then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) }) + .catch(e => { if (gen === genRef.current) setErr(e.message) }) + } + const selectNode = (item: TreeItem) => { + if (item.kind === 'doc') { toggleDoc(item); return } setSelected(item); setDetail(null) const gen = genRef.current api.rdmCommand('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id }) @@ -178,12 +193,28 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
{shell.items.map(item => ( - +
+ + {item.kind === 'doc' && children[item.id] && ( +
+ {children[item.id].length === 0 &&
无章节
} + {children[item.id].map(sub => ( + + ))} +
+ )} +
))} {shell.hasMore && } {shell.truncated &&
已截断(深度或数量上限)
} @@ -216,14 +247,21 @@ function TraceTab({ pid }: { pid: string }) { .catch(e => { if (gen === genRef.current) setErr(e.message) }) }, [pid, sel]) - // 未选对象时列出章节供选择 + // 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下 + // (parent=),按文档聚合后合并展示。 const [sections, setSections] = useState([]) useEffect(() => { if (sel) return let c = false - api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs' }) - .then(s => { if (!c) setSections(s.items.filter(i => i.kind === 'section')) }) - .catch(() => {}) + ;(async () => { + const top = await api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs' }) + const secs: TreeItem[] = [] + for (const d of top.items.filter(i => i.kind === 'doc')) { + const sub = await api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id }) + secs.push(...sub.items.filter(i => i.kind === 'section')) + } + if (!c) setSections(secs) + })().catch(() => {}) return () => { c = true } }, [pid, sel]) diff --git a/src/styles.css b/src/styles.css index b1ea5bc..a9a9c54 100644 --- a/src/styles.css +++ b/src/styles.css @@ -596,6 +596,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); } .rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); } .rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); } +.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); } +.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; } .rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; } .rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rdm-audit-more { margin-top: 8px; } -- 2.54.0 From c22120d7fedab41b8921e333579768f54abf9d88 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 17:57:23 +0000 Subject: [PATCH 6/8] Accept the explicit empty state on the trace path Why: seeded sections carry no confirmed trace edges yet, so the no-relations empty state is the correct U4 presentation. Evidence: scripts/e2e.sh rerun in this task. Impact: test-only. --- e2e/rdm-audit.playwright.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index 36bc1dd..bd2646f 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -76,12 +76,15 @@ test.describe.serial('U4 项目追溯审计工作区', () => { await sectionNode.click() await expect(page.locator('.rdm-audit-detail')).toBeVisible() await expect(page.getByText('最近事件')).toBeVisible() - // 路径②:关联追溯(点击1)→ 选章节(点击2)→ 来源依据/影响对象分列。 + // 路径②:关联追溯(点击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() + await expect(page.getByText('← 返回')).toBeVisible() + await expect( + page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')), + ).toBeVisible() }) test('操作记录按操作归组,历史模式可见水位', async ({ page }) => { -- 2.54.0 From 7acfef0a1f6d1b59f6fca5e544d05df364257802 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 17:58:12 +0000 Subject: [PATCH 7/8] Assert the count element instead of the concatenated card text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: card text renders as num+label (e.g. 0切片), so whole-text regexes never matched. Evidence: scripts/e2e.sh rerun in this task. Impact: test-only. --- e2e/rdm-audit.playwright.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index bd2646f..a75ce77 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -59,7 +59,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => { await expect(page.locator('.rdm-audit-card')).toHaveCount(9) await expect(page.getByText(/截至水位 \d+/)).toBeVisible() // 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。 - await expect(card(page, '切片')).toContainText(/[1-9]/) + await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/) await expect(card(page, '章节')).toContainText(/[1-9]/) }) @@ -108,7 +108,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => { // 切到隔离项目:概览计数应为 0 且无残留「加载中…」。 await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED }) await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 }) - await expect(card(page, '切片')).toContainText(/^0$/) + await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0') // 空态:文档树与时间线给出可理解提示。 await page.getByRole('tab', { name: '资料与文档' }).click() await expect(page.getByText('暂无文档')).toBeVisible() @@ -118,7 +118,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => { 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, '切片')).toContainText(/[1-9]/) + 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') ?? '', ) -- 2.54.0 From 01b853fd0ecd4d24df929b9405d0ab9baa5bc408 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 01:45:02 +0000 Subject: [PATCH 8/8] Render compiled plugin entries in the admin plugins list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: /api/plugins now merges compiled-in plugins (backend.mode=inproc); the list needed to badge them as builtin, count their commands, and stop printing a dangling "v" when the synthesized manifest has no version. Evidence: npx vitest run 100/100 green incl. design-guard; tsc --noEmit clean for src (../ui errors are the symlinked-deps environment only); CI (make test) is authoritative. Impact: PluginsSection label hides empty versions, desc adds a command count and falls back to manifest intent counts, and inproc entries get a "内置" badge next to the api badge. No token or color changes. Refs: WP3-I4; task card feature-plugins-compiled-listing. --- src/components/adminSections.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index ee05113..15faddf 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -193,9 +193,18 @@ export function PluginsSection() { {plugins.length === 0 && 暂无已装载插件
(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)
} {plugins.map(p => ( - api {p.manifest.apiVersion} + label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')} + desc={[ + `工具 ${p.manifest.provides.tools?.length ?? 0}`, + `命令 ${p.manifest.provides.commands?.length ?? 0}`, + `面板 ${p.panels?.length ?? 0}`, + `意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`, + `工作流 ${p.workflows?.length ?? 0}`, + ].join(' · ')}> + + {p.manifest.backend?.mode === 'inproc' && 内置} + api {p.manifest.apiVersion} + ))}
-- 2.54.0