fs.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /**
  2. * Behavioural check of this package's `node:fs` bridge over a real MemoryVfs:
  3. * encoding branches, Dirent, file descriptors, FileHandle append/replace semantics,
  4. * and Node's error codes.
  5. *
  6. * Every import resolves through `src/` so the harness and bridge share the same
  7. * module-level VFS slot. Mixing the bare package's built entry with `/src/*`
  8. * imports can create two slots, leaving the bridge with no mounted filesystem.
  9. */
  10. import { expect, test } from 'vitest'
  11. import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
  12. import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
  13. import * as fs from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs.ts'
  14. import * as fsp from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts'
  15. import { promisify } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/util.ts'
  16. import type { VfsBigIntStats, VfsMutationSink, VfsStats } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types.ts'
  17. let flushes = 0
  18. const sink: VfsMutationSink = {
  19. record: () => {},
  20. flush: () => {
  21. flushes += 1
  22. return Promise.resolve()
  23. },
  24. }
  25. const vfs = new MemoryVfs({ sink })
  26. setActiveVfs(vfs)
  27. // Identity precondition: the bridge must read this exact mounted VFS; successful
  28. // calls alone could come from another mounted instance.
  29. fs.mkdirSync('/dsh/.probe', { recursive: true })
  30. fs.writeFileSync('/dsh/.probe/instance', 'x')
  31. if (!vfs.existsSync('/dsh/.probe/instance')) {
  32. throw new Error('fs-check: the fs bridge is not reading the VFS this harness mounted '
  33. + '(two module instances — check that every import resolves through src/)')
  34. }
  35. vfs.rmSync('/dsh/.probe', { recursive: true })
  36. const check = (label: string, actual: unknown, expected: unknown): void => {
  37. const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
  38. test(label, () => { expect(seen).toBe(wanted) })
  39. }
  40. const throws = (label: string, run: () => unknown, code: string): void => {
  41. let outcome: string
  42. try {
  43. run()
  44. outcome = 'did not throw'
  45. } catch (error) {
  46. outcome = (error as { code?: string }).code ?? (error as Error).message
  47. }
  48. test(label, () => { expect(outcome).toContain(code) })
  49. }
  50. fs.mkdirSync('/dsh/config', { recursive: true })
  51. fs.writeFileSync('/dsh/config/cordis.yml', '- id: timer\n')
  52. check('readFileSync utf8', fs.readFileSync('/dsh/config/cordis.yml', 'utf8'), '- id: timer\n')
  53. check('readFileSync options object', fs.readFileSync('/dsh/config/cordis.yml', { encoding: 'utf8' }), '- id: timer\n')
  54. check('readFileSync bytes length', (fs.readFileSync('/dsh/config/cordis.yml') as Uint8Array).byteLength, 12)
  55. check('readFileSync is Buffer', Buffer.isBuffer(fs.readFileSync('/dsh/config/cordis.yml')), true)
  56. check('existsSync true', fs.existsSync('/dsh/config/cordis.yml'), true)
  57. check('existsSync false', fs.existsSync('/dsh/nope'), false)
  58. check('statSync isFile', fs.statSync('/dsh/config/cordis.yml').isFile(), true)
  59. check('statSync size', fs.statSync('/dsh/config/cordis.yml').size, 12)
  60. check('statSync dir', fs.statSync('/dsh/config').isDirectory(), true)
  61. check('realpathSync', fs.realpathSync('/dsh/config/../config/cordis.yml'), '/dsh/config/cordis.yml')
  62. test('native realpath supports the filesystem provider promise wrapper', async () => {
  63. expect(fs.default.realpath).toBe(fs.realpath)
  64. const resolveNative = promisify(fs.realpath.native)
  65. expect(await resolveNative('/dsh/config/../config/cordis.yml')).toBe('/dsh/config/cordis.yml')
  66. await expect(resolveNative('/dsh/missing-realpath')).rejects.toMatchObject({ code: 'ENOENT' })
  67. })
  68. test('callback realpath settles after the current call returns', async () => {
  69. let returned = false
  70. const completion = new Promise<unknown>((resolve) => {
  71. fs.realpath('/dsh/config/cordis.yml', (error, path) => { resolve({ error, path, returned }) })
  72. })
  73. returned = true
  74. expect(await completion).toEqual({ error: null, path: '/dsh/config/cordis.yml', returned: true })
  75. })
  76. fs.appendFileSync('/dsh/config/cordis.yml', '- id: llm\n')
  77. check('appendFileSync', fs.readFileSync('/dsh/config/cordis.yml', 'utf8'), '- id: timer\n- id: llm\n')
  78. fs.mkdirSync('/dsh/config/agent-presets/standard', { recursive: true })
  79. fs.writeFileSync('/dsh/config/agent-presets/standard/SKILL.md', '# skill\n')
  80. check('readdirSync names', fs.readdirSync('/dsh/config'), ['agent-presets', 'cordis.yml'])
  81. const entries = fs.readdirSync('/dsh/config', { withFileTypes: true }) as fs.Dirent[]
  82. check('readdirSync withFileTypes', entries.map(entry => [entry.name, entry.isFile(), entry.isDirectory()]), [
  83. ['agent-presets', false, true],
  84. ['cordis.yml', true, false],
  85. ])
  86. check('Dirent parentPath', entries[0]!.parentPath, '/dsh/config')
  87. const temporary = fs.mkdtempSync('/dsh/tmp/run-')
  88. check('mkdtempSync creates directory', fs.statSync(temporary).isDirectory(), true)
  89. check('mkdtempSync unique', fs.mkdtempSync('/dsh/tmp/run-') === temporary, false)
  90. throws('readFileSync missing', () => fs.readFileSync('/dsh/missing'), 'ENOENT')
  91. throws('statSync missing', () => fs.statSync('/dsh/missing'), 'ENOENT')
  92. throws('accessSync missing', () =>{ fs.accessSync('/dsh/missing') }, 'ENOENT')
  93. throws('readdirSync missing', () => fs.readdirSync('/dsh/missing'), 'ENOENT')
  94. const appendFd = fs.openSync('/dsh/log.jsonl', 'a')
  95. fs.writeSync(appendFd, '{"a":1}\n')
  96. fs.writeSync(appendFd, '{"a":2}\n')
  97. fs.closeSync(appendFd)
  98. check('append fd writes', fs.readFileSync('/dsh/log.jsonl', 'utf8'), '{"a":1}\n{"a":2}\n')
  99. const readFd = fs.openSync('/dsh/log.jsonl', 'r')
  100. const target = new Uint8Array(8)
  101. check('readSync count', fs.readSync(readFd, target, 0, 8), 8)
  102. check('readSync bytes', new TextDecoder().decode(target), '{"a":1}\n')
  103. check('readSync continues', fs.readSync(readFd, target, 0, 8), 8)
  104. check('readSync second line', new TextDecoder().decode(target), '{"a":2}\n')
  105. check('readSync at eof', fs.readSync(readFd, target, 0, 8), 0)
  106. fs.closeSync(readFd)
  107. throws('closed fd', () => fs.readSync(readFd, target, 0, 8), 'EBADF')
  108. const writeFd = fs.openSync('/dsh/truncated.txt', 'w')
  109. fs.writeSync(writeFd, 'abc')
  110. fs.closeSync(writeFd)
  111. check('write fd truncates', fs.readFileSync('/dsh/truncated.txt', 'utf8'), 'abc')
  112. fs.renameSync('/dsh/truncated.txt', '/dsh/renamed.txt')
  113. check('renameSync moves', [fs.existsSync('/dsh/truncated.txt'), fs.readFileSync('/dsh/renamed.txt', 'utf8')], [false, 'abc'])
  114. fs.rmSync('/dsh/renamed.txt')
  115. check('rmSync removes', fs.existsSync('/dsh/renamed.txt'), false)
  116. // A FileHandle opened for appending must append, not replace: the JSONL session
  117. // log writes its header frame first and every batch after it through this path.
  118. fs.writeFileSync('/dsh/log-handle.jsonl', 'header\n')
  119. const appendHandle = await fsp.open('/dsh/log-handle.jsonl', 'a')
  120. check('append handle sees the existing size', (await appendHandle.stat()).size, 7)
  121. const appendHandleStats = await appendHandle.stat({ bigint: true }) as VfsBigIntStats
  122. const appendPathStats = await fsp.stat('/dsh/log-handle.jsonl', { bigint: true }) as VfsBigIntStats
  123. check('bigint handle stat matches the path identity', [
  124. typeof appendHandleStats.ino,
  125. appendHandleStats.ino === appendPathStats.ino,
  126. appendHandleStats.dev === appendPathStats.dev,
  127. ], ['bigint', true, true])
  128. await appendHandle.writeFile('batch-1\n')
  129. await appendHandle.sync()
  130. check('handle.sync flushes the active VFS', flushes, 1)
  131. await appendHandle.close()
  132. const secondHandle = await fsp.open('/dsh/log-handle.jsonl', 'a')
  133. await secondHandle.writeFile('batch-2\n')
  134. await secondHandle.close()
  135. check('handle.writeFile appends in append mode', fs.readFileSync('/dsh/log-handle.jsonl', 'utf8'), 'header\nbatch-1\nbatch-2\n')
  136. const replaceHandle = await fsp.open('/dsh/log-handle.jsonl', 'w')
  137. await replaceHandle.writeFile('replaced\n')
  138. await replaceHandle.close()
  139. check('handle.writeFile replaces without append mode', fs.readFileSync('/dsh/log-handle.jsonl', 'utf8'), 'replaced\n')
  140. const truncHandle = await fsp.open('/dsh/log-handle.jsonl', 'r+')
  141. await truncHandle.truncate(4)
  142. await truncHandle.close()
  143. check('handle.truncate cuts the tail', fs.readFileSync('/dsh/log-handle.jsonl', 'utf8'), 'repl')
  144. check('promises.readFile', await fsp.readFile('/dsh/config/cordis.yml', 'utf8'), '- id: timer\n- id: llm\n')
  145. await fsp.writeFile('/dsh/promise.txt', 'p')
  146. check('promises.writeFile', fs.readFileSync('/dsh/promise.txt', 'utf8'), 'p')
  147. check('promises.stat', (await fsp.stat('/dsh/promise.txt')).isFile(), true)
  148. await fsp.cp('/dsh/config', '/dsh/config-copy')
  149. check('promises.cp tree', await fsp.readFile('/dsh/config-copy/agent-presets/standard/SKILL.md', 'utf8'), '# skill\n')
  150. await fsp.rm('/dsh/config-copy', { recursive: true })
  151. check('promises.rm recursive', fs.existsSync('/dsh/config-copy'), false)
  152. // ---------------------------------------------------------------------------
  153. // The `{ bigint: true }` stats the filesystem service reads.
  154. //
  155. // `dsh-fs-local` stats EVERY target this way before it lists or reads: it masks
  156. // `mode` with a BigInt literal and builds its version token from
  157. // `dev:ino:size:mtimeNs:ctimeNs`. A number-valued `mode` here made that mask
  158. // throw `Cannot mix BigInt and other types`, which the service reported as
  159. // FS_IO_ERROR and skill discovery swallowed as "empty directory" — the worker
  160. // booted with an empty skill catalog and no error anywhere.
  161. // ---------------------------------------------------------------------------
  162. const bigStats = (path: string): VfsBigIntStats => fs.statSync(path, { bigint: true }) as VfsBigIntStats
  163. fs.writeFileSync('/dsh/versioned.txt', 'one')
  164. {
  165. const stats = bigStats('/dsh/versioned.txt')
  166. check('bigint stat reports mode as a BigInt', typeof stats.mode, 'bigint')
  167. check('bigint mode masks to the creation-default file permission', Number(stats.mode & 0o777n), 0o644)
  168. check('bigint stat reports the identity fields the version token needs', [
  169. typeof stats.dev, typeof stats.ino, typeof stats.size, typeof stats.mtimeNs, typeof stats.ctimeNs,
  170. ], ['bigint', 'bigint', 'bigint', 'bigint', 'bigint'])
  171. check('bigint nanosecond time scales the millisecond time', stats.mtimeNs === stats.mtimeMs * 1_000_000n, true)
  172. check('bigint stat still answers the type predicates', [stats.isFile(), stats.isDirectory()], [true, false])
  173. check('plain stat keeps its number shape', typeof fs.statSync('/dsh/versioned.txt').mode, 'number')
  174. }
  175. {
  176. // Two writes inside one millisecond must not produce one version: the service's
  177. // stale-write guard compares these tokens.
  178. const token = (path: string): string => {
  179. const stats = bigStats(path)
  180. return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`
  181. }
  182. const before = token('/dsh/versioned.txt')
  183. fs.writeFileSync('/dsh/versioned.txt', 'two')
  184. check('a rewrite changes the version token', token('/dsh/versioned.txt') !== before, true)
  185. check('an unchanged file keeps its version token', token('/dsh/versioned.txt'), token('/dsh/versioned.txt'))
  186. }
  187. {
  188. const first = bigStats('/dsh/versioned.txt').ino
  189. fs.writeFileSync('/dsh/versioned.txt', 'three')
  190. check('identity survives a write to the same path', String(bigStats('/dsh/versioned.txt').ino), String(first))
  191. fs.rmSync('/dsh/versioned.txt')
  192. fs.writeFileSync('/dsh/versioned.txt', 'four')
  193. check('a removed and recreated path reports a new identity', bigStats('/dsh/versioned.txt').ino !== first, true)
  194. }
  195. check('a directory reports the creation-default mode in the bigint shape', Number(bigStats('/dsh/config').mode & 0o777n), 0o755)
  196. // ---------------------------------------------------------------------------
  197. // Permission bits round-trip: creation takes the caller's mode, chmod changes
  198. // it, stat reads back the stored value. dsh-credentials-local's owner-only
  199. // check reads exactly this (writeFileAtomic writes `wx` + mode 600, renames
  200. // into place, then the provider stats the result).
  201. // ---------------------------------------------------------------------------
  202. const plainMode = (path: string): number => (fs.statSync(path) as VfsStats).mode & 0o777
  203. fs.writeFileSync('/dsh/secrets.tmp', 'k: v\n', { mode: 0o600, flag: 'wx' })
  204. fs.renameSync('/dsh/secrets.tmp', '/dsh/secrets.yaml')
  205. check('a wx write with mode 600 stats as 600 after rename', plainMode('/dsh/secrets.yaml'), 0o600)
  206. fs.writeFileSync('/dsh/secrets.yaml', 'k: w\n')
  207. check('a rewrite keeps the creation bits', plainMode('/dsh/secrets.yaml'), 0o600)
  208. const chmodHandle = await fsp.open('/dsh/secrets.yaml', 'r+')
  209. await chmodHandle.chmod(0o640)
  210. await chmodHandle.close()
  211. check('FileHandle.chmod updates the opened file', plainMode('/dsh/secrets.yaml'), 0o640)
  212. fs.chmodSync('/dsh/secrets.yaml', 0o640)
  213. check('chmod reads back exactly what was set', plainMode('/dsh/secrets.yaml'), 0o640)
  214. await fsp.chmod('/dsh/secrets.yaml', 0o600)
  215. check('promises.chmod reads back through the bigint shape', Number(bigStats('/dsh/secrets.yaml').mode & 0o777n), 0o600)
  216. fs.mkdirSync('/dsh/vault', { mode: 0o700 })
  217. check('mkdir honours its mode option', plainMode('/dsh/vault'), 0o700)
  218. check('promises.stat forwards the bigint option', typeof (await fsp.stat('/dsh/config', { bigint: true })).mode, 'bigint')