1import { assert, expect, it } from "@effect/vitest"
2import { Array, ByteSize, Cause, Option, Result } from "effect"
3import * as Effect from "effect/Effect"
4import * as Fs from "effect/FileSystem"
5import type * as Layer from "effect/Layer"
6import * as Stream from "effect/Stream"
7
8export interface TestLayerOptions {
9 /** Whether writable access to a directory is supported. Deno's open-based access check rejects directories. Defaults to `true`. */
10 readonly accessOnDirectory?: boolean
11 /** Whether a scoped temporary file removes its containing directory. Deno removes only the file. Defaults to `true`. */
12 readonly tempFileScopedRemovesDirectory?: boolean
13}
14
15export const testLayer = <E>(layer: Layer.Layer<Fs.FileSystem, E>, options: TestLayerOptions = {}) => {
16 const runPromise = <E2, A>(self: Effect.Effect<A, E2, Fs.FileSystem>) =>
17 Effect.runPromise(
18 Effect.provide(self, layer)
19 )
20
21 it("readFile", () =>
22 runPromise(Effect.gen(function*() {
23 const fs = yield* Fs.FileSystem
24 const data = yield* fs.readFile(`${__dirname}/fixtures/text.txt`)
25 const text = new TextDecoder().decode(data)
26 expect(text.trim()).toEqual("lorem ipsum dolar sit amet")
27 })))
28
29 it("makeTempDirectory", () =>
30 runPromise(Effect.gen(function*() {
31 const fs = yield* Fs.FileSystem
32 let dir = ""
33 yield* Effect.scoped(Effect.gen(function*() {
34 dir = yield* fs.makeTempDirectory()
35 const stat = yield* fs.stat(dir)
36 expect(stat.type).toEqual("Directory")
37 }))
38 const stat = yield* fs.stat(dir)
39 expect(stat.type).toEqual("Directory")
40 })))
41
42 it("makeTempDirectoryScoped", () =>
43 runPromise(Effect.gen(function*() {
44 const fs = yield* Fs.FileSystem
45 let dir = ""
46 yield* Effect.scoped(
47 Effect.gen(function*() {
48 dir = yield* fs.makeTempDirectoryScoped()
49 const stat = yield* fs.stat(dir)
50 expect(stat.type).toEqual("Directory")
51 })
52 )
53 const error = yield* Effect.flip(fs.stat(dir))
54 assert(error.reason._tag === "NotFound")
55 })))
56
57 it.skipIf(options.accessOnDirectory === false)(
58 "access on a writable directory",
59 () =>
60 runPromise(Effect.gen(function*() {
61 const fs = yield* Fs.FileSystem
62 yield* Effect.scoped(Effect.gen(function*() {
63 const dir = yield* fs.makeTempDirectoryScoped()
64 yield* fs.access(dir, { writable: true })
65 }))
66 }))
67 )
68
69 it("makeTempFileScoped cleans up", () =>
70 runPromise(Effect.gen(function*() {
71 const fs = yield* Fs.FileSystem
72 yield* Effect.scoped(Effect.gen(function*() {
73 const root = yield* fs.makeTempDirectoryScoped()
74 let file = ""
75 let dir = ""
76 yield* Effect.scoped(Effect.gen(function*() {
77 file = yield* fs.makeTempFileScoped({ directory: root })
78 const separator = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\"))
79 assert(separator > 0, "Expected temp file path to contain a directory separator")
80 dir = file.slice(0, separator)
81 const stat = yield* fs.stat(dir)
82 expect(stat.type).toEqual("Directory")
83 }))
84 const fileError = yield* Effect.flip(fs.stat(file))
85 assert(fileError.reason._tag === "NotFound")
86 if (options.tempFileScopedRemovesDirectory !== false) {
87 const directoryError = yield* Effect.flip(fs.stat(dir))
88 assert(directoryError.reason._tag === "NotFound")
89 }
90 }))
91 })))
92
93 it("truncate", () =>
94 runPromise(Effect.gen(function*() {
95 const fs = yield* Fs.FileSystem
96 const file = yield* fs.makeTempFile()
97
98 const text = "hello world"
99 yield* fs.writeFile(file, new TextEncoder().encode(text))
100
101 const before = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_))
102 expect(before).toEqual(text)
103
104 yield* fs.truncate(file, 5)
105 const truncated = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_))
106 expect(truncated).toEqual("hello")
107
108 yield* fs.truncate(file)
109
110 const after = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_))
111 expect(after).toEqual("")
112 })))
113
114 it("writeFile with r+ overwrites without truncating", () =>
115 runPromise(Effect.gen(function*() {
116 const fs = yield* Fs.FileSystem
117 const path = yield* fs.makeTempFile()
118
119 yield* fs.writeFileString(path, "abcdef")
120 yield* fs.writeFileString(path, "xy", { flag: "r+" })
121
122 assert.strictEqual(yield* fs.readFileString(path), "xycdef")
123 })))
124
125 it("writeFile with empty data honors the flag", () =>
126 runPromise(Effect.gen(function*() {
127 const fs = yield* Fs.FileSystem
128 const path = yield* fs.makeTempFile()
129
130 yield* fs.writeFileString(path, "abc")
131 yield* fs.writeFileString(path, "")
132 assert.strictEqual(yield* fs.readFileString(path), "")
133
134 yield* fs.writeFileString(path, "abc")
135 yield* fs.writeFileString(path, "", { flag: "r+" })
136 assert.strictEqual(yield* fs.readFileString(path), "abc")
137 })))
138
139 it("writeFile with r rejects writes", () =>
140 runPromise(Effect.gen(function*() {
141 const fs = yield* Fs.FileSystem
142 const path = yield* fs.makeTempFile()
143
144 const error = yield* fs.writeFileString(path, "data", { flag: "r" }).pipe(Effect.flip)
145
146 assert(error.reason._tag !== "BadArgument")
147 assert.strictEqual(error.reason.method, "writeFile")
148 assert.strictEqual(error.reason.pathOrDescriptor, path)
149 assert.strictEqual(yield* fs.readFileString(path), "")
150 })))
151
152 it("writeFile with a appends", () =>
153 runPromise(Effect.gen(function*() {
154 const fs = yield* Fs.FileSystem
155 const path = yield* fs.makeTempFile()
156
157 yield* fs.writeFileString(path, "abc")
158 yield* fs.writeFileString(path, "def", { flag: "a" })
159
160 assert.strictEqual(yield* fs.readFileString(path), "abcdef")
161 })))
162
163 it("writeFile with wx exclusively creates", () =>
164 runPromise(Effect.gen(function*() {
165 const fs = yield* Fs.FileSystem
166 const root = yield* fs.makeTempDirectory()
167 const path = `${root}/file.txt`
168
169 yield* fs.writeFileString(path, "first", { flag: "wx" })
170 yield* fs.writeFileString(path, "second", { flag: "wx" }).pipe(Effect.flip)
171
172 assert.strictEqual(yield* fs.readFileString(path), "first")
173 })))
174
175 it("copy with overwrite false preserves an existing destination", () =>
176 runPromise(Effect.gen(function*() {
177 const fs = yield* Fs.FileSystem
178 const root = yield* fs.makeTempDirectory()
179 const source = `${root}/source.txt`
180 const destination = `${root}/destination.txt`
181 yield* fs.writeFileString(source, "source")
182 yield* fs.writeFileString(destination, "destination")
183
184 const result = yield* Effect.result(fs.copy(source, destination, { overwrite: false }))
185
186 if (Result.isFailure(result)) {
187 assert(result.failure.reason._tag === "AlreadyExists")
188 assert.strictEqual(result.failure.reason.method, "copy")
189 assert.strictEqual(result.failure.reason.pathOrDescriptor, source)
190 }
191 assert.strictEqual(yield* fs.readFileString(source), "source")
192 assert.strictEqual(yield* fs.readFileString(destination), "destination")
193 })))
194
195 it("should track the cursor position when reading", () =>
196 runPromise(Effect.gen(function*() {
197 const fs = yield* Fs.FileSystem
198
199 yield* Effect.gen(function*() {
200 let text: string
201 const file = yield* fs.open(`${__dirname}/fixtures/text.txt`)
202
203 text = yield* file.readAlloc(5).pipe(
204 Effect.flatMap(Effect.fromOption),
205 Effect.map((_) => new TextDecoder().decode(_))
206 )
207 expect(text).toBe("lorem")
208
209 yield* file.seek(BigInt(7), "current")
210 text = yield* file.readAlloc(5).pipe(
211 Effect.flatMap(Effect.fromOption),
212 Effect.map((_) => new TextDecoder().decode(_))
213 )
214 expect(text).toBe("dolar")
215
216 yield* file.seek(BigInt(1), "current")
217 text = yield* file.readAlloc(8).pipe(
218 Effect.flatMap(Effect.fromOption),
219 Effect.map((_) => new TextDecoder().decode(_))
220 )
221 expect(text).toBe("sit amet")
222
223 yield* file.seek(BigInt(0), "start")
224 text = yield* file.readAlloc(11).pipe(
225 Effect.flatMap(Effect.fromOption),
226 Effect.map((_) => new TextDecoder().decode(_))
227 )
228 expect(text).toBe("lorem ipsum")
229
230 text = yield* fs.stream(`${__dirname}/fixtures/text.txt`, {
231 offset: ByteSize.bytes(6),
232 bytesToRead: ByteSize.bytes(5)
233 }).pipe(
234 Stream.map((_) => new TextDecoder().decode(_)),
235 Stream.runCollect,
236 Effect.map(Array.join(""))
237 )
238 expect(text).toBe("ipsum")
239 }).pipe(
240 Effect.scoped
241 )
242 })))
243
244 it.each([
245 { chunkSize: 1.5, bytesToRead: undefined },
246 { chunkSize: NaN, bytesToRead: undefined },
247 { chunkSize: 1.5, bytesToRead: 0 },
248 { chunkSize: NaN, bytesToRead: 0 }
249 ])(
250 "rejects stream chunkSize $chunkSize with bytesToRead $bytesToRead",
251 ({ bytesToRead, chunkSize }) =>
252 runPromise(Effect.gen(function*() {
253 const fs = yield* Fs.FileSystem
254 const chunks: Array<Uint8Array> = []
255 const stream = fs.stream(`${__dirname}/fixtures/text.txt`, { bytesToRead, chunkSize })
256 const exit = yield* Effect.exit(stream.pipe(
257 Stream.tap((chunk) => Effect.sync(() => chunks.push(chunk))),
258 Stream.runDrain
259 ))
260
261 assert.deepStrictEqual(chunks, [])
262 assert.strictEqual(exit._tag, "Failure")
263 if (exit._tag === "Failure") {
264 assert.isTrue(Cause.hasDies(exit.cause))
265 assert.isFalse(Cause.hasFails(exit.cause))
266 assert.instanceOf(Cause.squash(exit.cause), RangeError)
267 }
268 }))
269 )
270
271 it("should return a numeric byte count when reading", () =>
272 runPromise(Effect.gen(function*() {
273 const fs = yield* Fs.FileSystem
274
275 yield* Effect.gen(function*() {
276 const file = yield* fs.open(`${__dirname}/fixtures/text.txt`)
277 const buffer = new Uint8Array(5)
278
279 assert.strictEqual(yield* file.read(buffer), 5)
280 assert.strictEqual(new TextDecoder().decode(buffer), "lorem")
281 }).pipe(Effect.scoped)
282 })))
283
284 it("should read from a backwards seek", () =>
285 runPromise(Effect.gen(function*() {
286 const fs = yield* Fs.FileSystem
287
288 yield* Effect.gen(function*() {
289 const file = yield* fs.open(`${__dirname}/fixtures/text.txt`)
290
291 const first = yield* file.readAlloc(5).pipe(
292 Effect.flatMap(Effect.fromOption),
293 Effect.map((_) => new TextDecoder().decode(_))
294 )
295 expect(first).toBe("lorem")
296
297 yield* file.seek(BigInt(-3), "current")
298 const second = yield* file.readAlloc(3).pipe(
299 Effect.flatMap(Effect.fromOption),
300 Effect.map((_) => new TextDecoder().decode(_))
301 )
302 expect(second).toBe("rem")
303 }).pipe(
304 Effect.scoped
305 )
306 })))
307
308 it.each([
309 { offset: BigInt(-1), from: "start" as const },
310 { offset: BigInt(-3), from: "start" as const },
311 { offset: BigInt(-6), from: "current" as const },
312 { offset: BigInt(-8), from: "current" as const }
313 ])(
314 "should reject seeks before the start of the file ($offset from $from)",
315 ({ from, offset }) =>
316 runPromise(Effect.gen(function*() {
317 const fs = yield* Fs.FileSystem
318
319 yield* Effect.gen(function*() {
320 const file = yield* fs.open(`${__dirname}/fixtures/text.txt`)
321 const buffer = new Uint8Array(5)
322 assert.strictEqual(yield* file.read(buffer), 5)
323 assert.strictEqual(new TextDecoder().decode(buffer), "lorem")
324
325 const result = yield* Effect.result(file.seek(offset, from))
326 expect(result).toMatchObject({
327 _tag: "Failure",
328 failure: {
329 _tag: "PlatformError",
330 reason: { _tag: "BadArgument", module: "FileSystem", method: "seek" }
331 }
332 })
333 assert.strictEqual(yield* file.seek(BigInt(0), "current"), BigInt(5))
334 assert.strictEqual(yield* file.read(buffer), 5)
335 assert.strictEqual(new TextDecoder().decode(buffer), " ipsu")
336 assert.strictEqual(yield* file.seek(BigInt(0), "current"), BigInt(10))
337
338 assert.strictEqual(yield* file.seek(BigInt(-10), "current"), BigInt(0))
339 assert.strictEqual(yield* file.read(buffer), 5)
340 assert.strictEqual(new TextDecoder().decode(buffer), "lorem")
341 }).pipe(Effect.scoped)
342 }))
343 )
344
345 it.each([-1, 1.5, NaN, Infinity, Number.MAX_VALUE])(
346 "readAlloc(%s) fails with BadArgument without moving the cursor",
347 (size) =>
348 runPromise(Effect.gen(function*() {
349 const fs = yield* Fs.FileSystem
350
351 yield* Effect.gen(function*() {
352 const file = yield* fs.open(`${__dirname}/fixtures/text.txt`)
353 const first = yield* file.readAlloc(6).pipe(Effect.flatMap(Effect.fromOption))
354 assert.strictEqual(new TextDecoder().decode(first), "lorem ")
355
356 const exit = yield* Effect.exit(file.readAlloc(size))
357 const position = yield* file.seek(BigInt(0), "current")
358 const next = yield* file.readAlloc(5).pipe(Effect.flatMap(Effect.fromOption))
359
360 assert.strictEqual(position, BigInt(6))
361 assert.strictEqual(new TextDecoder().decode(next), "ipsum")
362 assert(exit._tag === "Failure")
363 assert.isFalse(Cause.hasDies(exit.cause))
364 const error = Cause.findErrorOption(exit.cause)
365 assert(Option.isSome(error))
366 assert.strictEqual(error.value._tag, "PlatformError")
367 assert.strictEqual(error.value.reason._tag, "BadArgument")
368 assert.strictEqual(error.value.reason.module, "FileSystem")
369 assert.strictEqual(error.value.reason.method, "readAlloc")
370 }).pipe(Effect.scoped)
371 }))
372 )
373
374 it("readAlloc(0) returns None without moving the cursor", () =>
375 runPromise(Effect.gen(function*() {
376 const fs = yield* Fs.FileSystem
377
378 yield* Effect.gen(function*() {
379 const file = yield* fs.open(`${__dirname}/fixtures/text.txt`)
380 const first = yield* file.readAlloc(6).pipe(Effect.flatMap(Effect.fromOption))
381 assert.strictEqual(new TextDecoder().decode(first), "lorem ")
382
383 assert.deepStrictEqual(yield* file.readAlloc(0), Option.none())
384 assert.strictEqual(yield* file.seek(BigInt(0), "current"), BigInt(6))
385
386 const next = yield* file.readAlloc(5).pipe(Effect.flatMap(Effect.fromOption))
387 assert.strictEqual(new TextDecoder().decode(next), "ipsum")
388 }).pipe(Effect.scoped)
389 })))
390
391 it("should read sequentially without an intervening seek", () =>
392 runPromise(Effect.gen(function*() {
393 const fs = yield* Fs.FileSystem
394
395 yield* Effect.gen(function*() {
396 const file = yield* fs.open(`${__dirname}/fixtures/text.txt`)
397
398 const first = yield* file.readAlloc(5).pipe(
399 Effect.flatMap(Effect.fromOption),
400 Effect.map((_) => new TextDecoder().decode(_))
401 )
402 expect(first).toBe("lorem")
403
404 const second = yield* file.readAlloc(6).pipe(
405 Effect.flatMap(Effect.fromOption),
406 Effect.map((_) => new TextDecoder().decode(_))
407 )
408 expect(second).toBe(" ipsum")
409 }).pipe(
410 Effect.scoped
411 )
412 })))
413
414 it("should track the cursor position when writing", () =>
415 runPromise(Effect.gen(function*() {
416 const fs = yield* Fs.FileSystem
417
418 yield* Effect.gen(function*() {
419 let text: string
420 const path = yield* fs.makeTempFileScoped()
421 const file = yield* fs.open(path, { flag: "w+" })
422
423 assert.strictEqual(yield* file.write(new TextEncoder().encode("lorem ipsum")), 11)
424 yield* file.write(new TextEncoder().encode(" "))
425 yield* file.write(new TextEncoder().encode("dolor sit amet"))
426 text = yield* fs.readFileString(path)
427 expect(text).toBe("lorem ipsum dolor sit amet")
428
429 yield* file.seek(BigInt(-4), "current")
430 yield* file.write(new TextEncoder().encode("hello world"))
431 text = yield* fs.readFileString(path)
432 expect(text).toBe("lorem ipsum dolor sit hello world")
433
434 yield* file.seek(BigInt(6), "start")
435 yield* file.write(new TextEncoder().encode("blabl"))
436 text = yield* fs.readFileString(path)
437 expect(text).toBe("lorem blabl dolor sit hello world")
438 }).pipe(
439 Effect.scoped
440 )
441 })))
442
443 it("should maintain a read cursor in append mode", () =>
444 runPromise(Effect.gen(function*() {
445 const fs = yield* Fs.FileSystem
446
447 yield* Effect.gen(function*() {
448 let text: string
449 const path = yield* fs.makeTempFileScoped()
450 const file = yield* fs.open(path, { flag: "a+" })
451
452 yield* file.write(new TextEncoder().encode("foo"))
453 yield* file.seek(BigInt(0), "start")
454
455 yield* file.write(new TextEncoder().encode("bar"))
456 text = yield* fs.readFileString(path)
457 expect(text).toBe("foobar")
458
459 text = yield* file.readAlloc(3).pipe(
460 Effect.flatMap(Effect.fromOption),
461 Effect.map((_) => new TextDecoder().decode(_))
462 )
463 expect(text).toBe("foo")
464
465 yield* file.write(new TextEncoder().encode("baz"))
466 text = yield* fs.readFileString(path)
467 expect(text).toBe("foobarbaz")
468
469 text = yield* file.readAlloc(6).pipe(
470 Effect.flatMap(Effect.fromOption),
471 Effect.map((_) => new TextDecoder().decode(_))
472 )
473 expect(text).toBe("barbaz")
474 }).pipe(
475 Effect.scoped
476 )
477 })))
478
479 it("should restore the read cursor after an append write", () =>
480 runPromise(Effect.gen(function*() {
481 const fs = yield* Fs.FileSystem
482
483 yield* Effect.gen(function*() {
484 const path = yield* fs.makeTempFileScoped()
485 const file = yield* fs.open(path, { flag: "a+" })
486
487 yield* file.write(new TextEncoder().encode("foo"))
488 yield* file.seek(BigInt(0), "start")
489
490 const first = yield* file.readAlloc(1).pipe(
491 Effect.flatMap(Effect.fromOption),
492 Effect.map((_) => new TextDecoder().decode(_))
493 )
494 expect(first).toBe("f")
495
496 yield* file.write(new TextEncoder().encode("bar"))
497 const second = yield* file.readAlloc(2).pipe(
498 Effect.flatMap(Effect.fromOption),
499 Effect.map((_) => new TextDecoder().decode(_))
500 )
501 expect(second).toBe("oo")
502 }).pipe(
503 Effect.scoped
504 )
505 })))
506
507 it("should keep the current cursor if truncating doesn't affect it", () =>
508 runPromise(Effect.gen(function*() {
509 const fs = yield* Fs.FileSystem
510
511 yield* Effect.gen(function*() {
512 const path = yield* fs.makeTempFileScoped()
513 const file = yield* fs.open(path, { flag: "w+" })
514
515 yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet"))
516 yield* file.seek(BigInt(6), "start")
517 yield* file.truncate(11)
518
519 const cursor = yield* file.seek(BigInt(0), "current")
520 assert.strictEqual(cursor, BigInt(6))
521 }).pipe(
522 Effect.scoped
523 )
524 })))
525
526 it("should update the current cursor if truncating affects it", () =>
527 runPromise(Effect.gen(function*() {
528 const fs = yield* Fs.FileSystem
529
530 yield* Effect.gen(function*() {
531 const path = yield* fs.makeTempFileScoped()
532 const file = yield* fs.open(path, { flag: "w+" })
533
534 yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet"))
535 yield* file.truncate(11)
536
537 const cursor = yield* file.seek(BigInt(0), "current")
538 assert.strictEqual(cursor, BigInt(11))
539 }).pipe(
540 Effect.scoped
541 )
542 })))
543
544 it("should read from the clamped cursor after truncating", () =>
545 runPromise(Effect.gen(function*() {
546 const fs = yield* Fs.FileSystem
547
548 yield* Effect.gen(function*() {
549 const path = yield* fs.makeTempFileScoped()
550 const file = yield* fs.open(path, { flag: "w+" })
551
552 yield* file.write(new TextEncoder().encode("abcdefghij"))
553 yield* file.truncate(5)
554 yield* fs.writeFile(path, new TextEncoder().encode("xyz"), { flag: "a" })
555
556 const text = yield* file.readAlloc(3).pipe(
557 Effect.flatMap(Effect.fromOption),
558 Effect.map((_) => new TextDecoder().decode(_))
559 )
560 expect(text).toBe("xyz")
561 }).pipe(
562 Effect.scoped
563 )
564 })))
565}