Files
deepseek-harness/packages/host/directory-picker-browse/tests/service.spec.ts
T
creatixchu da970ea269 fix(host,client): stream bounded listings, declare schemastery, guard Choose again
The browse level now streams through opendir into a name-sorted window of
maxEntries + 1 candidates (boundedInsert), so memory stays O(maxEntries)
no matter how many children a directory holds and enterability probing
touches only windowed candidates; a windowed broken symlink is not
backfilled since the eviction already marks the level truncated.
schemastery joins the package's runtime dependencies (the source launcher
and isolated installs failed to resolve the value import). The
folder-error dialog's Choose again goes inert while the flow hole is
empty, and the withdrawal effect also keys on the open transition, so a
flow can never open over a hole nobody serves.
2026-07-29 04:11:41 +08:00

177 lines
8.9 KiB
TypeScript

/** Behavior of the browse backend over a real temporary directory tree. */
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker, { boundedInsert, fullyQualified } from '../src/index.ts'
import type { ListingCandidate } from '../src/index.ts'
let root: string
let capability: DirectoryPickerBrowseCapability
let dispose: () => Promise<void>
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-browse-'))
await mkdir(join(root, 'projects'))
await mkdir(join(root, 'projects', 'harness'))
await mkdir(join(root, '.hidden-dir'))
await writeFile(join(root, 'notes.txt'), 'not a directory')
await symlink(join(root, 'projects'), join(root, 'linked'), 'junction')
await symlink(join(root, 'gone'), join(root, 'broken'), 'junction')
try {
await symlink(join(root, 'notes.txt'), join(root, 'file-link'))
} catch {
// Windows denies unprivileged file symlinks; the file-link row only
// feeds the POSIX lanes' coverage of the symlink-to-file arm, and every
// assertion below expects it to be filtered out anyway.
}
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker)
await fiber.await()
const picked = ctx.get('directoryPicker')!.capability()
if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
capability = picked
dispose = () => fiber.dispose()
})
afterAll(async () => {
await dispose()
await rm(root, { recursive: true, force: true })
})
describe('BrowseDirectoryPicker', () => {
it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => {
const listing = await capability.list(root)
expect(listing.path).toBe(root)
expect(listing.home).toBe(homedir())
expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects'])
expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false])
// Every entry path is absolute and host-joined — clients never join segments.
expect(listing.entries.every(entry => entry.path === join(root, entry.name))).toBe(true)
// Well under the default bound: the complete level, not a cut one.
expect(listing.truncated).toBe(false)
})
it('cuts a level at maxEntries keeping the name-sorted head, and flags the cut', async () => {
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker, { maxEntries: 1 })
await fiber.await()
const bounded = ctx.get('directoryPicker')!.capability()
if (bounded.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
try {
const cut = await bounded.list(root)
expect(cut.entries.map(entry => entry.name)).toEqual(['.hidden-dir'])
expect(cut.truncated).toBe(true)
// Exactly at the bound is complete, not truncated.
const exact = await bounded.list(join(root, 'projects'))
expect(exact.entries.map(entry => entry.name)).toEqual(['harness'])
expect(exact.truncated).toBe(false)
// A level that fits the window but exceeds the bound (two rows, bound
// one): the in-window extra row proves the cut without any eviction.
await mkdir(join(root, 'projects', 'harness', 'a'))
await mkdir(join(root, 'projects', 'harness', 'b'))
const inWindow = await bounded.list(join(root, 'projects', 'harness'))
expect(inWindow.entries.map(entry => entry.name)).toEqual(['a'])
expect(inWindow.truncated).toBe(true)
} finally {
await fiber.dispose()
}
})
it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => {
const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false })
const window: ListingCandidate[] = []
expect(boundedInsert(window, candidate('m'), 2)).toBe(false)
expect(boundedInsert(window, candidate('z'), 2)).toBe(false)
// A smaller name lands in place and pushes the current largest out.
expect(boundedInsert(window, candidate('a'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
// A name beyond the window's tail enters last and leaves immediately.
expect(boundedInsert(window, candidate('t'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
})
it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => {
const listing = await capability.list(join(root, 'projects'))
const tail = listing.crumbs.at(-1)!
expect(tail).toMatchObject({ name: 'projects', path: join(root, 'projects'), hidden: false })
expect(listing.crumbs.at(-2)!.path).toBe(root)
expect(listing.crumbs.at(-2)!.name).toBe(basename(root))
// The chain starts at the filesystem root, whose crumb is labeled by its full path.
expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path)
})
it('lists the home directory when no path is given', async () => {
const listing = await capability.list()
expect(listing.path).toBe(homedir())
})
it('throws directory-unreadable for a missing target', async () => {
const missing = join(root, 'no-such-dir')
const failure = await capability.list(missing).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-unreadable')
expect((failure as DirectoryPickerError).path).toBe(missing)
})
it('classifies fully qualified paths per platform (drive-less rooted Windows forms rejected)', () => {
expect(fullyQualified('/home/x', 'linux')).toBe(true)
expect(fullyQualified('x/y', 'darwin')).toBe(false)
expect(fullyQualified('C:\\projects', 'win32')).toBe(true)
expect(fullyQualified('C:/projects', 'win32')).toBe(true)
expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true)
expect(fullyQualified('//server/share/deep', 'win32')).toBe(true)
// Rooted but drive-less: isAbsolute accepts these, yet resolve() would
// inject the process's current drive.
expect(fullyQualified('\\foo', 'win32')).toBe(false)
expect(fullyQualified('/foo', 'win32')).toBe(false)
expect(fullyQualified('C:relative', 'win32')).toBe(false)
// Incomplete UNC prefixes collapse to drive-relative roots under resolve().
expect(fullyQualified('\\\\', 'win32')).toBe(false)
expect(fullyQualified('\\\\server', 'win32')).toBe(false)
expect(fullyQualified('\\\\server\\', 'win32')).toBe(false)
})
it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => {
for (const relative of ['', 'projects', './projects', '..']) {
const listFailure = await capability.list(relative).catch((error: unknown) => error)
expect(listFailure).toBeInstanceOf(DirectoryPickerError)
expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable')
expect((listFailure as DirectoryPickerError).path).toBe(relative)
const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error)
expect(createFailure).toBeInstanceOf(DirectoryPickerError)
expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed')
expect((createFailure as DirectoryPickerError).path).toBe(relative)
}
})
it('creates one child directory and surfaces it in the next listing', async () => {
const created = await capability.createDirectory(root, 'fresh')
expect(created).toBe(join(root, 'fresh'))
const listing = await capability.list(root)
expect(listing.entries.map(entry => entry.name)).toContain('fresh')
})
it('refuses an existing child with directory-exists', async () => {
const failure = await capability.createDirectory(root, 'projects').catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-exists')
})
it('refuses non-segment names and other filesystem failures with directory-create-failed', async () => {
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
const failure = await capability.createDirectory(root, name).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-create-failed')
}
// Missing parent is a real failure, not a level to invent.
const missingParent = await capability.createDirectory(join(root, 'no-such-dir'), 'child').catch((error: unknown) => error)
expect((missingParent as DirectoryPickerError).code).toBe('directory-create-failed')
})
})