| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609 |
- /**
- * Tests for the local backend through the `ctx.fs` provider seam: stat, whole-
- * file/streamed text reads, atomic guarded writes (createIfAbsent /
- * replaceIfVersion), version-guarded literal edits, concurrency races, symlink
- * identity, and HMR/disposal. Read WINDOWING is policy and lives in
- * `dsh-fs-policy`, so it is not exercised here.
- */
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } 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 { FsVersion } from '@deepseek-ai/dsh-fs'
- import type { FsTarget } 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 })
- })
- function lockCount(localFs: LocalFileSystem): number {
- return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
- }
- /** The version the backend currently reports for a resolved target. */
- async function versionOf(target: FsTarget): Promise<FsVersion> {
- const info = await fs.stat(target)
- if (!info) throw new Error('expected target to exist')
- return info.version
- }
- 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('resolve', () => {
- it('resolves a relative path against opts.cwd, not config.cwd', async () => {
- // config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
- // path there (the per-session-workspace seam — mirrors tool-bash workdir).
- const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
- try {
- await writeFile(join(other, 'x.txt'), 'in other')
- const viaOther = await fs.resolve('x.txt', { cwd: other })
- expect(await fs.readText(viaOther)).toBe('in other')
- // Same relative path with no opts falls back to config.cwd (= dir), where
- // x.txt does not exist.
- await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
- } finally {
- await rm(other, { recursive: true, force: true })
- }
- })
- it('ignores opts.cwd for an ABSOLUTE path', async () => {
- await writeFile(join(dir, 'abs.txt'), 'absolute')
- const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
- expect(await fs.readText(target)).toBe('absolute')
- })
- it('honors a pre-aborted signal', async () => {
- await expect(fs.resolve('a.txt', { signal: AbortSignal.abort() })).rejects.toMatchObject({ code: 'FS_ABORTED' })
- })
- it('honors a signal aborted while resolution is in flight', async () => {
- const controller = new AbortController()
- const pending = fs.resolve('a.txt', { signal: controller.signal })
- controller.abort()
- await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
- })
- })
- describe('stat', () => {
- it('returns file metadata, directory type, and undefined for absent', async () => {
- await writeFile(join(dir, 'a.txt'), 'hello')
- const fileInfo = await fs.stat(await fs.resolve('a.txt'))
- expect(fileInfo?.type).toBe('file')
- expect(fileInfo?.size).toBe(5)
- expect(typeof fileInfo?.version).toBe('string')
- expect((await fs.stat(await fs.resolve('.')))?.type).toBe('directory')
- expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
- })
- it('changes version after a same-size rewrite even when mtime is restored', async () => {
- const path = join(dir, 'same-size.txt')
- await writeFile(path, 'first')
- const target = await fs.resolve(path)
- const beforeInfo = await stat(path)
- const beforeVersion = await versionOf(target)
- await fs.writeText(target, 'other')
- await utimes(path, beforeInfo.atime, beforeInfo.mtime)
- expect((await stat(path)).size).toBe(beforeInfo.size)
- expect(await versionOf(target)).not.toBe(beforeVersion)
- })
- it('honors a pre-aborted signal', async () => {
- await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
- })
- })
- describe('lstat', () => {
- it('reports path metadata without following the final symlink component', async () => {
- await writeFile(join(dir, 'real.txt'), 'hello')
- await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
- expect((await fs.lstat('real.txt'))?.type).toBe('file')
- expect((await fs.lstat('link.txt'))?.type).toBe('symlink')
- expect(await fs.lstat('missing.txt')).toBeUndefined()
- })
- it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => {
- const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
- try {
- await writeFile(join(other, 'x.txt'), 'in other')
- expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file')
- await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
- await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
- } finally {
- await rm(other, { recursive: true, force: true })
- }
- })
- })
- describe('metadata cancellation', () => {
- it('rejects stat and lstat when their signals abort while the metadata probes are in flight', async () => {
- await writeFile(join(dir, 'slow.txt'), 'hello')
- const statStarted = Promise.withResolvers<undefined>()
- const statRelease = Promise.withResolvers<undefined>()
- const lstatStarted = Promise.withResolvers<undefined>()
- const lstatRelease = Promise.withResolvers<undefined>()
- let isolatedCtx: Context | undefined
- vi.resetModules()
- vi.doMock('node:fs/promises', async (importOriginal) => {
- const actual = await importOriginal<typeof import('node:fs/promises')>()
- return {
- ...actual,
- async stat(path: string) {
- statStarted.resolve(undefined)
- await statRelease.promise
- return actual.stat(path, { bigint: true })
- },
- async lstat(path: string) {
- lstatStarted.resolve(undefined)
- await lstatRelease.promise
- return actual.lstat(path, { bigint: true })
- },
- }
- })
- try {
- const { LocalFileSystem: IsolatedLocalFileSystem } = await import('../src/index.ts')
- isolatedCtx = new Context()
- await isolatedCtx.plugin(IsolatedLocalFileSystem, { cwd: dir })
- const isolatedFs = isolatedCtx.fs as InstanceType<typeof IsolatedLocalFileSystem>
- const target = await isolatedFs.resolve('slow.txt')
- const statController = new AbortController()
- const lstatController = new AbortController()
- const pendingStat = isolatedFs.stat(target, statController.signal)
- const pendingLstat = isolatedFs.lstat('slow.txt', undefined, lstatController.signal)
- await Promise.all([statStarted.promise, lstatStarted.promise])
- statController.abort()
- lstatController.abort()
- const statRejected = expect(pendingStat).rejects.toMatchObject({ code: 'FS_ABORTED' })
- const lstatRejected = expect(pendingLstat).rejects.toMatchObject({ code: 'FS_ABORTED' })
- statRelease.resolve(undefined)
- lstatRelease.resolve(undefined)
- await Promise.all([statRejected, lstatRejected])
- } finally {
- statRelease.resolve(undefined)
- lstatRelease.resolve(undefined)
- await isolatedCtx?.fiber.dispose()
- vi.doUnmock('node:fs/promises')
- vi.resetModules()
- }
- })
- })
- describe('readText / streamText', () => {
- it('reads whole-file text', async () => {
- await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
- expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
- })
- it('streams the same text', async () => {
- await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
- const target = await fs.resolve('a.txt')
- let streamed = ''
- for await (const chunk of await fs.streamText(target)) streamed += chunk
- expect(streamed).toBe('one\ntwo\nthree')
- })
- it('rejects a missing file, a directory, binary, and invalid UTF-8', async () => {
- await expect(fs.readText(await fs.resolve('nope'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
- await expect(fs.readText(await fs.resolve('.'))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
- await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
- await expect(fs.readText(await fs.resolve('bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
- await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
- await expect(fs.readText(await fs.resolve('bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
- })
- })
- describe('listDir', () => {
- it('lists files and directories in stable name order with resolved child targets', async () => {
- await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
- await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta')
- await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha')
- await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link'))
- const entries = await fs.listDir(await fs.resolve('skills'))
- expect(entries.map(entry => [entry.name, entry.type])).toEqual([
- ['alpha.md', 'file'],
- ['broken-link', 'other'],
- ['dir-skill', 'directory'],
- ['zeta.md', 'file'],
- ])
- expect(entries.map(entry => entry.target.displayPath)).toEqual([
- join(dir, 'skills', 'alpha.md'),
- join(dir, 'skills', 'broken-link'),
- join(dir, 'skills', 'dir-skill'),
- join(dir, 'skills', 'zeta.md'),
- ])
- const materializedEntries = entries.filter(entry => entry.version !== undefined)
- expect(materializedEntries.map(entry => entry.target.targetKey))
- .toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
- expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
- expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
- expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
- expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
- })
- it('reports a missing directory as FS_NOT_FOUND', async () => {
- await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
- })
- it('reports a file target as FS_NOT_DIRECTORY', async () => {
- await writeFile(join(dir, 'a.txt'), 'text')
- await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
- })
- it('honors a pre-aborted signal', async () => {
- await mkdir(join(dir, 'skills'), { recursive: true })
- await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
- })
- })
- describe('writeText', () => {
- it('createIfAbsent creates a new file', async () => {
- const target = await fs.resolve('new.txt')
- const outcome = await fs.writeText(target, 'fresh', { kind: 'createIfAbsent' })
- expect(outcome.operation).toBe('create')
- expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
- })
- it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => {
- await writeFile(join(dir, 'a.txt'), 'old')
- const target = await fs.resolve('a.txt')
- await expect(fs.writeText(target, 'new', { kind: 'createIfAbsent' }))
- .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
- })
- it('replaceIfVersion replaces when the version matches', async () => {
- await writeFile(join(dir, 'a.txt'), 'old')
- const target = await fs.resolve('a.txt')
- const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: await versionOf(target) })
- expect(outcome.operation).toBe('update')
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
- })
- it('replaceIfVersion rejects a stale version', async () => {
- await writeFile(join(dir, 'a.txt'), 'v1')
- const target = await fs.resolve('a.txt')
- const stale = await versionOf(target)
- await writeFile(join(dir, 'a.txt'), 'changed-externally')
- await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version: stale }))
- .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
- })
- it('replaceIfVersion rejects a deleted target as stale, without recreating it', async () => {
- const path = join(dir, 'a.txt')
- await writeFile(path, 'v1')
- const target = await fs.resolve('a.txt')
- const version = await versionOf(target)
- await unlink(path)
- await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version }))
- .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
- await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
- })
- it('rejects writing onto a directory', async () => {
- const target = await fs.resolve('.')
- await expect(fs.writeText(target, 'x', { kind: 'createIfAbsent' }))
- .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
- })
- it('unconditionally creates a new file with no expectation (bare provider)', async () => {
- const target = await fs.resolve('new.txt')
- const outcome = await fs.writeText(target, 'fresh')
- expect(outcome.operation).toBe('create')
- expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
- })
- it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
- await writeFile(join(dir, 'a.txt'), 'old')
- const target = await fs.resolve('a.txt')
- const outcome = await fs.writeText(target, 'clobbered')
- expect(outcome.operation).toBe('update')
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
- })
- it('rejects writing onto a directory even with no expectation', async () => {
- const target = await fs.resolve('.')
- await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
- })
- it('a create reports before:null and after = the written content (no prior file)', async () => {
- const target = await fs.resolve('new.txt')
- const outcome = await fs.writeText(target, 'fresh')
- expect(outcome.before).toBeNull()
- expect(outcome.after).toBe('fresh')
- })
- it('an overwrite reports before = the OLD content and after = the new content', async () => {
- await writeFile(join(dir, 'a.txt'), 'old body')
- const target = await fs.resolve('a.txt')
- const outcome = await fs.writeText(target, 'new body')
- expect(outcome.before).toBe('old body')
- expect(outcome.after).toBe('new body')
- })
- it('an overwrite returns LF-normalized before AND after (a CRLF rewrite is not every-line-changed)', async () => {
- // The applied-hunk diff bases on `before`/`after`; if `after` kept CRLF while
- // `before` is LF-normalized, a CRLF rewrite would read as every line changed.
- // Both sides are LF so only the genuinely-changed line diffs.
- await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\nc\r\n')
- const target = await fs.resolve('a.txt')
- const outcome = await fs.writeText(target, 'a\r\nB\r\nc\r\n')
- expect(outcome.before).toBe('a\nb\nc\n')
- expect(outcome.after).toBe('a\nB\nc\n')
- })
- it('an overwrite of a BINARY prior file reports before:null (undiffable), still succeeds', async () => {
- await writeFile(join(dir, 'a.bin'), Buffer.from([0x00, 0x01, 0x02]))
- const target = await fs.resolve('a.bin')
- const outcome = await fs.writeText(target, 'now text')
- expect(outcome.operation).toBe('update')
- expect(outcome.before).toBeNull()
- expect(outcome.after).toBe('now text')
- })
- it('an overwrite of an INVALID-UTF-8 (non-NUL) prior file reports before:null, still succeeds', async () => {
- // 0xff is never valid UTF-8 but is not a NUL, so it exercises the decoder's
- // fatal-throw path (not the NUL-scan short-circuit): an undiffable prior file
- // still yields a successful write with no before-content basis.
- await writeFile(join(dir, 'a.bin'), Buffer.from([0x68, 0xff, 0x69]))
- const target = await fs.resolve('a.bin')
- const outcome = await fs.writeText(target, 'now valid')
- expect(outcome.operation).toBe('update')
- expect(outcome.before).toBeNull()
- expect(outcome.after).toBe('now valid')
- })
- it('releases per-target mutation locks after success and failure', async () => {
- const target = await fs.resolve('a.txt')
- await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
- expect(lockCount(fs)).toBe(0)
- await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' }))
- .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
- expect(lockCount(fs)).toBe(0)
- })
- it('replaceIfVersion returns the post-write version (matches a fresh stat)', async () => {
- await writeFile(join(dir, 'a.txt'), 'v1')
- const target = await fs.resolve('a.txt')
- const before = await versionOf(target)
- const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
- expect(outcome.version).not.toBe(before)
- expect(outcome.version).toBe(await versionOf(target))
- })
- it('honors a pre-aborted signal without creating the file', async () => {
- const target = await fs.resolve('aborted.txt')
- await expect(fs.writeText(target, 'x', undefined, AbortSignal.abort()))
- .rejects.toMatchObject({ code: 'FS_ABORTED' })
- await expect(stat(join(dir, 'aborted.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
- expect(lockCount(fs)).toBe(0)
- })
- it('two concurrent guarded writes: one updates, the other is rejected as stale', async () => {
- await writeFile(join(dir, 'a.txt'), 'base')
- const target = await fs.resolve('a.txt')
- const version = await versionOf(target)
- const results = await Promise.allSettled([
- fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
- fs.writeText(target, 'two', { kind: 'replaceIfVersion', version }),
- ])
- expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
- const rejected = results.filter(r => r.status === 'rejected')
- expect(rejected).toHaveLength(1)
- expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
- expect(lockCount(fs)).toBe(0)
- })
- })
- describe('editText', () => {
- it('applies a literal edit at the matching version', async () => {
- await writeFile(join(dir, 'a.txt'), 'hello world')
- const target = await fs.resolve('a.txt')
- const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
- expect(outcome.after).toBe('hello there')
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
- })
- it('reports before/after content (the applied-hunk basis), LF-normalized', async () => {
- await writeFile(join(dir, 'a.txt'), 'a\r\nOLD\r\nb\r\n')
- const target = await fs.resolve('a.txt')
- const outcome = await fs.editText(target, { oldString: 'OLD', newString: 'NEW', replaceAll: false })
- expect(outcome.before).toBe('a\nOLD\nb\n')
- expect(outcome.after).toBe('a\nNEW\nb\n')
- // The written file keeps the original CRLF endings (before/after are the
- // LF-normalized diff basis, not the on-disk bytes).
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a\r\nNEW\r\nb\r\n')
- })
- it('checks the stale version BEFORE literal matching', async () => {
- await writeFile(join(dir, 'a.txt'), 'hello world')
- const target = await fs.resolve('a.txt')
- const stale = await versionOf(target)
- // Change the file so 'world' is gone — a stale edit must report STALE, not NOT_FOUND.
- await writeFile(join(dir, 'a.txt'), 'goodbye')
- await expect(fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: stale }))
- .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
- })
- it('unconditionally edits the current content with no expectation (bare provider)', async () => {
- await writeFile(join(dir, 'a.txt'), 'hello world')
- const target = await fs.resolve('a.txt')
- // No version guard: any current content is edited, regardless of version.
- const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
- expect(outcome.after).toBe('hello there')
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
- })
- it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
- const target = await fs.resolve('missing.txt')
- await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
- .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
- })
- it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
- await writeFile(join(dir, 'a.txt'), 'hello world')
- const target = await fs.resolve('a.txt')
- await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
- .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
- })
- it('rejects a deleted target as stale (before matching)', async () => {
- await writeFile(join(dir, 'a.txt'), 'hello')
- const target = await fs.resolve('a.txt')
- const version = await versionOf(target)
- await unlink(join(dir, 'a.txt'))
- await expect(fs.editText(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
- .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
- })
- it('rejects a non-regular target', async () => {
- const target = await fs.resolve('.')
- await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: FsVersion('v') }))
- .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
- })
- it('rejects zero matches and ambiguous matches at the right version', async () => {
- await writeFile(join(dir, 'a.txt'), 'a a a')
- const target = await fs.resolve('a.txt')
- const version = await versionOf(target)
- await expect(fs.editText(target, { oldString: 'z', newString: 'X', replaceAll: false }, { version }))
- .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
- await expect(fs.editText(target, { oldString: 'a', newString: 'X', replaceAll: false }, { version }))
- .rejects.toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
- })
- it('replaces all matches with replaceAll', async () => {
- await writeFile(join(dir, 'a.txt'), 'a a a')
- const target = await fs.resolve('a.txt')
- const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
- expect(outcome.after).toBe('b b b')
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
- })
- it('rejects invalid UTF-8 without rewriting the file', async () => {
- const path = join(dir, 'bad.txt')
- const bytes = Buffer.from([0x68, 0xff, 0x69])
- await writeFile(path, bytes)
- const target = await fs.resolve('bad.txt')
- const version = await versionOf(target)
- await expect(fs.editText(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version }))
- .rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
- expect(await readFile(path)).toEqual(bytes)
- })
- it('two concurrent edits: one wins, the other is rejected as stale', async () => {
- await writeFile(join(dir, 'a.txt'), 'base')
- const target = await fs.resolve('a.txt')
- const version = await versionOf(target)
- const results = await Promise.allSettled([
- fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }),
- fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
- ])
- expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
- const rejected = results.filter(r => r.status === 'rejected')
- expect(rejected).toHaveLength(1)
- expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
- expect(lockCount(fs)).toBe(0)
- })
- it('honors a pre-aborted signal without rewriting the file', async () => {
- await writeFile(join(dir, 'a.txt'), 'keep')
- const target = await fs.resolve('a.txt')
- await expect(fs.editText(target, { oldString: 'keep', newString: 'x', replaceAll: false }, undefined, AbortSignal.abort()))
- .rejects.toMatchObject({ code: 'FS_ABORTED' })
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('keep')
- expect(lockCount(fs)).toBe(0)
- })
- it('a successful edit refreshes the version so an immediate follow-up edit proceeds', async () => {
- await writeFile(join(dir, 'a.txt'), 'one two')
- const target = await fs.resolve('a.txt')
- const first = await fs.editText(target, { oldString: 'one', newString: 'ONE', replaceAll: false }, { version: await versionOf(target) })
- // The version the first edit returned is a valid guard for a second edit —
- // no intervening re-stat needed.
- const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
- expect(second.after).toBe('ONE TWO')
- expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
- })
- it('concurrent write vs edit at the same version: one wins, the other is stale', async () => {
- await writeFile(join(dir, 'a.txt'), 'base')
- const target = await fs.resolve('a.txt')
- const version = await versionOf(target)
- const results = await Promise.allSettled([
- fs.writeText(target, 'written', { kind: 'replaceIfVersion', version }),
- fs.editText(target, { oldString: 'base', newString: 'edited', replaceAll: false }, { version }),
- ])
- expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
- const rejected = results.filter(r => r.status === 'rejected')
- expect(rejected).toHaveLength(1)
- expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
- expect(lockCount(fs)).toBe(0)
- })
- })
- describe('symlink targetKey identity', () => {
- it('two paths to the same file via a symlink share one version and write the real target', async () => {
- await writeFile(join(dir, 'real.txt'), 'hello')
- await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
- const viaReal = await fs.resolve('real.txt')
- const viaLink = await fs.resolve('link.txt')
- expect(viaLink.targetKey).toBe(viaReal.targetKey)
- const version = await versionOf(viaReal)
- await fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })
- expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved
- })
- 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 viaReal = await fs.resolve('real.txt')
- const stale = await versionOf(viaReal)
- await writeFile(join(dir, 'real.txt'), 'changed')
- const viaLink = await fs.resolve('link.txt')
- await expect(fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version: stale }))
- .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
- })
- })
- describe('HMR / disposal', () => {
- it('disposing the fiber withdraws ctx.fs', async () => {
- const local = new Context()
- const localFiber = await local.plugin(LocalFileSystem, { cwd: dir })
- expect(local.fs).toBeDefined()
- await localFiber.dispose()
- expect(local.fs).toBeUndefined()
- })
- })
|