Files
apes-sidebar/tests/prefs.spec.ts
T
Menghuan1918 5c95002641 perf(sidebar): fs.tree 22.8ms→3.8ms + 删除工作区路径检测(放开权限)+ 树展开状态按会话
用户实测反馈:

1) `/sidebar/api/fs.tree` 性能极差 —— 先测后改(10k 条目目录、热缓存、中位数):
   - 冷调用 cap=1000:22.76ms → **3.77ms**;TTL 内重复:≈23ms → **0.000ms**
   - 改法:opendir for-await → readdir(withFileTypes) 一次取回;只为 cap 条建行
     (slice + 定长数组);路径用字符串拼接而非 path.join;排序弃 localeCompare
     (14.6ms/10k → 0.35ms)改小写比较 + 原名码点兜底;新增 1.5s 目录级缓存
     (键 绝对路径\0cap,上限 512),由 fs.write/rename/remove/mkdir/upload 与
     fs-watch 的变更通知显式失效
   - 契约逐字不变;新增 tests/fs-tree.spec.ts 6 例 + fs-watch 失效用例(判别性验证)

2) 删除工作区路径检测(默认不检测、放开权限):
   - path-security 变纯词法解析(realpath/包含性判定删除)、fs-operations 去 fence
     分支、所有 fs 路由不再读 fence、403 分支消失、config 删 workspaceFence 字段
     (schemastery 容忍未知键,旧 profile 不会加载失败)、settings 开关删除
   - ⚠️ 安全影响:插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/
     upload/archive)现在可读写宿主用户可访问的任意路径,只受 OS 权限约束;
     指南 §8.1 已加醒目声明
   - 死代码清理:FenceErrorNotice 组件 + EditorHost 分支 + 4 个词条(settingsFence*、
     fenceErrorReason、fenceDisableAction)+ isOutsideWorkspace* 助手 + TreePanel 的
     store prop + 相关 CSS 块

3) 批量列举路由 `fs.trees`(单层失败只在该层带 error)+ 客户端批量加载:
   挂载/刷新时的 N+1 请求变为 1 个(8 个展开目录:9 → 1,探针断言)
   refreshTick 不再先清缓存(旧行保留到新答案;失败保留旧行 + warn 提示)

4) 修复「关闭文件后返回文件树,展开状态被重置」:
   原生承载面的展开集合原来存在每个 tab 的记录里(drop 即丢),改为**每会话状态**
   的投影(ensure 从会话态读、toggleExpanded 写回会话态、registerNativeSurface 用
   attachStore 订阅);同一会话多 tab 共享、不同会话隔离;revealed 保持 per-tab

验证:typecheck / lint / test(136 文件 / 1484 通过 / 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
版本镜像同步(dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
2026-09-28 20:37:56 +08:00

252 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import { loadBootDecision, loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** A fake settings wire face whose settingsGet resolves to one raw value. */
const wire = (value: unknown): SidebarSettingsClient => ({
settingsGet: async () => ({ value, revision: 1 }),
settingsUpdate: async () => ({ value, revision: 2 }),
})
/** A fake wire carrying an explicit externalDisable flag. */
const wireWithDisable = (externalDisable: boolean): SidebarSettingsClient => ({
settingsGet: async () => ({ value: {}, revision: 1, externalDisable }),
settingsUpdate: async () => ({ value: {}, revision: 2 }),
})
const rejecting = (): SidebarSettingsClient => ({
settingsGet: async () => { throw new Error('route rejected') },
settingsUpdate: async () => { throw new Error('route rejected') },
})
describe('side card preferences', () => {
it('falls back to the defaults when the settings route rejects', async () => {
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('falls back to the defaults when the value is absent or malformed', async () => {
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('parses a valid value', async () => {
expect(await loadPrefs(wire({ autoOpenSubagent: false, agentOpenTools: true })))
.toEqual({
autoOpenSubagent: false,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: true,
editorExplorer: false,
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('falls back per-field when a stored field is malformed', async () => {
expect(await loadPrefs(wire({ autoOpenSubagent: 'no', agentOpenTools: 'yes' })))
.toEqual({
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false,
editorExplorer: false,
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('defaults autoOpenSubagent to true and the agent toggles to false when the stored value is absent or malformed', async () => {
expect(await loadPrefs(wire({})))
.toEqual({
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false,
editorExplorer: false,
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
expect((await loadPrefs(wire({ autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true)
// The sidebar-open tool is OFF by default; only an explicit true turns it on.
expect((await loadPrefs(wire({}))).agentOpenTools)
.toBe(false)
expect((await loadPrefs(wire({ agentOpenTools: 1 }))).agentOpenTools)
.toBe(false)
expect((await loadPrefs(wire({ agentOpenTools: true }))).agentOpenTools)
.toBe(true)
// The job auto-open is ON by default; only an explicit false turns it off.
expect((await loadPrefs(wire({ autoOpenJobs: 1 }))).autoOpenJobs)
.toBe(true)
expect((await loadPrefs(wire({ autoOpenJobs: false }))).autoOpenJobs)
.toBe(false)
})
it('defaults both mobile adaptations to on; only an explicit false disarms one', async () => {
// Absent or malformed → ON: a phone is exactly the case these exist for,
// and each one only changes what happens on a NARROW viewport.
for (const key of ['mobileNoAutoOpen', 'mobileDefaultTree'] as const) {
expect((await loadPrefs(wire({})))[key]).toBe(true)
expect((await loadPrefs(wire({ [key]: 'yes' })))[key]).toBe(true)
expect((await loadPrefs(wire({ [key]: 1 })))[key]).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ [key]: false })))[key]).toBe(false)
expect((await loadPrefs(wire({ [key]: true })))[key]).toBe(true)
}
})
it('defaults editorExplorer to false; only an explicit true enables the merged editor-explorer', async () => {
// Absent or malformed → off (separate file windows are the default).
expect((await loadPrefs(wire({}))).editorExplorer).toBe(false)
expect((await loadPrefs(wire({ editorExplorer: 'yes' }))).editorExplorer).toBe(false)
expect((await loadPrefs(wire({ editorExplorer: 1 }))).editorExplorer).toBe(false)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ editorExplorer: false }))).editorExplorer).toBe(false)
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
})
it('no longer exposes a workspaceFence pref (the containment guard is gone)', async () => {
// The field was retired together with the fence: whatever a stored
// document (or a legacy profile) still carries, the typed prefs the client
// consumes do not have it — there is nothing left to arm or disarm.
expect('workspaceFence' in await loadPrefs(wire({}))).toBe(false)
expect('workspaceFence' in await loadPrefs(wire({ workspaceFence: true }))).toBe(false)
expect('workspaceFence' in await loadPrefs(wire({ workspaceFence: false }))).toBe(false)
})
it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => {
// Absent or malformed → auto (plain web keeps the untouched layout).
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({ titleBarScheme: 'weird' }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({ titleBarScheme: 1 }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({}))).titleBarPresetId).toBe('')
expect((await loadPrefs(wire({ titleBarPresetId: 5 }))).titleBarPresetId).toBe('')
expect((await loadPrefs(wire({}))).customCss).toBe('')
expect((await loadPrefs(wire({ customCss: 7 }))).customCss).toBe('')
// Valid values survive verbatim (including the explicit web scheme).
const picked = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', customCss: 'html { }' }))
expect(picked.titleBarScheme).toBe('preset')
expect(picked.titleBarPresetId).toBe('dsh-desktop')
expect(picked.customCss).toBe('html { }')
expect((await loadPrefs(wire({ titleBarScheme: 'web' }))).titleBarScheme).toBe('web')
})
it('migrates LEGACY documents that ALREADY HAVE VALUES into the custom scheme', async () => {
// A pre-scheme document with the manual compat flag on maps to the
// custom scheme, keeping the strip px the user chose.
const migrated = await loadPrefs(wire({ titleBarCompat: true, titleBarStripPx: 56 }))
expect(migrated.titleBarScheme).toBe('custom')
expect(migrated.titleBarStripPx).toBe(56)
// A non-default strip px alone (only reachable through the old gear
// popup) also counts as "already has values" → custom.
const stripOnly = await loadPrefs(wire({ titleBarStripPx: 48 }))
expect(stripOnly.titleBarScheme).toBe('custom')
expect(stripOnly.titleBarStripPx).toBe(48)
// A stored scheme always wins over the legacy fields (round-trip of the
// mirrored write: preset stays preset even though the mirror is true).
const roundTrip = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarCompat: true }))
expect(roundTrip.titleBarScheme).toBe('preset')
// Legacy off / absent / default strip → the conservative auto scheme.
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({ titleBarStripPx: 40 }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
})
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
// Absent or malformed → 40 (the strip default).
expect((await loadPrefs(wire({}))).titleBarStripPx).toBe(40)
expect((await loadPrefs(wire({ titleBarStripPx: 'yes' }))).titleBarStripPx).toBe(40)
// Out-of-range numbers clamp into 0–120.
expect((await loadPrefs(wire({ titleBarStripPx: -5 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 200 }))).titleBarStripPx).toBe(120)
expect((await loadPrefs(wire({ titleBarStripPx: 47.6 }))).titleBarStripPx).toBe(48)
// In-range values survive verbatim.
expect((await loadPrefs(wire({ titleBarStripPx: 0 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64)
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
// Non-boolean entries are dropped; boolean entries survive verbatim.
const parsed = await loadPrefs(wire({
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
viewersEnabled: { image: false, code: 1 },
}))
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
expect(parsed.viewersEnabled).toEqual({ image: false })
})
})
describe('external disable (aionui-panel provider choice)', () => {
it('reads true when the host reports the aionui provider active', async () => {
expect(await loadExternalDisable(wireWithDisable(true))).toBe(true)
})
it('reads false when the host reports no external disable', async () => {
expect(await loadExternalDisable(wireWithDisable(false))).toBe(false)
})
it('reads false when the flag is absent or the wire rejects', async () => {
expect(await loadExternalDisable(wire({}))).toBe(false)
expect(await loadExternalDisable(rejecting())).toBe(false)
})
})
describe('boot decision (one fetch for prefs + external disable)', () => {
it('answers both decisions from a single settingsGet call', async () => {
let calls = 0
const counting = (): SidebarSettingsClient => ({
settingsGet: async () => { calls += 1; return { value: { autoOpenSubagent: false, editorExplorer: true }, revision: 1, externalDisable: true } },
settingsUpdate: async () => ({ value: {}, revision: 2 }),
})
const decision = await loadBootDecision(counting())
expect(calls, 'the boot path must fetch the settings document exactly once').toBe(1)
expect(decision.suspended).toBe(true)
expect(decision.prefs.autoOpenSubagent).toBe(false)
})
it('falls back to the defaults + not suspended on any failure', async () => {
const decision = await loadBootDecision(rejecting())
expect(decision).toEqual({ prefs: SIDEBAR_PREFS_DEFAULTS, suspended: false })
})
it('reads suspended false when the flag is absent', async () => {
const decision = await loadBootDecision(wire({ titleBarStripPx: 60 }))
expect(decision.suspended).toBe(false)
expect(decision.prefs.titleBarStripPx).toBe(60)
})
})