@flighthq/filesystem 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.
@@ -0,0 +1,909 @@
1
+ import { setDialogBackend } from '@flighthq/dialog';
2
+ import type {
3
+ FileDialogHandle,
4
+ FileEntry,
5
+ FilePermissions,
6
+ FileStat,
7
+ FileSystemBackend,
8
+ FileSystemUsage,
9
+ } from '@flighthq/types';
10
+
11
+ import {
12
+ appendTextFile,
13
+ canAccessFile,
14
+ copyFile,
15
+ createFileSymlink,
16
+ createWebFileSystemBackend,
17
+ directoryExists,
18
+ fileExists,
19
+ findFiles,
20
+ getFileBaseName,
21
+ getFileDirectoryName,
22
+ getFileExtensionName,
23
+ getFilePermissions,
24
+ getFileRealPath,
25
+ getFileSystemBackend,
26
+ getFileSystemPath,
27
+ getFileSystemUsage,
28
+ isAbsoluteFilePath,
29
+ joinFilePath,
30
+ makeDirectory,
31
+ normalizeFilePath,
32
+ openFileReadStream,
33
+ openFileWriteStream,
34
+ readBinaryFile,
35
+ readBinaryFileRange,
36
+ readDialogHandleBinaryFile,
37
+ readDialogHandleTextFile,
38
+ readDirectory,
39
+ readDirectoryRecursive,
40
+ readFileSymlink,
41
+ readTextFile,
42
+ removeDirectory,
43
+ removeFile,
44
+ renameFile,
45
+ setFilePermissions,
46
+ setFileSystemBackend,
47
+ statFile,
48
+ watchPath,
49
+ writeBinaryFile,
50
+ writeBinaryFileChunks,
51
+ writeDialogHandleBinaryFile,
52
+ writeDialogHandleTextFile,
53
+ writeFileAtomic,
54
+ writeTextFile,
55
+ } from './filesystem';
56
+
57
+ function fakeBackend(): FileSystemBackend {
58
+ const files = new Map<string, string>();
59
+ const binary = new Map<string, Uint8Array>();
60
+ const dirs = new Set<string>();
61
+ // Symlink registry: linkPath → target
62
+ const symlinks = new Map<string, string>();
63
+ // Permission registry: path → FilePermissions
64
+ const perms = new Map<string, FilePermissions>();
65
+ return {
66
+ async appendTextFile(path, data) {
67
+ files.set(path, (files.has(path) ? (files.get(path) as string) : '') + data);
68
+ return true;
69
+ },
70
+ async canAccessFile(path, mode) {
71
+ if (mode === 'executable') return false;
72
+ return files.has(path) || binary.has(path) || dirs.has(path);
73
+ },
74
+ async copy(from, to) {
75
+ if (files.has(from)) {
76
+ files.set(to, files.get(from) as string);
77
+ return true;
78
+ }
79
+ if (binary.has(from)) {
80
+ binary.set(to, (binary.get(from) as Uint8Array).slice());
81
+ return true;
82
+ }
83
+ return false;
84
+ },
85
+ async createFileSymlink(target, linkPath) {
86
+ symlinks.set(linkPath, target);
87
+ return true;
88
+ },
89
+ async directoryExists(path) {
90
+ return dirs.has(path);
91
+ },
92
+ async fileExists(path) {
93
+ return files.has(path) || binary.has(path);
94
+ },
95
+ async getFilePermissions(path) {
96
+ return perms.has(path) ? (perms.get(path) as FilePermissions) : null;
97
+ },
98
+ async getFileRealPath(path) {
99
+ return path;
100
+ },
101
+ async getFileSystemUsage() {
102
+ return { usedBytes: 0, quotaBytes: 1024 };
103
+ },
104
+ async makeDirectory(path) {
105
+ dirs.add(path);
106
+ return true;
107
+ },
108
+ async openFileReadStream(path) {
109
+ if (!binary.has(path)) return null;
110
+ const data = (binary.get(path) as Uint8Array).slice();
111
+ return new ReadableStream<Uint8Array>({
112
+ start(controller) {
113
+ controller.enqueue(data);
114
+ controller.close();
115
+ },
116
+ });
117
+ },
118
+ async openFileWriteStream(path) {
119
+ const chunks: Uint8Array[] = [];
120
+ return new WritableStream<Uint8Array>({
121
+ write(chunk) {
122
+ chunks.push(chunk.slice());
123
+ },
124
+ close() {
125
+ // Concatenate all chunks and store in binary map.
126
+ let total = 0;
127
+ for (const c of chunks) total += c.length;
128
+ const merged = new Uint8Array(total);
129
+ let offset = 0;
130
+ for (const c of chunks) {
131
+ merged.set(c, offset);
132
+ offset += c.length;
133
+ }
134
+ binary.set(path, merged);
135
+ },
136
+ });
137
+ },
138
+ async readBinaryFile(path) {
139
+ return binary.has(path) ? (binary.get(path) as Uint8Array) : null;
140
+ },
141
+ async readBinaryFileRange(path, offset, length) {
142
+ if (!binary.has(path)) return null;
143
+ const data = binary.get(path) as Uint8Array;
144
+ if (offset >= data.length) return new Uint8Array(0);
145
+ return data.slice(offset, offset + length);
146
+ },
147
+ async readDirectory(): Promise<FileEntry[]> {
148
+ return [{ name: 'a.txt', path: 'a.txt', isDirectory: false }];
149
+ },
150
+ async readDirectoryRecursive(): Promise<readonly FileEntry[]> {
151
+ return [
152
+ { name: 'a.txt', path: 'a.txt', isDirectory: false },
153
+ { name: 'sub', path: 'sub', isDirectory: true },
154
+ { name: 'b.txt', path: 'sub/b.txt', isDirectory: false },
155
+ ];
156
+ },
157
+ async readFileSymlink(path) {
158
+ return symlinks.has(path) ? (symlinks.get(path) as string) : null;
159
+ },
160
+ async readTextFile(path) {
161
+ return files.has(path) ? (files.get(path) as string) : null;
162
+ },
163
+ async removeDirectory(path, recursive = false) {
164
+ if (!dirs.has(path)) return false;
165
+ dirs.delete(path);
166
+ if (recursive) {
167
+ const prefix = path + '/';
168
+ for (const d of [...dirs]) {
169
+ if (d.startsWith(prefix)) dirs.delete(d);
170
+ }
171
+ }
172
+ return true;
173
+ },
174
+ async removeFile(path) {
175
+ return files.delete(path) || binary.delete(path);
176
+ },
177
+ async rename(from, to) {
178
+ if (files.has(from)) {
179
+ files.set(to, files.get(from) as string);
180
+ files.delete(from);
181
+ return true;
182
+ }
183
+ if (binary.has(from)) {
184
+ binary.set(to, binary.get(from) as Uint8Array);
185
+ binary.delete(from);
186
+ return true;
187
+ }
188
+ return false;
189
+ },
190
+ async setFilePermissions(path, permissions) {
191
+ perms.set(path, permissions);
192
+ return true;
193
+ },
194
+ async statFile(path): Promise<FileStat | null> {
195
+ if (!files.has(path)) return null;
196
+ return {
197
+ size: (files.get(path) as string).length,
198
+ isDirectory: false,
199
+ modifiedTime: 0,
200
+ createdTime: 0,
201
+ isSymlink: false,
202
+ };
203
+ },
204
+ watch() {
205
+ return () => {};
206
+ },
207
+ async writeBinaryFile(path, data) {
208
+ binary.set(path, data.slice());
209
+ return true;
210
+ },
211
+ async writeFileAtomic(path, data) {
212
+ if (typeof data === 'string') {
213
+ files.set(path, data);
214
+ } else {
215
+ binary.set(path, (data as Uint8Array).slice());
216
+ }
217
+ return true;
218
+ },
219
+ async writeTextFile(path, data) {
220
+ files.set(path, data);
221
+ return true;
222
+ },
223
+ getPath() {
224
+ return '/home/user';
225
+ },
226
+ };
227
+ }
228
+
229
+ afterEach(() => {
230
+ setFileSystemBackend(null);
231
+ setDialogBackend(null);
232
+ });
233
+
234
+ describe('appendTextFile', () => {
235
+ it('appends to existing content through the backend', async () => {
236
+ setFileSystemBackend(fakeBackend());
237
+ await writeTextFile('a.txt', 'hello');
238
+ expect(await appendTextFile('a.txt', ' world')).toBe(true);
239
+ expect(await readTextFile('a.txt')).toBe('hello world');
240
+ });
241
+
242
+ it('creates the file when missing', async () => {
243
+ setFileSystemBackend(fakeBackend());
244
+ expect(await appendTextFile('new.txt', 'x')).toBe(true);
245
+ expect(await readTextFile('new.txt')).toBe('x');
246
+ });
247
+
248
+ it('returns false from the web backend without throwing in jsdom', async () => {
249
+ expect(await appendTextFile('a.txt', 'x')).toBe(false);
250
+ });
251
+ });
252
+
253
+ describe('canAccessFile', () => {
254
+ it('returns true for readable files', async () => {
255
+ setFileSystemBackend(fakeBackend());
256
+ await writeTextFile('a.txt', 'x');
257
+ expect(await canAccessFile('a.txt', 'readable')).toBe(true);
258
+ });
259
+
260
+ it('returns false for executable mode (web always false)', async () => {
261
+ setFileSystemBackend(fakeBackend());
262
+ await writeTextFile('a.txt', 'x');
263
+ expect(await canAccessFile('a.txt', 'executable')).toBe(false);
264
+ });
265
+
266
+ it('returns false when file is missing', async () => {
267
+ setFileSystemBackend(fakeBackend());
268
+ expect(await canAccessFile('missing.txt', 'readable')).toBe(false);
269
+ });
270
+
271
+ it('returns false from the web backend without throwing in jsdom', async () => {
272
+ expect(await canAccessFile('a.txt', 'readable')).toBe(false);
273
+ });
274
+ });
275
+
276
+ describe('copyFile', () => {
277
+ it('copies a file through the backend, leaving the source', async () => {
278
+ setFileSystemBackend(fakeBackend());
279
+ await writeTextFile('a.txt', 'hi');
280
+ expect(await copyFile('a.txt', 'b.txt')).toBe(true);
281
+ expect(await readTextFile('a.txt')).toBe('hi');
282
+ expect(await readTextFile('b.txt')).toBe('hi');
283
+ });
284
+
285
+ it('returns false when the source is missing', async () => {
286
+ setFileSystemBackend(fakeBackend());
287
+ expect(await copyFile('missing.txt', 'b.txt')).toBe(false);
288
+ });
289
+
290
+ it('returns false from the web backend without throwing in jsdom', async () => {
291
+ expect(await copyFile('a.txt', 'b.txt')).toBe(false);
292
+ });
293
+ });
294
+
295
+ describe('createFileSymlink', () => {
296
+ it('returns true through a backend that supports symlinks', async () => {
297
+ const backend = fakeBackend();
298
+ // Override createFileSymlink to track calls.
299
+ let called = false;
300
+ const orig = backend.createFileSymlink.bind(backend);
301
+ backend.createFileSymlink = async (target, linkPath) => {
302
+ called = true;
303
+ return orig(target, linkPath);
304
+ };
305
+ setFileSystemBackend(backend);
306
+ expect(await createFileSymlink('/real/path', 'link')).toBe(true);
307
+ expect(called).toBe(true);
308
+ });
309
+
310
+ it('returns false from the web backend without throwing in jsdom', async () => {
311
+ expect(await createFileSymlink('/target', 'link')).toBe(false);
312
+ });
313
+ });
314
+
315
+ describe('createWebFileSystemBackend', () => {
316
+ it('returns sentinels without throwing when OPFS is absent in jsdom', async () => {
317
+ const backend = createWebFileSystemBackend();
318
+ expect(await backend.readTextFile('a.txt')).toBeNull();
319
+ expect(await backend.writeTextFile('a.txt', 'x')).toBe(false);
320
+ expect(await backend.readBinaryFileRange('a.txt', 0, 4)).toBeNull();
321
+ expect(await backend.fileExists('a.txt')).toBe(false);
322
+ expect(await backend.directoryExists('dir')).toBe(false);
323
+ expect(await backend.readDirectory('/')).toEqual([]);
324
+ expect(await backend.readDirectoryRecursive('/')).toEqual([]);
325
+ expect(await backend.statFile('a.txt')).toBeNull();
326
+ expect(await backend.copy('a.txt', 'b.txt')).toBe(false);
327
+ expect(await backend.rename('a.txt', 'b.txt')).toBe(false);
328
+ expect(await backend.removeDirectory('dir', false)).toBe(false);
329
+ expect(await backend.appendTextFile('a.txt', 'x')).toBe(false);
330
+ expect(await backend.openFileReadStream('a.txt')).toBeNull();
331
+ expect(await backend.openFileWriteStream('a.txt')).toBeNull();
332
+ expect(await backend.createFileSymlink('/target', 'link')).toBe(false);
333
+ expect(await backend.readFileSymlink('link')).toBeNull();
334
+ expect(await backend.getFileRealPath('a.txt')).toBeNull();
335
+ expect(await backend.getFilePermissions('a.txt')).toBeNull();
336
+ expect(await backend.setFilePermissions('a.txt', { readable: true, writable: true, executable: false })).toBe(
337
+ false,
338
+ );
339
+ expect(await backend.canAccessFile('a.txt', 'executable')).toBe(false);
340
+ expect(await backend.getFileSystemUsage()).toBeNull();
341
+ expect(typeof backend.watch('a.txt', () => {})).toBe('function');
342
+ expect(backend.getPath('home')).toBe('');
343
+ });
344
+ });
345
+
346
+ describe('directoryExists', () => {
347
+ it('returns true for existing directories', async () => {
348
+ setFileSystemBackend(fakeBackend());
349
+ await makeDirectory('mydir');
350
+ expect(await directoryExists('mydir')).toBe(true);
351
+ });
352
+
353
+ it('returns false for missing directories', async () => {
354
+ setFileSystemBackend(fakeBackend());
355
+ expect(await directoryExists('missing')).toBe(false);
356
+ });
357
+
358
+ it('returns false from the web backend without throwing in jsdom', async () => {
359
+ expect(await directoryExists('dir')).toBe(false);
360
+ });
361
+ });
362
+
363
+ describe('fileExists', () => {
364
+ it('reflects backend state', async () => {
365
+ setFileSystemBackend(fakeBackend());
366
+ expect(await fileExists('a.txt')).toBe(false);
367
+ await writeTextFile('a.txt', 'hi');
368
+ expect(await fileExists('a.txt')).toBe(true);
369
+ });
370
+ });
371
+
372
+ describe('findFiles', () => {
373
+ it('returns entries whose name matches the glob pattern', async () => {
374
+ setFileSystemBackend(fakeBackend());
375
+ // fakeBackend readDirectoryRecursive returns: a.txt, sub (dir), sub/b.txt
376
+ const results = await findFiles('/', '*.txt');
377
+ expect(results.some((e) => e.name === 'a.txt')).toBe(true);
378
+ });
379
+
380
+ it('returns entries matching by path with ** glob', async () => {
381
+ setFileSystemBackend(fakeBackend());
382
+ const results = await findFiles('/', '**/*.txt');
383
+ expect(results.some((e) => e.path === 'sub/b.txt')).toBe(true);
384
+ });
385
+
386
+ it('returns [] from the web backend without throwing in jsdom', async () => {
387
+ expect(await findFiles('/', '*.txt')).toEqual([]);
388
+ });
389
+ });
390
+
391
+ describe('getFileBaseName', () => {
392
+ it('returns the final path segment', () => {
393
+ expect(getFileBaseName('foo/bar.txt')).toBe('bar.txt');
394
+ expect(getFileBaseName('bar.txt')).toBe('bar.txt');
395
+ expect(getFileBaseName('/a/b/c.js')).toBe('c.js');
396
+ });
397
+
398
+ it('returns empty string for empty path', () => {
399
+ expect(getFileBaseName('')).toBe('');
400
+ });
401
+ });
402
+
403
+ describe('getFileDirectoryName', () => {
404
+ it('returns all segments before the last', () => {
405
+ expect(getFileDirectoryName('foo/bar.txt')).toBe('foo');
406
+ expect(getFileDirectoryName('a/b/c.txt')).toBe('a/b');
407
+ });
408
+
409
+ it('returns empty string when there is no directory', () => {
410
+ expect(getFileDirectoryName('bar.txt')).toBe('');
411
+ expect(getFileDirectoryName('')).toBe('');
412
+ });
413
+ });
414
+
415
+ describe('getFileExtensionName', () => {
416
+ it('returns extension including dot', () => {
417
+ expect(getFileExtensionName('foo/bar.txt')).toBe('.txt');
418
+ expect(getFileExtensionName('archive.tar.gz')).toBe('.gz');
419
+ });
420
+
421
+ it('returns empty string when no extension', () => {
422
+ expect(getFileExtensionName('Makefile')).toBe('');
423
+ expect(getFileExtensionName('.hidden')).toBe('');
424
+ expect(getFileExtensionName('')).toBe('');
425
+ });
426
+ });
427
+
428
+ describe('getFilePermissions', () => {
429
+ it('returns null when no permissions are set for the path', async () => {
430
+ setFileSystemBackend(fakeBackend());
431
+ await writeTextFile('a.txt', 'x');
432
+ // fakeBackend returns null unless permissions have been explicitly set.
433
+ expect(await getFilePermissions('a.txt')).toBeNull();
434
+ });
435
+
436
+ it('returns permissions after they have been set', async () => {
437
+ setFileSystemBackend(fakeBackend());
438
+ const p: FilePermissions = { readable: true, writable: false, executable: false };
439
+ await setFilePermissions('a.txt', p);
440
+ const got = await getFilePermissions('a.txt');
441
+ expect(got).not.toBeNull();
442
+ expect(got?.readable).toBe(true);
443
+ expect(got?.writable).toBe(false);
444
+ });
445
+
446
+ it('returns null from the web backend without throwing in jsdom', async () => {
447
+ expect(await getFilePermissions('a.txt')).toBeNull();
448
+ });
449
+ });
450
+
451
+ describe('getFileRealPath', () => {
452
+ it('resolves a path through the backend', async () => {
453
+ setFileSystemBackend(fakeBackend());
454
+ expect(await getFileRealPath('a.txt')).toBe('a.txt');
455
+ });
456
+
457
+ it('returns null from the web backend without throwing in jsdom', async () => {
458
+ expect(await getFileRealPath('a.txt')).toBeNull();
459
+ });
460
+ });
461
+
462
+ describe('getFileSystemBackend', () => {
463
+ it('falls back to a web backend', () => {
464
+ expect(getFileSystemBackend()).not.toBeNull();
465
+ });
466
+
467
+ it('returns the registered backend', () => {
468
+ const backend = fakeBackend();
469
+ setFileSystemBackend(backend);
470
+ expect(getFileSystemBackend()).toBe(backend);
471
+ });
472
+ });
473
+
474
+ describe('getFileSystemPath', () => {
475
+ it('delegates to the active backend', () => {
476
+ setFileSystemBackend(fakeBackend());
477
+ expect(getFileSystemPath('home')).toBe('/home/user');
478
+ });
479
+
480
+ it('returns "" from the web backend', () => {
481
+ expect(getFileSystemPath('documents')).toBe('');
482
+ });
483
+ });
484
+
485
+ describe('getFileSystemUsage', () => {
486
+ it('returns usage through the backend', async () => {
487
+ setFileSystemBackend(fakeBackend());
488
+ const usage = await getFileSystemUsage();
489
+ expect(usage).not.toBeNull();
490
+ expect(typeof usage?.quotaBytes).toBe('number');
491
+ });
492
+
493
+ it('returns null from the web backend without throwing in jsdom', async () => {
494
+ expect(await getFileSystemUsage()).toBeNull();
495
+ });
496
+ });
497
+
498
+ describe('isAbsoluteFilePath', () => {
499
+ it('returns true for Unix absolute paths', () => {
500
+ expect(isAbsoluteFilePath('/foo/bar')).toBe(true);
501
+ expect(isAbsoluteFilePath('/')).toBe(true);
502
+ });
503
+
504
+ it('returns true for Windows drive-letter paths', () => {
505
+ expect(isAbsoluteFilePath('C:/foo')).toBe(true);
506
+ expect(isAbsoluteFilePath('D:\\foo')).toBe(true);
507
+ });
508
+
509
+ it('returns false for relative paths and empty string', () => {
510
+ expect(isAbsoluteFilePath('foo/bar')).toBe(false);
511
+ expect(isAbsoluteFilePath('')).toBe(false);
512
+ expect(isAbsoluteFilePath('relative')).toBe(false);
513
+ });
514
+ });
515
+
516
+ describe('joinFilePath', () => {
517
+ it('joins segments with /', () => {
518
+ expect(joinFilePath('foo', 'bar', 'baz.txt')).toBe('foo/bar/baz.txt');
519
+ });
520
+
521
+ it('collapses redundant separators and . segments', () => {
522
+ expect(joinFilePath('foo/', '/bar', './baz')).toBe('foo/bar/baz');
523
+ expect(joinFilePath('a', '.', 'b')).toBe('a/b');
524
+ });
525
+
526
+ it('preserves leading slash when first segment is absolute', () => {
527
+ expect(joinFilePath('/foo', 'bar')).toBe('/foo/bar');
528
+ });
529
+
530
+ it('returns empty string for empty segments', () => {
531
+ expect(joinFilePath()).toBe('');
532
+ });
533
+ });
534
+
535
+ describe('makeDirectory', () => {
536
+ it('delegates to the active backend', async () => {
537
+ setFileSystemBackend(fakeBackend());
538
+ expect(await makeDirectory('a/b')).toBe(true);
539
+ });
540
+ });
541
+
542
+ describe('normalizeFilePath', () => {
543
+ it('collapses double slashes and dot segments', () => {
544
+ expect(normalizeFilePath('foo//./bar')).toBe('foo/bar');
545
+ expect(normalizeFilePath('./a/./b')).toBe('a/b');
546
+ });
547
+
548
+ it('preserves leading slash', () => {
549
+ expect(normalizeFilePath('/foo//bar')).toBe('/foo/bar');
550
+ });
551
+
552
+ it('returns empty string for empty input', () => {
553
+ expect(normalizeFilePath('')).toBe('');
554
+ });
555
+ });
556
+
557
+ describe('openFileReadStream', () => {
558
+ it('returns a ReadableStream for existing binary files', async () => {
559
+ const backend = fakeBackend();
560
+ setFileSystemBackend(backend);
561
+ await backend.writeBinaryFile('data.bin', new Uint8Array([1, 2, 3]));
562
+ const stream = await openFileReadStream('data.bin');
563
+ expect(stream).not.toBeNull();
564
+ const reader = stream!.getReader();
565
+ const { value } = await reader.read();
566
+ expect(Array.from(value ?? [])).toEqual([1, 2, 3]);
567
+ });
568
+
569
+ it('returns null for missing files', async () => {
570
+ setFileSystemBackend(fakeBackend());
571
+ expect(await openFileReadStream('missing.bin')).toBeNull();
572
+ });
573
+
574
+ it('returns null from the web backend without throwing in jsdom', async () => {
575
+ expect(await openFileReadStream('a.bin')).toBeNull();
576
+ });
577
+ });
578
+
579
+ describe('openFileWriteStream', () => {
580
+ it('returns a WritableStream that writes data to the backend', async () => {
581
+ const backend = fakeBackend();
582
+ setFileSystemBackend(backend);
583
+ const stream = await openFileWriteStream('out.bin');
584
+ expect(stream).not.toBeNull();
585
+ const writer = stream!.getWriter();
586
+ await writer.write(new Uint8Array([4, 5, 6]));
587
+ await writer.close();
588
+ // fakeBackend's openFileWriteStream stores data on close; read it back
589
+ const stored = await backend.readBinaryFile('out.bin');
590
+ expect(Array.from(stored ?? [])).toEqual([4, 5, 6]);
591
+ });
592
+
593
+ it('returns null from the web backend without throwing in jsdom', async () => {
594
+ expect(await openFileWriteStream('a.bin')).toBeNull();
595
+ });
596
+ });
597
+
598
+ describe('readBinaryFile', () => {
599
+ it('round-trips through the backend', async () => {
600
+ setFileSystemBackend(fakeBackend());
601
+ await writeBinaryFile('b.bin', new Uint8Array([1, 2, 3]));
602
+ expect(Array.from((await readBinaryFile('b.bin')) ?? [])).toEqual([1, 2, 3]);
603
+ });
604
+
605
+ it('returns null when missing', async () => {
606
+ setFileSystemBackend(fakeBackend());
607
+ expect(await readBinaryFile('missing.bin')).toBeNull();
608
+ });
609
+ });
610
+
611
+ describe('readBinaryFileRange', () => {
612
+ it('returns the requested slice', async () => {
613
+ setFileSystemBackend(fakeBackend());
614
+ await writeBinaryFile('data.bin', new Uint8Array([10, 20, 30, 40, 50]));
615
+ expect(Array.from((await readBinaryFileRange('data.bin', 1, 3)) ?? [])).toEqual([20, 30, 40]);
616
+ });
617
+
618
+ it('returns empty Uint8Array for out-of-range offset', async () => {
619
+ setFileSystemBackend(fakeBackend());
620
+ await writeBinaryFile('data.bin', new Uint8Array([1, 2, 3]));
621
+ const result = await readBinaryFileRange('data.bin', 100, 4);
622
+ expect(result).not.toBeNull();
623
+ expect(result?.length).toBe(0);
624
+ });
625
+
626
+ it('returns null for missing files', async () => {
627
+ setFileSystemBackend(fakeBackend());
628
+ expect(await readBinaryFileRange('missing.bin', 0, 4)).toBeNull();
629
+ });
630
+
631
+ it('returns null from the web backend without throwing in jsdom', async () => {
632
+ expect(await readBinaryFileRange('a.bin', 0, 4)).toBeNull();
633
+ });
634
+ });
635
+
636
+ describe('readDialogHandleBinaryFile', () => {
637
+ it('reads from handle.path via the backend when path is non-null', async () => {
638
+ setFileSystemBackend(fakeBackend());
639
+ await writeBinaryFile('/tmp/data.bin', new Uint8Array([4, 5, 6]));
640
+ const handle: FileDialogHandle = { kind: 'File', name: 'data.bin', path: '/tmp/data.bin' };
641
+ expect(Array.from((await readDialogHandleBinaryFile(handle)) ?? [])).toEqual([4, 5, 6]);
642
+ });
643
+
644
+ it('returns null when path is null and no web handle is registered', async () => {
645
+ const handle: FileDialogHandle = { kind: 'File', name: 'test.bin', path: null };
646
+ expect(await readDialogHandleBinaryFile(handle)).toBeNull();
647
+ });
648
+
649
+ it('delegates to the fake backend for path-based reads', async () => {
650
+ const backend = fakeBackend();
651
+ setFileSystemBackend(backend);
652
+ await backend.writeBinaryFile('file.bin', new Uint8Array([1, 2]));
653
+ const handle: FileDialogHandle = { kind: 'File', name: 'file.bin', path: 'file.bin' };
654
+ expect(Array.from((await readDialogHandleBinaryFile(handle)) ?? [])).toEqual([1, 2]);
655
+ });
656
+ });
657
+
658
+ describe('readDialogHandleTextFile', () => {
659
+ it('reads from handle.path via the backend when path is non-null', async () => {
660
+ setFileSystemBackend(fakeBackend());
661
+ await writeTextFile('doc.txt', 'hello world');
662
+ const handle: FileDialogHandle = { kind: 'File', name: 'doc.txt', path: 'doc.txt' };
663
+ expect(await readDialogHandleTextFile(handle)).toBe('hello world');
664
+ });
665
+
666
+ it('returns null when path is null and no web handle is registered', async () => {
667
+ const handle: FileDialogHandle = { kind: 'File', name: 'unknown.txt', path: null };
668
+ expect(await readDialogHandleTextFile(handle)).toBeNull();
669
+ });
670
+
671
+ it('reads a file using a path-based dialog handle (cellular round-trip)', async () => {
672
+ // This test simulates the full round-trip: a dialog handle with a real path (as produced by
673
+ // native Electron/Tauri backends) is passed to readDialogHandleTextFile which delegates to the
674
+ // active filesystem backend. On web, path is null and getWebFileSystemHandle provides the handle.
675
+ setFileSystemBackend(fakeBackend());
676
+ await writeTextFile('picked.txt', 'content');
677
+ // Simulate a dialog handle as returned by a native backend (path is non-null on native).
678
+ const handle: FileDialogHandle = { kind: 'File', name: 'picked.txt', path: 'picked.txt' };
679
+ expect(await readDialogHandleTextFile(handle)).toBe('content');
680
+ });
681
+ });
682
+
683
+ describe('readDirectory', () => {
684
+ it('delegates to the active backend', async () => {
685
+ setFileSystemBackend(fakeBackend());
686
+ expect(await readDirectory('/')).toEqual([{ name: 'a.txt', path: 'a.txt', isDirectory: false }]);
687
+ });
688
+ });
689
+
690
+ describe('readDirectoryRecursive', () => {
691
+ it('returns all descendants through the backend', async () => {
692
+ setFileSystemBackend(fakeBackend());
693
+ const entries = await readDirectoryRecursive('/');
694
+ expect(entries.length).toBe(3);
695
+ expect(entries.some((e) => e.path === 'sub/b.txt')).toBe(true);
696
+ });
697
+
698
+ it('returns [] from the web backend without throwing in jsdom', async () => {
699
+ expect(await readDirectoryRecursive('/')).toEqual([]);
700
+ });
701
+ });
702
+
703
+ describe('readFileSymlink', () => {
704
+ it('returns null for a regular file', async () => {
705
+ setFileSystemBackend(fakeBackend());
706
+ await writeTextFile('a.txt', 'x');
707
+ expect(await readFileSymlink('a.txt')).toBeNull();
708
+ });
709
+
710
+ it('returns the symlink target after createFileSymlink', async () => {
711
+ const backend = fakeBackend();
712
+ setFileSystemBackend(backend);
713
+ await backend.createFileSymlink('/real/file.txt', 'link.txt');
714
+ expect(await readFileSymlink('link.txt')).toBe('/real/file.txt');
715
+ });
716
+
717
+ it('returns null from the web backend without throwing in jsdom', async () => {
718
+ expect(await readFileSymlink('link.txt')).toBeNull();
719
+ });
720
+ });
721
+
722
+ describe('readTextFile', () => {
723
+ it('round-trips through the backend', async () => {
724
+ setFileSystemBackend(fakeBackend());
725
+ await writeTextFile('a.txt', 'hello');
726
+ expect(await readTextFile('a.txt')).toBe('hello');
727
+ });
728
+ });
729
+
730
+ describe('removeDirectory', () => {
731
+ it('removes a directory through the backend', async () => {
732
+ setFileSystemBackend(fakeBackend());
733
+ await makeDirectory('mydir');
734
+ expect(await directoryExists('mydir')).toBe(true);
735
+ expect(await removeDirectory('mydir')).toBe(true);
736
+ expect(await directoryExists('mydir')).toBe(false);
737
+ });
738
+
739
+ it('returns false for missing directories', async () => {
740
+ setFileSystemBackend(fakeBackend());
741
+ expect(await removeDirectory('missing')).toBe(false);
742
+ });
743
+
744
+ it('removes recursively when recursive is true', async () => {
745
+ setFileSystemBackend(fakeBackend());
746
+ await makeDirectory('parent');
747
+ await makeDirectory('parent/child');
748
+ expect(await removeDirectory('parent', true)).toBe(true);
749
+ });
750
+
751
+ it('returns false from the web backend without throwing in jsdom', async () => {
752
+ expect(await removeDirectory('dir')).toBe(false);
753
+ });
754
+ });
755
+
756
+ describe('removeFile', () => {
757
+ it('removes via the active backend', async () => {
758
+ setFileSystemBackend(fakeBackend());
759
+ await writeTextFile('a.txt', 'x');
760
+ expect(await removeFile('a.txt')).toBe(true);
761
+ expect(await fileExists('a.txt')).toBe(false);
762
+ });
763
+ });
764
+
765
+ describe('renameFile', () => {
766
+ it('moves a file through the backend', async () => {
767
+ setFileSystemBackend(fakeBackend());
768
+ await writeTextFile('a.txt', 'hi');
769
+ expect(await renameFile('a.txt', 'b.txt')).toBe(true);
770
+ expect(await readTextFile('a.txt')).toBeNull();
771
+ expect(await readTextFile('b.txt')).toBe('hi');
772
+ });
773
+
774
+ it('returns false when the source is missing', async () => {
775
+ setFileSystemBackend(fakeBackend());
776
+ expect(await renameFile('missing.txt', 'b.txt')).toBe(false);
777
+ });
778
+
779
+ it('returns false from the web backend without throwing in jsdom', async () => {
780
+ expect(await renameFile('a.txt', 'b.txt')).toBe(false);
781
+ });
782
+ });
783
+
784
+ describe('setFilePermissions', () => {
785
+ it('delegates to the backend', async () => {
786
+ setFileSystemBackend(fakeBackend());
787
+ const perms: FilePermissions = { readable: true, writable: false, executable: false };
788
+ expect(await setFilePermissions('a.txt', perms)).toBe(true);
789
+ });
790
+
791
+ it('returns false from the web backend without throwing in jsdom', async () => {
792
+ const perms: FilePermissions = { readable: true, writable: false, executable: false };
793
+ expect(await setFilePermissions('a.txt', perms)).toBe(false);
794
+ });
795
+ });
796
+
797
+ describe('setFileSystemBackend', () => {
798
+ it('clears back to the web fallback when passed null', () => {
799
+ setFileSystemBackend(fakeBackend());
800
+ setFileSystemBackend(null);
801
+ expect(getFileSystemBackend()).not.toBeNull();
802
+ });
803
+ });
804
+
805
+ describe('statFile', () => {
806
+ it('reports size via the active backend', async () => {
807
+ setFileSystemBackend(fakeBackend());
808
+ await writeTextFile('a.txt', 'abcd');
809
+ expect((await statFile('a.txt'))?.size).toBe(4);
810
+ });
811
+ });
812
+
813
+ describe('watchPath', () => {
814
+ it('returns an unsubscribe function from the active backend', () => {
815
+ setFileSystemBackend(fakeBackend());
816
+ const unsubscribe = watchPath('a.txt', () => {});
817
+ expect(typeof unsubscribe).toBe('function');
818
+ unsubscribe();
819
+ });
820
+
821
+ it('returns a callable no-op from the web backend', () => {
822
+ const unsubscribe = watchPath('a.txt', () => {});
823
+ expect(typeof unsubscribe).toBe('function');
824
+ expect(() => unsubscribe()).not.toThrow();
825
+ });
826
+ });
827
+
828
+ describe('writeBinaryFile', () => {
829
+ it('writes via the active backend', async () => {
830
+ setFileSystemBackend(fakeBackend());
831
+ expect(await writeBinaryFile('b.bin', new Uint8Array([9]))).toBe(true);
832
+ });
833
+ });
834
+
835
+ describe('writeBinaryFileChunks', () => {
836
+ it('writes all chunks to the file via the stream', async () => {
837
+ const backend = fakeBackend();
838
+ setFileSystemBackend(backend);
839
+ async function* makeChunks(): AsyncIterable<Uint8Array> {
840
+ yield new Uint8Array([1, 2]);
841
+ yield new Uint8Array([3, 4, 5]);
842
+ }
843
+ expect(await writeBinaryFileChunks('chunked.bin', makeChunks())).toBe(true);
844
+ const stored = await backend.readBinaryFile('chunked.bin');
845
+ expect(Array.from(stored ?? [])).toEqual([1, 2, 3, 4, 5]);
846
+ });
847
+
848
+ it('returns false when the stream cannot be opened (jsdom web backend)', async () => {
849
+ async function* makeChunks(): AsyncIterable<Uint8Array> {
850
+ yield new Uint8Array([1]);
851
+ }
852
+ expect(await writeBinaryFileChunks('a.bin', makeChunks())).toBe(false);
853
+ });
854
+ });
855
+
856
+ describe('writeDialogHandleBinaryFile', () => {
857
+ it('writes to handle.path via the backend when path is non-null', async () => {
858
+ setFileSystemBackend(fakeBackend());
859
+ const handle: FileDialogHandle = { kind: 'File', name: 'out.bin', path: 'out.bin' };
860
+ expect(await writeDialogHandleBinaryFile(handle, new Uint8Array([7, 8, 9]))).toBe(true);
861
+ expect(Array.from((await readBinaryFile('out.bin')) ?? [])).toEqual([7, 8, 9]);
862
+ });
863
+
864
+ it('returns false when path is null and no web handle is registered', async () => {
865
+ const handle: FileDialogHandle = { kind: 'File', name: 'out.bin', path: null };
866
+ expect(await writeDialogHandleBinaryFile(handle, new Uint8Array([1]))).toBe(false);
867
+ });
868
+ });
869
+
870
+ describe('writeDialogHandleTextFile', () => {
871
+ it('writes to handle.path via the backend when path is non-null', async () => {
872
+ setFileSystemBackend(fakeBackend());
873
+ const handle: FileDialogHandle = { kind: 'File', name: 'out.txt', path: 'out.txt' };
874
+ expect(await writeDialogHandleTextFile(handle, 'saved content')).toBe(true);
875
+ expect(await readTextFile('out.txt')).toBe('saved content');
876
+ });
877
+
878
+ it('returns false when path is null and no web handle is registered', async () => {
879
+ const handle: FileDialogHandle = { kind: 'File', name: 'out.txt', path: null };
880
+ expect(await writeDialogHandleTextFile(handle, 'data')).toBe(false);
881
+ });
882
+ });
883
+
884
+ describe('writeFileAtomic', () => {
885
+ it('writes binary data to the file atomically', async () => {
886
+ setFileSystemBackend(fakeBackend());
887
+ expect(await writeFileAtomic('atomic.bin', new Uint8Array([10, 20]))).toBe(true);
888
+ const stored = await readBinaryFile('atomic.bin');
889
+ expect(Array.from(stored ?? [])).toEqual([10, 20]);
890
+ });
891
+
892
+ it('writes text data to the file atomically', async () => {
893
+ setFileSystemBackend(fakeBackend());
894
+ expect(await writeFileAtomic('atomic.txt', 'hello')).toBe(true);
895
+ expect(await readTextFile('atomic.txt')).toBe('hello');
896
+ });
897
+
898
+ it('returns false from the web backend without throwing in jsdom', async () => {
899
+ expect(await writeFileAtomic('atomic.txt', 'data')).toBe(false);
900
+ });
901
+ });
902
+
903
+ describe('writeTextFile', () => {
904
+ it('writes via the active backend', async () => {
905
+ setFileSystemBackend(fakeBackend());
906
+ expect(await writeTextFile('a.txt', 'z')).toBe(true);
907
+ expect(await readTextFile('a.txt')).toBe('z');
908
+ });
909
+ });