1// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO.
2import * as NodeChildProcess from "node:child_process";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it, describe, expect } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as Stream from "effect/Stream";
11
12import * as ServerConfig from "../config.ts";
13import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as WorkspaceEntries from "./WorkspaceEntries.ts";
16import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";
17import * as WorkspacePaths from "./WorkspacePaths.ts";
18import * as HostProcess from "@t3tools/shared/HostProcess";
19import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
20
21const layerProject = WorkspaceFileSystem.layer.pipe(
22 Layer.provide(WorkspacePaths.layer),
23 Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
24);
25
26const layerTest = Layer.empty.pipe(
27 Layer.provideMerge(layerProject),
28 Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
29 Layer.provideMerge(WorkspacePaths.layer),
30 Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))),
31 Layer.provide(
32 ServerConfig.ServerConfig.layerTest(process.cwd(), {
33 prefix: "t3-workspace-files-test-",
34 }),
35 ),
36 Layer.provideMerge(NodeServices.layer),
37);
38
39const makeTempDir = Effect.gen(function* () {
40 const fileSystem = yield* FileSystem.FileSystem;
41 return yield* fileSystem.makeTempDirectoryScoped({
42 prefix: "t3code-workspace-files-",
43 });
44});
45
46const writeTextFile = Effect.fn("writeTextFile")(function* (
47 cwd: string,
48 relativePath: string,
49 contents = "",
50) {
51 const fileSystem = yield* FileSystem.FileSystem;
52 const path = yield* Path.Path;
53 const absolutePath = path.join(cwd, relativePath);
54 yield* fileSystem
55 .makeDirectory(path.dirname(absolutePath), { recursive: true })
56 .pipe(Effect.orDie);
57 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
58});
59
60it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => {
61 describe("watch", () => {
62 it.effect("observes file edits, atomic replacements, deletion, and recreation", () =>
63 Effect.gen(function* () {
64 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const cwd = yield* makeTempDir;
68 yield* writeTextFile(cwd, "note.md", "first");
69 let step = 0;
70 const observed: Array<string | null> = [];
71 yield* service.watch({ cwd, relativePath: "note.md", directory: false }).pipe(
72 Stream.take(5),
73 Stream.runForEach(() =>
74 Effect.gen(function* () {
75 observed.push(
76 yield* fs
77 .readFileString(path.join(cwd, "note.md"))
78 .pipe(Effect.catch(() => Effect.succeed(null))),
79 );
80 switch (step++) {
81 case 0:
82 yield* writeTextFile(cwd, "note.md", "edited");
83 break;
84 case 1:
85 yield* writeTextFile(cwd, "replacement", "replaced");
86 yield* fs.rename(path.join(cwd, "replacement"), path.join(cwd, "note.md"));
87 break;
88 case 2:
89 yield* fs.remove(path.join(cwd, "note.md"));
90 break;
91 case 3:
92 yield* writeTextFile(cwd, "note.md", "recreated");
93 break;
94 }
95 }),
96 ),
97 );
98 expect(observed).toEqual(["first", "edited", "replaced", null, "recreated"]);
99 }),
100 );
101
102 it.effect("reattaches after an open directory is deleted and recreated", () =>
103 Effect.gen(function* () {
104 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
105 const fs = yield* FileSystem.FileSystem;
106 const path = yield* Path.Path;
107 const cwd = yield* makeTempDir;
108 const directory = path.join(cwd, "open");
109 yield* fs.makeDirectory(directory);
110 let step = 0;
111 const observed: Array<readonly string[] | null> = [];
112 yield* service.watch({ cwd, relativePath: "open", directory: true }).pipe(
113 Stream.take(5),
114 Stream.runForEach(() =>
115 Effect.gen(function* () {
116 observed.push(
117 yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed(null))),
118 );
119 switch (step++) {
120 case 0:
121 yield* writeTextFile(directory, "one", "one");
122 break;
123 case 1:
124 yield* fs.remove(directory, { recursive: true });
125 break;
126 case 2:
127 yield* fs.makeDirectory(directory);
128 break;
129 case 3:
130 yield* writeTextFile(directory, "two", "two");
131 break;
132 }
133 }),
134 ),
135 );
136 expect(observed).toEqual([[], ["one"], null, [], ["two"]]);
137 }),
138 );
139 });
140
141 describe("watch targets", () => {
142 it.effect("observes initially missing targets and recreation of missing parents", () =>
143 Effect.gen(function* () {
144 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
145 const fs = yield* FileSystem.FileSystem;
146 const path = yield* Path.Path;
147 const cwd = yield* makeTempDir;
148 let step = 0;
149 const observed: Array<string | null> = [];
150 yield* service.watch({ cwd, relativePath: "nested/deep/note.md", directory: false }).pipe(
151 Stream.take(4),
152 Stream.runForEach(() =>
153 Effect.gen(function* () {
154 observed.push(
155 yield* fs
156 .readFileString(path.join(cwd, "nested/deep/note.md"))
157 .pipe(Effect.orElseSucceed(() => null)),
158 );
159 switch (step++) {
160 case 0:
161 yield* writeTextFile(cwd, "nested/deep/note.md", "created");
162 break;
163 case 1:
164 yield* fs.remove(path.join(cwd, "nested"), { recursive: true });
165 break;
166 case 2:
167 yield* writeTextFile(cwd, "nested/deep/note.md", "recreated");
168 break;
169 }
170 }),
171 ),
172 );
173 expect(observed).toEqual([null, "created", null, "recreated"]);
174 }),
175 );
176
177 it.effect.skipIf(!symlinksSupported)("follows a file symlink when its target changes", () =>
178 Effect.gen(function* () {
179 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
180 const fs = yield* FileSystem.FileSystem;
181 const path = yield* Path.Path;
182 const cwd = yield* makeTempDir;
183 yield* writeTextFile(cwd, "first/note.md", "first");
184 yield* writeTextFile(cwd, "second/note.md", "second");
185 const link = path.join(cwd, "link.md");
186 yield* fs.symlink(path.join(cwd, "first/note.md"), link);
187 let step = 0;
188 const observed: string[] = [];
189 yield* service.watch({ cwd, relativePath: "link.md", directory: false }).pipe(
190 Stream.take(3),
191 Stream.runForEach(() =>
192 Effect.gen(function* () {
193 observed.push(yield* fs.readFileString(link));
194 if (step++ === 0) {
195 yield* fs.remove(link);
196 yield* fs.symlink(path.join(cwd, "second/note.md"), link);
197 } else {
198 yield* writeTextFile(cwd, "second/note.md", "edited");
199 }
200 }),
201 ),
202 );
203 expect(observed).toEqual(["first", "second", "edited"]);
204 }),
205 );
206
207 it.effect("watches the workspace folder itself", () =>
208 Effect.gen(function* () {
209 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
210 const fs = yield* FileSystem.FileSystem;
211 const cwd = yield* makeTempDir;
212 let step = 0;
213 const entries: Array<readonly string[]> = [];
214 yield* service.watch({ cwd, relativePath: ".", directory: true }).pipe(
215 Stream.take(2),
216 Stream.runForEach(() =>
217 Effect.gen(function* () {
218 entries.push(yield* fs.readDirectory(cwd));
219 if (step++ === 0) yield* writeTextFile(cwd, "new.md", "created");
220 }),
221 ),
222 );
223 expect(entries).toEqual([[], ["new.md"]]);
224 }),
225 );
226 });
227
228 describe("getMetadata", () => {
229 it.effect("expands home-relative paths on the server", () =>
230 Effect.gen(function* () {
231 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
232 const home = yield* makeTempDir;
233 yield* writeTextFile(home, "file", "hello");
234 expect(
235 (yield* service
236 .getMetadata({ paths: ["~/file"] })
237 .pipe(Effect.provideService(HostProcess.HomeDirectory, home))).entries,
238 ).toEqual([{ kind: "file", byteLength: 5 }]);
239 }),
240 );
241
242 it.effect(
243 "reports actual kinds for extensionless files, dotfiles, and dotted directories",
244 () =>
245 Effect.gen(function* () {
246 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
247 const fs = yield* FileSystem.FileSystem;
248 const path = yield* Path.Path;
249 const cwd = yield* makeTempDir;
250 yield* writeTextFile(cwd, "custom-name", "hello");
251 yield* writeTextFile(cwd, ".npmrc", "registry=test");
252 yield* fs.makeDirectory(path.join(cwd, "folder.ts"));
253 const file = path.join(cwd, "custom-name");
254 const result = yield* service.getMetadata({
255 paths: [
256 file,
257 path.join(cwd, ".npmrc"),
258 path.join(cwd, "folder.ts"),
259 file,
260 path.join(cwd, "missing"),
261 "relative-path",
262 ],
263 });
264 expect(result.entries).toEqual([
265 { kind: "file", byteLength: 5 },
266 { kind: "file", byteLength: 13 },
267 { kind: "directory" },
268 { kind: "file", byteLength: 5 },
269 null,
270 null,
271 ]);
272 }),
273 );
274
275 it.effect("detects an extensionless script and image without returning their contents", () =>
276 Effect.gen(function* () {
277 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
278 const fs = yield* FileSystem.FileSystem;
279 const path = yield* Path.Path;
280 const cwd = yield* makeTempDir;
281 const script = "#!/usr/bin/env python3\nprint('hello')\n";
282 yield* writeTextFile(cwd, "run", script);
283 const image = path.join(cwd, "image");
284 yield* fs.writeFile(
285 image,
286 new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
287 );
288 expect(
289 (yield* service.getMetadata({ paths: [path.join(cwd, "run"), image] })).entries,
290 ).toEqual([
291 { kind: "file", byteLength: script.length, mimeType: "text/x-python" },
292 { kind: "file", byteLength: 8, mimeType: "image/png" },
293 ]);
294 }),
295 );
296
297 it.effect.skipIf(!symlinksSupported)("follows file and directory symlinks", () =>
298 Effect.gen(function* () {
299 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
300 const fs = yield* FileSystem.FileSystem;
301 const path = yield* Path.Path;
302 const cwd = yield* makeTempDir;
303 yield* writeTextFile(cwd, "target", "text");
304 yield* fs.makeDirectory(path.join(cwd, "directory"));
305 const fileLink = path.join(cwd, "file-link");
306 const directoryLink = path.join(cwd, "directory-link");
307 yield* fs.symlink(path.join(cwd, "target"), fileLink);
308 yield* fs.symlink(path.join(cwd, "directory"), directoryLink);
309 expect((yield* service.getMetadata({ paths: [fileLink, directoryLink] })).entries).toEqual([
310 { kind: "file", byteLength: 4 },
311 { kind: "directory" },
312 ]);
313 }),
314 );
315 });
316
317 describe("readFile", () => {
318 it.effect("reads UTF-8 files relative to the workspace root", () =>
319 Effect.gen(function* () {
320 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
321 const cwd = yield* makeTempDir;
322 yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n");
323
324 const result = yield* workspaceFileSystem.readFile({
325 cwd,
326 relativePath: "src/index.ts",
327 });
328
329 expect(result).toEqual({
330 relativePath: "src/index.ts",
331 contents: "export const answer = 42;\n",
332 byteLength: 26,
333 truncated: false,
334 });
335 }),
336 );
337
338 it.effect("reads host files outside the workspace root by absolute path", () =>
339 Effect.gen(function* () {
340 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
341 const path = yield* Path.Path;
342 const cwd = yield* makeTempDir;
343 const outsideDir = yield* makeTempDir;
344 yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n");
345 const absolutePath = path.join(outsideDir, "cleanup-report.md");
346
347 const result = yield* workspaceFileSystem.readFile({
348 cwd,
349 relativePath: absolutePath,
350 });
351
352 expect(result).toEqual({
353 relativePath: absolutePath,
354 contents: "# Report\n",
355 byteLength: 9,
356 truncated: false,
357 });
358 }),
359 );
360
361 // Needs mkfifo; Windows has no FIFOs to reject.
362 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
363 "rejects a FIFO without blocking on open",
364 () =>
365 Effect.gen(function* () {
366 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
367 const path = yield* Path.Path;
368 const cwd = yield* makeTempDir;
369 const outsideDir = yield* makeTempDir;
370 const fifoPath = path.join(outsideDir, "pipe");
371 yield* Effect.promise(
372 () =>
373 new Promise<void>((resolve, reject) =>
374 NodeChildProcess.execFile("mkfifo", [fifoPath], (error) =>
375 error ? reject(error) : resolve(),
376 ),
377 ),
378 );
379
380 const error = yield* workspaceFileSystem
381 .readFile({ cwd, relativePath: fifoPath })
382 .pipe(Effect.flip);
383
384 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
385 expect((yield* workspaceFileSystem.getMetadata({ paths: [fifoPath] })).entries).toEqual([
386 { kind: "other" },
387 ]);
388 }),
389 );
390
391 it.effect("rejects reads outside the workspace root", () =>
392 Effect.gen(function* () {
393 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
394 const cwd = yield* makeTempDir;
395
396 const error = yield* workspaceFileSystem
397 .readFile({ cwd, relativePath: "../escape.md" })
398 .pipe(Effect.flip);
399
400 expect(error.message).toContain(
401 "Workspace file path must be relative to the project root: ../escape.md",
402 );
403 }),
404 );
405
406 it.effect.skipIf(!symlinksSupported)(
407 "rejects symlinks that resolve outside the workspace root",
408 () =>
409 Effect.gen(function* () {
410 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
411 const fileSystem = yield* FileSystem.FileSystem;
412 const path = yield* Path.Path;
413 const cwd = yield* makeTempDir;
414 const outsideDir = yield* makeTempDir;
415 yield* writeTextFile(outsideDir, "secret.txt", "outside\n");
416 yield* fileSystem.symlink(
417 path.join(outsideDir, "secret.txt"),
418 path.join(cwd, "linked-secret.txt"),
419 );
420
421 const error = yield* workspaceFileSystem
422 .readFile({ cwd, relativePath: "linked-secret.txt" })
423 .pipe(Effect.flip);
424 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
425 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));
426
427 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
428 expect(error).toMatchObject({
429 workspaceRoot: cwd,
430 relativePath: "linked-secret.txt",
431 resolvedWorkspaceRoot,
432 resolvedPath,
433 });
434 expect("cause" in error).toBe(false);
435 }),
436 );
437
438 it.effect("rejects directories without manufacturing an I/O cause", () =>
439 Effect.gen(function* () {
440 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
441 const fileSystem = yield* FileSystem.FileSystem;
442 const path = yield* Path.Path;
443 const cwd = yield* makeTempDir;
444 yield* fileSystem.makeDirectory(path.join(cwd, "src"));
445
446 const error = yield* workspaceFileSystem
447 .readFile({ cwd, relativePath: "src" })
448 .pipe(Effect.flip);
449 const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src"));
450
451 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
452 expect(error).toMatchObject({
453 workspaceRoot: cwd,
454 relativePath: "src",
455 resolvedPath,
456 });
457 expect("cause" in error).toBe(false);
458 }),
459 );
460
461 it.effect("rejects binary files without leaking their contents into the error", () =>
462 Effect.gen(function* () {
463 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
464 const fileSystem = yield* FileSystem.FileSystem;
465 const path = yield* Path.Path;
466 const cwd = yield* makeTempDir;
467 const absolutePath = path.join(cwd, "asset.bin");
468 yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62]));
469
470 const error = yield* workspaceFileSystem
471 .readFile({ cwd, relativePath: "asset.bin" })
472 .pipe(Effect.flip);
473 const resolvedPath = yield* fileSystem.realPath(absolutePath);
474
475 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError);
476 expect(error).toMatchObject({
477 workspaceRoot: cwd,
478 relativePath: "asset.bin",
479 resolvedPath,
480 });
481 expect("cause" in error).toBe(false);
482 expect("contents" in error).toBe(false);
483 }),
484 );
485
486 it.effect("preserves the real cause and path for I/O failures", () =>
487 Effect.gen(function* () {
488 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
489 const path = yield* Path.Path;
490 const cwd = yield* makeTempDir;
491 const resolvedPath = path.join(cwd, "missing.txt");
492
493 const error = yield* workspaceFileSystem
494 .readFile({ cwd, relativePath: "missing.txt" })
495 .pipe(Effect.flip);
496
497 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError);
498 expect(error).toMatchObject({
499 workspaceRoot: cwd,
500 relativePath: "missing.txt",
501 resolvedPath,
502 operationPath: resolvedPath,
503 operation: "realpath-target",
504 });
505 expect(error.cause).toBeInstanceOf(Error);
506 expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT");
507 }),
508 );
509 });
510
511 describe("writeFile", () => {
512 it.effect("writes files relative to the workspace root", () =>
513 Effect.gen(function* () {
514 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
515 const cwd = yield* makeTempDir;
516 const fileSystem = yield* FileSystem.FileSystem;
517 const path = yield* Path.Path;
518 const result = yield* workspaceFileSystem.writeFile({
519 cwd,
520 relativePath: "plans/effect-rpc.md",
521 contents: "# Plan\n",
522 });
523 const saved = yield* fileSystem
524 .readFileString(path.join(cwd, "plans/effect-rpc.md"))
525 .pipe(Effect.orDie);
526
527 expect(result).toEqual({ relativePath: "plans/effect-rpc.md" });
528 expect(saved).toBe("# Plan\n");
529 }),
530 );
531
532 it.effect("rejects writes by absolute path", () =>
533 Effect.gen(function* () {
534 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
535 const path = yield* Path.Path;
536 const cwd = yield* makeTempDir;
537 const outsideDir = yield* makeTempDir;
538 const absolutePath = path.join(outsideDir, "cleanup-report.md");
539
540 const error = yield* workspaceFileSystem
541 .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" })
542 .pipe(Effect.flip);
543
544 expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
545 }),
546 );
547
548 it.effect("invalidates workspace entry search cache after writes", () =>
549 Effect.gen(function* () {
550 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
551 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
552 const cwd = yield* makeTempDir;
553 yield* writeTextFile(cwd, "src/existing.ts", "export {};\n");
554
555 const beforeWrite = yield* workspaceEntries.list({ cwd });
556 expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe(
557 false,
558 );
559
560 yield* workspaceFileSystem.writeFile({
561 cwd,
562 relativePath: "plans/effect-rpc.md",
563 contents: "# Plan\n",
564 });
565
566 const afterWrite = yield* workspaceEntries.list({ cwd });
567 expect(afterWrite.entries).toEqual(
568 expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]),
569 );
570 expect(afterWrite.truncated).toBe(false);
571 }),
572 );
573
574 it.effect("rejects writes outside the workspace root", () =>
575 Effect.gen(function* () {
576 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
577 const cwd = yield* makeTempDir;
578 const path = yield* Path.Path;
579 const fileSystem = yield* FileSystem.FileSystem;
580
581 const error = yield* workspaceFileSystem
582 .writeFile({
583 cwd,
584 relativePath: "../escape.md",
585 contents: "# nope\n",
586 })
587 .pipe(Effect.flip);
588
589 expect(error.message).toContain(
590 "Workspace file path must be relative to the project root: ../escape.md",
591 );
592
593 const escapedPath = path.resolve(cwd, "..", "escape.md");
594 const escapedStat = yield* fileSystem
595 .stat(escapedPath)
596 .pipe(Effect.orElseSucceed(() => null));
597 expect(escapedStat).toBeNull();
598 }),
599 );
600 });
601});