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)
This commit is contained in:
@@ -143,7 +143,8 @@ describe('built-in tab registrations', () => {
|
||||
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('editor')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence'])
|
||||
// The workspace-fence switch is gone with the fence itself.
|
||||
expect(toggles.map(t => t.key)).toEqual(['editorExplorer'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
// The merged mode is an iconed select (merged vs separate), not a switch.
|
||||
@@ -151,9 +152,6 @@ describe('built-in tab registrations', () => {
|
||||
const options = toggles[0]?.options ?? []
|
||||
expect(options.map(o => o.value)).toEqual([true, false])
|
||||
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
|
||||
// The workspace fence switch rides the same card as a plain boolean row.
|
||||
expect(toggles[1]?.title).toBeDefined()
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
// The open-with configuration (SSH host + custom editors) is the custom
|
||||
// panel rendered below the declarative rows.
|
||||
expect(service.getTab('editor')?.settings?.render).toBeDefined()
|
||||
|
||||
@@ -22,13 +22,16 @@ import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/st
|
||||
// answer without a host (the sibling FileTree specs mock it the same way).
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
path: '/tmp',
|
||||
entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
// The tree lists its visible set in ONE batched call.
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({
|
||||
path,
|
||||
entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
})),
|
||||
}),
|
||||
// The shared git-status store polls this; a non-repo answer keeps rows plain.
|
||||
gitStatus: async () => ({ isRepo: false, entries: [] }),
|
||||
|
||||
@@ -20,7 +20,6 @@ import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -38,14 +37,12 @@ const { archiveBuild, archiveStatus, fetchMock } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
path: '/tmp',
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
], truncated: false })),
|
||||
}),
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain (this spec is about the archive row).
|
||||
@@ -95,7 +92,6 @@ async function mountTree(): Promise<Harness> {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Level loading is BATCHED: the visible set (the workspace root plus every
|
||||
* expanded directory) is listed with ONE `fs.trees` request on mount, on every
|
||||
* refresh tick and when a directory expands — instead of one `fs.tree` POST
|
||||
* per level (the N+1 the user's "频繁刷新" report pointed at).
|
||||
*
|
||||
* This spec is the probe AND the guard. It counts both routes, so running it
|
||||
* before the batch change shows the N+1 shape and after it the single request:
|
||||
* - 8 expanded directories: 9 × `fs.tree` → 1 × `fs.trees` (9 paths);
|
||||
* - expanding one more directory lists ONLY that level (batch of 1);
|
||||
* - a refresh tick re-lists the whole visible set in ONE request;
|
||||
* - a watch notice still re-lists ONLY the stale level;
|
||||
* - one level's `error` stays on that level; a whole-request failure keeps
|
||||
* the cached levels and marks only the ones that had nothing.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
|
||||
// vitest 4.1.11+ follows the OS locale; pin en-US so the error copy is English.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
const fsTree = vi.hoisted(() => vi.fn())
|
||||
const fsTrees = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: (...args: unknown[]) => fsTree(...args),
|
||||
fsTrees: (...args: unknown[]) => fsTrees(...args),
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain.
|
||||
gitStatus: async () => ({ isRepo: false, entries: [] }),
|
||||
},
|
||||
downloadUrl: () => '/sidebar/file',
|
||||
isOutsideWorkspaceMessage: () => false,
|
||||
}))
|
||||
|
||||
const DIRS = ['/tmp/d1', '/tmp/d2', '/tmp/d3', '/tmp/d4', '/tmp/d5', '/tmp/d6', '/tmp/d7', '/tmp/d8']
|
||||
|
||||
/** One level's listing: the root lists the eight directories (so the expanded
|
||||
* levels actually render) and every directory lists one file named after it. */
|
||||
function levelOf(path: string): {
|
||||
path: string; entries: { name: string; path: string; isDir: boolean }[]; truncated: boolean
|
||||
} {
|
||||
if (path === '/tmp') {
|
||||
return {
|
||||
path,
|
||||
entries: DIRS.map(dir => ({ name: dir.slice('/tmp/'.length), path: dir, isDir: true })),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
return { path, entries: [{ name: `${name}-file.ts`, path: `${path}/${name}-file.ts`, isDir: false }], truncated: false }
|
||||
}
|
||||
|
||||
/** The smallest WebSocket the directory watcher talks to (see its spec). */
|
||||
class FakeSocket {
|
||||
static instances: FakeSocket[] = []
|
||||
static readonly OPEN = 1
|
||||
static readonly CONNECTING = 0
|
||||
readyState = FakeSocket.CONNECTING
|
||||
readonly url: string
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((event: { data: string }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
FakeSocket.instances.push(this)
|
||||
}
|
||||
send(): void {}
|
||||
close(): void { this.readyState = 3 }
|
||||
open(): void { this.readyState = FakeSocket.OPEN; this.onopen?.() }
|
||||
frame(payload: unknown): void { this.onmessage?.({ data: JSON.stringify(payload) }) }
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
/** Re-render with a new expansion set; `refreshTick` only when bumping it. */
|
||||
rerender: (expanded: string[], refreshTick?: number) => void
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function mountTree(expanded: string[] = DIRS, refreshTick = 0): Harness {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const render = (dirs: string[], tick: number): void => {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
expanded: dirs,
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onReferenceFile: () => {},
|
||||
refreshTick: tick,
|
||||
onUploadRequest: () => {},
|
||||
busy: false,
|
||||
}))
|
||||
}
|
||||
let tick = refreshTick
|
||||
act(() => { render(expanded, tick) })
|
||||
return {
|
||||
container,
|
||||
rerender: (dirs: string[], nextTick?: number) => {
|
||||
if (nextTick !== undefined) tick = nextTick
|
||||
act(() => { render(dirs, tick) })
|
||||
},
|
||||
unmount: () => { act(() => { root.unmount() }); container.remove() },
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush the batch promise chain. */
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/** The paths a batch call was asked for. */
|
||||
function batchPaths(index: number): string[] {
|
||||
const call = fsTrees.mock.calls[index]
|
||||
if (call === undefined) throw new Error(`no fs.trees call #${index}`)
|
||||
return (call[1] as string[]).slice().sort()
|
||||
}
|
||||
|
||||
let harness: Harness
|
||||
beforeEach(() => {
|
||||
FakeSocket.instances = []
|
||||
vi.stubGlobal('WebSocket', FakeSocket)
|
||||
fsTree.mockReset()
|
||||
fsTree.mockImplementation(async (_scope: unknown, path: string) => levelOf(path))
|
||||
fsTrees.mockReset()
|
||||
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => levelOf(path)),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('FileTree level loading is batched', () => {
|
||||
it('lists the root plus 8 expanded directories with ONE fs.trees request', async () => {
|
||||
harness = mountTree()
|
||||
await flush()
|
||||
console.log('BATCH-PERF', JSON.stringify({ fsTree: fsTree.mock.calls.length, fsTrees: fsTrees.mock.calls.length }))
|
||||
|
||||
// The N+1 shape is gone: one batch for the whole visible set.
|
||||
expect(fsTree).not.toHaveBeenCalled()
|
||||
expect(fsTrees).toHaveBeenCalledTimes(1)
|
||||
expect(batchPaths(0)).toEqual(['/tmp', ...DIRS].sort())
|
||||
// Every level rendered (root + the 8 directories' children).
|
||||
expect(harness.container.textContent).toContain('d1-file.ts')
|
||||
expect(harness.container.textContent).toContain('d8-file.ts')
|
||||
})
|
||||
|
||||
it('lists only the newly expanded directory (batch of 1)', async () => {
|
||||
harness = mountTree(DIRS)
|
||||
await flush()
|
||||
fsTrees.mockClear()
|
||||
harness.rerender([...DIRS, '/tmp/d9'])
|
||||
await flush()
|
||||
expect(fsTrees).toHaveBeenCalledTimes(1)
|
||||
expect(batchPaths(0)).toEqual(['/tmp/d9'])
|
||||
})
|
||||
|
||||
it('re-lists the whole visible set in ONE request on a refresh tick', async () => {
|
||||
harness = mountTree(DIRS)
|
||||
await flush()
|
||||
fsTrees.mockClear()
|
||||
harness.rerender(DIRS, 1)
|
||||
await flush()
|
||||
expect(fsTrees).toHaveBeenCalledTimes(1)
|
||||
expect(batchPaths(0)).toEqual(['/tmp', ...DIRS].sort())
|
||||
})
|
||||
|
||||
it('re-lists ONLY the stale level when the directory watcher reports one', async () => {
|
||||
harness = mountTree(DIRS)
|
||||
await flush()
|
||||
const socket = FakeSocket.instances[0]
|
||||
if (socket === undefined) throw new Error('no watch socket opened')
|
||||
act(() => { socket.open() })
|
||||
fsTrees.mockClear()
|
||||
act(() => { socket.frame({ dir: '/tmp/d3' }) })
|
||||
await flush()
|
||||
// A stale notice drops exactly that level and re-lists it — never the
|
||||
// whole visible set (the batch path is for mount/expand/refresh only).
|
||||
expect(fsTrees).toHaveBeenCalledTimes(1)
|
||||
expect(batchPaths(0)).toEqual(['/tmp/d3'])
|
||||
})
|
||||
|
||||
it('keeps one level error on that level only', async () => {
|
||||
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => path === '/tmp/d3'
|
||||
? { path, entries: [], truncated: false, error: 'cannot read d3' }
|
||||
: levelOf(path)),
|
||||
}))
|
||||
harness = mountTree(DIRS)
|
||||
await flush()
|
||||
// The failing level shows its raw message; the others still render.
|
||||
expect(harness.container.textContent).toContain('cannot read d3')
|
||||
expect(harness.container.textContent).toContain('d1-file.ts')
|
||||
expect(harness.container.textContent).toContain('d8-file.ts')
|
||||
})
|
||||
|
||||
it('keeps the cached levels (plus a hint) when the whole batch request fails', async () => {
|
||||
harness = mountTree(DIRS)
|
||||
await flush()
|
||||
// Everything is cached now: a failed refresh must not blank the tree — it
|
||||
// keeps the previous listing and explains itself in a hint line.
|
||||
fsTrees.mockRejectedValue(new Error('listing offline'))
|
||||
harness.rerender(DIRS, 1)
|
||||
await flush()
|
||||
expect(harness.container.textContent).toContain('d1-file.ts')
|
||||
expect(harness.container.textContent).toContain('d8-file.ts')
|
||||
const hint = harness.container.querySelector<HTMLElement>('[data-kind="warn"]')
|
||||
expect(hint?.textContent).toBe('listing offline')
|
||||
// The stale rows are NOT replaced by per-level error rows.
|
||||
expect(harness.container.querySelectorAll('[class*="explorerError"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows the failure for levels that had nothing cached', async () => {
|
||||
fsTrees.mockRejectedValue(new Error('listing offline'))
|
||||
harness = mountTree(DIRS)
|
||||
await flush()
|
||||
// First load, nothing cached: the one request failed, so the root and the
|
||||
// expanded levels carry the message.
|
||||
expect(harness.container.textContent).toContain('listing offline')
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,6 @@ import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx'
|
||||
import type { UploadItem } from '../src/client/upload.ts'
|
||||
|
||||
@@ -30,11 +29,11 @@ beforeAll(() => {
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'src', path: '/tmp/src', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
],
|
||||
], truncated: false })),
|
||||
}),
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain (this spec is about the drop surface).
|
||||
@@ -60,7 +59,6 @@ async function mountTree(busy = false): Promise<Harness> {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -12,7 +12,6 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import type { GitStatusResult } from '../src/client/api.ts'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -26,14 +25,12 @@ const { gitStatus } = vi.hoisted(() => ({ gitStatus: vi.fn() }))
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
path: '/tmp',
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
], truncated: false })),
|
||||
}),
|
||||
gitStatus,
|
||||
},
|
||||
@@ -65,7 +62,6 @@ async function mountTree(): Promise<Harness> {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 'git-spec',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -22,7 +22,6 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import type { BetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -32,8 +31,9 @@ beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
const { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus } = vi.hoisted(() => ({
|
||||
const { fsTree, fsTrees, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus } = vi.hoisted(() => ({
|
||||
fsTree: vi.fn(),
|
||||
fsTrees: vi.fn(),
|
||||
gitStatus: vi.fn(),
|
||||
fileApps: vi.fn(),
|
||||
directoryApps: vi.fn(),
|
||||
@@ -42,7 +42,7 @@ const { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus
|
||||
}))
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus },
|
||||
api: { fsTree, fsTrees, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus },
|
||||
downloadUrl: () => '/sidebar/file',
|
||||
archiveDownloadUrl: (id: string) => `/sidebar/archive/${id}`,
|
||||
isOutsideWorkspaceMessage: () => false,
|
||||
@@ -75,7 +75,6 @@ async function mountTree(): Promise<Harness> {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 'menu-perf',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
@@ -167,16 +166,21 @@ function mark(): void {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// The single-level route must never be used again (the batch one replaced
|
||||
// it); it stays mocked so the probe can assert the count is 0.
|
||||
fsTree.mockReset()
|
||||
fsTree.mockImplementation(async (_scope: unknown, path: string) => ({
|
||||
path,
|
||||
entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
|
||||
{ name: 'c.ts', path: '/tmp/c.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
fsTrees.mockReset()
|
||||
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({
|
||||
path,
|
||||
entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
|
||||
{ name: 'c.ts', path: '/tmp/c.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
})),
|
||||
}))
|
||||
gitStatus.mockReset()
|
||||
gitStatus.mockResolvedValue({ isRepo: false, entries: [] })
|
||||
|
||||
@@ -17,7 +17,6 @@ import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -27,22 +26,25 @@ beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
const { fsRemove, fsTree } = vi.hoisted(() => ({
|
||||
const { fsRemove, fsTrees } = vi.hoisted(() => ({
|
||||
fsRemove: vi.fn(async (_scope: unknown, path: string) => ({ path })),
|
||||
fsTree: vi.fn(async () => ({
|
||||
path: '/tmp',
|
||||
entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
// One BATCHED list call answers every requested level with the same shape.
|
||||
fsTrees: vi.fn(async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({
|
||||
path,
|
||||
entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree,
|
||||
fsTrees,
|
||||
fsRemove,
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain (this spec is about the selection).
|
||||
@@ -69,7 +71,6 @@ async function mountTree(expanded: string[] = []): Promise<Harness> {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded,
|
||||
revealed: [],
|
||||
onToggle: (path: string) => { toggled.push(path) },
|
||||
@@ -135,7 +136,7 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
fsRemove.mockReset()
|
||||
fsRemove.mockImplementation(async (_scope: unknown, path: string) => ({ path }))
|
||||
fsTree.mockClear()
|
||||
fsTrees.mockClear()
|
||||
})
|
||||
|
||||
describe('FileTree multi-selection', () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { createElement, useState, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -23,16 +22,16 @@ beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
const { fsMkdir, fsTree } = vi.hoisted(() => ({
|
||||
const { fsMkdir, fsTrees } = vi.hoisted(() => ({
|
||||
fsMkdir: vi.fn(async (_scope: unknown, _path: string, name: string) => ({ path: `/tmp/${name}` })),
|
||||
// `listingFor` is a hoisted function declaration, so the factory may
|
||||
// reference it; it only runs when the mock is called (after module init).
|
||||
fsTree: vi.fn(async (_scope: unknown, path: string) => listingFor(path)),
|
||||
fsTrees: vi.fn(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => listingFor(path)) })),
|
||||
}))
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree,
|
||||
fsTrees,
|
||||
fsMkdir,
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain (this spec is about folder creation).
|
||||
@@ -79,7 +78,6 @@ function mountTree(initialExpanded: string[] = []): Harness {
|
||||
return createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded,
|
||||
revealed: [],
|
||||
onToggle: (path: string) => {
|
||||
@@ -154,8 +152,8 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
fsMkdir.mockReset()
|
||||
fsMkdir.mockImplementation(async (_scope: unknown, _path: string, name: string) => ({ path: `/tmp/${name}` }))
|
||||
fsTree.mockReset()
|
||||
fsTree.mockImplementation(async (_scope: unknown, path: string) => listingFor(path))
|
||||
fsTrees.mockReset()
|
||||
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => listingFor(path)) }))
|
||||
})
|
||||
|
||||
describe('FileTree new folder', () => {
|
||||
@@ -181,12 +179,12 @@ describe('FileTree new folder', () => {
|
||||
// level's first row.
|
||||
expect(editorIndent(harness.container)).toBe('28px')
|
||||
setNativeValue(input, 'newdir')
|
||||
const listings = fsTree.mock.calls.length
|
||||
const listings = fsTrees.mock.calls.length
|
||||
await act(async () => { pressKey(input, 'Enter') })
|
||||
expect(fsMkdir).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp', 'newdir')
|
||||
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
|
||||
// The level was dropped and re-listed, so the new folder shows up.
|
||||
expect(fsTree.mock.calls.length).toBeGreaterThan(listings)
|
||||
expect(fsTrees.mock.calls.length).toBeGreaterThan(listings)
|
||||
})
|
||||
|
||||
it('expands a collapsed directory, edits at ITS level, and creates there', async () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createOpenInApp, type OpenInApp, type OpenInAppEntry } from '../src/client/open-in-app.ts'
|
||||
import type { OpenWithTarget } from '../src/client/open-with.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -33,13 +32,11 @@ beforeAll(() => {
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
path: '/tmp',
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
], truncated: false })),
|
||||
}),
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain (this spec is about the open-in-app rows).
|
||||
@@ -114,7 +111,6 @@ async function mountTree(handle?: Handle, plugin: {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import type { OpenWithTarget } from '../src/client/open-with.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -27,13 +26,11 @@ beforeAll(() => {
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
path: '/tmp',
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
], truncated: false })),
|
||||
}),
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain.
|
||||
@@ -77,7 +74,6 @@ async function mountTree(overrides: {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
@@ -30,11 +29,11 @@ const { fsRename, fsRemove } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
],
|
||||
], truncated: false })),
|
||||
}),
|
||||
fsRename,
|
||||
fsRemove,
|
||||
@@ -63,7 +62,6 @@ async function mountTree(): Promise<Harness> {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -28,11 +27,11 @@ afterEach(() => { scrollIntoView.mockClear() })
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'src', path: '/tmp/src', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
],
|
||||
], truncated: false })),
|
||||
}),
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain (this spec is about the reveal scroll).
|
||||
@@ -59,7 +58,6 @@ async function mountTree(bodyScroll: { top: number; client: number; height: numb
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed,
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -12,7 +12,6 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import type { BetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -24,15 +23,13 @@ beforeAll(() => {
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
path: '/tmp',
|
||||
entries: [
|
||||
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
|
||||
{ name: 'c.ts', path: '/tmp/c.ts', isDir: false },
|
||||
],
|
||||
truncated: false,
|
||||
], truncated: false })),
|
||||
}),
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain (its arrival must not defeat the memo bail-out either).
|
||||
@@ -66,7 +63,6 @@ async function mountTree(): Promise<Harness> {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 'memo-spec',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
|
||||
@@ -15,7 +15,6 @@ import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { TreePanel } from '../src/client/TreePanel.tsx'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
@@ -37,14 +36,14 @@ function defaultListing(path: string): Listing {
|
||||
return { path, entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }], truncated: false }
|
||||
}
|
||||
|
||||
const { fsTree, fsSearch } = vi.hoisted(() => ({
|
||||
fsTree: vi.fn(async (_scope: unknown, path: string) => defaultListing(path)),
|
||||
const { fsTrees, fsSearch } = vi.hoisted(() => ({
|
||||
fsTrees: vi.fn(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => defaultListing(path)) })),
|
||||
fsSearch: vi.fn(async () => ({ matches: ['a.ts'], truncated: false })),
|
||||
}))
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree,
|
||||
fsTrees,
|
||||
fsSearch,
|
||||
// The tree reads the shared git-status store; a non-repo answer keeps
|
||||
// every row plain.
|
||||
@@ -67,7 +66,6 @@ function mountPanel(): Harness {
|
||||
root.render(createElement(TreePanel, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
@@ -116,8 +114,8 @@ let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
fsTree.mockReset()
|
||||
fsTree.mockImplementation(async (_scope: unknown, path: string) => defaultListing(path))
|
||||
fsTrees.mockReset()
|
||||
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => defaultListing(path)) }))
|
||||
fsSearch.mockClear()
|
||||
})
|
||||
|
||||
@@ -125,7 +123,7 @@ describe('TreePanel search keeps the tree mounted', () => {
|
||||
it('parks the tree instead of unmounting it, so clearing the query keeps the level cache', async () => {
|
||||
harness = mountPanel()
|
||||
await act(async () => {})
|
||||
expect(fsTree).toHaveBeenCalledTimes(1)
|
||||
expect(fsTrees).toHaveBeenCalledTimes(1)
|
||||
expect(treeBody(harness.container).hasAttribute('hidden')).toBe(false)
|
||||
expect(harness.container.textContent).toContain('a.ts')
|
||||
|
||||
@@ -140,7 +138,7 @@ describe('TreePanel search keeps the tree mounted', () => {
|
||||
expect(treeBody(harness.container).hasAttribute('hidden')).toBe(false)
|
||||
expect(resultsBody(harness.container).hasAttribute('hidden')).toBe(true)
|
||||
// No second listing: the cache (and the expansion state) survived.
|
||||
expect(fsTree).toHaveBeenCalledTimes(1)
|
||||
expect(fsTrees).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,8 +146,8 @@ describe('TreePanel refresh and truncated levels', () => {
|
||||
it('drops a level response that lands after the cache was wiped', async () => {
|
||||
// The FIRST listing never settles until the test resolves it; the refresh
|
||||
// click starts the second one, which answers immediately.
|
||||
let resolveStale: (listing: Listing) => void = () => {}
|
||||
fsTree.mockImplementationOnce(async () => await new Promise<Listing>(resolve => { resolveStale = resolve }))
|
||||
let resolveStale: (result: { levels: Listing[] }) => void = () => {}
|
||||
fsTrees.mockImplementationOnce(async () => await new Promise<{ levels: Listing[] }>(resolve => { resolveStale = resolve }))
|
||||
harness = mountPanel()
|
||||
await act(async () => {})
|
||||
// The pending level shows nothing yet (its rows are not known).
|
||||
@@ -162,12 +160,12 @@ describe('TreePanel refresh and truncated levels', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(fsTree).toHaveBeenCalledTimes(2)
|
||||
expect(fsTrees).toHaveBeenCalledTimes(2)
|
||||
expect(harness.container.textContent).toContain('a.ts')
|
||||
|
||||
// The stale first answer arrives LAST and must be discarded.
|
||||
await act(async () => {
|
||||
resolveStale({ path: '/tmp', entries: [{ name: 'stale.ts', path: '/tmp/stale.ts', isDir: false }], truncated: false })
|
||||
resolveStale({ levels: [{ path: '/tmp', entries: [{ name: 'stale.ts', path: '/tmp/stale.ts', isDir: false }], truncated: false }] })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
@@ -176,10 +174,8 @@ describe('TreePanel refresh and truncated levels', () => {
|
||||
})
|
||||
|
||||
it('surfaces a truncated level instead of hiding it', async () => {
|
||||
fsTree.mockImplementation(async (_scope: unknown, path: string) => ({
|
||||
path,
|
||||
entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }],
|
||||
truncated: true,
|
||||
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({
|
||||
levels: paths.map(path => ({ path, entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }], truncated: true })),
|
||||
}))
|
||||
harness = mountPanel()
|
||||
await act(async () => {})
|
||||
|
||||
+21
-15
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* The host's "new folder" mutation (`fs.mkdir` route → mkdirWorkspaceEntry):
|
||||
* shape rules, existence refusal, containment under the fence, the root row
|
||||
* as a legal PARENT, and the happy path against a real temporary filesystem.
|
||||
* shape rules, existence refusal, the root row as a legal PARENT, and the
|
||||
* happy path against a real temporary filesystem.
|
||||
*
|
||||
* The workspace fence is GONE: these cases assert the LEXICAL path is returned
|
||||
* as composed (no realpath) and that a parent outside the session workspace is
|
||||
* accepted like any other directory the host user can write.
|
||||
*/
|
||||
import { mkdtemp, mkdir, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, readdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdirWorkspaceEntry } from '../src/fs-operations.ts'
|
||||
|
||||
let root: string
|
||||
/** The workspace root as the host will report it (macOS /tmp is a symlink). */
|
||||
let realRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-mkdir-'))
|
||||
realRoot = await realpath(root)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -36,13 +37,15 @@ describe('mkdirWorkspaceEntry', () => {
|
||||
it('creates one directory inside the named row and returns its absolute path', async () => {
|
||||
await mkdir(join(root, 'sub'))
|
||||
const result = await mkdirWorkspaceEntry({ cwd: root, path: join(root, 'sub'), name: 'fresh' })
|
||||
expect(result.path).toBe(join(realRoot, 'sub', 'fresh'))
|
||||
// Lexical: the path comes back exactly as composed (no realpath — on macOS
|
||||
// /var would otherwise be reported as /private/var).
|
||||
expect(result.path).toBe(join(root, 'sub', 'fresh'))
|
||||
expect(await readdir(join(root, 'sub'))).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('accepts the workspace root itself as the parent', async () => {
|
||||
const result = await mkdirWorkspaceEntry({ cwd: root, path: root, name: 'top' })
|
||||
expect(result.path).toBe(join(realRoot, 'top'))
|
||||
expect(result.path).toBe(join(root, 'top'))
|
||||
expect(await readdir(root)).toEqual(['top'])
|
||||
})
|
||||
|
||||
@@ -57,24 +60,27 @@ describe('mkdirWorkspaceEntry', () => {
|
||||
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'taken' }))).toBe('fs-error')
|
||||
})
|
||||
|
||||
it('refuses a parent outside the workspace while the fence is armed, and allows it when disarmed', async () => {
|
||||
it('creates a directory under a parent outside the workspace (fence removed)', async () => {
|
||||
const outside = await mkdtemp(join(tmpdir(), 'dsh-outside-'))
|
||||
try {
|
||||
await writeFile(join(outside, 'marker.txt'), 'x')
|
||||
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'nope' }))).toBe('forbidden')
|
||||
// The fence is the guard, not the path shape: disarmed, the same call lands.
|
||||
const result = await mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'allowed', fence: false })
|
||||
expect(result.path).toBe(join(await realpath(outside), 'allowed'))
|
||||
// ⚠️ PERMISSION CHANGE: no containment → any writable parent is legal.
|
||||
const result = await mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'allowed' })
|
||||
expect(result.path).toBe(join(outside, 'allowed'))
|
||||
expect(await readdir(outside)).toEqual(['allowed', 'marker.txt'])
|
||||
} finally {
|
||||
await rm(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to create INSIDE a symlink that escapes the workspace', async () => {
|
||||
it('creates INSIDE a symlink that points outside the workspace', async () => {
|
||||
const outside = await mkdtemp(join(tmpdir(), 'dsh-link-'))
|
||||
try {
|
||||
await symlink(outside, join(root, 'escape'))
|
||||
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: join(root, 'escape'), name: 'nope' }))).toBe('forbidden')
|
||||
// The link is followed like any directory (no realpath guard).
|
||||
const result = await mkdirWorkspaceEntry({ cwd: root, path: join(root, 'escape'), name: 'inside-link' })
|
||||
expect(result.path).toBe(join(root, 'escape', 'inside-link'))
|
||||
expect(await readdir(outside)).toEqual(['inside-link'])
|
||||
} finally {
|
||||
await rm(outside, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
+33
-10
@@ -7,6 +7,19 @@ import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from
|
||||
/** The test workspace root (each suite gets its own temp tree). */
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-'))
|
||||
|
||||
/** Symlink creation needs privileges on Windows; the link cases skip there. */
|
||||
const canSymlink = (() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-probe-'))
|
||||
try {
|
||||
symlinkSync(dir, join(dir, 'probe-link'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})()
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
@@ -92,28 +105,38 @@ describe('writeWorkspaceUpload', () => {
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
})
|
||||
|
||||
it('refuses an upload directory outside the workspace', async () => {
|
||||
it('uploads into a directory outside the workspace (containment was removed)', async () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-outside-'))
|
||||
try {
|
||||
await expect(writeWorkspaceUpload({
|
||||
// ⚠️ PERMISSION CHANGE: the workspace fence is gone, so an absolute
|
||||
// directory the host user can write is a valid upload target.
|
||||
const result = await writeWorkspaceUpload({
|
||||
cwd: root, dir: outside, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'forbidden' })
|
||||
})
|
||||
expect(result).toEqual({ path: join(outside, 'x.txt'), size: 1 })
|
||||
expect(readFileSync(join(outside, 'x.txt'), 'utf8')).toBe('x')
|
||||
} finally {
|
||||
rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses upload directories and targets that resolve outside the workspace', async () => {
|
||||
it('follows an upload directory symlink outside the workspace (no realpath guard)', async () => {
|
||||
if (!canSymlink) return
|
||||
const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-symlink-outside-'))
|
||||
const link = join(root, 'upload-link')
|
||||
try {
|
||||
symlinkSync(outside, link)
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: link, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'forbidden' })
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: 'upload-link/x.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'forbidden' })
|
||||
const viaLink = await writeWorkspaceUpload({
|
||||
cwd: root, dir: link, relativePath: 'a.txt', chunks: chunksOf('a'), limit: 1024,
|
||||
})
|
||||
expect(viaLink.path).toBe(join(link, 'a.txt'))
|
||||
// …and a relative path that walks through the link lands there too.
|
||||
const viaRelative = await writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: 'upload-link/b.txt', chunks: chunksOf('b'), limit: 1024,
|
||||
})
|
||||
expect(viaRelative.path).toBe(join(root, 'upload-link', 'b.txt'))
|
||||
expect(readFileSync(join(outside, 'a.txt'), 'utf8')).toBe('a')
|
||||
expect(readFileSync(join(outside, 'b.txt'), 'utf8')).toBe('b')
|
||||
} finally {
|
||||
rmSync(link, { force: true })
|
||||
rmSync(outside, { recursive: true, force: true })
|
||||
|
||||
+104
-3
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { compareEntries, isWithin, invalidateDirectoryCache, listDirectory, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
|
||||
import { isWin32 } from './platform.ts'
|
||||
|
||||
describe('fs-tree', () => {
|
||||
@@ -96,3 +98,102 @@ describe('fs-tree', () => {
|
||||
expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory (capped rows + cache)', () => {
|
||||
const roots: string[] = []
|
||||
const tempDir = (): string => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-list-'))
|
||||
roots.push(dir)
|
||||
return dir
|
||||
}
|
||||
afterEach(() => {
|
||||
invalidateDirectoryCache()
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('caps the rows and flags truncated', async () => {
|
||||
const dir = tempDir()
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
writeFileSync(join(dir, `file-${String(index).padStart(2, '0')}.txt`), 'x')
|
||||
}
|
||||
mkdirSync(join(dir, 'zz-dir'))
|
||||
const listing = await listDirectory(dir, 10)
|
||||
expect(listing.path).toBe(dir)
|
||||
// The cap is the row budget of one level; `truncated` is true whenever the
|
||||
// level held MORE rows than the budget (regardless of which ones survived).
|
||||
expect(listing.entries).toHaveLength(10)
|
||||
expect(listing.truncated).toBe(true)
|
||||
for (const entry of listing.entries) {
|
||||
expect(entry.path).toBe(`${dir}${sep}${entry.name}`)
|
||||
expect(entry.hidden).toBe(entry.name.startsWith('.'))
|
||||
}
|
||||
})
|
||||
|
||||
it('sorts directories first within the returned rows', async () => {
|
||||
const dir = tempDir()
|
||||
mkdirSync(join(dir, 'Dir-A'))
|
||||
mkdirSync(join(dir, 'dir-b'))
|
||||
writeFileSync(join(dir, 'a.txt'), 'a')
|
||||
writeFileSync(join(dir, 'B.txt'), 'b')
|
||||
const listing = await listDirectory(dir, 10)
|
||||
// Directories first, then case-insensitive names (the comparator's contract
|
||||
// applied to real rows).
|
||||
expect(listing.entries.map(entry => entry.name)).toEqual(['Dir-A', 'dir-b', 'a.txt', 'B.txt'])
|
||||
expect(listing.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag truncated when the level fits', async () => {
|
||||
const dir = tempDir()
|
||||
writeFileSync(join(dir, 'a.txt'), 'a')
|
||||
const listing = await listDirectory(dir, 10)
|
||||
expect(listing.entries.map(entry => entry.name)).toEqual(['a.txt'])
|
||||
expect(listing.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it('composes row paths with the platform separator', async () => {
|
||||
const dir = tempDir()
|
||||
writeFileSync(join(dir, 'a.txt'), 'a')
|
||||
const listing = await listDirectory(dir, 10)
|
||||
expect(listing.entries[0]!.path).toBe(`${dir}${sep}a.txt`)
|
||||
})
|
||||
|
||||
it('serves a repeat listing from the TTL cache and re-reads after invalidation', async () => {
|
||||
const dir = tempDir()
|
||||
writeFileSync(join(dir, 'a.txt'), 'a')
|
||||
const first = await listDirectory(dir, 10)
|
||||
// A file created behind the plugin's back is invisible until the cache
|
||||
// expires or an invalidation arrives (the writers call it explicitly).
|
||||
writeFileSync(join(dir, 'b.txt'), 'b')
|
||||
const cached = await listDirectory(dir, 10)
|
||||
expect(cached).toBe(first)
|
||||
expect(cached.entries.map(entry => entry.name)).toEqual(['a.txt'])
|
||||
invalidateDirectoryCache(dir)
|
||||
const fresh = await listDirectory(dir, 10)
|
||||
expect(fresh).not.toBe(first)
|
||||
expect(fresh.entries.map(entry => entry.name)).toEqual(['a.txt', 'b.txt'])
|
||||
})
|
||||
|
||||
it('keys the cache by cap as well as by directory', async () => {
|
||||
const dir = tempDir()
|
||||
for (let index = 0; index < 5; index += 1) writeFileSync(join(dir, `f${index}.txt`), 'x')
|
||||
const capped = await listDirectory(dir, 2)
|
||||
const full = await listDirectory(dir, 10)
|
||||
expect(capped.entries).toHaveLength(2)
|
||||
expect(full.entries).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('invalidateDirectoryCache() with no argument clears every level', async () => {
|
||||
const dir = tempDir()
|
||||
writeFileSync(join(dir, 'a.txt'), 'a')
|
||||
const first = await listDirectory(dir, 10)
|
||||
invalidateDirectoryCache()
|
||||
const second = await listDirectory(dir, 10)
|
||||
expect(second).not.toBe(first)
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('throws fs-error for a missing directory', async () => {
|
||||
const dir = tempDir()
|
||||
await expect(listDirectory(join(dir, 'nope'), 10)).rejects.toMatchObject({ code: 'fs-error' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ 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. */
|
||||
@@ -35,6 +36,28 @@ const withScratch = (run: (dir: string) => Promise<void> | void): Promise<void>
|
||||
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[] = []
|
||||
|
||||
@@ -11,7 +11,7 @@ import { act } from 'react-dom/test-utils'
|
||||
import { createNativeTabRecords, NativeTabBody, NativeTabTitle } from '../src/client/native/tab-adapter.tsx'
|
||||
import { registerNativeSurface } from '../src/client/native/index.ts'
|
||||
import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts'
|
||||
import { createSidebarStore, type SidebarTab } from '../src/client/state.ts'
|
||||
import { createSidebarStore, toggleExpanded, type SidebarTab } from '../src/client/state.ts'
|
||||
|
||||
const scope = { sessionId: 's1', cwd: '/work' }
|
||||
|
||||
@@ -45,17 +45,89 @@ describe('createNativeTabRecords', () => {
|
||||
expect(view.tab).toMatchObject({ id: 'tab-3', path: '/work/b.ts', title: 'renamed.ts' })
|
||||
})
|
||||
|
||||
it('tracks expansion per record and bumps its version', () => {
|
||||
it('tracks expansion in the SESSION state and bumps the record version', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const records = createNativeTabRecords()
|
||||
records.attachStore(store)
|
||||
records.ensure({ id: 'tab-4', kind: 'editor', title: 'Files', params: undefined, scope })
|
||||
const before = records.versionOf('tab-4')
|
||||
records.toggleExpanded('tab-4', '/work/src')
|
||||
expect(records.get('tab-4')?.expanded).toEqual(['/work/src'])
|
||||
expect(store.getSessionStates().get('s1')?.expanded).toEqual(['/work/src'])
|
||||
expect(records.versionOf('tab-4')).toBeGreaterThan(before)
|
||||
records.toggleExpanded('tab-4', '/work/src')
|
||||
expect(records.get('tab-4')?.expanded).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the expansion after a tab record is DROPPED and rebuilt (the reported bug)', () => {
|
||||
// The user's flow: open a file preview (the files tab unmounts and its
|
||||
// native record is dropped), close it, return to the files page — a NEW
|
||||
// record for the SAME session must show the set the user had.
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const records = createNativeTabRecords()
|
||||
records.attachStore(store)
|
||||
records.ensure({ id: 'files-1', kind: 'files', title: 'Files', params: undefined, scope })
|
||||
records.toggleExpanded('files-1', '/work/src')
|
||||
records.toggleExpanded('files-1', '/work/src/components')
|
||||
expect(records.get('files-1')?.expanded).toEqual(['/work/src', '/work/src/components'])
|
||||
|
||||
records.drop('files-1')
|
||||
const rebuilt = records.ensure({ id: 'files-2', kind: 'files', title: 'Files', params: undefined, scope })
|
||||
expect(rebuilt.expanded).toEqual(['/work/src', '/work/src/components'])
|
||||
})
|
||||
|
||||
it('shares one expansion set between two native tabs of the same session', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const records = createNativeTabRecords()
|
||||
records.attachStore(store)
|
||||
records.ensure({ id: 'files-a', kind: 'files', title: 'Files', params: undefined, scope })
|
||||
records.ensure({ id: 'files-b', kind: 'files', title: 'Files', params: undefined, scope })
|
||||
|
||||
records.toggleExpanded('files-a', '/work/src')
|
||||
expect(records.get('files-b')?.expanded).toEqual(['/work/src'])
|
||||
|
||||
// The WORKBENCH's own toggle (the per-session reducer) is the same state.
|
||||
store.reduce(state => toggleExpanded(state, '/work/lib'))
|
||||
expect(records.get('files-a')?.expanded).toEqual(['/work/src', '/work/lib'])
|
||||
expect(records.get('files-b')?.expanded).toEqual(['/work/src', '/work/lib'])
|
||||
|
||||
// …and a toggle from the native side is visible to the workbench reducer's
|
||||
// state (one authority, two surfaces).
|
||||
records.toggleExpanded('files-b', '/work/src')
|
||||
expect(store.getSessionStates().get('s1')?.expanded).toEqual(['/work/lib'])
|
||||
})
|
||||
|
||||
it('keeps two sessions apart (a native tab in each)', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const records = createNativeTabRecords()
|
||||
records.attachStore(store)
|
||||
records.ensure({ id: 's1-files', kind: 'files', title: 'Files', params: undefined, scope })
|
||||
records.ensure({ id: 's2-files', kind: 'files', title: 'Files', params: undefined, scope: { sessionId: 's2', cwd: '/other' } })
|
||||
|
||||
// The ACTIVE session goes through `reduce`; the background one through
|
||||
// `reduceFor` — both must land in their own session only.
|
||||
records.toggleExpanded('s1-files', '/work/src')
|
||||
records.toggleExpanded('s2-files', '/other/lib')
|
||||
expect(records.get('s1-files')?.expanded).toEqual(['/work/src'])
|
||||
expect(records.get('s2-files')?.expanded).toEqual(['/other/lib'])
|
||||
expect(store.getSessionStates().get('s1')?.expanded).toEqual(['/work/src'])
|
||||
expect(store.getSessionStates().get('s2')?.expanded).toEqual(['/other/lib'])
|
||||
})
|
||||
|
||||
it('reflects a store change made while no view is subscribed yet', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
store.reduce(state => toggleExpanded(state, '/work/src'))
|
||||
const records = createNativeTabRecords()
|
||||
records.attachStore(store)
|
||||
const view = records.ensure({ id: 'late', kind: 'files', title: 'Files', params: undefined, scope })
|
||||
expect(view.expanded).toEqual(['/work/src'])
|
||||
})
|
||||
|
||||
it('notifies subscribers and forgets a dropped record', () => {
|
||||
const records = createNativeTabRecords()
|
||||
records.ensure({ id: 'tab-5', kind: 'terminal', title: 'Terminal', params: undefined, scope })
|
||||
|
||||
@@ -70,9 +70,14 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// The separate file-window mode is the default (each file opens its own
|
||||
// tab; the merged editor-explorer is opt-in).
|
||||
expect(resolved.editorExplorer).toBe(false)
|
||||
// The workspace fence (containment over the sidebar fs routes) defaults
|
||||
// ON — the safe default never depends on the stored document.
|
||||
expect(resolved.workspaceFence).toBe(true)
|
||||
// The workspace fence is GONE: `workspaceFence` is no longer a declared
|
||||
// schema field, so a resolved document does not carry it. A legacy stored
|
||||
// value passes through the OPEN object schema untouched (and is inert).
|
||||
expect(resolved.workspaceFence).toBeUndefined()
|
||||
const legacy = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ workspaceFence: true })
|
||||
expect(legacy.workspaceFence).toBe(true)
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write); the new pref keeps its
|
||||
// default when the stored document predates it.
|
||||
|
||||
+7
-10
@@ -39,7 +39,6 @@ describe('side card preferences', () => {
|
||||
mobileDefaultTree: true,
|
||||
agentOpenTools: true,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
@@ -63,7 +62,6 @@ describe('side card preferences', () => {
|
||||
mobileDefaultTree: true,
|
||||
agentOpenTools: false,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
@@ -87,7 +85,6 @@ describe('side card preferences', () => {
|
||||
mobileDefaultTree: true,
|
||||
agentOpenTools: false,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
@@ -138,13 +135,13 @@ describe('side card preferences', () => {
|
||||
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults workspaceFence to true; only an explicit false disarms the containment guard', async () => {
|
||||
// Absent or malformed → on (the fs routes keep refusing outside paths).
|
||||
expect((await loadPrefs(wire({}))).workspaceFence).toBe(true)
|
||||
expect((await loadPrefs(wire({ workspaceFence: 'no' }))).workspaceFence).toBe(true)
|
||||
expect((await loadPrefs(wire({ workspaceFence: 0 }))).workspaceFence).toBe(true)
|
||||
// An explicit false survives (the one-click off in the fence error notice).
|
||||
expect((await loadPrefs(wire({ workspaceFence: false }))).workspaceFence).toBe(false)
|
||||
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 () => {
|
||||
|
||||
+122
-36
@@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSyn
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply, mediaTypeForPath } from '../src/index.ts'
|
||||
import { apply, FS_TREES_MAX_PATHS, mediaTypeForPath } from '../src/index.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { encodeHtmlUrl } from '../src/html-route.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
@@ -475,17 +475,20 @@ describe('session cwd resolution over the API route', () => {
|
||||
expect(value.value?.content).toContain('runGit')
|
||||
})
|
||||
|
||||
it('rejects repo-root-relative fs.read paths outside a nested session workspace', async () => {
|
||||
it('reads repo-root-relative paths outside a nested session workspace (fence removed)', async () => {
|
||||
const route = mount({
|
||||
sessions: {
|
||||
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
|
||||
},
|
||||
})
|
||||
// ⚠️ PERMISSION CHANGE: `package.json` sits ABOVE the nested session cwd.
|
||||
// The containment guard is gone, so this now reads like any other path.
|
||||
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'package.json' })
|
||||
expect(result).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
|
||||
expect(result.ok).toBe(true)
|
||||
expect((result.value as { content?: string } | undefined)?.content).toContain('"name"')
|
||||
})
|
||||
|
||||
it('rejects fs.tree paths outside the session workspace', async () => {
|
||||
it('lists a directory outside the session workspace (fence removed)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
@@ -496,13 +499,16 @@ describe('session cwd resolution over the API route', () => {
|
||||
try {
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: outside })
|
||||
expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
|
||||
expect(tree.ok).toBe(true)
|
||||
const value = tree.value as unknown as { path: string; entries: Array<{ name: string }> }
|
||||
expect(value.path).toBe(outside)
|
||||
expect(value.entries.map(entry => entry.name)).toEqual(['secret.txt'])
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects fs.read paths outside the session workspace', async () => {
|
||||
it('reads a file outside the session workspace (fence removed)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
@@ -513,52 +519,56 @@ describe('session cwd resolution over the API route', () => {
|
||||
try {
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const read = await invoke(route, 'fs.read', { sessionId: 'security', path: outsideFile })
|
||||
expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
|
||||
expect(read.ok).toBe(true)
|
||||
expect((read.value as unknown as { kind: string; content: string }).content).toBe('secret')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects fs.write paths outside the session workspace', async () => {
|
||||
it('writes a file outside the session workspace (fence removed)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(outside)
|
||||
const written = join(outside, 'written.txt')
|
||||
try {
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(outside, 'written.txt'), content: 'hack' })
|
||||
expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
|
||||
const write = await invoke(route, 'fs.write', { sessionId: 'security', path: written, content: 'hack' })
|
||||
expect(write.ok).toBe(true)
|
||||
expect(readFileSync(written, 'utf8')).toBe('hack')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects media and HTML reads through a workspace symlink', async () => {
|
||||
it('serves media and HTML through a workspace symlink (fence removed)', async () => {
|
||||
if (!canCreateSymlink) return
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-route-symlink-security-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(outside)
|
||||
const mediaPath = join(outside, 'secret.png')
|
||||
const htmlPath = join(outside, 'secret.html')
|
||||
writeFileSync(mediaPath, 'not an image')
|
||||
writeFileSync(htmlPath, '<p>secret</p>')
|
||||
const mediaPath = join(outside, 'pic.png')
|
||||
const htmlPath = join(outside, 'page.html')
|
||||
// A real 1x1 PNG: the media route sniffs the magic bytes, a text file
|
||||
// would be refused as "not a file or too large" for the wrong reason.
|
||||
writeFileSync(mediaPath, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', 'base64'))
|
||||
writeFileSync(htmlPath, '<p>outside</p>')
|
||||
try {
|
||||
symlinkSync(outside, join(workspace, 'link'))
|
||||
const routes = mountAll({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const media = routes.find(route => route.path === '/sidebar/file')!
|
||||
const html = routes.find(route => route.path === '/sidebar/html')!
|
||||
const mediaResult = await invokeGet(media, `/sidebar/file?sessionId=security&path=${encodeURIComponent(join(workspace, 'link', 'secret.png'))}`)
|
||||
const mediaResult = await invokeGet(media, `/sidebar/file?sessionId=security&path=${encodeURIComponent(join(workspace, 'link', 'pic.png'))}`)
|
||||
// Use the production encoder so the URL is well-formed on every
|
||||
// platform (a Windows drive path needs the leading slash separator
|
||||
// that a naive join-without-separator drops).
|
||||
const htmlResult = await invokeGet(html, encodeHtmlUrl('security', join(workspace, 'link', 'secret.html')))
|
||||
expect(mediaResult).toMatchObject({ status: 403 })
|
||||
expect(JSON.parse(mediaResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } })
|
||||
expect(htmlResult).toMatchObject({ status: 403 })
|
||||
expect(JSON.parse(htmlResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } })
|
||||
const htmlResult = await invokeGet(html, encodeHtmlUrl('security', join(workspace, 'link', 'page.html')))
|
||||
expect(mediaResult).toMatchObject({ status: 200 })
|
||||
expect(htmlResult).toMatchObject({ status: 200 })
|
||||
expect(htmlResult.body).toContain('outside')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -577,7 +587,76 @@ describe('session cwd resolution over the API route', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!canCreateSymlink)('rejects workspace symlinks that resolve outside the workspace', async () => {
|
||||
it('fs.trees returns every requested level in one batch, errors in place', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-trees-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
mkdirSync(join(workspace, 'src', 'deep'), { recursive: true })
|
||||
writeFileSync(join(workspace, 'src', 'a.ts'), 'a')
|
||||
writeFileSync(join(workspace, 'src', 'deep', 'b.ts'), 'b')
|
||||
try {
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const result = await invoke(route, 'fs.trees', {
|
||||
sessionId: 's-tree',
|
||||
// A relative path, an absolute path and one that does not exist: the
|
||||
// batch answers all three, the failure only on its own level.
|
||||
paths: ['src', join(workspace, 'src', 'deep'), join(workspace, 'missing')],
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
const levels = (result.value as unknown as { levels: Array<{ path: string; entries: Array<{ name: string }>; truncated: boolean; error?: string }> }).levels
|
||||
expect(levels).toHaveLength(3)
|
||||
expect(levels[0]).toMatchObject({ path: join(workspace, 'src'), truncated: false })
|
||||
expect(levels[0]!.entries.map(entry => entry.name)).toEqual(['deep', 'a.ts'])
|
||||
expect(levels[1]!.entries.map(entry => entry.name)).toEqual(['b.ts'])
|
||||
expect(levels[1]!.error).toBeUndefined()
|
||||
expect(levels[2]!.path).toBe(join(workspace, 'missing'))
|
||||
expect(levels[2]!.entries).toEqual([])
|
||||
expect(levels[2]!.truncated).toBe(false)
|
||||
expect(levels[2]!.error).toMatch(/missing/)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fs.trees warms the same cache fs.tree reads (and vice versa)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-trees-cache-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
writeFileSync(join(workspace, 'a.txt'), 'a')
|
||||
try {
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const batch = await invoke(route, 'fs.trees', { sessionId: 's-tree', paths: [workspace] })
|
||||
expect(batch.ok).toBe(true)
|
||||
const level = (batch.value as unknown as { levels: Array<{ entries: Array<{ name: string }> }> }).levels[0]!
|
||||
// A file created after the batch is invisible to the single-path route
|
||||
// too — proof the two routes share one cache entry.
|
||||
writeFileSync(join(workspace, 'b.txt'), 'b')
|
||||
const single = await invoke(route, 'fs.tree', { sessionId: 's-tree', path: workspace })
|
||||
expect((single.value as unknown as { entries: Array<{ name: string }> }).entries.map(entry => entry.name))
|
||||
.toEqual(level.entries.map(entry => entry.name))
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fs.trees rejects an empty list and an oversized batch', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-trees-guard-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
try {
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const empty = await invoke(route, 'fs.trees', { sessionId: 's-tree', paths: [] })
|
||||
expect(empty).toMatchObject({ ok: false, status: 400, error: { code: 'bad-request' } })
|
||||
const tooMany = await invoke(route, 'fs.trees', {
|
||||
sessionId: 's-tree',
|
||||
paths: Array.from({ length: FS_TREES_MAX_PATHS + 1 }, () => workspace),
|
||||
})
|
||||
expect(tooMany).toMatchObject({ ok: false, status: 400, error: { code: 'bad-request' } })
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!canCreateSymlink)('lists a workspace symlink that resolves outside the workspace', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
@@ -587,14 +666,16 @@ describe('session cwd resolution over the API route', () => {
|
||||
try {
|
||||
symlinkSync(outside, join(workspace, 'link'))
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
// No realpath: the link is followed like any other directory.
|
||||
const tree = await invoke(route, 'fs.tree', { sessionId: 'security', path: join(workspace, 'link') })
|
||||
expect(tree).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
|
||||
expect(tree.ok).toBe(true)
|
||||
expect((tree.value as unknown as { entries: Array<{ name: string }> }).entries.map(entry => entry.name)).toEqual(['secret.txt'])
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!canCreateSymlink)('rejects fs.read through a workspace symlink', async () => {
|
||||
it.skipIf(!canCreateSymlink)('reads through a workspace symlink', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
@@ -605,13 +686,14 @@ describe('session cwd resolution over the API route', () => {
|
||||
symlinkSync(outside, join(workspace, 'link'))
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const read = await invoke(route, 'fs.read', { sessionId: 'security', path: join(workspace, 'link', 'secret.txt') })
|
||||
expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
|
||||
expect(read.ok).toBe(true)
|
||||
expect((read.value as unknown as { content: string }).content).toBe('secret')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!canCreateSymlink)('rejects fs.write through a workspace symlink', async () => {
|
||||
it.skipIf(!canCreateSymlink)('writes through a workspace symlink', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
@@ -621,7 +703,9 @@ describe('session cwd resolution over the API route', () => {
|
||||
symlinkSync(outside, join(workspace, 'link'))
|
||||
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
|
||||
const write = await invoke(route, 'fs.write', { sessionId: 'security', path: join(workspace, 'link', 'new.txt'), content: 'hack' })
|
||||
expect(write).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
|
||||
expect(write.ok).toBe(true)
|
||||
// The write landed on the link's TARGET (the link is not replaced).
|
||||
expect(readFileSync(join(outside, 'new.txt'), 'utf8')).toBe('hack')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -794,7 +878,6 @@ describe('side card settings routes', () => {
|
||||
autoOpenJobs: true,
|
||||
agentOpenTools: false,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
// These title-bar fields are declared without a schema default on
|
||||
// purpose, so a document predating them migrates rather than flips.
|
||||
titleBarCompat: false,
|
||||
@@ -866,7 +949,7 @@ describe('side card settings routes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('disarms the workspace fence for the fs routes when the pref is off', async () => {
|
||||
it('reaches outside the workspace with no pref set (the fence is gone)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fence-off-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
@@ -874,14 +957,10 @@ describe('side card settings routes', () => {
|
||||
mkdirSync(outside)
|
||||
writeFileSync(join(outside, 'secret.txt'), 'global instructions')
|
||||
try {
|
||||
// ⚠️ PERMISSION CHANGE: the default is now "no containment", and there
|
||||
// is no switch to arm it — the routes read/write wherever the host user
|
||||
// can. (`workspaceFence` in an old profile is simply an unknown key.)
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
// Default (fence on): the outside read is refused as usual…
|
||||
const refused = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
|
||||
expect(refused).toMatchObject({ ok: false, error: { code: 'forbidden' } })
|
||||
// …then the settings-page switch (or the fence notice's one-click off)
|
||||
// disarms every fs route for paths outside the workspace.
|
||||
const off = await invoke(route, 'settings.update', { patch: { workspaceFence: false } })
|
||||
expect(off.ok).toBe(true)
|
||||
const read = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
|
||||
expect(read).toMatchObject({ ok: true, value: { kind: 'text', content: 'global instructions' } })
|
||||
const tree = await invoke(route, 'fs.tree', { sessionId: 'fence', cwd: workspace, path: outside })
|
||||
@@ -889,6 +968,13 @@ describe('side card settings routes', () => {
|
||||
const write = await invoke(route, 'fs.write', { sessionId: 'fence', cwd: workspace, path: join(outside, 'written.txt'), content: 'ok' })
|
||||
expect(write).toMatchObject({ ok: true })
|
||||
expect(readFileSync(join(outside, 'written.txt'), 'utf8')).toBe('ok')
|
||||
// A legacy `workspaceFence: true` patch is just an unknown prefs key now
|
||||
// (the schema no longer declares it): it must not fail the write and must
|
||||
// not re-arm anything.
|
||||
const legacy = await invoke(route, 'settings.update', { patch: { workspaceFence: true } })
|
||||
expect(legacy.ok).toBe(true)
|
||||
const stillOpen = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
|
||||
expect(stillOpen).toMatchObject({ ok: true })
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
+27
-33
@@ -826,7 +826,27 @@ describe('archive task flow (build → status → download)', () => {
|
||||
expect(byName.get('b/index.ts')!.data.toString('utf8')).toBe('from b\n')
|
||||
})
|
||||
|
||||
it('never follows a symlink out of the workspace', async () => {
|
||||
it('zips a path outside the session workspace (fence removed)', async () => {
|
||||
const root = tempRoot()
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(outside)
|
||||
writeFileSync(join(outside, 'secret.txt'), 'secret')
|
||||
const { download } = await archiveFlow({
|
||||
sessionId: 's-zip',
|
||||
cwd: workspace,
|
||||
paths: [join(outside, 'secret.txt')],
|
||||
})
|
||||
// ⚠️ PERMISSION CHANGE: an absolute path outside the workspace is now a
|
||||
// normal selection (no 403 branch exists any more).
|
||||
expect(download.status).toBe(200)
|
||||
const entries = readZip(download.body)
|
||||
expect(entries.map(entry => entry.name)).toEqual(['secret.txt'])
|
||||
expect(entries[0]!.data.toString('utf8')).toBe('secret')
|
||||
})
|
||||
|
||||
it('skips a symlink row while walking a directory (no escape by following)', async () => {
|
||||
const root = tempRoot()
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
@@ -835,46 +855,20 @@ describe('archive task flow (build → status → download)', () => {
|
||||
writeFileSync(join(outside, 'secret.txt'), 'secret')
|
||||
try {
|
||||
symlinkSync(outside, join(workspace, 'link'))
|
||||
// A file INSIDE the workspace, so the archive itself is readable.
|
||||
writeFileSync(join(workspace, 'keep.txt'), 'keep')
|
||||
} catch {
|
||||
return // symlink creation needs privileges on Windows
|
||||
}
|
||||
const { api, download } = mountHost()
|
||||
// Selecting the escaping link refuses at the workspace fence (the build
|
||||
// canonicalizes before walking), so its target is never read…
|
||||
const linked = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [join(workspace, 'link')] })
|
||||
expect(linked.ok).toBe(false)
|
||||
expect(linked.status).toBe(403)
|
||||
// …and walking the containing directory reports the link as an entry-less
|
||||
// row instead of descending into it.
|
||||
const build = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [workspace] })
|
||||
const id = (build.value as { id: string }).id
|
||||
await waitForReady(api, id, 's-zip')
|
||||
const walked = await get(download, `/sidebar/archive?sessionId=s-zip&id=${encodeURIComponent(id)}`)
|
||||
expect(walked.status).toBe(200)
|
||||
const entries = readZip(walked.body)
|
||||
// A DIRECTORY walk still never descends through a link (that rule is about
|
||||
// cycles, not containment): the link row is reported as an entry-less row,
|
||||
// so the archive holds no bytes from its target.
|
||||
const { download } = await archiveFlow({ sessionId: 's-zip', cwd: workspace, paths: [workspace] })
|
||||
expect(download.status).toBe(200)
|
||||
const entries = readZip(download.body)
|
||||
expect(entries.map(entry => entry.name)).toEqual(['workspace/', 'workspace/keep.txt'])
|
||||
expect(entries.some(entry => entry.data.toString('utf8').includes('secret'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a path outside the workspace with 403', async () => {
|
||||
const root = tempRoot()
|
||||
const workspace = join(root, 'workspace')
|
||||
const outside = join(root, 'outside')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(outside)
|
||||
writeFileSync(join(outside, 'secret.txt'), 'secret')
|
||||
const { api } = mountHost()
|
||||
const response = await post(api, 'archive.build', {
|
||||
sessionId: 's-zip',
|
||||
cwd: workspace,
|
||||
paths: [join(outside, 'secret.txt')],
|
||||
})
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.error?.code).toBe('forbidden')
|
||||
})
|
||||
|
||||
it('rejects an empty path list and a download without sessionId/id (400)', async () => {
|
||||
const root = tempRoot()
|
||||
const workspace = join(root, 'workspace')
|
||||
|
||||
Reference in New Issue
Block a user