@stablemodels/durable-bash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/final-review.md +20 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/publish.yml +28 -0
- package/CLAUDE.md +62 -0
- package/README.md +97 -0
- package/biome.json +25 -0
- package/bun.lock +442 -0
- package/bunfig.toml +2 -0
- package/dist/durable-fs.d.ts +88 -0
- package/dist/durable-fs.d.ts.map +1 -0
- package/dist/durable-fs.js +175 -0
- package/dist/durable-fs.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs-object.d.ts +68 -0
- package/dist/fs-object.d.ts.map +1 -0
- package/dist/fs-object.js +455 -0
- package/dist/fs-object.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
- package/src/durable-fs.ts +249 -0
- package/src/errors.ts +21 -0
- package/src/fs-object.ts +603 -0
- package/src/index.ts +12 -0
- package/src/types.ts +16 -0
- package/tests/durable-fs.test.ts +356 -0
- package/tests/fs-object.test.ts +435 -0
- package/tests/helpers.ts +105 -0
- package/tests/integration.test.ts +189 -0
- package/tests/just-bash.test.ts +85 -0
- package/tests/setup.ts +14 -0
- package/tsconfig.json +22 -0
- package/wrangler.toml +13 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTestFsObject } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
import type { FsObject } from "../src/fs-object.js";
|
|
5
|
+
|
|
6
|
+
let obj: FsObject;
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
const result = await createTestFsObject();
|
|
10
|
+
obj = result.obj;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// ─── Basic File Operations ────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe("writeFile + readFile", () => {
|
|
16
|
+
test("roundtrip", () => {
|
|
17
|
+
obj.writeFile("/hello.txt", "hello world");
|
|
18
|
+
const result = obj.readFile("/hello.txt");
|
|
19
|
+
expect(result.content).toBe("hello world");
|
|
20
|
+
expect(result.encoding).toBe("utf-8");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("creates parent directories automatically", () => {
|
|
24
|
+
obj.writeFile("/a/b/c.txt", "deep");
|
|
25
|
+
expect(obj.exists("/a")).toBe(true);
|
|
26
|
+
expect(obj.exists("/a/b")).toBe(true);
|
|
27
|
+
const stat = obj.stat("/a");
|
|
28
|
+
expect(stat.isDir).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("overwrites existing file", () => {
|
|
32
|
+
obj.writeFile("/file.txt", "first");
|
|
33
|
+
obj.writeFile("/file.txt", "second");
|
|
34
|
+
expect(obj.readFile("/file.txt").content).toBe("second");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("readFile throws ENOENT for missing file", () => {
|
|
38
|
+
expect(() => obj.readFile("/missing.txt")).toThrow("ENOENT");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("readFile throws EISDIR for directory", () => {
|
|
42
|
+
obj.mkdir("/mydir");
|
|
43
|
+
expect(() => obj.readFile("/mydir")).toThrow("EISDIR");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("readFileBuffer", () => {
|
|
48
|
+
test("returns Uint8Array for text content", () => {
|
|
49
|
+
obj.writeFile("/test.txt", "hello");
|
|
50
|
+
const result = obj.readFileBuffer("/test.txt");
|
|
51
|
+
expect(result.content).toBeInstanceOf(Uint8Array);
|
|
52
|
+
expect(new TextDecoder().decode(result.content)).toBe("hello");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("preserves binary content", () => {
|
|
56
|
+
const binary = new Uint8Array([0, 1, 2, 255, 128]);
|
|
57
|
+
obj.writeFile("/binary.bin", binary);
|
|
58
|
+
const result = obj.readFileBuffer("/binary.bin");
|
|
59
|
+
expect(result.content).toEqual(binary);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("appendFile", () => {
|
|
64
|
+
test("creates file if missing", () => {
|
|
65
|
+
obj.appendFile("/new.txt", "hello");
|
|
66
|
+
expect(obj.readFile("/new.txt").content).toBe("hello");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("appends to existing file", () => {
|
|
70
|
+
obj.writeFile("/log.txt", "line1\n");
|
|
71
|
+
obj.appendFile("/log.txt", "line2\n");
|
|
72
|
+
expect(obj.readFile("/log.txt").content).toBe("line1\nline2\n");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("throws EISDIR on directory", () => {
|
|
76
|
+
obj.mkdir("/dir");
|
|
77
|
+
expect(() => obj.appendFile("/dir", "data")).toThrow("EISDIR");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ─── exists ───────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe("exists", () => {
|
|
84
|
+
test("returns true for existing file", () => {
|
|
85
|
+
obj.writeFile("/file.txt", "data");
|
|
86
|
+
expect(obj.exists("/file.txt")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("returns true for existing directory", () => {
|
|
90
|
+
obj.mkdir("/dir");
|
|
91
|
+
expect(obj.exists("/dir")).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns false for missing path", () => {
|
|
95
|
+
expect(obj.exists("/nope")).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("returns true for root", () => {
|
|
99
|
+
expect(obj.exists("/")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── stat ─────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("stat", () => {
|
|
106
|
+
test("returns correct shape for file", () => {
|
|
107
|
+
obj.writeFile("/file.txt", "hello");
|
|
108
|
+
const s = obj.stat("/file.txt");
|
|
109
|
+
expect(s.isDir).toBe(false);
|
|
110
|
+
expect(s.isSymlink).toBe(false);
|
|
111
|
+
expect(s.size).toBe(5);
|
|
112
|
+
expect(s.mode).toBe(0o644);
|
|
113
|
+
expect(typeof s.mtimeMs).toBe("number");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("returns correct shape for directory", () => {
|
|
117
|
+
obj.mkdir("/dir");
|
|
118
|
+
const s = obj.stat("/dir");
|
|
119
|
+
expect(s.isDir).toBe(true);
|
|
120
|
+
expect(s.isSymlink).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("throws ENOENT for missing path", () => {
|
|
124
|
+
expect(() => obj.stat("/missing")).toThrow("ENOENT");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ─── mkdir ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("mkdir", () => {
|
|
131
|
+
test("creates directory", () => {
|
|
132
|
+
obj.mkdir("/newdir");
|
|
133
|
+
expect(obj.exists("/newdir")).toBe(true);
|
|
134
|
+
expect(obj.stat("/newdir").isDir).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("recursive creates full chain", () => {
|
|
138
|
+
obj.mkdir("/a/b/c", { recursive: true });
|
|
139
|
+
expect(obj.exists("/a")).toBe(true);
|
|
140
|
+
expect(obj.exists("/a/b")).toBe(true);
|
|
141
|
+
expect(obj.exists("/a/b/c")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("throws EEXIST for existing path (non-recursive)", () => {
|
|
145
|
+
obj.mkdir("/existing");
|
|
146
|
+
expect(() => obj.mkdir("/existing")).toThrow("EEXIST");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("recursive on existing dir is no-op", () => {
|
|
150
|
+
obj.mkdir("/existing");
|
|
151
|
+
obj.mkdir("/existing", { recursive: true }); // Should not throw
|
|
152
|
+
expect(obj.exists("/existing")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("throws ENOENT if parent doesn't exist (non-recursive)", () => {
|
|
156
|
+
expect(() => obj.mkdir("/no/parent")).toThrow("ENOENT");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ─── readdir ──────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("readdir", () => {
|
|
163
|
+
test("lists direct children only", () => {
|
|
164
|
+
obj.writeFile("/dir/a.txt", "a");
|
|
165
|
+
obj.writeFile("/dir/b.txt", "b");
|
|
166
|
+
obj.writeFile("/dir/sub/c.txt", "c");
|
|
167
|
+
const entries = obj.readdir("/dir");
|
|
168
|
+
expect(entries).toEqual(["a.txt", "b.txt", "sub"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("throws ENOTDIR for a file", () => {
|
|
172
|
+
obj.writeFile("/file.txt", "data");
|
|
173
|
+
expect(() => obj.readdir("/file.txt")).toThrow("ENOTDIR");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("throws ENOENT for missing directory", () => {
|
|
177
|
+
expect(() => obj.readdir("/missing")).toThrow("ENOENT");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("returns empty array for empty directory", () => {
|
|
181
|
+
obj.mkdir("/empty");
|
|
182
|
+
expect(obj.readdir("/empty")).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("readdirWithFileTypes", () => {
|
|
187
|
+
test("returns DirentData with correct flags", () => {
|
|
188
|
+
obj.writeFile("/dir/file.txt", "data");
|
|
189
|
+
obj.mkdir("/dir/subdir");
|
|
190
|
+
const entries = obj.readdirWithFileTypes("/dir");
|
|
191
|
+
expect(entries).toHaveLength(2);
|
|
192
|
+
|
|
193
|
+
const file = entries.find((e) => e.name === "file.txt");
|
|
194
|
+
expect(file?.isFile).toBe(true);
|
|
195
|
+
expect(file?.isDirectory).toBe(false);
|
|
196
|
+
|
|
197
|
+
const dir = entries.find((e) => e.name === "subdir");
|
|
198
|
+
expect(dir?.isFile).toBe(false);
|
|
199
|
+
expect(dir?.isDirectory).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ─── rm ───────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe("rm", () => {
|
|
206
|
+
test("removes a file", () => {
|
|
207
|
+
obj.writeFile("/file.txt", "data");
|
|
208
|
+
obj.rm("/file.txt");
|
|
209
|
+
expect(obj.exists("/file.txt")).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("removes directory recursively", () => {
|
|
213
|
+
obj.writeFile("/dir/a.txt", "a");
|
|
214
|
+
obj.writeFile("/dir/sub/b.txt", "b");
|
|
215
|
+
obj.rm("/dir", { recursive: true });
|
|
216
|
+
expect(obj.exists("/dir")).toBe(false);
|
|
217
|
+
expect(obj.exists("/dir/a.txt")).toBe(false);
|
|
218
|
+
expect(obj.exists("/dir/sub/b.txt")).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("throws ENOENT for missing path (no force)", () => {
|
|
222
|
+
expect(() => obj.rm("/missing")).toThrow("ENOENT");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("force ignores ENOENT", () => {
|
|
226
|
+
obj.rm("/missing", { force: true }); // Should not throw
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("throws ENOTEMPTY for non-recursive rm on non-empty dir", () => {
|
|
230
|
+
obj.writeFile("/dir/file.txt", "data");
|
|
231
|
+
expect(() => obj.rm("/dir")).toThrow("ENOTEMPTY");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ─── cp ───────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe("cp", () => {
|
|
238
|
+
test("copies a file", () => {
|
|
239
|
+
obj.writeFile("/src.txt", "content");
|
|
240
|
+
obj.cp("/src.txt", "/dest.txt");
|
|
241
|
+
expect(obj.readFile("/dest.txt").content).toBe("content");
|
|
242
|
+
// Original still exists
|
|
243
|
+
expect(obj.readFile("/src.txt").content).toBe("content");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("copies directory recursively", () => {
|
|
247
|
+
obj.writeFile("/src/a.txt", "a");
|
|
248
|
+
obj.writeFile("/src/sub/b.txt", "b");
|
|
249
|
+
obj.cp("/src", "/dest", { recursive: true });
|
|
250
|
+
expect(obj.readFile("/dest/a.txt").content).toBe("a");
|
|
251
|
+
expect(obj.readFile("/dest/sub/b.txt").content).toBe("b");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("throws EISDIR for directory without recursive", () => {
|
|
255
|
+
obj.mkdir("/dir");
|
|
256
|
+
expect(() => obj.cp("/dir", "/copy")).toThrow("EISDIR");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ─── mv ───────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe("mv", () => {
|
|
263
|
+
test("moves a file", () => {
|
|
264
|
+
obj.writeFile("/old.txt", "data");
|
|
265
|
+
obj.mv("/old.txt", "/new.txt");
|
|
266
|
+
expect(obj.exists("/old.txt")).toBe(false);
|
|
267
|
+
expect(obj.readFile("/new.txt").content).toBe("data");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("moves a directory with contents", () => {
|
|
271
|
+
obj.writeFile("/old/file.txt", "data");
|
|
272
|
+
obj.mv("/old", "/new");
|
|
273
|
+
expect(obj.exists("/old")).toBe(false);
|
|
274
|
+
expect(obj.readFile("/new/file.txt").content).toBe("data");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("throws ENOENT for missing source", () => {
|
|
278
|
+
expect(() => obj.mv("/missing", "/dest")).toThrow("ENOENT");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ─── chmod ────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
describe("chmod", () => {
|
|
285
|
+
test("changes mode", () => {
|
|
286
|
+
obj.writeFile("/file.txt", "data");
|
|
287
|
+
obj.chmod("/file.txt", 0o755);
|
|
288
|
+
expect(obj.stat("/file.txt").mode).toBe(0o755);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("throws ENOENT for missing path", () => {
|
|
292
|
+
expect(() => obj.chmod("/missing", 0o644)).toThrow("ENOENT");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ─── symlink ──────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
describe("symlink", () => {
|
|
299
|
+
test("creates symlink and readlink returns target", () => {
|
|
300
|
+
obj.writeFile("/target.txt", "data");
|
|
301
|
+
obj.symlink("/target.txt", "/link.txt");
|
|
302
|
+
expect(obj.readlink("/link.txt")).toBe("/target.txt");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("readFile follows symlink", () => {
|
|
306
|
+
obj.writeFile("/target.txt", "hello");
|
|
307
|
+
obj.symlink("/target.txt", "/link.txt");
|
|
308
|
+
expect(obj.readFile("/link.txt").content).toBe("hello");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("lstat does not follow symlink", () => {
|
|
312
|
+
obj.writeFile("/target.txt", "hello");
|
|
313
|
+
obj.symlink("/target.txt", "/link.txt");
|
|
314
|
+
const s = obj.lstat("/link.txt");
|
|
315
|
+
expect(s.isSymlink).toBe(true);
|
|
316
|
+
expect(s.isDir).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("stat follows symlink", () => {
|
|
320
|
+
obj.writeFile("/target.txt", "hello");
|
|
321
|
+
obj.symlink("/target.txt", "/link.txt");
|
|
322
|
+
const s = obj.stat("/link.txt");
|
|
323
|
+
expect(s.isSymlink).toBe(false);
|
|
324
|
+
expect(s.isDir).toBe(false);
|
|
325
|
+
expect(s.size).toBe(5);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("throws EEXIST when link path exists", () => {
|
|
329
|
+
obj.writeFile("/target.txt", "data");
|
|
330
|
+
obj.writeFile("/existing.txt", "existing");
|
|
331
|
+
expect(() => obj.symlink("/target.txt", "/existing.txt")).toThrow("EEXIST");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("writeFile through symlink writes to target", () => {
|
|
335
|
+
obj.writeFile("/target.txt", "original");
|
|
336
|
+
obj.symlink("/target.txt", "/link.txt");
|
|
337
|
+
obj.writeFile("/link.txt", "updated");
|
|
338
|
+
expect(obj.readFile("/target.txt").content).toBe("updated");
|
|
339
|
+
expect(obj.readFile("/link.txt").content).toBe("updated");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("readFile on broken symlink throws ENOENT", () => {
|
|
343
|
+
obj.symlink("/nonexistent.txt", "/broken-link.txt");
|
|
344
|
+
expect(obj.exists("/broken-link.txt")).toBe(true);
|
|
345
|
+
expect(() => obj.readFile("/broken-link.txt")).toThrow("ENOENT");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ─── hard link ────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
describe("link (hard link)", () => {
|
|
352
|
+
test("creates hard link that reads same content", () => {
|
|
353
|
+
obj.writeFile("/original.txt", "shared content");
|
|
354
|
+
obj.link("/original.txt", "/hardlink.txt");
|
|
355
|
+
expect(obj.readFile("/hardlink.txt").content).toBe("shared content");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("throws ENOENT for missing source", () => {
|
|
359
|
+
expect(() => obj.link("/missing.txt", "/link.txt")).toThrow("ENOENT");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("throws EEXIST for existing destination", () => {
|
|
363
|
+
obj.writeFile("/src.txt", "data");
|
|
364
|
+
obj.writeFile("/dest.txt", "existing");
|
|
365
|
+
expect(() => obj.link("/src.txt", "/dest.txt")).toThrow("EEXIST");
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ─── realpath ─────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
describe("realpath", () => {
|
|
372
|
+
test("resolves chain of symlinks", () => {
|
|
373
|
+
obj.writeFile("/actual.txt", "data");
|
|
374
|
+
obj.symlink("/actual.txt", "/link1.txt");
|
|
375
|
+
obj.symlink("/link1.txt", "/link2.txt");
|
|
376
|
+
expect(obj.realpath("/link2.txt")).toBe("/actual.txt");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("returns same path for non-symlink", () => {
|
|
380
|
+
obj.writeFile("/file.txt", "data");
|
|
381
|
+
expect(obj.realpath("/file.txt")).toBe("/file.txt");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ─── utimes ───────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
describe("utimes", () => {
|
|
388
|
+
test("updates mtime", () => {
|
|
389
|
+
obj.writeFile("/file.txt", "data");
|
|
390
|
+
const newMtime = 1700000000000;
|
|
391
|
+
obj.utimes("/file.txt", newMtime, newMtime);
|
|
392
|
+
expect(obj.stat("/file.txt").mtimeMs).toBe(newMtime);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("throws ENOENT for missing path", () => {
|
|
396
|
+
expect(() => obj.utimes("/missing", 0, 0)).toThrow("ENOENT");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ─── getAllPaths ───────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
describe("getAllPaths", () => {
|
|
403
|
+
test("returns all paths including root", () => {
|
|
404
|
+
obj.writeFile("/a.txt", "a");
|
|
405
|
+
obj.mkdir("/dir");
|
|
406
|
+
obj.writeFile("/dir/b.txt", "b");
|
|
407
|
+
const paths = obj.getAllPaths();
|
|
408
|
+
expect(paths).toContain("/");
|
|
409
|
+
expect(paths).toContain("/a.txt");
|
|
410
|
+
expect(paths).toContain("/dir");
|
|
411
|
+
expect(paths).toContain("/dir/b.txt");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("returns sorted paths", () => {
|
|
415
|
+
obj.writeFile("/z.txt", "z");
|
|
416
|
+
obj.writeFile("/a.txt", "a");
|
|
417
|
+
const paths = obj.getAllPaths();
|
|
418
|
+
const sorted = [...paths].sort();
|
|
419
|
+
expect(paths).toEqual(sorted);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ─── Path normalization ───────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
describe("path normalization", () => {
|
|
426
|
+
test("handles dot segments", () => {
|
|
427
|
+
obj.writeFile("/a/b/../c.txt", "data");
|
|
428
|
+
expect(obj.readFile("/a/c.txt").content).toBe("data");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("handles double slashes", () => {
|
|
432
|
+
obj.writeFile("/a//b.txt", "data");
|
|
433
|
+
expect(obj.readFile("/a/b.txt").content).toBe("data");
|
|
434
|
+
});
|
|
435
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { FsObject } from "../src/fs-object.js";
|
|
3
|
+
|
|
4
|
+
function processRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
5
|
+
const processed: Record<string, unknown> = {};
|
|
6
|
+
for (const [key, value] of Object.entries(row)) {
|
|
7
|
+
if (Buffer.isBuffer(value)) {
|
|
8
|
+
processed[key] = (value as Buffer).buffer.slice(
|
|
9
|
+
value.byteOffset,
|
|
10
|
+
value.byteOffset + value.byteLength,
|
|
11
|
+
);
|
|
12
|
+
} else {
|
|
13
|
+
processed[key] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return processed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function processBindings(bindings: unknown[]): unknown[] {
|
|
20
|
+
return bindings.map((b) => (b instanceof Uint8Array ? Buffer.from(b) : b));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a mock DurableObjectState with real SQLite storage.
|
|
25
|
+
*/
|
|
26
|
+
export function createMockState(): {
|
|
27
|
+
ctx: MockDurableObjectState;
|
|
28
|
+
db: Database;
|
|
29
|
+
} {
|
|
30
|
+
const db = new Database(":memory:");
|
|
31
|
+
|
|
32
|
+
const sql: MockSqlStorage = {
|
|
33
|
+
exec(query: string, ...bindings: unknown[]) {
|
|
34
|
+
const isSelect = query.trimStart().toUpperCase().startsWith("SELECT");
|
|
35
|
+
|
|
36
|
+
if (bindings.length > 0) {
|
|
37
|
+
const processed = processBindings(bindings);
|
|
38
|
+
const stmt = db.prepare(query);
|
|
39
|
+
if (isSelect) {
|
|
40
|
+
return {
|
|
41
|
+
toArray: () =>
|
|
42
|
+
stmt
|
|
43
|
+
.all(...processed)
|
|
44
|
+
.map((r) => processRow(r as Record<string, unknown>)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
stmt.run(...processed);
|
|
48
|
+
return { toArray: () => [] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (isSelect) {
|
|
52
|
+
return {
|
|
53
|
+
toArray: () =>
|
|
54
|
+
db
|
|
55
|
+
.prepare(query)
|
|
56
|
+
.all()
|
|
57
|
+
.map((r) => processRow(r as Record<string, unknown>)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
db.exec(query);
|
|
61
|
+
return { toArray: () => [] };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return { ctx: { storage: { sql } } as MockDurableObjectState, db };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface MockSqlStorage {
|
|
69
|
+
exec(query: string, ...bindings: unknown[]): { toArray(): unknown[] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface MockDurableObjectState {
|
|
73
|
+
storage: {
|
|
74
|
+
sql: MockSqlStorage;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a fresh FsObject instance for testing.
|
|
80
|
+
*/
|
|
81
|
+
export async function createTestFsObject() {
|
|
82
|
+
const { FsObject } = await import("../src/fs-object.js");
|
|
83
|
+
const { ctx, db } = createMockState();
|
|
84
|
+
const obj = new FsObject(ctx as never, {});
|
|
85
|
+
return { obj, db };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a stub that wraps FsObject methods in Promises to simulate DO RPC.
|
|
90
|
+
* Used by integration and smoke tests.
|
|
91
|
+
*/
|
|
92
|
+
export function createDirectStub(obj: FsObject): DurableObjectStub<FsObject> {
|
|
93
|
+
return new Proxy(obj, {
|
|
94
|
+
get(target, prop: string) {
|
|
95
|
+
const method = (target as Record<string, unknown>)[prop];
|
|
96
|
+
if (typeof method === "function") {
|
|
97
|
+
return (...args: unknown[]) => {
|
|
98
|
+
const result = method.apply(target, args);
|
|
99
|
+
return result instanceof Promise ? result : Promise.resolve(result);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return method;
|
|
103
|
+
},
|
|
104
|
+
}) as unknown as DurableObjectStub<FsObject>;
|
|
105
|
+
}
|