Add filesystem capability seam and tools
This commit is contained in:
268
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
268
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Tests for the local backend through the `ctx.fs` service: the full
|
||||
* read→write→edit lifecycle with the read-before-write policy, stale-version
|
||||
* guards, concurrency races, symlink identity, and HMR/disposal.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import type { FsExecContext } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fs: LocalFileSystem
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fs-'))
|
||||
ctx = new Context()
|
||||
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const READ_ALL = { offset: 1, limit: 2000 }
|
||||
const exec = (): FsExecContext => ({ agent: { session: {} } })
|
||||
function lockCount(localFs: LocalFileSystem): number {
|
||||
return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
|
||||
const bare = new Context()
|
||||
const bareFiber = await bare.plugin(LocalFileSystem)
|
||||
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
|
||||
await bareFiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read → write → edit lifecycle', () => {
|
||||
it('creates a new file without a prior read', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.write(target, 'fresh', exec())
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('updates an existing file after reading it', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
const outcome = await fs.write(target, 'new', owner)
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('edits an existing file after reading it', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
const outcome = await fs.edit(target, { oldString: 'world', newString: 'there', replaceAll: false }, owner)
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('rejects an empty edit oldString through ctx.fs without hanging or changing the file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
|
||||
await expect(fs.edit(target, { oldString: '', newString: 'boom', replaceAll: false }, owner))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('propagates truncatedByBytes from a byte-capped read', async () => {
|
||||
await writeFile(join(dir, 'big.txt'), Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const outcome = await fs.read(await fs.resolve('big.txt'), READ_ALL, exec())
|
||||
expect(outcome.truncatedByBytes).toBe(true)
|
||||
expect(outcome.view).toBe('partial')
|
||||
})
|
||||
|
||||
it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a b')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
await fs.edit(target, { oldString: 'a', newString: 'X', replaceAll: false }, owner)
|
||||
await fs.edit(target, { oldString: 'b', newString: 'Y', replaceAll: false }, owner)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('X Y')
|
||||
})
|
||||
|
||||
it('releases per-target mutation locks after success and failure', async () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.write(target, 'created', exec())
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
|
||||
await expect(fs.write(target, 'blind overwrite', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-before-write policy', () => {
|
||||
it('rejects a blind overwrite of an existing file (no prior read)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.write(target, 'new', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects a write after only a partial read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, { offset: 1, limit: 1 }, owner)
|
||||
await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
|
||||
})
|
||||
|
||||
it('rejects a write after a partial read when the file was deleted, without recreating it', async () => {
|
||||
const path = join(dir, 'a.txt')
|
||||
await writeFile(path, 'one\ntwo')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, { offset: 1, limit: 1 }, owner)
|
||||
await unlink(path)
|
||||
|
||||
await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects an edit with no prior read (FS_NOT_OBSERVED)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec()))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale-version guard + concurrency (defensive class B)', () => {
|
||||
it('rejects a write when the file changed since it was read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
// An out-of-band change after the read.
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally')
|
||||
await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects an observed write when the file was deleted after the read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
await unlink(join(dir, 'a.txt')) // file vanishes; observed write must fail (not silently create)
|
||||
await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('two concurrent edits: one wins, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
// Both edits captured the same recorded version; only one rename can match it.
|
||||
const results = await Promise.allSettled([
|
||||
fs.edit(target, { oldString: 'base', newString: 'one', replaceAll: false }, owner),
|
||||
fs.edit(target, { oldString: 'base', newString: 'two', replaceAll: false }, owner),
|
||||
])
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled')
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(fulfilled).toHaveLength(1)
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('symlink targetKey identity (defensive class F)', () => {
|
||||
it('a read via the real path authorizes an edit via the symlink path', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const owner = exec()
|
||||
await fs.read(await fs.resolve('real.txt'), READ_ALL, owner)
|
||||
// Edit through the link: same realpath → same targetKey → prior read counts.
|
||||
const linkTarget = await fs.resolve('link.txt')
|
||||
const outcome = await fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved, target written
|
||||
})
|
||||
|
||||
it('write through a symlink preserves the link and writes the real target', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const owner = exec()
|
||||
const linkTarget = await fs.resolve('link.txt')
|
||||
await fs.read(linkTarget, READ_ALL, owner)
|
||||
await fs.write(linkTarget, 'replaced', owner)
|
||||
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('replaced')
|
||||
})
|
||||
|
||||
it('a stale change is detected across both paths', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const owner = exec()
|
||||
await fs.read(await fs.resolve('real.txt'), READ_ALL, owner)
|
||||
await writeFile(join(dir, 'real.txt'), 'changed') // out-of-band via real path
|
||||
const linkTarget = await fs.resolve('link.txt')
|
||||
await expect(fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-regular targets', () => {
|
||||
it('rejects writing onto a directory', async () => {
|
||||
const target = await fs.resolve('.') // the cwd dir
|
||||
await expect(fs.write(target, 'x', exec())).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('applyEdit rejects a target that vanished after the read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = (await fs.read(target, READ_ALL, owner)).version
|
||||
await unlink(join(dir, 'a.txt'))
|
||||
await expect(fs.applyEdit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('applyEdit rejects a non-regular target', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.applyEdit(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: 'v' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR / disposal (defensive class D)', () => {
|
||||
it('disposing the fiber withdraws ctx.fs', async () => {
|
||||
const local = new Context()
|
||||
const fiber = await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
expect(local.fs).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(local.fs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a fresh provider does not inherit recorded file state', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const local = new Context()
|
||||
const owner = exec()
|
||||
const fiber = await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
await (local.fs as LocalFileSystem).read(await local.fs.resolve('a.txt'), READ_ALL, owner)
|
||||
await fiber.dispose()
|
||||
|
||||
await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
const fs2 = local.fs as LocalFileSystem
|
||||
const target = await fs2.resolve('a.txt')
|
||||
// Same owner object, but state was released on disposal.
|
||||
await expect(fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
364
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
364
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution,
|
||||
* fast/streaming reads, pagination/caps, binary rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
formatReadBody,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextPage,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
writeFileAtomic,
|
||||
} from '@deepseek-ai/dsh-fs-local'
|
||||
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const READ_ALL = { offset: 1, limit: 2000 }
|
||||
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path })
|
||||
|
||||
describe('resolveLocalTarget', () => {
|
||||
it('resolves a relative path from cwd and realpaths it', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const target = await resolveLocalTarget(dir, 'a.txt')
|
||||
expect(target.displayPath).toBe(file)
|
||||
expect(target.targetKey).toBe(await (await import('node:fs/promises')).realpath(file))
|
||||
})
|
||||
|
||||
it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
|
||||
const { realpath } = await import('node:fs/promises')
|
||||
const target = await resolveLocalTarget(dir, 'missing.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
|
||||
})
|
||||
|
||||
it('two paths to the same file via a symlink share one targetKey', async () => {
|
||||
const real = join(dir, 'real.txt')
|
||||
await writeFile(real, 'hi')
|
||||
const link = join(dir, 'link.txt')
|
||||
await symlink(real, link)
|
||||
const viaReal = await resolveLocalTarget(dir, 'real.txt')
|
||||
const viaLink = await resolveLocalTarget(dir, 'link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
expect(viaLink.displayPath).toBe(link)
|
||||
})
|
||||
|
||||
it('falls back to the absolute path when even the parent dir is absent', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt')
|
||||
expect(target.targetKey).toBe(join(dir, 'no-such-dir', 'child.txt'))
|
||||
})
|
||||
|
||||
it('rejects a blank path', async () => {
|
||||
await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextPage', () => {
|
||||
it('reads a small file with line numbers and full view', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines).toEqual([
|
||||
{ number: 1, text: 'one' },
|
||||
{ number: 2, text: 'two' },
|
||||
{ number: 3, text: 'three' },
|
||||
])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.view).toBe('full')
|
||||
})
|
||||
|
||||
it('paginates with offset/limit and reports a partial view', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree\nfour')
|
||||
const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 })
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.view).toBe('partial')
|
||||
expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
|
||||
})
|
||||
|
||||
it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full')
|
||||
expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial')
|
||||
})
|
||||
|
||||
it('truncates an over-long line', async () => {
|
||||
const file = join(dir, 'long.txt')
|
||||
await writeFile(file, 'x'.repeat(3000))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
|
||||
})
|
||||
|
||||
it('caps output bytes and reports truncatedByBytes', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100))
|
||||
await writeFile(file, lines.join('\n'))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB')
|
||||
})
|
||||
|
||||
it('strips CRLF so a Windows file reads like LF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('reads an empty file at offset 1', async () => {
|
||||
const file = join(dir, 'empty.txt')
|
||||
await writeFile(file, '')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines).toEqual([])
|
||||
expect(result.totalLines).toBe(0)
|
||||
expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)')
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a binary file (fast path)', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('rejects a missing file and a directory', async () => {
|
||||
await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the fast path', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal)
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('streaming path (forced via a tiny fastPathMaxSize)', () => {
|
||||
const stream = { fastPathMaxSize: 1 }
|
||||
|
||||
it('reads and paginates large files the same way', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream)
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it('rejects a binary file on the streaming path', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('caps a newline-free giant line without unbounded buffering', async () => {
|
||||
const file = join(dir, 'one-line.txt')
|
||||
await writeFile(file, 'z'.repeat(5000))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
|
||||
})
|
||||
|
||||
it('honors abort on the streaming path', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
const file = join(dir, 'no-nl.txt')
|
||||
await writeFile(file, 'one\ntwo') // no trailing \n
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('handles a trailing newline (no dangling buffer at EOF)', async () => {
|
||||
const file = join(dir, 'nl.txt')
|
||||
await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through to the stream', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream)
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
it('scans across multiple stream chunks', async () => {
|
||||
// A file well past the default 64 KB stream highWaterMark yields multiple chunks,
|
||||
// exercising the non-first-chunk branch and the line-buffer cap across appends.
|
||||
const file = join(dir, 'multi.txt')
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`)
|
||||
await writeFile(file, lines.join('\n'))
|
||||
const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream)
|
||||
expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true)
|
||||
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
|
||||
expect(result.totalLines).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileAtomic — temp-file safety (defensive class A)', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
let inspected = false
|
||||
await writeFileAtomic(file, 'hello', 0o640, undefined, {
|
||||
inspectTemp: async ({ stagingDir, tempPath }) => {
|
||||
inspected = true
|
||||
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
|
||||
},
|
||||
})
|
||||
expect(inspected).toBe(true)
|
||||
expect(await readFile(file, 'utf8')).toBe('hello')
|
||||
const info = await stat(file)
|
||||
expect(info.mode & 0o777).toBe(0o640)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('creates new files owner-only by default', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hello', undefined, undefined)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
const tempDirName = '.fixed-temp.tmpdir'
|
||||
await mkdir(join(dir, tempDirName))
|
||||
await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep')
|
||||
await expect(
|
||||
writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
|
||||
).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
// The pre-existing staging dir is intact and the target was not created.
|
||||
expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('creates parent directories as needed', async () => {
|
||||
const file = join(dir, 'nested', 'deep', 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, undefined)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the write', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, new AbortController().signal)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('aborts before writing when the signal is already aborted', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('cleans up the temp file when the final rename fails', async () => {
|
||||
const sub = join(dir, 'occupied')
|
||||
await mkdir(sub) // rename(temp, sub) fails because sub is a non-empty/dir target
|
||||
await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
|
||||
// No leftover staging dirs in the directory.
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyLiteralEdit', () => {
|
||||
it('replaces a unique match', () => {
|
||||
expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 })
|
||||
})
|
||||
|
||||
it('rejects zero matches', () => {
|
||||
expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects an empty oldString without scanning forever', () => {
|
||||
expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects multiple matches without replaceAll', () => {
|
||||
expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' }))
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', () => {
|
||||
expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 })
|
||||
})
|
||||
|
||||
it('matches across normalized line endings', () => {
|
||||
expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readForEdit + restoreLineEndings', () => {
|
||||
it('round-trips CRLF: matches on LF, writes back CRLF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const original = await readForEdit(file, file)
|
||||
expect(original.lineEndings).toBe('CRLF')
|
||||
const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file)
|
||||
expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
|
||||
})
|
||||
|
||||
it('rejects a binary file', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x00, 0x01]))
|
||||
await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the read', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const original = await readForEdit(file, file, new AbortController().signal)
|
||||
expect(original.content).toBe('one\ntwo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns null for a missing path and info for a file', async () => {
|
||||
expect(await probe(join(dir, 'nope'))).toBeNull()
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const info = await probe(file)
|
||||
expect(info?.isFile).toBe(true)
|
||||
expect(typeof info?.version).toBe('string')
|
||||
})
|
||||
|
||||
it('marks a directory as not a regular file', async () => {
|
||||
const sub = join(dir, 'sub')
|
||||
await mkdir(sub)
|
||||
expect((await probe(sub))?.isFile).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user