1import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem"
2import { assert, describe, it } from "@effect/vitest"
3import * as ByteSize from "effect/ByteSize"
4import * as Deferred from "effect/Deferred"
5import * as Effect from "effect/Effect"
6import * as Fiber from "effect/Fiber"
7import * as FileSystem from "effect/FileSystem"
8import * as Option from "effect/Option"
9import * as Stream from "effect/Stream"
10import * as TestClock from "effect/testing/TestClock"
11import type * as NFS from "node:fs"
12import { afterEach, beforeEach, vi } from "vitest"
13import { testLayer } from "../../../effect/test/FileSystem.test-utils.ts"
14
15const startWatch = <E, R>(
16 fs: FileSystem.FileSystem,
17 root: string,
18 watch: () => Stream.Stream<FileSystem.WatchEvent, E, R>
19) =>
20 Effect.gen(function*() {
21 const ready = yield* Deferred.make<void>()
22 const readyName = ".watch-ready"
23 const fiber = yield* watch().pipe(
24 Stream.tap((event) =>
25 event.path === readyName
26 ? Deferred.succeed(ready, undefined)
27 : Effect.void
28 ),
29 Stream.dropUntil((event) => event.path === readyName),
30 Stream.filter((event) => event.path !== readyName),
31 Stream.runHead,
32 Effect.flatMap(Effect.fromOption),
33 Effect.forkChild
34 )
35 const signalFiber = yield* Effect.sleep("10 millis").pipe(
36 TestClock.withLive,
37 Effect.andThen(fs.writeFileString(`${root}/${readyName}`, "")),
38 Effect.forever,
39 Effect.forkChild
40 )
41 yield* Deferred.await(ready).pipe(
42 Effect.raceFirst(Fiber.join(fiber).pipe(Effect.asVoid)),
43 Effect.ensuring(Fiber.interrupt(signalFiber))
44 )
45 return fiber
46 })
47
48describe("FileSystem", { concurrent: false }, () => {
49 testLayer(NodeFileSystem.layer)
50
51 it.effect("writeAll accepts an empty buffer", () =>
52 Effect.gen(function*() {
53 const fs = yield* FileSystem.FileSystem
54 const path = yield* fs.makeTempFileScoped()
55 const file = yield* fs.open(path, { flag: "r+" })
56
57 yield* file.writeAll(new Uint8Array(0))
58 }).pipe(
59 Effect.provide(NodeFileSystem.layer)
60 ))
61
62 it.effect("watch reports Create for a new file outside the current directory", () =>
63 Effect.gen(function*() {
64 const fs = yield* FileSystem.FileSystem
65 const root = yield* fs.makeTempDirectoryScoped()
66 const name = "created.txt"
67 const path = `${root}/${name}`
68 assert.strictEqual(yield* fs.exists(name), false)
69
70 const fiber = yield* startWatch(fs, root, () => fs.watch(root))
71 yield* fs.writeFileString(path, "")
72
73 const event = yield* Fiber.join(fiber)
74 assert.strictEqual(yield* fs.exists(path), true)
75 assert.deepStrictEqual(event, { _tag: "Create", path: name })
76 }).pipe(
77 Effect.provide(NodeFileSystem.layer)
78 ))
79
80 it.effect("watch does not report nested changes when recursive is false", () =>
81 Effect.gen(function*() {
82 const fs = yield* FileSystem.FileSystem
83 const root = yield* fs.makeTempDirectoryScoped()
84 const nested = `${root}/nested`
85 yield* fs.makeDirectory(nested)
86
87 const fiber = yield* startWatch(fs, root, () => fs.watch(root, { recursive: false }))
88
89 yield* fs.writeFileString(`${nested}/nested.txt`, "")
90 yield* fs.writeFileString(`${root}/direct.txt`, "")
91
92 const event = yield* Fiber.join(fiber)
93 assert.strictEqual(event.path, "direct.txt")
94 }).pipe(
95 Effect.provide(NodeFileSystem.layer)
96 ))
97
98 it.effect("watch is non-recursive when options are omitted", () =>
99 Effect.gen(function*() {
100 const fs = yield* FileSystem.FileSystem
101 const root = yield* fs.makeTempDirectoryScoped()
102 const nested = `${root}/nested`
103 yield* fs.makeDirectory(nested)
104
105 const fiber = yield* startWatch(fs, root, () => fs.watch(root))
106
107 yield* fs.writeFileString(`${nested}/nested.txt`, "")
108 yield* fs.writeFileString(`${root}/direct.txt`, "")
109
110 const event = yield* Fiber.join(fiber)
111 assert.strictEqual(event.path, "direct.txt")
112 }).pipe(
113 Effect.provide(NodeFileSystem.layer)
114 ))
115
116 it.effect("watch reports nested changes when recursive is true", () =>
117 Effect.gen(function*() {
118 const fs = yield* FileSystem.FileSystem
119 const root = yield* fs.makeTempDirectoryScoped()
120 const nested = `${root}/nested`
121 yield* fs.makeDirectory(nested)
122
123 const fiber = yield* startWatch(fs, root, () => fs.watch(root, { recursive: true }))
124
125 yield* fs.writeFileString(`${nested}/nested.txt`, "")
126
127 const event = yield* Fiber.join(fiber)
128 assert(event.path.endsWith("nested.txt"))
129 }).pipe(
130 Effect.provide(NodeFileSystem.layer)
131 ))
132})
133
134const state = vi.hoisted(() => ({
135 enabled: false,
136 values: {} as Record<string, bigint>,
137 positions: [] as Array<number | bigint | undefined>
138}))
139
140vi.mock("node:fs", async (importOriginal) => {
141 const original = await importOriginal<typeof NFS>()
142 // Match Node's number and bigint stat modes.
143 const stat = (
144 _pathOrFd: string | number,
145 optionsOrCallback: { bigint?: boolean } | ((error: null, stats: object) => void),
146 callback?: (error: null, stats: object) => void
147 ) => {
148 const bigint = typeof optionsOrCallback === "object" && optionsOrCallback.bigint
149 const done = typeof optionsOrCallback === "function" ? optionsOrCallback : callback!
150 done(null, {
151 ...Object.fromEntries(Object.entries(state.values).map(([key, value]) => [key, bigint ? value : Number(value)])),
152 mtime: new Date(0),
153 atime: new Date(0),
154 birthtime: new Date(0),
155 isFile: () => true
156 })
157 }
158 const doubles = {
159 open: (_path: string, _flags: string, _mode: unknown, callback: (error: null, fd: number) => void) =>
160 callback(null, 42),
161 close: (_fd: number, callback: (error: null) => void) => callback(null),
162 stat,
163 fstat: stat,
164 write: (
165 _fd: number,
166 _buffer: Uint8Array,
167 _offset: unknown,
168 _length: unknown,
169 position: number | bigint | undefined,
170 callback: (error: null, bytesWritten: number) => void
171 ) => {
172 state.positions.push(position)
173 callback(null, 1)
174 }
175 }
176 return {
177 ...original,
178 ...Object.fromEntries(
179 Object.entries(doubles).map(([name, implementation]) => [
180 name,
181 (...args: Array<unknown>) =>
182 Reflect.apply(state.enabled ? implementation : original[name as keyof typeof doubles], original, args)
183 ])
184 )
185 }
186})
187
188const maxSafe = BigInt(Number.MAX_SAFE_INTEGER)
189const oversized = maxSafe + 2n
190
191describe("NodeFileSystem precision", { concurrent: false }, () => {
192 afterEach(() => {
193 state.enabled = false
194 })
195 beforeEach(() => {
196 state.enabled = true
197 state.positions = []
198 state.values = {
199 size: 4n,
200 blksize: 4096n,
201 dev: 1n,
202 ino: 2n,
203 mode: 33188n,
204 nlink: 1n,
205 uid: 1000n,
206 gid: 1000n,
207 rdev: 0n,
208 blocks: 8n
209 }
210 })
211
212 for (const method of ["write", "writeAll"] as const) {
213 it.effect(`${method} rejects an unsafe position before writing`, () =>
214 Effect.gen(function*() {
215 const fs = yield* FileSystem.FileSystem
216 const file = yield* fs.open("precision.bin", { flag: "r+" })
217 yield* file.seek(oversized, "start")
218 const error = yield* Effect.flip(file[method](new Uint8Array([1])))
219 assert.strictEqual(error.reason._tag, "BadArgument")
220 assert.deepStrictEqual(state.positions, [])
221 }).pipe(Effect.provide(NodeFileSystem.layer)))
222 }
223
224 it.effect("writeAll stops when a partial write advances beyond the safe integer limit", () =>
225 Effect.gen(function*() {
226 const fs = yield* FileSystem.FileSystem
227 const file = yield* fs.open("precision.bin", { flag: "r+" })
228 yield* file.seek(maxSafe, "start")
229 const error = yield* Effect.flip(file.writeAll(new Uint8Array([1, 2])))
230 assert.strictEqual(error.reason._tag, "BadArgument")
231 assert.deepStrictEqual(state.positions, [Number.MAX_SAFE_INTEGER])
232 assert.strictEqual(yield* file.seek(0n, "current"), maxSafe + 1n)
233 }).pipe(Effect.provide(NodeFileSystem.layer)))
234
235 for (const method of ["stat", "fstat"] as const) {
236 const getInfo = Effect.gen(function*() {
237 const fs = yield* FileSystem.FileSystem
238 return yield* method === "stat" ? fs.stat("precision.bin") : (yield* fs.open("precision.bin")).stat
239 })
240
241 it.effect(`${method} preserves oversized byte counts`, () =>
242 Effect.gen(function*() {
243 state.values.size = oversized
244 state.values.blksize = oversized
245 const info = yield* getInfo
246 assert.strictEqual(info.size, ByteSize.bytes(oversized))
247 assert.strictEqual(Option.getOrThrow(info.blksize), ByteSize.bytes(oversized))
248 }).pipe(Effect.provide(NodeFileSystem.layer)))
249
250 for (const field of ["dev", "mode"] as const) {
251 it.effect(`${method} rejects unsafe ${field} metadata`, () =>
252 Effect.gen(function*() {
253 state.values[field] = maxSafe + 1n
254 const error = yield* Effect.flip(getInfo)
255 assert.strictEqual(error.reason._tag, "BadArgument")
256 }).pipe(Effect.provide(NodeFileSystem.layer)))
257 }
258
259 it.effect(`${method} omits only the overflowing optional field`, () =>
260 Effect.gen(function*() {
261 Object.assign(state.values, { ino: maxSafe + 1n, nlink: 3n, uid: 1000n, gid: 1001n, rdev: 0n, blocks: 8n })
262 const info = yield* getInfo
263 assert.deepStrictEqual(info.ino, Option.none())
264 assert.deepStrictEqual(info.nlink, Option.some(3))
265 assert.deepStrictEqual(info.uid, Option.some(1000))
266 assert.deepStrictEqual(info.gid, Option.some(1001))
267 assert.deepStrictEqual(info.rdev, Option.some(0))
268 assert.deepStrictEqual(info.blocks, Option.some(8))
269 assert.strictEqual(info.type, "File")
270 assert.strictEqual(info.size, ByteSize.bytes(4n))
271 assert.deepStrictEqual(info.mtime, Option.some(new Date(0)))
272 }).pipe(Effect.provide(NodeFileSystem.layer)))
273
274 for (
275 const { expected, name, value } of [
276 {
277 name: "preserves optional metadata at MAX_SAFE_INTEGER",
278 value: maxSafe,
279 expected: Option.some(Number.MAX_SAFE_INTEGER)
280 },
281 { name: "omits optional metadata above MAX_SAFE_INTEGER", value: maxSafe + 1n, expected: Option.none() },
282 { name: "omits missing optional metadata", value: undefined, expected: Option.none() }
283 ]
284 ) {
285 it.effect(`${method} ${name}`, () =>
286 Effect.gen(function*() {
287 const fields = ["ino", "nlink", "uid", "gid", "rdev", "blocks"] as const
288 for (const field of fields) {
289 if (value === undefined) {
290 delete state.values[field]
291 } else {
292 state.values[field] = value
293 }
294 }
295 const info = yield* getInfo
296 for (const field of fields) {
297 assert.deepStrictEqual(info[field], expected, field)
298 }
299 assert.strictEqual(info.type, "File")
300 assert.strictEqual(info.size, ByteSize.bytes(4n))
301 assert.deepStrictEqual(info.mtime, Option.some(new Date(0)))
302 }).pipe(Effect.provide(NodeFileSystem.layer)))
303 }
304 }
305})