Line data Source code
1 : /// Shared in-memory filesystem backing the Electron filesystem mock used by the storage tests.
2 : ///
3 : /// `native_storage_backend.test.ts` and `composite_storage_backend.test.ts` both exercise the *real*
4 : /// `NativeStorageBackend` against a mocked Electron bridge. Keeping the mock implementation here
5 : /// ensures both tests model the filesystem consistently. Each test file receives an isolated module
6 : /// instance, and resets its store in `beforeEach`.
7 : export interface FsStore {
8 : files: Map<string, string>;
9 : /// Binary files written via `writeFile` / read via `readFile` (e.g. the `.arrow` cache entries),
10 : /// kept separate from the text `files` map so each mock method touches only its own kind.
11 : binFiles: Map<string, Uint8Array>;
12 : dirs: Set<string>;
13 : /// Per-path last-modified time in epoch ms, stamped on every write. Backs the `stat().mtime` the
14 : /// cache eviction policy sorts on. Uses a monotonic counter (not wall-clock) so write order is
15 : /// deterministic across test runs.
16 : mtimes: Map<string, number>;
17 : /// Monotonic clock feeding `mtimes`; bumped on each write.
18 : clock: number;
19 : }
20 :
21 : /// Store shared by the test and its dynamic `vi.mock` factory within one isolated test context.
22 1 : export const fsStore: FsStore = {
23 1 : files: new Map<string, string>(),
24 1 : binFiles: new Map<string, Uint8Array>(),
25 1 : dirs: new Set<string>(),
26 1 : mtimes: new Map<string, number>(),
27 1 : clock: 0,
28 1 : };
29 :
30 : /// Reset the shared store. Call from `beforeEach`.
31 1 : export function resetFsStore(): void {
32 1 : fsStore.files.clear();
33 1 : fsStore.binFiles.clear();
34 1 : fsStore.dirs.clear();
35 1 : fsStore.mtimes.clear();
36 1 : fsStore.clock = 0;
37 1 : }
38 :
39 : /// Build the Electron filesystem mock object over the shared store.
40 1 : export function makeFsMock() {
41 1 : const { files, binFiles, dirs, mtimes } = fsStore;
42 1 : const nextMtime = () => ++fsStore.clock;
43 1 : const parentOf = (p: string) => {
44 1 : const i = p.lastIndexOf('/');
45 1 : return i < 0 ? '' : p.substring(0, i);
46 1 : };
47 1 : const nameOf = (p: string) => {
48 1 : const i = p.lastIndexOf('/');
49 1 : return i < 0 ? p : p.substring(i + 1);
50 1 : };
51 1 : const isAncestorDir = (p: string) => {
52 1 : for (const f of files.keys()) if (f.startsWith(p + '/')) return true;
53 1 : for (const f of binFiles.keys()) if (f.startsWith(p + '/')) return true;
54 1 : for (const d of dirs) if (d.startsWith(p + '/')) return true;
55 1 : return false;
56 1 : };
57 1 : const stampOf = (p: string) => files.has(p) || binFiles.has(p)
58 1 : ? { size: binFiles.get(p)?.byteLength ?? files.get(p)!.length, mtimeMs: mtimes.get(p) ?? 0 }
59 1 : : null;
60 1 : return {
61 1 : exists: async (p: string) => files.has(p) || binFiles.has(p) || dirs.has(p) || isAncestorDir(p),
62 1 : fileStamp: async (p: string) => stampOf(p),
63 1 : listFiles: async (p: string) => [...files.keys(), ...binFiles.keys()]
64 1 : .filter(f => parentOf(f) === p)
65 1 : .map(f => ({ name: nameOf(f), ...stampOf(f)! })),
66 1 : mkdir: async (p: string) => {
67 : // Register the dir and all ancestors, preserving any leading slash (absolute paths)
68 : // the same way writeTextFile's parent walk does.
69 1 : dirs.add(p);
70 1 : let parent = parentOf(p);
71 1 : while (parent) {
72 1 : dirs.add(parent);
73 1 : parent = parentOf(parent);
74 1 : }
75 1 : },
76 1 : readDir: async (p: string) => {
77 1 : const children = new Map<string, { isFile: boolean; isDirectory: boolean }>();
78 1 : for (const f of files.keys()) {
79 1 : if (parentOf(f) === p) children.set(nameOf(f), { isFile: true, isDirectory: false });
80 : }
81 1 : for (const f of binFiles.keys()) {
82 1 : if (parentOf(f) === p) children.set(nameOf(f), { isFile: true, isDirectory: false });
83 : }
84 1 : for (const d of dirs) {
85 1 : if (parentOf(d) === p) children.set(nameOf(d), { isFile: false, isDirectory: true });
86 : }
87 1 : return [...children.entries()].map(([name, kind]) => ({
88 1 : name,
89 1 : isFile: kind.isFile,
90 1 : isDirectory: kind.isDirectory,
91 1 : isSymlink: false,
92 1 : }));
93 1 : },
94 1 : readTextFile: async (p: string) => {
95 1 : if (!files.has(p)) {
96 0 : throw new Error(`File not found: ${p}`);
97 : }
98 1 : return files.get(p)!;
99 1 : },
100 1 : writeTextFile: async (p: string, data: string) => {
101 1 : files.set(p, data);
102 1 : mtimes.set(p, nextMtime());
103 1 : let parent = parentOf(p);
104 1 : while (parent) {
105 1 : dirs.add(parent);
106 1 : parent = parentOf(parent);
107 1 : }
108 1 : return stampOf(p)!;
109 1 : },
110 1 : readFile: async (p: string) => {
111 1 : if (!binFiles.has(p)) {
112 0 : throw new Error(`File not found: ${p}`);
113 : }
114 1 : return binFiles.get(p)!;
115 1 : },
116 1 : writeFile: async (p: string, data: Uint8Array) => {
117 : // Copy so a later mutation of the caller's buffer can't retroactively change stored bytes.
118 1 : binFiles.set(p, new Uint8Array(data));
119 1 : mtimes.set(p, nextMtime());
120 1 : let parent = parentOf(p);
121 1 : while (parent) {
122 1 : dirs.add(parent);
123 1 : parent = parentOf(parent);
124 1 : }
125 1 : },
126 1 : stat: async (p: string) => {
127 1 : const size = binFiles.has(p) ? binFiles.get(p)!.byteLength
128 1 : : files.has(p) ? files.get(p)!.length
129 0 : : 0;
130 1 : if (!binFiles.has(p) && !files.has(p)) {
131 0 : throw new Error(`File not found: ${p}`);
132 : }
133 1 : return {
134 1 : size,
135 1 : mtime: new Date(mtimes.get(p) ?? 0),
136 1 : isFile: true,
137 1 : isDirectory: false,
138 1 : isSymlink: false,
139 1 : };
140 1 : },
141 1 : remove: async (p: string, opts?: { recursive?: boolean }) => {
142 1 : files.delete(p);
143 1 : binFiles.delete(p);
144 1 : mtimes.delete(p);
145 1 : dirs.delete(p);
146 1 : if (opts?.recursive) {
147 0 : for (const f of [...files.keys()]) if (f.startsWith(p + '/')) { files.delete(f); mtimes.delete(f); }
148 0 : for (const f of [...binFiles.keys()]) if (f.startsWith(p + '/')) { binFiles.delete(f); mtimes.delete(f); }
149 0 : for (const d of [...dirs]) if (d.startsWith(p + '/')) dirs.delete(d);
150 0 : }
151 1 : },
152 1 : rename: async (from: string, to: string) => {
153 : // Move a file or a whole directory subtree, re-keying every path prefixed by `from`.
154 : // Mirrors the host filesystem's atomic rename.
155 1 : const reKey = (k: string) => (k === from ? to : k.startsWith(from + '/') ? to + k.substring(from.length) : null);
156 1 : for (const [k, v] of [...files.entries()]) {
157 1 : const nk = reKey(k);
158 1 : if (nk !== null) {
159 1 : files.delete(k);
160 1 : files.set(nk, v);
161 1 : const mt = mtimes.get(k);
162 1 : if (mt !== undefined) { mtimes.delete(k); mtimes.set(nk, mt); }
163 1 : }
164 1 : }
165 1 : for (const [k, v] of [...binFiles.entries()]) {
166 1 : const nk = reKey(k);
167 1 : if (nk !== null) {
168 1 : binFiles.delete(k);
169 1 : binFiles.set(nk, v);
170 1 : const mt = mtimes.get(k);
171 1 : if (mt !== undefined) { mtimes.delete(k); mtimes.set(nk, mt); }
172 1 : }
173 1 : }
174 1 : for (const d of [...dirs]) {
175 1 : const nd = reKey(d);
176 1 : if (nd !== null) {
177 0 : dirs.delete(d);
178 0 : dirs.add(nd);
179 0 : }
180 1 : }
181 1 : let parent = parentOf(to);
182 1 : while (parent) {
183 1 : dirs.add(parent);
184 1 : parent = parentOf(parent);
185 1 : }
186 1 : },
187 1 : };
188 1 : }
189 :
190 : /// Build the path mock object. Tests use "/" separators for simplicity.
191 : export function makePathMock() {
192 : return {
193 : join: async (...parts: string[]) => parts.filter(p => p.length > 0).join('/'),
194 : };
195 : }
|