Showing the changes to apps/server/src/workspace/WorkspaceFileSystem.test.ts (1 of 2 files). Show all
Unified · Split
| @@ -1,5 +1,5 @@ | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it, describe, expect } from "@effect/vitest"; | 2 | import { assert, it, describe } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| @@ -12,6 +12,8 @@ import * as WorkspaceEntries from "./WorkspaceEntries.ts"; · 6 unmodified lines | |||
| 12 | import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; | 12 | import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; |
| 13 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | 13 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 14 | 14 | ||
| 15 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; | ||
| 16 | |||
| 15 | const ProjectLayer = WorkspaceFileSystem.layer.pipe( | 17 | const ProjectLayer = WorkspaceFileSystem.layer.pipe( |
| 16 | Layer.provide(WorkspacePaths.layer), | 18 | Layer.provide(WorkspacePaths.layer), |
| 17 | Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), | 19 | Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), |
| @@ -51,6 +53,20 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( · 33 unmodified lines | |||
| 51 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | 53 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); |
| 52 | }); | 54 | }); |
| 53 | 55 | ||
| 56 | const writeBinaryFile = Effect.fn("writeBinaryFile")(function* ( | ||
| 57 | cwd: string, | ||
| 58 | relativePath: string, | ||
| 59 | contents: Uint8Array, | ||
| 60 | ) { | ||
| 61 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 62 | const path = yield* Path.Path; | ||
| 63 | const absolutePath = path.join(cwd, relativePath); | ||
| 64 | yield* fileSystem | ||
| 65 | .makeDirectory(path.dirname(absolutePath), { recursive: true }) | ||
| 66 | .pipe(Effect.orDie); | ||
| 67 | yield* fileSystem.writeFile(absolutePath, contents).pipe(Effect.orDie); | ||
| 68 | }); | ||
| 69 | |||
| 54 | it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { | 70 | it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { |
| 55 | describe("readFile", () => { | 71 | describe("readFile", () => { |
| 56 | it.effect("reads UTF-8 files relative to the workspace root", () => | 72 | it.effect("reads UTF-8 files relative to the workspace root", () => |
| @@ -64,7 +80,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 7 unmodified lines | |||
| 64 | relativePath: "src/index.ts", | 80 | relativePath: "src/index.ts", |
| 65 | }); | 81 | }); |
| 66 | 82 | ||
| 67 | expect(result).toEqual({ | 83 | assert.deepStrictEqual(result, { |
| 68 | relativePath: "src/index.ts", | 84 | relativePath: "src/index.ts", |
| 69 | contents: "export const answer = 42;\n", | 85 | contents: "export const answer = 42;\n", |
| 70 | byteLength: 26, | 86 | byteLength: 26, |
| @@ -82,9 +98,47 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 11 unmodified lines | |||
| 82 | .readFile({ cwd, relativePath: "../escape.md" }) | 98 | .readFile({ cwd, relativePath: "../escape.md" }) |
| 83 | .pipe(Effect.flip); | 99 | .pipe(Effect.flip); |
| 84 | 100 | ||
| 85 | expect(error.message).toContain( | 101 | assert.match( |
| 86 | "Workspace file path must be relative to the project root: ../escape.md", | 102 | error.message, |
| 103 | /Workspace file path must be relative to the project root: \.\.\/escape\.md/, | ||
| 104 | ); | ||
| 105 | }), | ||
| 106 | ); | ||
| 107 | |||
| 108 | it.effect("truncates text files larger than the preview byte limit", () => | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 111 | const cwd = yield* makeTempDir; | ||
| 112 | yield* writeTextFile(cwd, "large.txt", `${"a".repeat(PROJECT_READ_FILE_MAX_BYTES)}tail`); | ||
| 113 | |||
| 114 | const result = yield* workspaceFileSystem.readFile({ | ||
| 115 | cwd, | ||
| 116 | relativePath: "large.txt", | ||
| 117 | }); | ||
| 118 | |||
| 119 | assert.strictEqual(result.byteLength, PROJECT_READ_FILE_MAX_BYTES + 4); | ||
| 120 | assert.strictEqual(result.contents.length, PROJECT_READ_FILE_MAX_BYTES); | ||
| 121 | assert.strictEqual(result.contents, "a".repeat(PROJECT_READ_FILE_MAX_BYTES)); | ||
| 122 | assert.strictEqual(result.truncated, true); | ||
| 123 | }), | ||
| 124 | ); | ||
| 125 | |||
| 126 | it.effect("rejects binary files before decoding them as text", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 129 | const cwd = yield* makeTempDir; | ||
| 130 | yield* writeBinaryFile(cwd, "image.bin", new Uint8Array([0x66, 0x6f, 0x00, 0x6f])); | ||
| 131 | |||
| 132 | const error = yield* workspaceFileSystem | ||
| 133 | .readFile({ cwd, relativePath: "image.bin" }) | ||
| 134 | .pipe(Effect.flip); | ||
| 135 | |||
| 136 | assert.strictEqual( | ||
| 137 | error.message, | ||
| 138 | `Workspace file operation 'workspaceFileSystem.readFile' failed for 'image.bin' in '${cwd}'.`, | ||
| 87 | ); | 139 | ); |
| 140 | assert.ok(error.cause instanceof Error); | ||
| 141 | assert.strictEqual((error.cause as Error).message, "Binary files cannot be previewed as text."); | ||
| 88 | }), | 142 | }), |
| 89 | ); | 143 | ); |
| 90 | 144 | ||
| @@ -105,11 +159,13 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 14 unmodified lines | |||
| 105 | .readFile({ cwd, relativePath: "linked-secret.txt" }) | 159 | .readFile({ cwd, relativePath: "linked-secret.txt" }) |
| 106 | .pipe(Effect.flip); | 160 | .pipe(Effect.flip); |
| 107 | 161 | ||
| 108 | expect(error.message).toBe( | 162 | assert.strictEqual( |
| 163 | error.message, | ||
| 109 | `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, | 164 | `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, |
| 110 | ); | 165 | ); |
| 111 | expect(error.cause).toBeInstanceOf(Error); | 166 | assert.ok(error.cause instanceof Error); |
| 112 | expect((error.cause as Error).message).toBe( | 167 | assert.strictEqual( |
| 168 | (error.cause as Error).message, | ||
| 113 | "Workspace file path resolves outside the project root.", | 169 | "Workspace file path resolves outside the project root.", |
| 114 | ); | 170 | ); |
| 115 | }), | 171 | }), |
| @@ -132,8 +188,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 16 unmodified lines | |||
| 132 | .readFileString(path.join(cwd, "plans/effect-rpc.md")) | 188 | .readFileString(path.join(cwd, "plans/effect-rpc.md")) |
| 133 | .pipe(Effect.orDie); | 189 | .pipe(Effect.orDie); |
| 134 | 190 | ||
| 135 | expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); | 191 | assert.deepStrictEqual(result, { relativePath: "plans/effect-rpc.md" }); |
| 136 | expect(saved).toBe("# Plan\n"); | 192 | assert.strictEqual(saved, "# Plan\n"); |
| 137 | }), | 193 | }), |
| 138 | ); | 194 | ); |
| 139 | 195 | ||
| @@ -145,7 +201,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 5 unmodified lines | |||
| 145 | yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); | 201 | yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); |
| 146 | 202 | ||
| 147 | const beforeWrite = yield* workspaceEntries.list({ cwd }); | 203 | const beforeWrite = yield* workspaceEntries.list({ cwd }); |
| 148 | expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( | 204 | assert.strictEqual( |
| 205 | beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md"), | ||
| 149 | false, | 206 | false, |
| 150 | ); | 207 | ); |
| 151 | 208 | ||
| @@ -156,10 +213,11 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 4 unmodified lines | |||
| 156 | }); | 213 | }); |
| 157 | 214 | ||
| 158 | const afterWrite = yield* workspaceEntries.list({ cwd }); | 215 | const afterWrite = yield* workspaceEntries.list({ cwd }); |
| 159 | expect(afterWrite.entries).toEqual( | 216 | assert.strictEqual( |
| 160 | expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), | 217 | afterWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md"), |
| 218 | true, | ||
| 161 | ); | 219 | ); |
| 162 | expect(afterWrite.truncated).toBe(false); | 220 | assert.strictEqual(afterWrite.truncated, false); |
| 163 | }), | 221 | }), |
| 164 | ); | 222 | ); |
| 165 | 223 | ||
| @@ -178,15 +236,14 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 12 unmodified lines | |||
| 178 | }) | 236 | }) |
| 179 | .pipe(Effect.flip); | 237 | .pipe(Effect.flip); |
| 180 | 238 | ||
| 181 | expect(error.message).toContain( | 239 | assert.match( |
| 182 | "Workspace file path must be relative to the project root: ../escape.md", | 240 | error.message, |
| 241 | /Workspace file path must be relative to the project root: \.\.\/escape\.md/, | ||
| 183 | ); | 242 | ); |
| 184 | 243 | ||
| 185 | const escapedPath = path.resolve(cwd, "..", "escape.md"); | 244 | const escapedPath = path.resolve(cwd, "..", "escape.md"); |
| 186 | const escapedStat = yield* fileSystem | 245 | const escapedExists = yield* fileSystem.exists(escapedPath); |
| 187 | .stat(escapedPath) | 246 | assert.strictEqual(escapedExists, false); |
| 188 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 189 | expect(escapedStat).toBeNull(); | ||
| 190 | }), | 247 | }), |
| 191 | ); | 248 | ); |
| 192 | }); | 249 | }); |