Files
apes-sidebar/tests/fs-watch.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

151 lines
6.3 KiB
TypeScript

/**
* Directory-watch spec: the file tree's live refresh rests on this module, so
* the tests use real directories and the real `fs.watch` rather than a stub —
* the debounce, the handle accounting and the error path are the behaviour
* being pinned.
*
* Timing note: `fs.watch` delivery is neither prompt nor one-to-one with the
* writes that caused it (macOS FSEvents batches on its own schedule, and a
* loaded machine stretches it further). The assertions below therefore pin
* the PROPERTIES the tree depends on — a change is reported, a burst is
* folded, an unwatched directory stays silent — instead of an exact event
* count, and every wait is given a budget far above the module's 150 ms
* debounce.
*/
import { describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { listDirectory } from '../src/fs-tree.ts'
import { createDirectoryWatchers, type DirectoryWatchEvent } from '../src/fs-watch.ts'
/** Generous delivery budget: the module debounces at 150 ms, the OS adds its own latency. */
const DELIVERY_TIMEOUT_MS = 15_000
/** Long enough for a second (tail) delivery to have surfaced, so "folded" means something. */
const SETTLE_MS = 1_000
/** One scratch tree with a directory to watch. */
const withScratch = (run: (dir: string) => Promise<void> | void): Promise<void> => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-watch-'))
const dir = join(root, 'watched')
mkdirSync(dir)
return Promise.resolve(run(dir)).finally(() => { rmSync(root, { recursive: true, force: true }) })
}
/** Sleep for a fixed window. */
const settle = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
describe('directory watchers', () => {
it('drops the level cache for a changed directory before it pushes', async () => {
await withScratch(async (dir) => {
const events: DirectoryWatchEvent[] = []
// Warm the listing cache with the pre-change level…
const before = await listDirectory(dir, 100)
expect(before.entries).toEqual([])
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
try {
expect(watchers.add(dir)).toBe(true)
writeFileSync(join(dir, 'new.txt'), 'x')
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
// …then the re-list the client is about to send must NOT be answered
// from that warm entry (the watcher invalidates before pushing).
const after = await listDirectory(dir, 100)
expect(after).not.toBe(before)
expect(after.entries.map(entry => entry.name)).toEqual(['new.txt'])
} finally {
watchers.close()
}
})
})
it('folds a burst of changes into a single notice', async () => {
await withScratch(async (dir) => {
const events: DirectoryWatchEvent[] = []
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
try {
expect(watchers.add(dir)).toBe(true)
// One save is several filesystem events; the tree re-lists the whole
// directory either way, so they must collapse. Twenty writes make the
// fold unambiguous even if the OS delivers one late tail batch.
for (let index = 0; index < 20; index++) writeFileSync(join(dir, `f${index}.txt`), 'x')
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
await settle(SETTLE_MS)
expect(events.length).toBeLessThanOrEqual(2)
for (const event of events) expect(event).toEqual({ dir })
} finally {
watchers.close()
}
})
})
it('stops reporting once a directory is unwatched or closed', async () => {
await withScratch(async (dir) => {
const events: DirectoryWatchEvent[] = []
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
watchers.add(dir)
watchers.remove(dir)
// Removing twice is a no-op, never a throw.
watchers.remove(dir)
writeFileSync(join(dir, 'a.txt'), 'x')
// The waiting budget matches the positive case: a silent assertion is
// only meaningful if an event had ample time to arrive.
await settle(SETTLE_MS)
expect(events).toEqual([])
// Closing the whole set is idempotent too.
watchers.close()
watchers.close()
})
})
it('treats a repeat add as already-watched', async () => {
await withScratch(async (dir) => {
const events: DirectoryWatchEvent[] = []
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
try {
expect(watchers.add(dir)).toBe(true)
// A second add must not install a second watcher — that would double
// every notice for the same directory.
expect(watchers.add(dir)).toBe(true)
writeFileSync(join(dir, 'a.txt'), 'x')
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
await settle(SETTLE_MS)
expect(events.length).toBeLessThanOrEqual(2)
} finally {
watchers.close()
}
})
})
it('caps the handles one connection may hold', async () => {
await withScratch(async (dir) => {
const root = join(dir, '..')
const watchers = createDirectoryWatchers(() => {}, () => {})
try {
// The cap is 64: the first 64 directories are watched, the rest are
// refused rather than throwing, and a refusal evicts nothing.
let accepted = 0
for (let index = 0; index < 70; index++) {
const child = join(root, `child-${index}`)
mkdirSync(child)
if (watchers.add(child)) accepted += 1
}
expect(accepted).toBe(64)
} finally {
watchers.close()
}
})
})
it('reports an unwatchable directory instead of throwing', async () => {
const failures: string[] = []
const watchers = createDirectoryWatchers(() => {}, (dir) => { failures.push(dir) })
try {
const missing = join(tmpdir(), 'dsh-sidebar-watch-does-not-exist-42')
expect(watchers.add(missing)).toBe(false)
expect(failures).toEqual([missing])
} finally {
watchers.close()
}
})
})