Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b853fd0e | ||
|
|
7acfef0a1f | ||
|
|
c22120d7fe | ||
|
|
15d8a4093b | ||
|
|
d2d81e119c | ||
|
|
fb846bed14 | ||
|
|
566fec9f56 | ||
|
|
98751916da | ||
|
|
ed9845f8e4 |
@@ -6,3 +6,6 @@ dist/
|
||||
|
||||
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
||||
.mimosa
|
||||
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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: 'section' }) }).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') ?? '',
|
||||
)
|
||||
})
|
||||
})
|
||||
Generated
+46
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -144,11 +144,14 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||
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<ListShell<TreeItem>>('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<ListShell<TreeItem>>('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<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
@@ -178,12 +193,28 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<button key={item.id} className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
<div key={item.id}>
|
||||
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && children[item.id] && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||
{children[item.id].map(sub => (
|
||||
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(sub)}>
|
||||
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
@@ -216,14 +247,21 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, sel])
|
||||
|
||||
// 未选对象时列出章节供选择
|
||||
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||||
// (parent=<docId>),按文档聚合后合并展示。
|
||||
const [sections, setSections] = useState<TreeItem[]>([])
|
||||
useEffect(() => {
|
||||
if (sel) return
|
||||
let c = false
|
||||
api.rdmCommand<ListShell<TreeItem>>('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<ListShell<TreeItem>>('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<ListShell<TreeItem>>('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])
|
||||
|
||||
|
||||
@@ -193,9 +193,18 @@ export function PluginsSection() {
|
||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||
{plugins.map(p => (
|
||||
<SettingRow key={p.manifest.id}
|
||||
label={`${p.manifest.name} v${p.manifest.version}`}
|
||||
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
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(' · ')}>
|
||||
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
</span>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
+16
-6
@@ -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('追溯审计')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user