From 1e14c2e7a84450dc3b5affdad1d737b2e29019f8 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Wed, 22 Jul 2026 21:49:50 +0200 Subject: [PATCH 1/2] test: extract FileSystemTest suite --- packages/effect/test/FileSystemTest.ts | 453 ++++++++++++++++++ .../test/NodeFileSystem.test.ts | 237 ++------- 2 files changed, 488 insertions(+), 202 deletions(-) create mode 100644 packages/effect/test/FileSystemTest.ts diff --git a/packages/effect/test/FileSystemTest.ts b/packages/effect/test/FileSystemTest.ts new file mode 100644 index 00000000000..c6d4f5adf95 --- /dev/null +++ b/packages/effect/test/FileSystemTest.ts @@ -0,0 +1,453 @@ +import { assert, it } from "@effect/vitest" +import { Effect, type Layer, Option, Stream } from "effect" +import * as FileSystem from "effect/FileSystem" +import * as PlatformError from "effect/PlatformError" + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +const makeTestContext = Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.makeTempDirectoryScoped({ prefix: "effect-filesystem-test-" }) + return { + fs, + path: (...segments: ReadonlyArray) => [root, ...segments].join("/") + } +}) + +const decode = (bytes: Uint8Array): string => decoder.decode(bytes) + +const fillBuffer = Effect.fnUntraced(function*(file: FileSystem.File, buffer: Uint8Array) { + let offset = 0 + while (offset < buffer.length) { + const bytesRead = Number(yield* file.read(buffer.subarray(offset))) + assert.isTrue(bytesRead >= 0) + assert.isTrue(bytesRead <= buffer.length - offset) + if (bytesRead === 0) { + break + } + offset += bytesRead + } + return FileSystem.Size(offset) +}) + +const readAllocUpTo = Effect.fnUntraced(function*(file: FileSystem.File, size: number) { + const bytes = new Uint8Array(size) + let offset = 0 + while (offset < size) { + const chunk = yield* file.readAlloc(size - offset) + if (Option.isNone(chunk)) { + break + } + assert.isTrue(chunk.value.length > 0) + assert.isTrue(chunk.value.length <= size - offset) + bytes.set(chunk.value, offset) + offset += chunk.value.length + } + return bytes.subarray(0, offset) +}) + +const writeUsingWrite = Effect.fnUntraced(function*(file: FileSystem.File, bytes: Uint8Array) { + let offset = 0 + while (offset < bytes.length) { + const bytesWritten = Number(yield* file.write(bytes.subarray(offset))) + assert.isTrue(bytesWritten > 0) + assert.isTrue(bytesWritten <= bytes.length - offset) + offset += bytesWritten + } +}) + +const assertSystemError = ( + error: PlatformError.PlatformError, + expected: { + readonly tag?: PlatformError.SystemErrorTag | undefined + readonly method?: string | undefined + readonly pathOrDescriptor: string | number + } +): PlatformError.SystemError => { + assert.strictEqual(error._tag, "PlatformError") + if (!(error.reason instanceof PlatformError.SystemError)) { + return assert.fail("Expected a SystemError") + } + assert.strictEqual(error.reason.module, "FileSystem") + if (expected.method !== undefined) { + assert.strictEqual(error.reason.method, expected.method) + } + assert.strictEqual(error.reason.pathOrDescriptor, expected.pathOrDescriptor) + if (expected.tag !== undefined) { + assert.strictEqual(error.reason._tag, expected.tag) + } + return error.reason +} + +export const suite = (name: string, layer: Layer.Layer) => + it.layer(layer, { timeout: { seconds: 30 } })(`FileSystem (${name})`, (it) => { + it.effect("reads and writes binary files", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("binary.dat") + const expected = new Uint8Array([0, 42, 128, 255]) + const input = expected.slice() + + yield* fs.writeFile(file, input) + input[1] = 0 + + const firstRead = yield* fs.readFile(file) + assert.deepStrictEqual(firstRead, expected) + firstRead[2] = 0 + assert.deepStrictEqual(yield* fs.readFile(file), expected) + })) + + it.effect("reads and writes UTF-8 text", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("utf8.txt") + const text = "héllo 👋" + + yield* fs.writeFileString(file, text) + + assert.strictEqual(yield* fs.readFileString(file), text) + })) + + it.effect("creates and lists directories", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + yield* fs.makeDirectory(path("src", "features"), { recursive: true }) + yield* fs.writeFileString(path("src", "index.ts"), "export {}") + yield* fs.writeFileString(path("src", "features", "chat.ts"), "export {}") + + assert.deepStrictEqual((yield* fs.readDirectory(path("src"))).sort(), ["features", "index.ts"]) + assert.strictEqual((yield* fs.stat(path("src"))).type, "Directory") + assert.strictEqual((yield* fs.stat(path("src", "index.ts"))).type, "File") + assert.strictEqual((yield* fs.stat(path("src", "index.ts"))).size, FileSystem.Size(9)) + })) + + it.effect("reports existence", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("exists.txt") + + assert.isFalse(yield* fs.exists(file)) + yield* fs.writeFileString(file, "exists") + assert.isTrue(yield* fs.exists(file)) + yield* fs.remove(file) + assert.isFalse(yield* fs.exists(file)) + })) + + it.effect("removes non-empty directories recursively", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const directory = path("nested") + yield* fs.makeDirectory(path("nested", "child"), { recursive: true }) + yield* fs.writeFileString(path("nested", "child", "file.txt"), "content") + + yield* fs.remove(directory, { recursive: true }) + yield* fs.remove(directory, { force: true }) + + assert.isFalse(yield* fs.exists(directory)) + })) + + it.effect("copies files and directory trees", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + yield* fs.makeDirectory(path("source", "nested"), { recursive: true }) + yield* fs.writeFileString(path("source", "file.txt"), "file") + yield* fs.writeFileString(path("source", "nested", "child.txt"), "child") + + yield* fs.copyFile(path("source", "file.txt"), path("copy.txt")) + yield* fs.copy(path("source"), path("copy")) + + yield* fs.writeFileString(path("source", "file.txt"), "changed file") + yield* fs.writeFileString(path("source", "nested", "child.txt"), "changed child") + + assert.strictEqual(yield* fs.readFileString(path("copy.txt")), "file") + assert.strictEqual(yield* fs.readFileString(path("copy", "file.txt")), "file") + assert.strictEqual(yield* fs.readFileString(path("copy", "nested", "child.txt")), "child") + })) + + it.effect("renames files and directories", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + yield* fs.makeDirectory(path("before", "nested"), { recursive: true }) + yield* fs.writeFileString(path("before", "nested", "file.txt"), "content") + + yield* fs.rename(path("before"), path("after")) + yield* fs.rename(path("after", "nested", "file.txt"), path("after", "nested", "renamed.txt")) + + assert.isFalse(yield* fs.exists(path("before"))) + assert.isFalse(yield* fs.exists(path("after", "nested", "file.txt"))) + assert.strictEqual(yield* fs.readFileString(path("after", "nested", "renamed.txt")), "content") + })) + + it.effect("truncates and extends files", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("truncate.txt") + yield* fs.writeFileString(file, "hello world") + + yield* fs.truncate(file, 5) + assert.strictEqual(yield* fs.readFileString(file), "hello") + + yield* fs.truncate(file, 8) + assert.deepStrictEqual(yield* fs.readFile(file), encoder.encode("hello\0\0\0")) + + yield* fs.truncate(file) + assert.deepStrictEqual(yield* fs.readFile(file), new Uint8Array(0)) + })) + + it.effect("normalizes stable filesystem errors", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const missingPath = path("missing.txt") + const missing = yield* Effect.flip(fs.readFile(missingPath)) + assertSystemError(missing, { tag: "NotFound", method: "readFile", pathOrDescriptor: missingPath }) + + const directory = path("existing") + yield* fs.makeDirectory(directory) + const existing = yield* Effect.flip(fs.makeDirectory(directory)) + assertSystemError(existing, { tag: "AlreadyExists", method: "makeDirectory", pathOrDescriptor: directory }) + + const file = path("file.txt") + yield* fs.writeFileString(file, "content") + const badResource = yield* Effect.flip(fs.readDirectory(file)) + assertSystemError(badResource, { tag: "BadResource", method: "readDirectory", pathOrDescriptor: file }) + })) + + it.effect("creates temporary resources and cleans up scoped resources", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const directory = yield* Effect.scoped( + Effect.gen(function*() { + const directory = yield* fs.makeTempDirectory() + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + return directory + }) + ) + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + yield* fs.remove(directory, { recursive: true }) + + const file = yield* fs.makeTempFile() + assert.strictEqual((yield* fs.stat(file)).type, "File") + yield* fs.remove(file) + + const suffixedFile = yield* fs.makeTempFile({ directory: path(), prefix: "plain-", suffix: ".txt" }) + assert.strictEqual((yield* fs.stat(suffixedFile)).type, "File") + assert.isTrue(suffixedFile.endsWith(".txt")) + + const scopedDirectory = yield* Effect.scoped( + Effect.gen(function*() { + const directory = yield* fs.makeTempDirectoryScoped({ directory: path(), prefix: "scoped-" }) + yield* fs.makeDirectory(`${directory}/nested`) + yield* fs.writeFileString(`${directory}/nested/file.txt`, "content") + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + return directory + }) + ) + const scopedDirectoryError = yield* Effect.flip(fs.stat(scopedDirectory)) + assertSystemError(scopedDirectoryError, { + tag: "NotFound", + method: "stat", + pathOrDescriptor: scopedDirectory + }) + + const scopedFile = yield* Effect.scoped( + Effect.gen(function*() { + const file = yield* fs.makeTempFileScoped({ + directory: path(), + prefix: "scoped-", + suffix: ".txt" + }) + assert.strictEqual((yield* fs.stat(file)).type, "File") + assert.isTrue(file.endsWith(".txt")) + return file + }) + ) + const scopedFileError = yield* Effect.flip(fs.stat(scopedFile)) + assertSystemError(scopedFileError, { tag: "NotFound", method: "stat", pathOrDescriptor: scopedFile }) + })) + + it.effect("streams bounded ranges with default and configured chunks", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("stream.txt") + yield* fs.writeFileString(file, "0123456789") + + const defaultChunks = yield* fs.stream(file, { + offset: 6, + bytesToRead: 3 + }).pipe(Stream.runCollect) + const chunks = yield* fs.stream(file, { + offset: 2, + bytesToRead: 5, + chunkSize: 2 + }).pipe(Stream.runCollect) + + assert.strictEqual(defaultChunks.map(decode).join(""), "678") + assert.isTrue(chunks.every((chunk) => chunk.length > 0 && chunk.length <= 2)) + assert.strictEqual(chunks.map(decode).join(""), "23456") + })) + + it.effect("writes stream chunks through a file sink", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("sink.txt") + yield* fs.writeFileString(file, "old content") + + yield* Stream.run( + Stream.make(encoder.encode("new "), encoder.encode("content")), + fs.sink(file) + ) + + assert.strictEqual(yield* fs.readFileString(file), "new content") + })) + + it.effect("tracks independent read cursors", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("cursors.txt") + yield* fs.writeFileString(file, "0123456789") + + const first = yield* fs.open(file) + const second = yield* fs.open(file) + + const buffer = new Uint8Array(2) + assert.strictEqual(yield* fillBuffer(first, buffer), FileSystem.Size(2)) + assert.strictEqual(decode(buffer), "01") + assert.strictEqual(decode(yield* readAllocUpTo(second, 2)), "01") + yield* first.seek(3, "current") + assert.strictEqual(decode(yield* readAllocUpTo(first, 2)), "56") + yield* first.seek(0, "start") + assert.strictEqual(decode(yield* readAllocUpTo(first, 2)), "01") + })) + + it.effect("tracks a write cursor", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("write-cursor.txt") + const file = yield* fs.open(filePath, { flag: "w+" }) + + yield* writeUsingWrite(file, encoder.encode("abc")) + yield* writeUsingWrite(file, encoder.encode("def")) + assert.strictEqual(yield* fs.readFileString(filePath), "abcdef") + + yield* file.seek(-2, "current") + yield* writeUsingWrite(file, encoder.encode("WXYZ")) + assert.strictEqual(yield* fs.readFileString(filePath), "abcdWXYZ") + + yield* file.seek(2, "start") + yield* writeUsingWrite(file, encoder.encode("!!")) + yield* file.sync + + assert.strictEqual((yield* file.stat).size, FileSystem.Size(8)) + assert.strictEqual(yield* fs.readFileString(filePath), "ab!!WXYZ") + })) + + it.effect("appends primitive writes regardless of the cursor", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("append-write.txt") + yield* fs.writeFileString(filePath, "seed") + const file = yield* fs.open(filePath, { flag: "a+" }) + + yield* file.seek(0, "start") + yield* writeUsingWrite(file, encoder.encode("xy")) + + assert.strictEqual(yield* fs.readFileString(filePath), "seedxy") + })) + + it.effect("returns partial reads and EOF", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("eof.txt") + yield* fs.writeFileString(filePath, "abc") + const file = yield* fs.open(filePath) + + const contents = yield* readAllocUpTo(file, 10) + assert.strictEqual(decode(contents), "abc") + assert.isTrue(Option.isNone(yield* file.readAlloc(1))) + })) + + it.effect("rejects operations after a file-handle scope closes", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("closed.txt") + yield* fs.writeFileString(filePath, "content") + + const file = yield* Effect.scoped(fs.open(filePath)) + const error = yield* Effect.flip(file.readAlloc(1)) + + assertSystemError(error, { method: "readAlloc", pathOrDescriptor: file.fd }) + })) + + const openFlags = [ + ["r", true, false, false, false], + ["r+", true, true, false, false], + ["w", false, true, true, false], + ["wx", false, true, true, true], + ["w+", true, true, true, false], + ["wx+", true, true, true, true], + ["a", false, true, false, false], + ["ax", false, true, false, true], + ["a+", true, true, false, false], + ["ax+", true, true, false, true] + ] as const + + it.effect.each(openFlags)( + "supports the %s open flag", + ([flag, readable, writable, truncates, exclusive]) => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const existing = path("existing.txt") + const created = path("created.txt") + yield* fs.writeFileString(existing, "seed") + + if (exclusive) { + const error = yield* Effect.flip(fs.open(existing, { flag })) + assertSystemError(error, { tag: "AlreadyExists", method: "open", pathOrDescriptor: existing }) + assert.strictEqual(yield* fs.readFileString(existing), "seed") + } + + if (flag.startsWith("r")) { + const error = yield* Effect.flip(fs.open(created, { flag })) + assertSystemError(error, { tag: "NotFound", method: "open", pathOrDescriptor: created }) + } + + const filePath = exclusive ? created : existing + const file = yield* fs.open(filePath, { flag }) + + if (exclusive) { + assert.isTrue(yield* fs.exists(filePath)) + } + if (truncates) { + assert.strictEqual(yield* fs.readFileString(filePath), "") + } else if (!exclusive && flag.startsWith("a")) { + assert.strictEqual(yield* fs.readFileString(filePath), "seed") + } + + if (writable) { + yield* file.writeAll(encoder.encode("x")) + } else { + const error = yield* Effect.flip(file.writeAll(encoder.encode("x"))) + assert.strictEqual(error._tag, "PlatformError") + } + + const expectedContent = writable + ? flag.startsWith("a") ? `${exclusive ? "" : "seed"}x` : flag.startsWith("r") ? "xeed" : "x" + : "seed" + assert.strictEqual(yield* fs.readFileString(filePath), expectedContent) + + if (readable) { + yield* file.seek(0, "start") + assert.strictEqual(decode(yield* readAllocUpTo(file, 1)), expectedContent.slice(0, 1)) + } else { + const error = yield* Effect.flip(file.readAlloc(1)) + assert.strictEqual(error._tag, "PlatformError") + } + + if (!exclusive && !flag.startsWith("r")) { + yield* fs.open(created, { flag }) + assert.isTrue(yield* fs.exists(created)) + } + }) + ) + }) diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index 017f2eee945..c3e36d68488 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -1,214 +1,47 @@ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem" -import { assert, describe, expect, it } from "@effect/vitest" -import { Array } from "effect" -import * as Effect from "effect/Effect" -import * as Fs from "effect/FileSystem" -import * as Stream from "effect/Stream" +import { assert, it } from "@effect/vitest" +import { Effect } from "effect" +import * as FileSystemTest from "effect-test/FileSystemTest" +import * as FileSystem from "effect/FileSystem" -const runPromise = (self: Effect.Effect) => - Effect.runPromise( - Effect.provide(self, NodeFileSystem.layer) - ) +const encoder = new TextEncoder() +const decoder = new TextDecoder() -describe("FileSystem", () => { - it("readFile", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - const data = yield* fs.readFile(`${__dirname}/fixtures/text.txt`) - const text = new TextDecoder().decode(data) - expect(text.trim()).toEqual("lorem ipsum dolar sit amet") - }))) - - it("makeTempDirectory", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - let dir = "" - yield* Effect.scoped(Effect.gen(function*() { - dir = yield* fs.makeTempDirectory() - const stat = yield* fs.stat(dir) - expect(stat.type).toEqual("Directory") - })) - const stat = yield* fs.stat(dir) - expect(stat.type).toEqual("Directory") - }))) - - it("makeTempDirectoryScoped", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - let dir = "" - yield* Effect.scoped( - Effect.gen(function*() { - dir = yield* fs.makeTempDirectoryScoped() - const stat = yield* fs.stat(dir) - expect(stat.type).toEqual("Directory") - }) - ) - const error = yield* Effect.flip(fs.stat(dir)) - assert(error.reason._tag === "NotFound") - }))) - - it("truncate", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - const file = yield* fs.makeTempFile() - - const text = "hello world" - yield* fs.writeFile(file, new TextEncoder().encode(text)) - - const before = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_)) - expect(before).toEqual(text) - - yield* fs.truncate(file) - - const after = yield* Effect.map(fs.readFile(file), (_) => new TextDecoder().decode(_)) - expect(after).toEqual("") - }))) - - it("should track the cursor position when reading", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - let text: string - const file = yield* fs.open(`${__dirname}/fixtures/text.txt`) - - text = yield* file.readAlloc(Fs.Size(5)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("lorem") - - yield* file.seek(Fs.Size(7), "current") - text = yield* file.readAlloc(Fs.Size(5)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("dolar") - - yield* file.seek(Fs.Size(1), "current") - text = yield* file.readAlloc(Fs.Size(8)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("sit amet") - - yield* file.seek(Fs.Size(0), "start") - text = yield* file.readAlloc(Fs.Size(11)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("lorem ipsum") +FileSystemTest.suite("node", NodeFileSystem.layer) - text = yield* fs.stream(`${__dirname}/fixtures/text.txt`, { offset: Fs.Size(6), bytesToRead: Fs.Size(5) }).pipe( - Stream.map((_) => new TextDecoder().decode(_)), - Stream.runCollect, - Effect.map(Array.join("")) - ) - expect(text).toBe("ipsum") - }).pipe( - Effect.scoped - ) - }))) - - it("should track the cursor position when writing", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - let text: string - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "w+" }) - - yield* file.write(new TextEncoder().encode("lorem ipsum")) - yield* file.write(new TextEncoder().encode(" ")) - yield* file.write(new TextEncoder().encode("dolor sit amet")) - text = yield* fs.readFileString(path) - expect(text).toBe("lorem ipsum dolor sit amet") - - yield* file.seek(Fs.Size(-4), "current") - yield* file.write(new TextEncoder().encode("hello world")) - text = yield* fs.readFileString(path) - expect(text).toBe("lorem ipsum dolor sit hello world") - - yield* file.seek(Fs.Size(6), "start") - yield* file.write(new TextEncoder().encode("blabl")) - text = yield* fs.readFileString(path) - expect(text).toBe("lorem blabl dolor sit hello world") - }).pipe( - Effect.scoped - ) - }))) - - it("should maintain a read cursor in append mode", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem - - yield* Effect.gen(function*() { - let text: string - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "a+" }) - - yield* file.write(new TextEncoder().encode("foo")) - yield* file.seek(Fs.Size(0), "start") - - yield* file.write(new TextEncoder().encode("bar")) - text = yield* fs.readFileString(path) - expect(text).toBe("foobar") - - text = yield* file.readAlloc(Fs.Size(3)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("foo") - - yield* file.write(new TextEncoder().encode("baz")) - text = yield* fs.readFileString(path) - expect(text).toBe("foobarbaz") - - text = yield* file.readAlloc(Fs.Size(6)).pipe( - Effect.flatMap(Effect.fromOption), - Effect.map((_) => new TextDecoder().decode(_)) - ) - expect(text).toBe("barbaz") - }).pipe( - Effect.scoped - ) - }))) - - it("should keep the current cursor if truncating doesn't affect it", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem +it.layer(NodeFileSystem.layer)("FileSystem (node-specific)", (it) => { + it.effect("should read a pre-existing host file when the fixture exists", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const data = yield* fs.readFile(`${__dirname}/fixtures/text.txt`) - yield* Effect.gen(function*() { - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "w+" }) + assert.strictEqual(decoder.decode(data).trim(), "lorem ipsum dolar sit amet") + })) - yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")) - yield* file.seek(Fs.Size(6), "start") - yield* file.truncate(Fs.Size(11)) + it.effect("should keep the file-handle cursor when truncation does not shorten before it", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "w+" }) - const cursor = yield* file.seek(Fs.Size(0), "current") - expect(cursor).toBe(Fs.Size(6)) - }).pipe( - Effect.scoped - ) - }))) + yield* file.writeAll(encoder.encode("lorem ipsum dolor sit amet")) + yield* file.seek(6, "start") + yield* file.truncate(11) + yield* file.writeAll(encoder.encode("!")) - it("should update the current cursor if truncating affects it", () => - runPromise(Effect.gen(function*() { - const fs = yield* Fs.FileSystem + assert.strictEqual(yield* fs.readFileString(path), "lorem !psum") + })) - yield* Effect.gen(function*() { - const path = yield* fs.makeTempFileScoped() - const file = yield* fs.open(path, { flag: "w+" }) + it.effect("should clamp the file-handle cursor when truncation shortens before it", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* fs.makeTempFileScoped() + const file = yield* fs.open(path, { flag: "w+" }) - yield* file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")) - yield* file.truncate(Fs.Size(11)) + yield* file.writeAll(encoder.encode("lorem ipsum dolor sit amet")) + yield* file.truncate(11) + yield* file.writeAll(encoder.encode("!")) - const cursor = yield* file.seek(Fs.Size(0), "current") - expect(cursor).toBe(Fs.Size(11)) - }).pipe( - Effect.scoped - ) - }))) + assert.strictEqual(yield* fs.readFileString(path), "lorem ipsum!") + })) }) From f1758cace5d499a43200c3233b84db88b30aecc4 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 23 Jul 2026 08:56:26 +0200 Subject: [PATCH 2/2] test: extend FileSystemTest suite --- packages/effect/test/FileSystemTest.ts | 1391 ++++++++++++++++++------ 1 file changed, 1042 insertions(+), 349 deletions(-) diff --git a/packages/effect/test/FileSystemTest.ts b/packages/effect/test/FileSystemTest.ts index c6d4f5adf95..0cfb173e1f7 100644 --- a/packages/effect/test/FileSystemTest.ts +++ b/packages/effect/test/FileSystemTest.ts @@ -1,5 +1,5 @@ -import { assert, it } from "@effect/vitest" -import { Effect, type Layer, Option, Stream } from "effect" +import { assert, describe, it } from "@effect/vitest" +import { DateTime, Effect, type Layer, Option, Ref, Result, Stream } from "effect" import * as FileSystem from "effect/FileSystem" import * as PlatformError from "effect/PlatformError" @@ -15,8 +15,6 @@ const makeTestContext = Effect.gen(function*() { } }) -const decode = (bytes: Uint8Array): string => decoder.decode(bytes) - const fillBuffer = Effect.fnUntraced(function*(file: FileSystem.File, buffer: Uint8Array) { let offset = 0 while (offset < buffer.length) { @@ -82,372 +80,1067 @@ const assertSystemError = ( export const suite = (name: string, layer: Layer.Layer) => it.layer(layer, { timeout: { seconds: 30 } })(`FileSystem (${name})`, (it) => { - it.effect("reads and writes binary files", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const file = path("binary.dat") - const expected = new Uint8Array([0, 42, 128, 255]) - const input = expected.slice() - - yield* fs.writeFile(file, input) - input[1] = 0 - - const firstRead = yield* fs.readFile(file) - assert.deepStrictEqual(firstRead, expected) - firstRead[2] = 0 - assert.deepStrictEqual(yield* fs.readFile(file), expected) - })) - - it.effect("reads and writes UTF-8 text", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const file = path("utf8.txt") - const text = "héllo 👋" - - yield* fs.writeFileString(file, text) - - assert.strictEqual(yield* fs.readFileString(file), text) - })) - - it.effect("creates and lists directories", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - yield* fs.makeDirectory(path("src", "features"), { recursive: true }) - yield* fs.writeFileString(path("src", "index.ts"), "export {}") - yield* fs.writeFileString(path("src", "features", "chat.ts"), "export {}") - - assert.deepStrictEqual((yield* fs.readDirectory(path("src"))).sort(), ["features", "index.ts"]) - assert.strictEqual((yield* fs.stat(path("src"))).type, "Directory") - assert.strictEqual((yield* fs.stat(path("src", "index.ts"))).type, "File") - assert.strictEqual((yield* fs.stat(path("src", "index.ts"))).size, FileSystem.Size(9)) - })) - - it.effect("reports existence", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const file = path("exists.txt") - - assert.isFalse(yield* fs.exists(file)) - yield* fs.writeFileString(file, "exists") - assert.isTrue(yield* fs.exists(file)) - yield* fs.remove(file) - assert.isFalse(yield* fs.exists(file)) - })) - - it.effect("removes non-empty directories recursively", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const directory = path("nested") - yield* fs.makeDirectory(path("nested", "child"), { recursive: true }) - yield* fs.writeFileString(path("nested", "child", "file.txt"), "content") - - yield* fs.remove(directory, { recursive: true }) - yield* fs.remove(directory, { force: true }) - - assert.isFalse(yield* fs.exists(directory)) - })) - - it.effect("copies files and directory trees", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - yield* fs.makeDirectory(path("source", "nested"), { recursive: true }) - yield* fs.writeFileString(path("source", "file.txt"), "file") - yield* fs.writeFileString(path("source", "nested", "child.txt"), "child") - - yield* fs.copyFile(path("source", "file.txt"), path("copy.txt")) - yield* fs.copy(path("source"), path("copy")) - - yield* fs.writeFileString(path("source", "file.txt"), "changed file") - yield* fs.writeFileString(path("source", "nested", "child.txt"), "changed child") - - assert.strictEqual(yield* fs.readFileString(path("copy.txt")), "file") - assert.strictEqual(yield* fs.readFileString(path("copy", "file.txt")), "file") - assert.strictEqual(yield* fs.readFileString(path("copy", "nested", "child.txt")), "child") - })) - - it.effect("renames files and directories", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - yield* fs.makeDirectory(path("before", "nested"), { recursive: true }) - yield* fs.writeFileString(path("before", "nested", "file.txt"), "content") - - yield* fs.rename(path("before"), path("after")) - yield* fs.rename(path("after", "nested", "file.txt"), path("after", "nested", "renamed.txt")) - - assert.isFalse(yield* fs.exists(path("before"))) - assert.isFalse(yield* fs.exists(path("after", "nested", "file.txt"))) - assert.strictEqual(yield* fs.readFileString(path("after", "nested", "renamed.txt")), "content") - })) - - it.effect("truncates and extends files", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const file = path("truncate.txt") - yield* fs.writeFileString(file, "hello world") - - yield* fs.truncate(file, 5) - assert.strictEqual(yield* fs.readFileString(file), "hello") - - yield* fs.truncate(file, 8) - assert.deepStrictEqual(yield* fs.readFile(file), encoder.encode("hello\0\0\0")) - - yield* fs.truncate(file) - assert.deepStrictEqual(yield* fs.readFile(file), new Uint8Array(0)) - })) - - it.effect("normalizes stable filesystem errors", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const missingPath = path("missing.txt") - const missing = yield* Effect.flip(fs.readFile(missingPath)) - assertSystemError(missing, { tag: "NotFound", method: "readFile", pathOrDescriptor: missingPath }) - - const directory = path("existing") - yield* fs.makeDirectory(directory) - const existing = yield* Effect.flip(fs.makeDirectory(directory)) - assertSystemError(existing, { tag: "AlreadyExists", method: "makeDirectory", pathOrDescriptor: directory }) - - const file = path("file.txt") - yield* fs.writeFileString(file, "content") - const badResource = yield* Effect.flip(fs.readDirectory(file)) - assertSystemError(badResource, { tag: "BadResource", method: "readDirectory", pathOrDescriptor: file }) - })) - - it.effect("creates temporary resources and cleans up scoped resources", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const directory = yield* Effect.scoped( - Effect.gen(function*() { - const directory = yield* fs.makeTempDirectory() - assert.strictEqual((yield* fs.stat(directory)).type, "Directory") - return directory + describe("path operations", () => { + it.effect("should preserve binary bytes when reading and writing files", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("binary.dat") + const expected = new Uint8Array([0, 42, 128, 255]) + const input = expected.slice() + + yield* fs.writeFile(file, input) + input[1] = 0 + + const firstRead = yield* fs.readFile(file) + assert.deepStrictEqual(firstRead, expected) + firstRead[2] = 0 + assert.deepStrictEqual(yield* fs.readFile(file), expected) + })) + + it.effect("should preserve UTF-8 text when reading and writing files", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("utf8.txt") + const text = "héllo 👋" + + yield* fs.writeFileString(file, text) + + assert.strictEqual(yield* fs.readFileString(file), text) + })) + + it.effect("should list directory entries and metadata when creating nested directories", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + yield* fs.makeDirectory(path("src", "features"), { recursive: true }) + yield* fs.writeFileString(path("src", "index.ts"), "export {}") + yield* fs.writeFileString(path("src", "features", "chat.ts"), "export {}") + + assert.deepStrictEqual((yield* fs.readDirectory(path("src"))).sort(), ["features", "index.ts"]) + assert.strictEqual((yield* fs.stat(path("src"))).type, "Directory") + assert.strictEqual((yield* fs.stat(path("src", "index.ts"))).type, "File") + assert.strictEqual((yield* fs.stat(path("src", "index.ts"))).size, FileSystem.Size(9)) + })) + + it.effect("should include nested files when listing directories recursively", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + yield* fs.makeDirectory(path("src", "features"), { recursive: true }) + yield* fs.writeFileString(path("src", "index.ts"), "export {}") + yield* fs.writeFileString(path("src", "features", "chat.ts"), "export {}") + + const entries = (yield* fs.readDirectory(path("src"), { recursive: true })) + .map((path) => path.replaceAll("\\", "/")) + .sort() + + assert.isTrue(entries.includes("features/chat.ts")) + assert.isTrue(entries.includes("index.ts")) + })) + + it.effect("should require parent directories when creation is not recursive", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const directory = path("missing", "nested") + const file = path("missing", "file.txt") + + const directoryError = yield* Effect.flip(fs.makeDirectory(directory)) + assertSystemError(directoryError, { + tag: "NotFound", + method: "makeDirectory", + pathOrDescriptor: directory }) - ) - assert.strictEqual((yield* fs.stat(directory)).type, "Directory") - yield* fs.remove(directory, { recursive: true }) - const file = yield* fs.makeTempFile() - assert.strictEqual((yield* fs.stat(file)).type, "File") - yield* fs.remove(file) + const fileError = yield* Effect.flip(fs.writeFile(file, encoder.encode("content"))) + assertSystemError(fileError, { tag: "NotFound", method: "writeFile", pathOrDescriptor: file }) - const suffixedFile = yield* fs.makeTempFile({ directory: path(), prefix: "plain-", suffix: ".txt" }) - assert.strictEqual((yield* fs.stat(suffixedFile)).type, "File") - assert.isTrue(suffixedFile.endsWith(".txt")) + yield* fs.makeDirectory(directory, { recursive: true }) + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + })) - const scopedDirectory = yield* Effect.scoped( - Effect.gen(function*() { - const directory = yield* fs.makeTempDirectoryScoped({ directory: path(), prefix: "scoped-" }) - yield* fs.makeDirectory(`${directory}/nested`) - yield* fs.writeFileString(`${directory}/nested/file.txt`, "content") - assert.strictEqual((yield* fs.stat(directory)).type, "Directory") - return directory + it.effect("should keep recursive directory creation idempotent when the path is a directory", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const directory = path("nested", "directory") + const file = path("file.txt") + + yield* fs.makeDirectory(directory, { recursive: true }) + yield* fs.makeDirectory(directory, { recursive: true }) + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + + yield* fs.writeFileString(file, "content") + const error = yield* Effect.flip(fs.makeDirectory(file, { recursive: true })) + assertSystemError(error, { tag: "AlreadyExists", method: "makeDirectory", pathOrDescriptor: file }) + assert.strictEqual(yield* fs.readFileString(file), "content") + })) + + it.effect("should report existence when a path is created and removed", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("exists.txt") + + assert.isFalse(yield* fs.exists(file)) + yield* fs.writeFileString(file, "exists") + assert.isTrue(yield* fs.exists(file)) + yield* fs.remove(file) + assert.isFalse(yield* fs.exists(file)) + })) + + it.effect("should access existing paths and reject missing paths when checking access", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const existing = path("existing.txt") + const missing = path("missing.txt") + + yield* fs.writeFileString(existing, "content") + yield* fs.access(existing) + + const error = yield* Effect.flip(fs.access(missing)) + assertSystemError(error, { tag: "NotFound", method: "access", pathOrDescriptor: missing }) + })) + + it.effect("should remove non-empty directories when removal is recursive", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const directory = path("nested") + yield* fs.makeDirectory(path("nested", "child"), { recursive: true }) + yield* fs.writeFileString(path("nested", "child", "file.txt"), "content") + + yield* fs.remove(directory, { recursive: true }) + + assert.isFalse(yield* fs.exists(directory)) + })) + + it.effect("should reject non-empty directories when removal is not recursive", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const directory = path("non-empty") + const file = path("non-empty", "file.txt") + yield* fs.makeDirectory(directory) + yield* fs.writeFileString(file, "content") + + const error = yield* Effect.flip(fs.remove(directory)) + assertSystemError(error, { method: "remove", pathOrDescriptor: directory }) + + assert.strictEqual(yield* fs.readFileString(file), "content") + })) + + it.effect("should ignore missing paths only when removal is forced", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const missing = path("missing") + + const error = yield* Effect.flip(fs.remove(missing)) + assertSystemError(error, { tag: "NotFound", method: "remove", pathOrDescriptor: missing }) + + yield* fs.remove(missing, { force: true }) + })) + + it.effect("should apply truncating, appending, and exclusive flags when writing files", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("write-flags.txt") + yield* fs.writeFileString(file, "original") + + yield* fs.writeFileString(file, "replacement") + yield* fs.writeFileString(file, " appended", { flag: "a" }) + assert.strictEqual(yield* fs.readFileString(file), "replacement appended") + + const error = yield* Effect.flip(fs.writeFile(file, encoder.encode("exclusive"), { flag: "wx" })) + assertSystemError(error, { tag: "AlreadyExists", method: "writeFile", pathOrDescriptor: file }) + assert.strictEqual(yield* fs.readFileString(file), "replacement appended") + })) + + it.effect("should copy independent file and directory-tree contents when copying paths", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + yield* fs.makeDirectory(path("source", "nested"), { recursive: true }) + yield* fs.writeFileString(path("source", "file.txt"), "file") + yield* fs.writeFileString(path("source", "nested", "child.txt"), "child") + + yield* fs.copyFile(path("source", "file.txt"), path("copy.txt")) + yield* fs.copy(path("source"), path("copy")) + + yield* fs.writeFileString(path("source", "file.txt"), "changed file") + yield* fs.writeFileString(path("source", "nested", "child.txt"), "changed child") + + assert.strictEqual(yield* fs.readFileString(path("copy.txt")), "file") + assert.strictEqual(yield* fs.readFileString(path("copy", "file.txt")), "file") + assert.strictEqual(yield* fs.readFileString(path("copy", "nested", "child.txt")), "child") + })) + + it.effect("should replace an existing copy destination when overwrite is requested", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const source = path("source.txt") + const destination = path("copy.txt") + yield* fs.writeFileString(source, "source") + yield* fs.writeFileString(destination, "old copy") + + yield* fs.copy(source, destination, { overwrite: true }) + + assert.strictEqual(yield* fs.readFileString(destination), "source") + })) + + it.effect("should move files and directories when renaming paths", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + yield* fs.makeDirectory(path("before", "nested"), { recursive: true }) + yield* fs.writeFileString(path("before", "nested", "file.txt"), "content") + + yield* fs.rename(path("before"), path("after")) + yield* fs.rename(path("after", "nested", "file.txt"), path("after", "nested", "renamed.txt")) + + assert.isFalse(yield* fs.exists(path("before"))) + assert.isFalse(yield* fs.exists(path("after", "nested", "file.txt"))) + assert.strictEqual(yield* fs.readFileString(path("after", "nested", "renamed.txt")), "content") + })) + + it.effect("should truncate and zero-extend files when changing their size", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("truncate.txt") + yield* fs.writeFileString(file, "hello world") + + yield* fs.truncate(file, 5) + assert.strictEqual(yield* fs.readFileString(file), "hello") + + yield* fs.truncate(file, 8) + assert.deepStrictEqual(yield* fs.readFile(file), encoder.encode("hello\0\0\0")) + + yield* fs.truncate(file) + assert.deepStrictEqual(yield* fs.readFile(file), new Uint8Array(0)) + })) + + it.effect("should normalize stable filesystem errors when path operations fail", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const missingPath = path("missing.txt") + const missing = yield* Effect.flip(fs.readFile(missingPath)) + assertSystemError(missing, { tag: "NotFound", method: "readFile", pathOrDescriptor: missingPath }) + + const directory = path("existing") + yield* fs.makeDirectory(directory) + const existing = yield* Effect.flip(fs.makeDirectory(directory)) + assertSystemError(existing, { tag: "AlreadyExists", method: "makeDirectory", pathOrDescriptor: directory }) + + const file = path("file.txt") + yield* fs.writeFileString(file, "content") + const badResource = yield* Effect.flip(fs.readDirectory(file)) + assertSystemError(badResource, { tag: "BadResource", method: "readDirectory", pathOrDescriptor: file }) + })) + }) + + describe("temporary resources", () => { + it.effect("should clean up scoped temporary resources when their scope closes", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const directory = yield* Effect.scoped( + Effect.gen(function*() { + const directory = yield* fs.makeTempDirectory({ directory: path() }) + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + return directory + }) + ) + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + yield* fs.remove(directory, { recursive: true }) + + const file = yield* fs.makeTempFile({ directory: path() }) + assert.strictEqual((yield* fs.stat(file)).type, "File") + yield* fs.remove(file) + + const suffixedFile = yield* fs.makeTempFile({ directory: path(), prefix: "plain-", suffix: ".txt" }) + assert.strictEqual((yield* fs.stat(suffixedFile)).type, "File") + assert.isTrue(suffixedFile.endsWith(".txt")) + + const scopedDirectory = yield* Effect.scoped( + Effect.gen(function*() { + const directory = yield* fs.makeTempDirectoryScoped({ directory: path(), prefix: "scoped-" }) + yield* fs.makeDirectory(`${directory}/nested`) + yield* fs.writeFileString(`${directory}/nested/file.txt`, "content") + assert.strictEqual((yield* fs.stat(directory)).type, "Directory") + return directory + }) + ) + const scopedDirectoryError = yield* Effect.flip(fs.stat(scopedDirectory)) + assertSystemError(scopedDirectoryError, { + tag: "NotFound", + method: "stat", + pathOrDescriptor: scopedDirectory }) - ) - const scopedDirectoryError = yield* Effect.flip(fs.stat(scopedDirectory)) - assertSystemError(scopedDirectoryError, { - tag: "NotFound", - method: "stat", - pathOrDescriptor: scopedDirectory - }) - - const scopedFile = yield* Effect.scoped( - Effect.gen(function*() { - const file = yield* fs.makeTempFileScoped({ - directory: path(), - prefix: "scoped-", - suffix: ".txt" + + const scopedFile = yield* Effect.scoped( + Effect.gen(function*() { + const file = yield* fs.makeTempFileScoped({ + directory: path(), + prefix: "scoped-", + suffix: ".txt" + }) + assert.strictEqual((yield* fs.stat(file)).type, "File") + assert.isTrue(file.endsWith(".txt")) + return file }) - assert.strictEqual((yield* fs.stat(file)).type, "File") - assert.isTrue(file.endsWith(".txt")) - return file + ) + const scopedFileError = yield* Effect.flip(fs.stat(scopedFile)) + assertSystemError(scopedFileError, { tag: "NotFound", method: "stat", pathOrDescriptor: scopedFile }) + })) + }) + + describe("streaming", () => { + it.effect("should stream bounded file ranges when offsets and chunk sizes are configured", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("stream.txt") + yield* fs.writeFileString(file, "0123456789") + + const defaultChunks = yield* fs.stream(file, { + offset: 6, + bytesToRead: 3 + }).pipe(Stream.runCollect) + const chunks = yield* fs.stream(file, { + offset: 2, + bytesToRead: 5, + chunkSize: 2 + }).pipe(Stream.runCollect) + + assert.strictEqual(defaultChunks.map((d) => decoder.decode(d)).join(""), "678") + assert.isTrue(chunks.every((chunk) => chunk.length > 0 && chunk.length <= 2)) + assert.strictEqual(chunks.map((d) => decoder.decode(d)).join(""), "23456") + })) + + it.effect("should write stream chunks when using a file sink", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("sink.txt") + yield* fs.writeFileString(file, "old content") + + yield* Stream.run( + Stream.make(encoder.encode("new "), encoder.encode("content")), + fs.sink(file) + ) + + assert.strictEqual(yield* fs.readFileString(file), "new content") + })) + + it.effect("should append stream chunks when a file sink uses the append flag", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("append-sink.txt") + yield* fs.writeFileString(file, "seed") + + yield* Stream.run( + Stream.make(encoder.encode(" appended")), + fs.sink(file, { flag: "a" }) + ) + + assert.strictEqual(yield* fs.readFileString(file), "seed appended") + })) + }) + + describe("file handles and open modes", () => { + it.effect("should keep read cursors independent when a file has multiple handles", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("cursors.txt") + yield* fs.writeFileString(file, "0123456789") + + const first = yield* fs.open(file) + const second = yield* fs.open(file) + + const buffer = new Uint8Array(2) + assert.strictEqual(yield* fillBuffer(first, buffer), FileSystem.Size(2)) + assert.strictEqual(decoder.decode(buffer), "01") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(second, 2)), "01") + yield* first.seek(3, "current") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(first, 2)), "56") + yield* first.seek(0, "start") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(first, 2)), "01") + })) + + it.effect("should advance and seek the write cursor when writing through a file handle", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("write-cursor.txt") + const file = yield* fs.open(filePath, { flag: "w+" }) + + yield* writeUsingWrite(file, encoder.encode("abc")) + yield* writeUsingWrite(file, encoder.encode("def")) + assert.strictEqual(yield* fs.readFileString(filePath), "abcdef") + + yield* file.seek(-2, "current") + yield* writeUsingWrite(file, encoder.encode("WXYZ")) + assert.strictEqual(yield* fs.readFileString(filePath), "abcdWXYZ") + + yield* file.seek(2, "start") + yield* writeUsingWrite(file, encoder.encode("!!")) + yield* file.sync + + assert.strictEqual((yield* file.stat).size, FileSystem.Size(8)) + assert.strictEqual(yield* fs.readFileString(filePath), "ab!!WXYZ") + })) + + it.effect("should append primitive writes when a handle uses the append flag", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("append-write.txt") + yield* fs.writeFileString(filePath, "seed") + const file = yield* fs.open(filePath, { flag: "a+" }) + + yield* file.seek(0, "start") + yield* writeUsingWrite(file, encoder.encode("xy")) + + assert.strictEqual(yield* fs.readFileString(filePath), "seedxy") + })) + + it.effect("should truncate and extend files when using a file handle", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("handle-truncate.txt") + yield* fs.writeFileString(filePath, "abcdef") + const file = yield* fs.open(filePath, { flag: "r+" }) + + yield* file.truncate(3) + assert.strictEqual((yield* file.stat).size, FileSystem.Size(3)) + assert.strictEqual(yield* fs.readFileString(filePath), "abc") + + yield* file.truncate(5) + assert.strictEqual((yield* file.stat).size, FileSystem.Size(5)) + assert.deepStrictEqual(yield* fs.readFile(filePath), encoder.encode("abc\0\0")) + })) + + it.effect("should return partial reads and EOF when reading beyond file contents", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("eof.txt") + yield* fs.writeFileString(filePath, "abc") + const file = yield* fs.open(filePath) + + const contents = yield* readAllocUpTo(file, 10) + assert.strictEqual(decoder.decode(contents), "abc") + assert.isTrue(Option.isNone(yield* file.readAlloc(1))) + })) + + // TODO: Decide whether closed-handle operations normalize OS-specific EBADF as BadResource. Node reports Unknown. + it.effect("should reject operations when a file-handle scope closes", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("closed.txt") + yield* fs.writeFileString(filePath, "content") + + const file = yield* Effect.scoped(fs.open(filePath, { flag: "r+" })) + + const operations = [ + ["stat", file.stat], + ["sync", file.sync], + ["read", file.read(new Uint8Array(1))], + ["readAlloc", file.readAlloc(1)], + ["truncate", file.truncate(0)], + ["write", file.write(new Uint8Array([1]))], + ["writeAll", file.writeAll(new Uint8Array([1]))] + ] as const + + for (const [method, operation] of operations) { + const error = yield* Effect.flip(operation) + assertSystemError(error, { method, pathOrDescriptor: file.fd }) + } + })) + + const openFlags = [ + { flag: "r", access: "read", existing: "seed", missing: "rejects", expectedContent: "seed" }, + { flag: "r+", access: "read-write", existing: "seed", missing: "rejects", expectedContent: "xeed" }, + { flag: "w", access: "write", existing: "", missing: "creates", expectedContent: "x" }, + { flag: "wx", access: "write", existing: "rejects", missing: "creates", expectedContent: "x" }, + { flag: "w+", access: "read-write", existing: "", missing: "creates", expectedContent: "x" }, + { flag: "wx+", access: "read-write", existing: "rejects", missing: "creates", expectedContent: "x" }, + { flag: "a", access: "write", existing: "seed", missing: "creates", expectedContent: "seedx" }, + { flag: "ax", access: "write", existing: "rejects", missing: "creates", expectedContent: "x" }, + { flag: "a+", access: "read-write", existing: "seed", missing: "creates", expectedContent: "seedx" }, + { flag: "ax+", access: "read-write", existing: "rejects", missing: "creates", expectedContent: "x" } + ] as const + + it.effect.each(openFlags)( + "supports the $flag open flag", + ({ access, existing: existingBehavior, expectedContent, flag, missing }) => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const existing = path("existing.txt") + const created = path("created.txt") + yield* fs.writeFileString(existing, "seed") + + if (existingBehavior === "rejects") { + const error = yield* Effect.flip(fs.open(existing, { flag })) + assertSystemError(error, { tag: "AlreadyExists", method: "open", pathOrDescriptor: existing }) + assert.strictEqual(yield* fs.readFileString(existing), "seed") + } + + if (missing === "rejects") { + const error = yield* Effect.flip(fs.open(created, { flag })) + assertSystemError(error, { tag: "NotFound", method: "open", pathOrDescriptor: created }) + } + + const filePath = existingBehavior === "rejects" ? created : existing + const file = yield* fs.open(filePath, { flag }) + + assert.strictEqual( + yield* fs.readFileString(filePath), + existingBehavior === "rejects" ? "" : existingBehavior + ) + + if (access !== "read") { + yield* file.writeAll(encoder.encode("x")) + } else { + const error = yield* Effect.flip(file.writeAll(encoder.encode("x"))) + assertSystemError(error, { method: "writeAll", pathOrDescriptor: file.fd }) + } + + assert.strictEqual(yield* fs.readFileString(filePath), expectedContent) + + if (access !== "write") { + yield* file.seek(0, "start") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(file, 1)), expectedContent.slice(0, 1)) + } else { + const error = yield* Effect.flip(file.readAlloc(1)) + assertSystemError(error, { method: "readAlloc", pathOrDescriptor: file.fd }) + } + + if (existingBehavior !== "rejects" && missing === "creates") { + yield* fs.open(created, { flag }) + assert.isTrue(yield* fs.exists(created)) + } }) - ) - const scopedFileError = yield* Effect.flip(fs.stat(scopedFile)) - assertSystemError(scopedFileError, { tag: "NotFound", method: "stat", pathOrDescriptor: scopedFile }) - })) - - it.effect("streams bounded ranges with default and configured chunks", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const file = path("stream.txt") - yield* fs.writeFileString(file, "0123456789") - - const defaultChunks = yield* fs.stream(file, { - offset: 6, - bytesToRead: 3 - }).pipe(Stream.runCollect) - const chunks = yield* fs.stream(file, { - offset: 2, - bytesToRead: 5, - chunkSize: 2 - }).pipe(Stream.runCollect) - - assert.strictEqual(defaultChunks.map(decode).join(""), "678") - assert.isTrue(chunks.every((chunk) => chunk.length > 0 && chunk.length <= 2)) - assert.strictEqual(chunks.map(decode).join(""), "23456") - })) - - it.effect("writes stream chunks through a file sink", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const file = path("sink.txt") - yield* fs.writeFileString(file, "old content") - - yield* Stream.run( - Stream.make(encoder.encode("new "), encoder.encode("content")), - fs.sink(file) - ) - - assert.strictEqual(yield* fs.readFileString(file), "new content") - })) - - it.effect("tracks independent read cursors", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const file = path("cursors.txt") - yield* fs.writeFileString(file, "0123456789") - - const first = yield* fs.open(file) - const second = yield* fs.open(file) - - const buffer = new Uint8Array(2) - assert.strictEqual(yield* fillBuffer(first, buffer), FileSystem.Size(2)) - assert.strictEqual(decode(buffer), "01") - assert.strictEqual(decode(yield* readAllocUpTo(second, 2)), "01") - yield* first.seek(3, "current") - assert.strictEqual(decode(yield* readAllocUpTo(first, 2)), "56") - yield* first.seek(0, "start") - assert.strictEqual(decode(yield* readAllocUpTo(first, 2)), "01") - })) - - it.effect("tracks a write cursor", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const filePath = path("write-cursor.txt") - const file = yield* fs.open(filePath, { flag: "w+" }) - - yield* writeUsingWrite(file, encoder.encode("abc")) - yield* writeUsingWrite(file, encoder.encode("def")) - assert.strictEqual(yield* fs.readFileString(filePath), "abcdef") - - yield* file.seek(-2, "current") - yield* writeUsingWrite(file, encoder.encode("WXYZ")) - assert.strictEqual(yield* fs.readFileString(filePath), "abcdWXYZ") - - yield* file.seek(2, "start") - yield* writeUsingWrite(file, encoder.encode("!!")) - yield* file.sync - - assert.strictEqual((yield* file.stat).size, FileSystem.Size(8)) - assert.strictEqual(yield* fs.readFileString(filePath), "ab!!WXYZ") - })) - - it.effect("appends primitive writes regardless of the cursor", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const filePath = path("append-write.txt") - yield* fs.writeFileString(filePath, "seed") - const file = yield* fs.open(filePath, { flag: "a+" }) - - yield* file.seek(0, "start") - yield* writeUsingWrite(file, encoder.encode("xy")) - - assert.strictEqual(yield* fs.readFileString(filePath), "seedxy") - })) - - it.effect("returns partial reads and EOF", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const filePath = path("eof.txt") - yield* fs.writeFileString(filePath, "abc") - const file = yield* fs.open(filePath) - - const contents = yield* readAllocUpTo(file, 10) - assert.strictEqual(decode(contents), "abc") - assert.isTrue(Option.isNone(yield* file.readAlloc(1))) - })) - - it.effect("rejects operations after a file-handle scope closes", () => - Effect.gen(function*() { - const { fs, path } = yield* makeTestContext - const filePath = path("closed.txt") - yield* fs.writeFileString(filePath, "content") - - const file = yield* Effect.scoped(fs.open(filePath)) - const error = yield* Effect.flip(file.readAlloc(1)) - - assertSystemError(error, { method: "readAlloc", pathOrDescriptor: file.fd }) - })) - - const openFlags = [ - ["r", true, false, false, false], - ["r+", true, true, false, false], - ["w", false, true, true, false], - ["wx", false, true, true, true], - ["w+", true, true, true, false], - ["wx+", true, true, true, true], - ["a", false, true, false, false], - ["ax", false, true, false, true], - ["a+", true, true, false, false], - ["ax+", true, true, false, true] - ] as const - - it.effect.each(openFlags)( - "supports the %s open flag", - ([flag, readable, writable, truncates, exclusive]) => + ) + + it.effect("should reject traversal through a file when a child path targets it", () => Effect.gen(function*() { const { fs, path } = yield* makeTestContext - const existing = path("existing.txt") - const created = path("created.txt") - yield* fs.writeFileString(existing, "seed") + const parent = path("file.txt") + const child = path("file.txt", "child.txt") + yield* fs.writeFileString(parent, "content") + + const writeError = yield* Effect.flip(fs.writeFileString(child, "child")) + assertSystemError(writeError, { tag: "BadResource", method: "writeFile", pathOrDescriptor: child }) + + const statError = yield* Effect.flip(fs.stat(child)) + assertSystemError(statError, { tag: "BadResource", method: "stat", pathOrDescriptor: child }) + assert.strictEqual(yield* fs.readFileString(parent), "content") + })) + + it.effect("should apply open flags consistently when high-level writes target existing and missing paths", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const cases = [ + { flag: "r", existing: "rejects", missing: "rejects" }, + { flag: "r+", existing: "xeed", missing: "rejects" }, + { flag: "w", existing: "x", missing: "x" }, + { flag: "wx", existing: "rejects", missing: "x" }, + { flag: "w+", existing: "x", missing: "x" }, + { flag: "wx+", existing: "rejects", missing: "x" }, + { flag: "a", existing: "seedx", missing: "x" }, + { flag: "ax", existing: "rejects", missing: "x" }, + { flag: "a+", existing: "seedx", missing: "x" }, + { flag: "ax+", existing: "rejects", missing: "x" } + ] as const + + for (const { existing: expectedExisting, flag, missing: expectedMissing } of cases) { + const existing = path(`${flag.replace("+", "plus")}-existing.txt`) + const missing = path(`${flag.replace("+", "plus")}-missing.txt`) + yield* fs.writeFileString(existing, "seed") + + const existingResult = yield* Effect.result(fs.writeFileString(existing, "x", { flag })) + if (expectedExisting === "rejects") { + assert.isTrue(Result.isFailure(existingResult)) + if (Result.isFailure(existingResult)) { + assertSystemError(existingResult.failure, { method: "writeFile", pathOrDescriptor: existing }) + } + assert.strictEqual(yield* fs.readFileString(existing), "seed") + } else { + assert.isTrue(Result.isSuccess(existingResult)) + assert.strictEqual(yield* fs.readFileString(existing), expectedExisting) + } + + const missingResult = yield* Effect.result(fs.writeFileString(missing, "x", { flag })) + if (expectedMissing === "rejects") { + assert.isTrue(Result.isFailure(missingResult)) + if (Result.isFailure(missingResult)) { + assertSystemError(missingResult.failure, { + tag: "NotFound", + method: "writeFile", + pathOrDescriptor: missing + }) + } + } else { + assert.isTrue(Result.isSuccess(missingResult)) + assert.strictEqual(yield* fs.readFileString(missing), expectedMissing) + } + } + })) + }) + + describe("open file state", () => { + it.effect("should zero-fill gaps when sparse writes extend a file", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("sparse.bin") + const file = yield* fs.open(filePath, { flag: "w+" }) + + yield* file.seek(4, "start") + yield* file.writeAll(new Uint8Array([42, 255])) + + assert.deepStrictEqual(yield* fs.readFile(filePath), new Uint8Array([0, 0, 0, 0, 42, 255])) + })) + + it.effect("should keep open handles attached when their file is renamed", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const before = path("before.txt") + const after = path("after.txt") + yield* fs.writeFileString(before, "content") + const file = yield* fs.open(before, { flag: "r+" }) + + yield* fs.rename(before, after) + yield* file.seek(0, "start") + yield* file.writeAll(encoder.encode("renamed")) + + assert.isFalse(yield* fs.exists(before)) + assert.strictEqual(yield* fs.readFileString(after), "renamed") + })) + + it.effect("should preserve both open handles when renaming over an existing destination", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const source = path("rename-source.txt") + const destination = path("rename-destination.txt") + yield* fs.writeFileString(source, "source") + yield* fs.writeFileString(destination, "destination") + const sourceHandle = yield* fs.open(source) + const destinationHandle = yield* fs.open(destination) + + yield* fs.rename(source, destination) + + assert.isFalse(yield* fs.exists(source)) + assert.strictEqual(yield* fs.readFileString(destination), "source") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(sourceHandle, 6)), "source") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(destinationHandle, 11)), "destination") + })) + + it.effect("should keep unlinked contents accessible when a file handle remains open", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("unlinked.txt") + yield* fs.writeFileString(filePath, "content") + const file = yield* fs.open(filePath, { flag: "r+" }) + + yield* fs.remove(filePath) + assert.isFalse(yield* fs.exists(filePath)) + assert.strictEqual(decoder.decode(yield* readAllocUpTo(file, 7)), "content") + assert.strictEqual((yield* file.stat).size, FileSystem.Size(7)) + })) + + it.effect("should preserve the read cursor when appending through a handle", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const filePath = path("append-cursor.txt") + const file = yield* fs.open(filePath, { flag: "a+" }) + + yield* file.writeAll(encoder.encode("foo")) + yield* file.seek(0, "start") + yield* file.writeAll(encoder.encode("bar")) + assert.strictEqual(decoder.decode(yield* readAllocUpTo(file, 3)), "foo") + + yield* file.writeAll(encoder.encode("baz")) + assert.strictEqual(decoder.decode(yield* readAllocUpTo(file, 6)), "barbaz") + assert.strictEqual(yield* fs.readFileString(filePath), "foobarbaz") + })) + + // TODO: Decide whether File.seek returns the resulting cursor, then change its public return type from void to Size. + // it.effect("should return the resulting cursor when seeking", () => {}) + + // TODO: Decide whether File.truncate clamps the calling handle's cursor. + // it.effect("should clamp a file-handle cursor when truncation shortens the file", () => {}) + }) + + describe("copy and rename", () => { + it.effect("should preserve existing destinations when source-based mutations fail", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const missing = path("missing") + const copyDestination = path("copy-destination.txt") + const renameDestination = path("rename-destination.txt") + yield* fs.writeFileString(copyDestination, "copy destination") + yield* fs.writeFileString(renameDestination, "rename destination") + + const copyError = yield* Effect.flip(fs.copy(missing, copyDestination, { overwrite: true })) + assertSystemError(copyError, { tag: "NotFound", method: "copy", pathOrDescriptor: missing }) + const renameError = yield* Effect.flip(fs.rename(missing, renameDestination)) + assertSystemError(renameError, { tag: "NotFound", method: "rename", pathOrDescriptor: missing }) + + assert.strictEqual(yield* fs.readFileString(copyDestination), "copy destination") + assert.strictEqual(yield* fs.readFileString(renameDestination), "rename destination") + })) + + it.effect("should replace existing file destinations when copying or renaming files", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const copySource = path("copy-source.txt") + const copyDestination = path("copy-destination.txt") + const renameSource = path("rename-source.txt") + const renameDestination = path("rename-destination.txt") + yield* fs.writeFileString(copySource, "copied") + yield* fs.writeFileString(copyDestination, "old copy") + yield* fs.writeFileString(renameSource, "renamed") + yield* fs.writeFileString(renameDestination, "old rename") + + yield* fs.copyFile(copySource, copyDestination) + yield* fs.rename(renameSource, renameDestination) + + assert.strictEqual(yield* fs.readFileString(copySource), "copied") + assert.strictEqual(yield* fs.readFileString(copyDestination), "copied") + assert.isFalse(yield* fs.exists(renameSource)) + assert.strictEqual(yield* fs.readFileString(renameDestination), "renamed") + })) + + it.effect("should preserve existing destination links and handles when copying file contents", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const source = path("copy-source.txt") + const destination = path("copy-destination.txt") + const alias = path("copy-destination-alias.txt") + yield* fs.writeFileString(source, "source") + yield* fs.writeFileString(destination, "destination") + yield* fs.link(destination, alias) + const destinationHandle = yield* fs.open(destination) + + yield* fs.copyFile(source, destination) + + assert.strictEqual(yield* fs.readFileString(destination), "source") + assert.strictEqual(yield* fs.readFileString(alias), "source") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(destinationHandle, 6)), "source") + })) + + // TODO: Define whether overwrite: false succeeds without changing an existing destination or fails with AlreadyExists. + it.effect("should preserve an existing copy destination when overwrite is false", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const source = path("copy-source.txt") + const destination = path("copy-existing.txt") + yield* fs.writeFileString(source, "source") + yield* fs.writeFileString(destination, "destination") + + const result = yield* Effect.result(fs.copy(source, destination, { overwrite: false })) + if (Result.isFailure(result)) { + assertSystemError(result.failure, { + tag: "AlreadyExists", + method: "copy", + pathOrDescriptor: source + }) + } + + assert.strictEqual(yield* fs.readFileString(source), "source") + assert.strictEqual(yield* fs.readFileString(destination), "destination") + })) + }) + + describe("temporary resource lifecycle", () => { + it.effect("should create uniquely named temporary resources when prefixes and suffixes are requested", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const firstDirectory = yield* fs.makeTempDirectory({ directory: path(), prefix: "directory-" }) + const secondDirectory = yield* fs.makeTempDirectory({ directory: path(), prefix: "directory-" }) + const firstFile = yield* fs.makeTempFile({ directory: path(), prefix: "file-", suffix: ".txt" }) + const secondFile = yield* fs.makeTempFile({ directory: path(), prefix: "file-", suffix: ".txt" }) + + assert.notStrictEqual(firstDirectory, secondDirectory) + assert.notStrictEqual(firstFile, secondFile) + assert.isTrue(firstDirectory.startsWith(path("directory-"))) + assert.isTrue(secondDirectory.startsWith(path("directory-"))) + assert.isTrue(firstFile.startsWith(path("file-")) && firstFile.endsWith(".txt")) + assert.isTrue(secondFile.startsWith(path("file-")) && secondFile.endsWith(".txt")) + })) + + it.effect("should clean up scoped temporary resources when their scope fails", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const result = yield* Effect.scoped( + Effect.gen(function*() { + const directory = yield* fs.makeTempDirectoryScoped({ directory: path(), prefix: "failed-" }) + yield* fs.writeFileString(`${directory}/file.txt`, "content") + return yield* Effect.fail({ _tag: "ExpectedScopeFailure" as const, directory }) + }) + ).pipe(Effect.result) + if (Result.isSuccess(result)) { + return assert.fail("Expected the scoped operation to fail") + } + if (result.failure._tag === "PlatformError") { + return yield* Effect.fail(result.failure) + } + const directory = result.failure.directory + + const error = yield* Effect.flip(fs.stat(directory)) + assertSystemError(error, { tag: "NotFound", method: "stat", pathOrDescriptor: directory }) + })) + }) - if (exclusive) { - const error = yield* Effect.flip(fs.open(existing, { flag })) - assertSystemError(error, { tag: "AlreadyExists", method: "open", pathOrDescriptor: existing }) - assert.strictEqual(yield* fs.readFileString(existing), "seed") + describe("stream lifecycle", () => { + it.effect("should stream no chunks when files are empty or offsets are beyond EOF", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const populated = path("populated.txt") + const empty = path("empty.txt") + yield* fs.writeFileString(populated, "content") + yield* fs.writeFile(empty, new Uint8Array(0)) + + const whole = yield* fs.stream(populated, { chunkSize: 2 }).pipe(Stream.runCollect) + const emptyChunks = yield* fs.stream(empty).pipe(Stream.runCollect) + const pastEnd = yield* fs.stream(populated, { offset: 20 }).pipe(Stream.runCollect) + + assert.strictEqual(whole.map((d) => decoder.decode(d)).join(""), "content") + assert.strictEqual(emptyChunks.length, 0) + assert.strictEqual(pastEnd.length, 0) + })) + + it.effect("should preserve the original path when opening a file stream fails", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const missing = path("missing-stream.txt") + + const error = yield* fs.stream(missing).pipe(Stream.runDrain, Effect.flip) + + assertSystemError(error, { tag: "NotFound", method: "open", pathOrDescriptor: missing }) + })) + + // TODO: Add a deterministic synchronization point before covering stream and sink finalization after interruption. + it.effect("should finalize derived stream and sink handles when their operations succeed or fail", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const streamed = path("finalized-stream.txt") + const sunk = path("finalized-sink.txt") + const failed = path("failed-sink.txt") + const finalized = yield* Ref.make>([]) + const trackedFs = FileSystem.make({ + ...fs, + open: (path, options) => + Effect.acquireRelease( + fs.open(path, options), + () => Ref.update(finalized, (paths) => [...paths, path]) + ) + }) + yield* fs.writeFileString(streamed, "content") + + yield* trackedFs.stream(streamed).pipe(Stream.runDrain) + yield* Stream.run(Stream.make(encoder.encode("content")), trackedFs.sink(sunk)) + const failedResult = yield* Stream.make(encoder.encode("content")).pipe( + Stream.concat(Stream.fail("expected failure")), + Stream.run(trackedFs.sink(failed)), + Effect.result + ) + + assert.isTrue(Result.isFailure(failedResult)) + if (Result.isFailure(failedResult)) { + assert.strictEqual(failedResult.failure, "expected failure") } + assert.deepStrictEqual(yield* Ref.get(finalized), [streamed, sunk, failed]) + })) + }) + + describe("adapter capabilities", () => { + // TODO: Define the public working-directory contract. Decide separately whether the memory adapter defaults to virtual `/`. + // it.effect("should resolve relative paths when the adapter working directory is defined", () => {}) - if (flag.startsWith("r")) { - const error = yield* Effect.flip(fs.open(created, { flag })) - assertSystemError(error, { tag: "NotFound", method: "open", pathOrDescriptor: created }) + it.effect("should normalize missing-path errors when primitive path operations fail", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const missing = path("missing-primitive") + const destination = path("destination") + const operations = [ + ["chmod", fs.chmod(missing, 0o600)], + ["chown", fs.chown(missing, 0, 0)], + ["copy", fs.copy(missing, destination)], + ["copyFile", fs.copyFile(missing, destination)], + ["link", fs.link(missing, destination)], + ["readDirectory", fs.readDirectory(missing)], + ["readLink", fs.readLink(missing)], + ["realPath", fs.realPath(missing)], + ["rename", fs.rename(missing, destination)], + ["stat", fs.stat(missing)], + ["truncate", fs.truncate(missing)], + // TODO: Decide whether FileSystem.utimes failures report `utimes` or the host's `utime`. Node reports `utime`. + [undefined, fs.utimes(missing, 0, 0)] + ] as const + + for (const [method, operation] of operations) { + const error = yield* Effect.flip(operation) + assertSystemError(error, { tag: "NotFound", method, pathOrDescriptor: missing }) } + })) - const filePath = exclusive ? created : existing - const file = yield* fs.open(filePath, { flag }) + // TODO: Define what ok, readable, and writable mean without user identity or permission state. + it.effect("should check readable and writable access when no virtual identity is available", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const existing = path("accessible.txt") + const missing = path("inaccessible.txt") + yield* fs.writeFileString(existing, "content") - if (exclusive) { - assert.isTrue(yield* fs.exists(filePath)) + yield* fs.access(existing, { ok: true, readable: true, writable: true }) + const error = yield* Effect.flip(fs.access(missing, { ok: true, readable: true, writable: true })) + + assertSystemError(error, { tag: "NotFound", method: "access", pathOrDescriptor: missing }) + })) + + it.effect("should share contents and metadata when creating hard links", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const source = path("hard-link-source.txt") + const linked = path("hard-link.txt") + yield* fs.writeFileString(source, "source") + + yield* fs.link(source, linked) + yield* fs.writeFileString(linked, "linked") + + assert.strictEqual(yield* fs.readFileString(source), "linked") + const sourceInfo = yield* fs.stat(source) + const linkedInfo = yield* fs.stat(linked) + if (Option.isSome(sourceInfo.ino) && Option.isSome(linkedInfo.ino)) { + assert.strictEqual(sourceInfo.ino.value, linkedInfo.ino.value) + } + if (Option.isSome(sourceInfo.nlink)) { + assert.strictEqual(sourceInfo.nlink.value, 2) } - if (truncates) { - assert.strictEqual(yield* fs.readFileString(filePath), "") - } else if (!exclusive && flag.startsWith("a")) { - assert.strictEqual(yield* fs.readFileString(filePath), "seed") + if (Option.isSome(linkedInfo.nlink)) { + assert.strictEqual(linkedInfo.nlink.value, 2) } + })) - if (writable) { - yield* file.writeAll(encoder.encode("x")) - } else { - const error = yield* Effect.flip(file.writeAll(encoder.encode("x"))) - assert.strictEqual(error._tag, "PlatformError") + it.effect("should preserve contents through remaining links and handles when hard links are removed", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const source = path("hard-link-lifecycle-source.txt") + const alias = path("hard-link-lifecycle-alias.txt") + yield* fs.writeFileString(source, "original") + yield* fs.link(source, alias) + const handle = yield* fs.open(source, { flag: "r+" }) + + yield* fs.remove(source) + + assert.isFalse(yield* fs.exists(source)) + assert.strictEqual(yield* fs.readFileString(alias), "original") + const linkedInfo = yield* fs.stat(alias) + if (Option.isSome(linkedInfo.nlink)) { + assert.strictEqual(linkedInfo.nlink.value, 1) } - const expectedContent = writable - ? flag.startsWith("a") ? `${exclusive ? "" : "seed"}x` : flag.startsWith("r") ? "xeed" : "x" - : "seed" - assert.strictEqual(yield* fs.readFileString(filePath), expectedContent) - - if (readable) { - yield* file.seek(0, "start") - assert.strictEqual(decode(yield* readAllocUpTo(file, 1)), expectedContent.slice(0, 1)) - } else { - const error = yield* Effect.flip(file.readAlloc(1)) - assert.strictEqual(error._tag, "PlatformError") + yield* fs.remove(alias) + yield* fs.writeFileString(source, "replacement") + yield* handle.seek(0, "start") + yield* handle.writeAll(encoder.encode("retained")) + + assert.strictEqual(yield* fs.readFileString(source), "replacement") + yield* handle.seek(0, "start") + assert.strictEqual(decoder.decode(yield* readAllocUpTo(handle, 8)), "retained") + })) + + it.effect("should resolve symbolic links when targets are relative or absolute", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const target = path("target.txt") + const relativeLink = path("relative-link.txt") + const absoluteLink = path("absolute-link.txt") + yield* fs.writeFileString(target, "content") + + yield* fs.symlink("target.txt", relativeLink) + yield* fs.symlink(target, absoluteLink) + + assert.strictEqual(yield* fs.readLink(relativeLink), "target.txt") + assert.strictEqual(yield* fs.readLink(absoluteLink), target) + assert.strictEqual(yield* fs.realPath(relativeLink), yield* fs.realPath(target)) + assert.strictEqual(yield* fs.realPath(absoluteLink), yield* fs.realPath(target)) + assert.strictEqual(yield* fs.readFileString(relativeLink), "content") + assert.strictEqual(yield* fs.readFileString(absoluteLink), "content") + })) + + it.effect("should preserve error context when symbolic links form a loop", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const first = path("first-link") + const second = path("second-link") + yield* fs.symlink("second-link", first) + yield* fs.symlink("first-link", second) + + const error = yield* Effect.flip(fs.realPath(first)) + + assertSystemError(error, { method: "realPath", pathOrDescriptor: first }) + })) + + // TODO: Add positive chmod and chown conformance in a POSIX-only metadata capability suite. + // it.effect("should change file permissions and ownership when POSIX metadata is supported", () => {}) + + it.effect("should report updated access and modification timestamps when metadata is available", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const file = path("times.txt") + const atime = DateTime.toDateUtc(DateTime.makeUnsafe("2001-02-03T04:05:06.000Z")) + const mtime = DateTime.toDateUtc(DateTime.makeUnsafe("2002-03-04T05:06:07.000Z")) + yield* fs.writeFileString(file, "content") + + yield* fs.utimes(file, atime, mtime) + const info = yield* fs.stat(file) + + if (Option.isSome(info.atime)) { + assert.strictEqual(info.atime.value.getTime(), atime.getTime()) } + if (Option.isSome(info.mtime)) { + assert.strictEqual(info.mtime.value.getTime(), mtime.getTime()) + } + })) - if (!exclusive && !flag.startsWith("r")) { - yield* fs.open(created, { flag }) - assert.isTrue(yield* fs.exists(created)) + // TODO: Define whether preserveTimestamps guarantees only mtime or both atime and mtime. Node preserves mtime but resets atime. + it.effect("should preserve the modification timestamp when copying with metadata preservation", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const source = path("timestamp-source.txt") + const destination = path("timestamp-copy.txt") + const atime = DateTime.toDateUtc(DateTime.makeUnsafe("2001-02-03T04:05:06.000Z")) + const mtime = DateTime.toDateUtc(DateTime.makeUnsafe("2002-03-04T05:06:07.000Z")) + yield* fs.writeFileString(source, "content") + yield* fs.utimes(source, atime, mtime) + + yield* fs.copy(source, destination, { preserveTimestamps: true }) + const info = yield* fs.stat(destination) + + if (Option.isSome(info.mtime)) { + assert.strictEqual(info.mtime.value.getTime(), mtime.getTime()) } - }) - ) + })) + + it.effect("should exclude matching entries when globbing from a root", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const root = path("glob") + yield* fs.makeDirectory(`${root}/src`, { recursive: true }) + yield* fs.writeFileString(`${root}/src/index.ts`, "export {}") + yield* fs.writeFileString(`${root}/src/index.test.ts`, "export {}") + yield* fs.writeFileString(`${root}/src/readme.md`, "readme") + + const matches = (yield* fs.glob("**/*.ts", { + root, + exclude: ["**/*.test.ts"] + })).map((entry) => entry.replaceAll("\\", "/")).sort() + + assert.strictEqual(matches.length, 1) + assert.isTrue(matches[0].endsWith("src/index.ts")) + })) + + // TODO: Add deterministic watcher-registration and subscription-control hooks before specifying event ordering, + // normalized paths, or watcher cleanup. + // it.effect("should emit normalized events when watching registered paths", () => {}) + + // TODO: Decide whether a missing watch target reports `watch` or the host preflight operation. Node exposes `stat`. + it.effect("should preserve error context when watching a missing path", () => + Effect.gen(function*() { + const { fs, path } = yield* makeTestContext + const missing = path("missing-watch") + + const error = yield* fs.watch(missing).pipe(Stream.runDrain, Effect.flip) + + assertSystemError(error, { tag: "NotFound", pathOrDescriptor: missing }) + })) + }) })