Gate the audit workspace entry on rdm.inspect command availability
Why: the rdm-audit entry was an unconditional builtin, so on a plain base without any rdm.inspect.* commands the workspace opened straight into a rejected receipt (400) - diagnosed 2026-10-12. The page also rendered raw receipt JSON instead of a readable error. Evidence: vitest 23 files / 112 tests pass incl. the new contract matrix (workspaces.test.ts, rdm-errors.test.ts) and design-guard; tsc -b && vite build pass; e2e plain-base scenario added (driven by dev-repo scripts/e2e.sh phase 4). Impact: audit entry shows only when GET /api/commands lists all seven rdm.inspect.* commands enabled (store.rdmAuditAvailable, refreshed with the workspace list); audit errors classify into capability-missing / auth-expired / query-failed with the raw receipt kept in a Collapsible. Refs: 2026-10-12 diagnostic report, increment 2.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
|
||||
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
|
||||
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
|
||||
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
|
||||
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
|
||||
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'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
|
||||
const rdmRequests: string[] = []
|
||||
page.on('request', r => {
|
||||
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
|
||||
})
|
||||
await login(page)
|
||||
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
|
||||
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
await page.waitForTimeout(500)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
|
||||
expect(rdmRequests).toEqual([])
|
||||
})
|
||||
+1
-1
@@ -114,7 +114,7 @@ export const api = {
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
|
||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, EmptyState, SectionLabel } from '@ui'
|
||||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
@@ -29,6 +30,20 @@ const TABS = [
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||||
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||||
function RdmErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
return (
|
||||
<div className="rdm-audit-err">
|
||||
<EmptyState icon="alert">{c.message}</EmptyState>
|
||||
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RdmAuditWorkspace() {
|
||||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||
const [pid, setPid] = useState('')
|
||||
@@ -44,7 +59,7 @@ export function RdmAuditWorkspace() {
|
||||
}, [])
|
||||
|
||||
const project = caps?.projects.find(p => p.projectId === pid)
|
||||
if (error) return <EmptyState icon="alert">{error}</EmptyState>
|
||||
if (error) return <RdmErrorState raw={error} />
|
||||
if (!caps) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
@@ -89,7 +104,7 @@ function SummaryTab({ pid }: { pid: string }) {
|
||||
.catch(e => { if (!c) setErr(e.message) })
|
||||
return () => { c = true }
|
||||
}, [pid])
|
||||
if (err) return <EmptyState icon="alert">{err}</EmptyState>
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!data) return <div className="empty-note">加载中…</div>
|
||||
const counts = data.counts || {}
|
||||
const entries: [string, string, number][] = [
|
||||
@@ -185,7 +200,7 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <EmptyState icon="alert">{err}</EmptyState>
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||
|
||||
@@ -292,7 +307,7 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
<strong>{sel.title}</strong>
|
||||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||
</div>
|
||||
{err && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
|
||||
{relations && relations.length > 0 && (
|
||||
@@ -407,7 +422,7 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{err && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
|
||||
{items.map((item, i) => {
|
||||
if ('events' in item && Array.isArray((item as OpGroup).events)) {
|
||||
|
||||
@@ -45,6 +45,8 @@ export function TaskSidebar() {
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const showArchived = useStore(s => s.ui.showArchived)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
@@ -66,7 +68,7 @@ export function TaskSidebar() {
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces)
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
|
||||
@@ -9,9 +9,11 @@ import { AgentRow } from './Workbench'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
return buildWorkspaceEntries(store.getState().serverWorkspaces)
|
||||
const s = store.getState()
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 审计命令错误分类(2026-10-12 增量 2):RdmAuditWorkspace 不再直出原始报错
|
||||
//(普通基座上曾是 `400: {"receipt":…unknown or disabled command…}`),改为
|
||||
//「能力缺失 / 登录已失效 / 查询失败」人话文案;raw 始终保留供「原始回执」折叠区诊断。
|
||||
// 依据:api.ts json() 对非 2xx 抛 `${status}: ${body}`;后端 unknown/disabled 命令回
|
||||
// 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
|
||||
export type RdmErrorKind = 'capability' | 'auth' | 'query'
|
||||
|
||||
export interface RdmErrorView {
|
||||
kind: RdmErrorKind
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function classifyRdmError(err: unknown): RdmErrorView {
|
||||
const raw = err instanceof Error ? err.message : String(err)
|
||||
if (/^40[13]:/.test(raw)) {
|
||||
return { kind: 'auth', message: '登录已失效,请重新登录后再查看追溯审计', raw }
|
||||
}
|
||||
if (/unknown or disabled command|commands disabled/.test(raw)) {
|
||||
return { kind: 'capability', message: '当前服务未提供审计能力(RDM 命令未注册或已停用)', raw }
|
||||
}
|
||||
return { kind: 'query', message: '查询失败,请稍后重试', raw }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessag
|
||||
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||
import { flashElement } from './scrollsync'
|
||||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
import { rdmAuditReady } from './workspaces'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
@@ -112,6 +113,8 @@ interface AppState {
|
||||
profiles: AgentProfile[]
|
||||
pluginPanels: PluginPanel[]
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||||
rdmAuditAvailable: boolean
|
||||
ui: UIState
|
||||
connected: boolean
|
||||
/** 发送请求已经出去,run 记录还没回来。 */
|
||||
@@ -175,6 +178,7 @@ let state: AppState = {
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
rdmAuditAvailable: false,
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {},
|
||||
@@ -784,6 +788,11 @@ export const store = {
|
||||
const ws = await api.listWorkspaces()
|
||||
set({ serverWorkspaces: ws ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||||
try {
|
||||
const cmds = await api.listCommands()
|
||||
set({ rdmAuditAvailable: rdmAuditReady(cmds) })
|
||||
} catch { set({ rdmAuditAvailable: false }) }
|
||||
try {
|
||||
const settings = await api.getSettings()
|
||||
const runtime = settings.user.runtime
|
||||
|
||||
@@ -628,3 +628,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
+27
-2
@@ -1,6 +1,9 @@
|
||||
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
|
||||
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
|
||||
// 插件管理面板不进这条清单。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口可达而进入即 400。仅当命令面探测通过
|
||||
//(rdmAuditReady / store.rdmAuditAvailable)才纳入;代理仍无条件以 ⌘1 补位。
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
key: string
|
||||
@@ -16,6 +19,23 @@ export interface WorkspaceSource {
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
/** 审计工作区消费的全部 rdm.inspect.* 命令(capabilities 入口 + 四页签取数)。 */
|
||||
export const RDM_AUDIT_COMMANDS = [
|
||||
'rdm.inspect.capabilities',
|
||||
'rdm.inspect.summary',
|
||||
'rdm.inspect.tree',
|
||||
'rdm.inspect.object',
|
||||
'rdm.inspect.relations',
|
||||
'rdm.inspect.events',
|
||||
'rdm.inspect.history',
|
||||
] as const
|
||||
|
||||
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
|
||||
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||
@@ -29,7 +49,12 @@ function shortcutNum(raw?: string): number | null {
|
||||
return n >= 1 && n <= 9 ? n : null
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
|
||||
export interface BuildWorkspaceOptions {
|
||||
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
|
||||
rdmAudit?: boolean
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
|
||||
const used = new Set<number>()
|
||||
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
|
||||
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
|
||||
@@ -57,7 +82,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] })
|
||||
if (opts.rdmAudit === true) 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)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyRdmError } from '../src/rdmErrors'
|
||||
|
||||
// 2026-10-12 增量 2(审计错误四分类):审计页不再把原始报错(400 回执 JSON)直出,
|
||||
// 改为「能力缺失 / 登录已失效 / 查询失败」文案并保留原始回执供诊断;
|
||||
// 「无可审计项目」由组件空态承担,不走本分类。
|
||||
// 分类依据:api.ts json() 抛 `${status}: ${body}`;后端 unknown/disabled 命令
|
||||
// 回 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
describe('classifyRdmError', () => {
|
||||
it('400 unknown or disabled command → 能力缺失', () => {
|
||||
const raw = '400: {"receipt":{"state":"rejected","error":"unknown or disabled command: rdm.inspect.capabilities"}}'
|
||||
const e = classifyRdmError(new Error(raw))
|
||||
expect(e.kind).toBe('capability')
|
||||
expect(e.message).toContain('当前服务未提供审计能力')
|
||||
expect(e.raw).toContain('unknown or disabled command')
|
||||
})
|
||||
|
||||
it('503 commands disabled → 能力缺失', () => {
|
||||
expect(classifyRdmError('503: commands disabled').kind).toBe('capability')
|
||||
})
|
||||
|
||||
it('401/403 → 登录已失效', () => {
|
||||
expect(classifyRdmError('401: session expired').kind).toBe('auth')
|
||||
expect(classifyRdmError(new Error('403: forbidden')).kind).toBe('auth')
|
||||
})
|
||||
|
||||
it('其余错误 → 查询失败,保留原始串', () => {
|
||||
const e = classifyRdmError('500: boom')
|
||||
expect(e.kind).toBe('query')
|
||||
expect(e.message).toContain('查询失败')
|
||||
expect(e.raw).toBe('500: boom')
|
||||
})
|
||||
|
||||
it('非 Error 输入可容忍', () => {
|
||||
expect(classifyRdmError(undefined).kind).toBe('query')
|
||||
expect(classifyRdmError({ weird: true }).kind).toBe('query')
|
||||
})
|
||||
})
|
||||
+47
-8
@@ -1,36 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries } from '../src/workspaces'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置
|
||||
// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口展示后进入即 400。新契约:仅当命令面探测
|
||||
// 通过(opts.rdmAudit)才纳入 rdm-audit;代理仍无条件以 ⌘1 补位。
|
||||
describe('buildWorkspaceEntries', () => {
|
||||
it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => {
|
||||
it('没有领域插件且无 RDM 能力:只有代理占 ⌘1', () => {
|
||||
const entries = buildWorkspaceEntries([])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
|
||||
})
|
||||
|
||||
it('没有领域插件且 RDM 能力通过:代理 ⌘1、追溯审计 ⌘2', () => {
|
||||
const entries = buildWorkspaceEntries([], { rdmAudit: true })
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
|
||||
})
|
||||
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位(能力开)', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
])
|
||||
], { rdmAudit: true })
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
|
||||
it('无 RDM 能力:让位后只有代理,不出现追溯审计', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
|
||||
})
|
||||
|
||||
it('未编号的工作区排在内置工具后面', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'mem', title: '记忆库' },
|
||||
])
|
||||
], { rdmAudit: true })
|
||||
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)
|
||||
const entries = buildWorkspaceEntries(server, { rdmAudit: true })
|
||||
// ⌘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('追溯审计')
|
||||
})
|
||||
})
|
||||
|
||||
// 能力探测依据后端 GET /api/commands(server.go listCommands:{ id, title, …, enabled })。
|
||||
// 审计工作区消费七个 rdm.inspect.* 命令;任一缺失或 enabled=false 都视为能力未就绪。
|
||||
describe('rdmAuditReady', () => {
|
||||
const all = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
|
||||
it('七命令齐备且启用 → true', () => {
|
||||
expect(rdmAuditReady(all)).toBe(true)
|
||||
})
|
||||
|
||||
it('普通基座空清单 / 缺任一命令 → false', () => {
|
||||
expect(rdmAuditReady([])).toBe(false)
|
||||
expect(rdmAuditReady(all.filter(c => c.id !== 'rdm.inspect.history'))).toBe(false)
|
||||
})
|
||||
|
||||
it('存在但 enabled=false → false', () => {
|
||||
const disabled = all.map(c => (c.id === 'rdm.inspect.capabilities' ? { ...c, enabled: false } : c))
|
||||
expect(rdmAuditReady(disabled)).toBe(false)
|
||||
})
|
||||
|
||||
it('清单不可用(未登录/请求失败)→ false,不展示入口', () => {
|
||||
expect(rdmAuditReady(null)).toBe(false)
|
||||
expect(rdmAuditReady(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user