filesystem.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. /**
  2. * Tests for the local backend through the `ctx.fs` provider seam: stat, whole-
  3. * file/streamed text reads, atomic guarded writes (createIfAbsent /
  4. * replaceIfVersion), version-guarded literal edits, concurrency races, symlink
  5. * identity, and HMR/disposal. Read WINDOWING is policy and lives in
  6. * `dsh-fs-policy`, so it is not exercised here.
  7. */
  8. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  9. import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
  10. import { tmpdir } from 'node:os'
  11. import { join } from 'node:path'
  12. import { Context } from 'cordis'
  13. import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
  14. import { FsVersion } from '@deepseek-ai/dsh-fs'
  15. import type { FsTarget } from '@deepseek-ai/dsh-fs'
  16. let dir: string
  17. let ctx: Context
  18. let fs: LocalFileSystem
  19. let fiber: Awaited<ReturnType<Context['plugin']>>
  20. beforeEach(async () => {
  21. dir = await mkdtemp(join(tmpdir(), 'dsh-fs-'))
  22. ctx = new Context()
  23. fiber = await ctx.plugin(LocalFileSystem, { cwd: dir })
  24. fs = ctx.fs as LocalFileSystem
  25. })
  26. afterEach(async () => {
  27. await fiber.dispose()
  28. await rm(dir, { recursive: true, force: true })
  29. })
  30. function lockCount(localFs: LocalFileSystem): number {
  31. return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
  32. }
  33. /** The version the backend currently reports for a resolved target. */
  34. async function versionOf(target: FsTarget): Promise<FsVersion> {
  35. const info = await fs.stat(target)
  36. if (!info) throw new Error('expected target to exist')
  37. return info.version
  38. }
  39. describe('registration', () => {
  40. it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
  41. const bare = new Context()
  42. const bareFiber = await bare.plugin(LocalFileSystem)
  43. expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
  44. await bareFiber.dispose()
  45. })
  46. })
  47. describe('resolve', () => {
  48. it('resolves a relative path against opts.cwd, not config.cwd', async () => {
  49. // config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
  50. // path there (the per-session-workspace seam — mirrors tool-bash workdir).
  51. const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
  52. try {
  53. await writeFile(join(other, 'x.txt'), 'in other')
  54. const viaOther = await fs.resolve('x.txt', { cwd: other })
  55. expect(await fs.readText(viaOther)).toBe('in other')
  56. // Same relative path with no opts falls back to config.cwd (= dir), where
  57. // x.txt does not exist.
  58. await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  59. } finally {
  60. await rm(other, { recursive: true, force: true })
  61. }
  62. })
  63. it('ignores opts.cwd for an ABSOLUTE path', async () => {
  64. await writeFile(join(dir, 'abs.txt'), 'absolute')
  65. const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
  66. expect(await fs.readText(target)).toBe('absolute')
  67. })
  68. it('honors a pre-aborted signal', async () => {
  69. await expect(fs.resolve('a.txt', { signal: AbortSignal.abort() })).rejects.toMatchObject({ code: 'FS_ABORTED' })
  70. })
  71. it('honors a signal aborted while resolution is in flight', async () => {
  72. const controller = new AbortController()
  73. const pending = fs.resolve('a.txt', { signal: controller.signal })
  74. controller.abort()
  75. await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
  76. })
  77. })
  78. describe('stat', () => {
  79. it('returns file metadata, directory type, and undefined for absent', async () => {
  80. await writeFile(join(dir, 'a.txt'), 'hello')
  81. const fileInfo = await fs.stat(await fs.resolve('a.txt'))
  82. expect(fileInfo?.type).toBe('file')
  83. expect(fileInfo?.size).toBe(5)
  84. expect(typeof fileInfo?.version).toBe('string')
  85. expect((await fs.stat(await fs.resolve('.')))?.type).toBe('directory')
  86. expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
  87. })
  88. it('changes version after a same-size rewrite even when mtime is restored', async () => {
  89. const path = join(dir, 'same-size.txt')
  90. await writeFile(path, 'first')
  91. const target = await fs.resolve(path)
  92. const beforeInfo = await stat(path)
  93. const beforeVersion = await versionOf(target)
  94. await fs.writeText(target, 'other')
  95. await utimes(path, beforeInfo.atime, beforeInfo.mtime)
  96. expect((await stat(path)).size).toBe(beforeInfo.size)
  97. expect(await versionOf(target)).not.toBe(beforeVersion)
  98. })
  99. it('honors a pre-aborted signal', async () => {
  100. await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
  101. })
  102. })
  103. describe('lstat', () => {
  104. it('reports path metadata without following the final symlink component', async () => {
  105. await writeFile(join(dir, 'real.txt'), 'hello')
  106. await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
  107. expect((await fs.lstat('real.txt'))?.type).toBe('file')
  108. expect((await fs.lstat('link.txt'))?.type).toBe('symlink')
  109. expect(await fs.lstat('missing.txt')).toBeUndefined()
  110. })
  111. it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => {
  112. const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
  113. try {
  114. await writeFile(join(other, 'x.txt'), 'in other')
  115. expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file')
  116. await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
  117. await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  118. } finally {
  119. await rm(other, { recursive: true, force: true })
  120. }
  121. })
  122. })
  123. describe('metadata cancellation', () => {
  124. it('rejects stat and lstat when their signals abort while the metadata probes are in flight', async () => {
  125. await writeFile(join(dir, 'slow.txt'), 'hello')
  126. const statStarted = Promise.withResolvers<undefined>()
  127. const statRelease = Promise.withResolvers<undefined>()
  128. const lstatStarted = Promise.withResolvers<undefined>()
  129. const lstatRelease = Promise.withResolvers<undefined>()
  130. let isolatedCtx: Context | undefined
  131. vi.resetModules()
  132. vi.doMock('node:fs/promises', async (importOriginal) => {
  133. const actual = await importOriginal<typeof import('node:fs/promises')>()
  134. return {
  135. ...actual,
  136. async stat(path: string) {
  137. statStarted.resolve(undefined)
  138. await statRelease.promise
  139. return actual.stat(path, { bigint: true })
  140. },
  141. async lstat(path: string) {
  142. lstatStarted.resolve(undefined)
  143. await lstatRelease.promise
  144. return actual.lstat(path, { bigint: true })
  145. },
  146. }
  147. })
  148. try {
  149. const { LocalFileSystem: IsolatedLocalFileSystem } = await import('../src/index.ts')
  150. isolatedCtx = new Context()
  151. await isolatedCtx.plugin(IsolatedLocalFileSystem, { cwd: dir })
  152. const isolatedFs = isolatedCtx.fs as InstanceType<typeof IsolatedLocalFileSystem>
  153. const target = await isolatedFs.resolve('slow.txt')
  154. const statController = new AbortController()
  155. const lstatController = new AbortController()
  156. const pendingStat = isolatedFs.stat(target, statController.signal)
  157. const pendingLstat = isolatedFs.lstat('slow.txt', undefined, lstatController.signal)
  158. await Promise.all([statStarted.promise, lstatStarted.promise])
  159. statController.abort()
  160. lstatController.abort()
  161. const statRejected = expect(pendingStat).rejects.toMatchObject({ code: 'FS_ABORTED' })
  162. const lstatRejected = expect(pendingLstat).rejects.toMatchObject({ code: 'FS_ABORTED' })
  163. statRelease.resolve(undefined)
  164. lstatRelease.resolve(undefined)
  165. await Promise.all([statRejected, lstatRejected])
  166. } finally {
  167. statRelease.resolve(undefined)
  168. lstatRelease.resolve(undefined)
  169. await isolatedCtx?.fiber.dispose()
  170. vi.doUnmock('node:fs/promises')
  171. vi.resetModules()
  172. }
  173. })
  174. })
  175. describe('readText / streamText', () => {
  176. it('reads whole-file text', async () => {
  177. await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
  178. expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
  179. })
  180. it('streams the same text', async () => {
  181. await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
  182. const target = await fs.resolve('a.txt')
  183. let streamed = ''
  184. for await (const chunk of await fs.streamText(target)) streamed += chunk
  185. expect(streamed).toBe('one\ntwo\nthree')
  186. })
  187. it('rejects a missing file, a directory, binary, and invalid UTF-8', async () => {
  188. await expect(fs.readText(await fs.resolve('nope'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  189. await expect(fs.readText(await fs.resolve('.'))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
  190. await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
  191. await expect(fs.readText(await fs.resolve('bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  192. await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
  193. await expect(fs.readText(await fs.resolve('bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  194. })
  195. })
  196. describe('listDir', () => {
  197. it('lists files and directories in stable name order with resolved child targets', async () => {
  198. await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
  199. await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta')
  200. await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha')
  201. await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link'))
  202. const entries = await fs.listDir(await fs.resolve('skills'))
  203. expect(entries.map(entry => [entry.name, entry.type])).toEqual([
  204. ['alpha.md', 'file'],
  205. ['broken-link', 'other'],
  206. ['dir-skill', 'directory'],
  207. ['zeta.md', 'file'],
  208. ])
  209. expect(entries.map(entry => entry.target.displayPath)).toEqual([
  210. join(dir, 'skills', 'alpha.md'),
  211. join(dir, 'skills', 'broken-link'),
  212. join(dir, 'skills', 'dir-skill'),
  213. join(dir, 'skills', 'zeta.md'),
  214. ])
  215. const materializedEntries = entries.filter(entry => entry.version !== undefined)
  216. expect(materializedEntries.map(entry => entry.target.targetKey))
  217. .toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
  218. expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
  219. expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
  220. expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
  221. expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
  222. })
  223. it('reports a missing directory as FS_NOT_FOUND', async () => {
  224. await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  225. })
  226. it('reports a file target as FS_NOT_DIRECTORY', async () => {
  227. await writeFile(join(dir, 'a.txt'), 'text')
  228. await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
  229. })
  230. it('honors a pre-aborted signal', async () => {
  231. await mkdir(join(dir, 'skills'), { recursive: true })
  232. await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
  233. })
  234. })
  235. describe('writeText', () => {
  236. it('createIfAbsent creates a new file', async () => {
  237. const target = await fs.resolve('new.txt')
  238. const outcome = await fs.writeText(target, 'fresh', { kind: 'createIfAbsent' })
  239. expect(outcome.operation).toBe('create')
  240. expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
  241. })
  242. it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => {
  243. await writeFile(join(dir, 'a.txt'), 'old')
  244. const target = await fs.resolve('a.txt')
  245. await expect(fs.writeText(target, 'new', { kind: 'createIfAbsent' }))
  246. .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
  247. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
  248. })
  249. it('replaceIfVersion replaces when the version matches', async () => {
  250. await writeFile(join(dir, 'a.txt'), 'old')
  251. const target = await fs.resolve('a.txt')
  252. const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: await versionOf(target) })
  253. expect(outcome.operation).toBe('update')
  254. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
  255. })
  256. it('replaceIfVersion rejects a stale version', async () => {
  257. await writeFile(join(dir, 'a.txt'), 'v1')
  258. const target = await fs.resolve('a.txt')
  259. const stale = await versionOf(target)
  260. await writeFile(join(dir, 'a.txt'), 'changed-externally')
  261. await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version: stale }))
  262. .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
  263. })
  264. it('replaceIfVersion rejects a deleted target as stale, without recreating it', async () => {
  265. const path = join(dir, 'a.txt')
  266. await writeFile(path, 'v1')
  267. const target = await fs.resolve('a.txt')
  268. const version = await versionOf(target)
  269. await unlink(path)
  270. await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version }))
  271. .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
  272. await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
  273. })
  274. it('rejects writing onto a directory', async () => {
  275. const target = await fs.resolve('.')
  276. await expect(fs.writeText(target, 'x', { kind: 'createIfAbsent' }))
  277. .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
  278. })
  279. it('unconditionally creates a new file with no expectation (bare provider)', async () => {
  280. const target = await fs.resolve('new.txt')
  281. const outcome = await fs.writeText(target, 'fresh')
  282. expect(outcome.operation).toBe('create')
  283. expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
  284. })
  285. it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
  286. await writeFile(join(dir, 'a.txt'), 'old')
  287. const target = await fs.resolve('a.txt')
  288. const outcome = await fs.writeText(target, 'clobbered')
  289. expect(outcome.operation).toBe('update')
  290. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
  291. })
  292. it('rejects writing onto a directory even with no expectation', async () => {
  293. const target = await fs.resolve('.')
  294. await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
  295. })
  296. it('a create reports before:null and after = the written content (no prior file)', async () => {
  297. const target = await fs.resolve('new.txt')
  298. const outcome = await fs.writeText(target, 'fresh')
  299. expect(outcome.before).toBeNull()
  300. expect(outcome.after).toBe('fresh')
  301. })
  302. it('an overwrite reports before = the OLD content and after = the new content', async () => {
  303. await writeFile(join(dir, 'a.txt'), 'old body')
  304. const target = await fs.resolve('a.txt')
  305. const outcome = await fs.writeText(target, 'new body')
  306. expect(outcome.before).toBe('old body')
  307. expect(outcome.after).toBe('new body')
  308. })
  309. it('an overwrite returns LF-normalized before AND after (a CRLF rewrite is not every-line-changed)', async () => {
  310. // The applied-hunk diff bases on `before`/`after`; if `after` kept CRLF while
  311. // `before` is LF-normalized, a CRLF rewrite would read as every line changed.
  312. // Both sides are LF so only the genuinely-changed line diffs.
  313. await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\nc\r\n')
  314. const target = await fs.resolve('a.txt')
  315. const outcome = await fs.writeText(target, 'a\r\nB\r\nc\r\n')
  316. expect(outcome.before).toBe('a\nb\nc\n')
  317. expect(outcome.after).toBe('a\nB\nc\n')
  318. })
  319. it('an overwrite of a BINARY prior file reports before:null (undiffable), still succeeds', async () => {
  320. await writeFile(join(dir, 'a.bin'), Buffer.from([0x00, 0x01, 0x02]))
  321. const target = await fs.resolve('a.bin')
  322. const outcome = await fs.writeText(target, 'now text')
  323. expect(outcome.operation).toBe('update')
  324. expect(outcome.before).toBeNull()
  325. expect(outcome.after).toBe('now text')
  326. })
  327. it('an overwrite of an INVALID-UTF-8 (non-NUL) prior file reports before:null, still succeeds', async () => {
  328. // 0xff is never valid UTF-8 but is not a NUL, so it exercises the decoder's
  329. // fatal-throw path (not the NUL-scan short-circuit): an undiffable prior file
  330. // still yields a successful write with no before-content basis.
  331. await writeFile(join(dir, 'a.bin'), Buffer.from([0x68, 0xff, 0x69]))
  332. const target = await fs.resolve('a.bin')
  333. const outcome = await fs.writeText(target, 'now valid')
  334. expect(outcome.operation).toBe('update')
  335. expect(outcome.before).toBeNull()
  336. expect(outcome.after).toBe('now valid')
  337. })
  338. it('releases per-target mutation locks after success and failure', async () => {
  339. const target = await fs.resolve('a.txt')
  340. await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
  341. expect(lockCount(fs)).toBe(0)
  342. await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' }))
  343. .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
  344. expect(lockCount(fs)).toBe(0)
  345. })
  346. it('replaceIfVersion returns the post-write version (matches a fresh stat)', async () => {
  347. await writeFile(join(dir, 'a.txt'), 'v1')
  348. const target = await fs.resolve('a.txt')
  349. const before = await versionOf(target)
  350. const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
  351. expect(outcome.version).not.toBe(before)
  352. expect(outcome.version).toBe(await versionOf(target))
  353. })
  354. it('honors a pre-aborted signal without creating the file', async () => {
  355. const target = await fs.resolve('aborted.txt')
  356. await expect(fs.writeText(target, 'x', undefined, AbortSignal.abort()))
  357. .rejects.toMatchObject({ code: 'FS_ABORTED' })
  358. await expect(stat(join(dir, 'aborted.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
  359. expect(lockCount(fs)).toBe(0)
  360. })
  361. it('two concurrent guarded writes: one updates, the other is rejected as stale', async () => {
  362. await writeFile(join(dir, 'a.txt'), 'base')
  363. const target = await fs.resolve('a.txt')
  364. const version = await versionOf(target)
  365. const results = await Promise.allSettled([
  366. fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
  367. fs.writeText(target, 'two', { kind: 'replaceIfVersion', version }),
  368. ])
  369. expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
  370. const rejected = results.filter(r => r.status === 'rejected')
  371. expect(rejected).toHaveLength(1)
  372. expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
  373. expect(lockCount(fs)).toBe(0)
  374. })
  375. })
  376. describe('editText', () => {
  377. it('applies a literal edit at the matching version', async () => {
  378. await writeFile(join(dir, 'a.txt'), 'hello world')
  379. const target = await fs.resolve('a.txt')
  380. const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
  381. expect(outcome.after).toBe('hello there')
  382. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
  383. })
  384. it('reports before/after content (the applied-hunk basis), LF-normalized', async () => {
  385. await writeFile(join(dir, 'a.txt'), 'a\r\nOLD\r\nb\r\n')
  386. const target = await fs.resolve('a.txt')
  387. const outcome = await fs.editText(target, { oldString: 'OLD', newString: 'NEW', replaceAll: false })
  388. expect(outcome.before).toBe('a\nOLD\nb\n')
  389. expect(outcome.after).toBe('a\nNEW\nb\n')
  390. // The written file keeps the original CRLF endings (before/after are the
  391. // LF-normalized diff basis, not the on-disk bytes).
  392. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a\r\nNEW\r\nb\r\n')
  393. })
  394. it('checks the stale version BEFORE literal matching', async () => {
  395. await writeFile(join(dir, 'a.txt'), 'hello world')
  396. const target = await fs.resolve('a.txt')
  397. const stale = await versionOf(target)
  398. // Change the file so 'world' is gone — a stale edit must report STALE, not NOT_FOUND.
  399. await writeFile(join(dir, 'a.txt'), 'goodbye')
  400. await expect(fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: stale }))
  401. .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
  402. })
  403. it('unconditionally edits the current content with no expectation (bare provider)', async () => {
  404. await writeFile(join(dir, 'a.txt'), 'hello world')
  405. const target = await fs.resolve('a.txt')
  406. // No version guard: any current content is edited, regardless of version.
  407. const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
  408. expect(outcome.after).toBe('hello there')
  409. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
  410. })
  411. it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
  412. const target = await fs.resolve('missing.txt')
  413. await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
  414. .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
  415. })
  416. it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
  417. await writeFile(join(dir, 'a.txt'), 'hello world')
  418. const target = await fs.resolve('a.txt')
  419. await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
  420. .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
  421. })
  422. it('rejects a deleted target as stale (before matching)', async () => {
  423. await writeFile(join(dir, 'a.txt'), 'hello')
  424. const target = await fs.resolve('a.txt')
  425. const version = await versionOf(target)
  426. await unlink(join(dir, 'a.txt'))
  427. await expect(fs.editText(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
  428. .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
  429. })
  430. it('rejects a non-regular target', async () => {
  431. const target = await fs.resolve('.')
  432. await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: FsVersion('v') }))
  433. .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
  434. })
  435. it('rejects zero matches and ambiguous matches at the right version', async () => {
  436. await writeFile(join(dir, 'a.txt'), 'a a a')
  437. const target = await fs.resolve('a.txt')
  438. const version = await versionOf(target)
  439. await expect(fs.editText(target, { oldString: 'z', newString: 'X', replaceAll: false }, { version }))
  440. .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
  441. await expect(fs.editText(target, { oldString: 'a', newString: 'X', replaceAll: false }, { version }))
  442. .rejects.toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
  443. })
  444. it('replaces all matches with replaceAll', async () => {
  445. await writeFile(join(dir, 'a.txt'), 'a a a')
  446. const target = await fs.resolve('a.txt')
  447. const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
  448. expect(outcome.after).toBe('b b b')
  449. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
  450. })
  451. it('rejects invalid UTF-8 without rewriting the file', async () => {
  452. const path = join(dir, 'bad.txt')
  453. const bytes = Buffer.from([0x68, 0xff, 0x69])
  454. await writeFile(path, bytes)
  455. const target = await fs.resolve('bad.txt')
  456. const version = await versionOf(target)
  457. await expect(fs.editText(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version }))
  458. .rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  459. expect(await readFile(path)).toEqual(bytes)
  460. })
  461. it('two concurrent edits: one wins, the other is rejected as stale', async () => {
  462. await writeFile(join(dir, 'a.txt'), 'base')
  463. const target = await fs.resolve('a.txt')
  464. const version = await versionOf(target)
  465. const results = await Promise.allSettled([
  466. fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }),
  467. fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
  468. ])
  469. expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
  470. const rejected = results.filter(r => r.status === 'rejected')
  471. expect(rejected).toHaveLength(1)
  472. expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
  473. expect(lockCount(fs)).toBe(0)
  474. })
  475. it('honors a pre-aborted signal without rewriting the file', async () => {
  476. await writeFile(join(dir, 'a.txt'), 'keep')
  477. const target = await fs.resolve('a.txt')
  478. await expect(fs.editText(target, { oldString: 'keep', newString: 'x', replaceAll: false }, undefined, AbortSignal.abort()))
  479. .rejects.toMatchObject({ code: 'FS_ABORTED' })
  480. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('keep')
  481. expect(lockCount(fs)).toBe(0)
  482. })
  483. it('a successful edit refreshes the version so an immediate follow-up edit proceeds', async () => {
  484. await writeFile(join(dir, 'a.txt'), 'one two')
  485. const target = await fs.resolve('a.txt')
  486. const first = await fs.editText(target, { oldString: 'one', newString: 'ONE', replaceAll: false }, { version: await versionOf(target) })
  487. // The version the first edit returned is a valid guard for a second edit —
  488. // no intervening re-stat needed.
  489. const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
  490. expect(second.after).toBe('ONE TWO')
  491. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
  492. })
  493. it('concurrent write vs edit at the same version: one wins, the other is stale', async () => {
  494. await writeFile(join(dir, 'a.txt'), 'base')
  495. const target = await fs.resolve('a.txt')
  496. const version = await versionOf(target)
  497. const results = await Promise.allSettled([
  498. fs.writeText(target, 'written', { kind: 'replaceIfVersion', version }),
  499. fs.editText(target, { oldString: 'base', newString: 'edited', replaceAll: false }, { version }),
  500. ])
  501. expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
  502. const rejected = results.filter(r => r.status === 'rejected')
  503. expect(rejected).toHaveLength(1)
  504. expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
  505. expect(lockCount(fs)).toBe(0)
  506. })
  507. })
  508. describe('symlink targetKey identity', () => {
  509. it('two paths to the same file via a symlink share one version and write the real target', async () => {
  510. await writeFile(join(dir, 'real.txt'), 'hello')
  511. await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
  512. const viaReal = await fs.resolve('real.txt')
  513. const viaLink = await fs.resolve('link.txt')
  514. expect(viaLink.targetKey).toBe(viaReal.targetKey)
  515. const version = await versionOf(viaReal)
  516. await fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })
  517. expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved
  518. })
  519. it('a stale change is detected across both paths', async () => {
  520. await writeFile(join(dir, 'real.txt'), 'hello')
  521. await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
  522. const viaReal = await fs.resolve('real.txt')
  523. const stale = await versionOf(viaReal)
  524. await writeFile(join(dir, 'real.txt'), 'changed')
  525. const viaLink = await fs.resolve('link.txt')
  526. await expect(fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version: stale }))
  527. .rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
  528. })
  529. })
  530. describe('HMR / disposal', () => {
  531. it('disposing the fiber withdraws ctx.fs', async () => {
  532. const local = new Context()
  533. const localFiber = await local.plugin(LocalFileSystem, { cwd: dir })
  534. expect(local.fs).toBeDefined()
  535. await localFiber.dispose()
  536. expect(local.fs).toBeUndefined()
  537. })
  538. })