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