@filen/sync 0.2.1 → 0.3.2

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.
Files changed (152) hide show
  1. package/.node-version +1 -1
  2. package/dist/ignorer.d.ts +6 -0
  3. package/dist/ignorer.js +43 -24
  4. package/dist/ignorer.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deltas.d.ts +58 -2
  9. package/dist/lib/deltas.js +693 -108
  10. package/dist/lib/deltas.js.map +1 -1
  11. package/dist/lib/environment.d.ts +47 -0
  12. package/dist/lib/environment.js +71 -0
  13. package/dist/lib/environment.js.map +1 -0
  14. package/dist/lib/filesystems/dirTree.d.ts +70 -0
  15. package/dist/lib/filesystems/dirTree.js +157 -0
  16. package/dist/lib/filesystems/dirTree.js.map +1 -0
  17. package/dist/lib/filesystems/local.d.ts +18 -8
  18. package/dist/lib/filesystems/local.js +166 -160
  19. package/dist/lib/filesystems/local.js.map +1 -1
  20. package/dist/lib/filesystems/remote.d.ts +12 -5
  21. package/dist/lib/filesystems/remote.js +226 -172
  22. package/dist/lib/filesystems/remote.js.map +1 -1
  23. package/dist/lib/ipc.js +1 -2
  24. package/dist/lib/ipc.js.map +1 -1
  25. package/dist/lib/lock.js +19 -12
  26. package/dist/lib/lock.js.map +1 -1
  27. package/dist/lib/logger.js +9 -7
  28. package/dist/lib/logger.js.map +1 -1
  29. package/dist/lib/state.js +159 -63
  30. package/dist/lib/state.js.map +1 -1
  31. package/dist/lib/sync.d.ts +18 -0
  32. package/dist/lib/sync.js +165 -96
  33. package/dist/lib/sync.js.map +1 -1
  34. package/dist/lib/tasks.d.ts +7 -8
  35. package/dist/lib/tasks.js +38 -45
  36. package/dist/lib/tasks.js.map +1 -1
  37. package/dist/semaphore.d.ts +1 -0
  38. package/dist/semaphore.js +22 -5
  39. package/dist/semaphore.js.map +1 -1
  40. package/dist/utils.js +51 -35
  41. package/dist/utils.js.map +1 -1
  42. package/eslint.config.mjs +36 -0
  43. package/package.json +19 -15
  44. package/tests/bench/collapse.bench.ts +114 -0
  45. package/tests/bench/cycle.bench.ts +111 -0
  46. package/tests/bench/deltas.bench.ts +151 -0
  47. package/tests/bench/harness/fake-sync.ts +32 -0
  48. package/tests/bench/harness/measure.ts +276 -0
  49. package/tests/bench/harness/scale-world.ts +160 -0
  50. package/tests/bench/harness/trees.ts +275 -0
  51. package/tests/bench/local-scan.bench.ts +74 -0
  52. package/tests/bench/longrun.bench.ts +130 -0
  53. package/tests/bench/profile-incremental.ts +90 -0
  54. package/tests/bench/remote-build.bench.ts +104 -0
  55. package/tests/bench/render.ts +14 -0
  56. package/tests/bench/semaphore.bench.ts +79 -0
  57. package/tests/bench/state.bench.ts +85 -0
  58. package/tests/bench/tasks-dispatch.bench.ts +156 -0
  59. package/tests/conformance/virtual-fs.test.ts +213 -0
  60. package/tests/e2e/backup.e2e.test.ts +130 -0
  61. package/tests/e2e/confirm.e2e.test.ts +191 -0
  62. package/tests/e2e/conflict.e2e.test.ts +261 -0
  63. package/tests/e2e/edge.e2e.test.ts +339 -0
  64. package/tests/e2e/harness/account.ts +104 -0
  65. package/tests/e2e/harness/assert.ts +127 -0
  66. package/tests/e2e/harness/drive.ts +88 -0
  67. package/tests/e2e/harness/mutations.ts +249 -0
  68. package/tests/e2e/harness/world.ts +222 -0
  69. package/tests/e2e/ignore.e2e.test.ts +123 -0
  70. package/tests/e2e/lifecycle.e2e.test.ts +290 -0
  71. package/tests/e2e/modes.e2e.test.ts +215 -0
  72. package/tests/e2e/platform.e2e.test.ts +157 -0
  73. package/tests/e2e/property.e2e.test.ts +163 -0
  74. package/tests/e2e/races.e2e.test.ts +90 -0
  75. package/tests/e2e/regressions.e2e.test.ts +212 -0
  76. package/tests/e2e/resilience.e2e.test.ts +231 -0
  77. package/tests/e2e/special.e2e.test.ts +185 -0
  78. package/tests/e2e/state.e2e.test.ts +229 -0
  79. package/tests/e2e/sync.e2e.test.ts +222 -0
  80. package/tests/fakes/fake-cloud.test.ts +267 -0
  81. package/tests/fakes/fake-cloud.ts +1094 -0
  82. package/tests/fakes/virtual-fs.ts +354 -0
  83. package/tests/harness/known-bug.ts +17 -0
  84. package/tests/harness/mutations.ts +65 -0
  85. package/tests/harness/runner.ts +141 -0
  86. package/tests/harness/snapshot.ts +113 -0
  87. package/tests/harness/world.ts +187 -0
  88. package/tests/scenarios/a-baseline.test.ts +107 -0
  89. package/tests/scenarios/aa-races.test.ts +258 -0
  90. package/tests/scenarios/ab-mode-property.test.ts +189 -0
  91. package/tests/scenarios/ac-platform.test.ts +320 -0
  92. package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
  93. package/tests/scenarios/b-additions.test.ts +160 -0
  94. package/tests/scenarios/c-modifications.test.ts +194 -0
  95. package/tests/scenarios/d-deletions.test.ts +259 -0
  96. package/tests/scenarios/e-rename-move.test.ts +288 -0
  97. package/tests/scenarios/f-ignore-filter.test.ts +346 -0
  98. package/tests/scenarios/g-large-deletion.test.ts +277 -0
  99. package/tests/scenarios/h-resilience.test.ts +167 -0
  100. package/tests/scenarios/i-lifecycle.test.ts +353 -0
  101. package/tests/scenarios/j-state-cache.test.ts +264 -0
  102. package/tests/scenarios/k-scale.test.ts +202 -0
  103. package/tests/scenarios/l-property.test.ts +145 -0
  104. package/tests/scenarios/m-golden.test.ts +452 -0
  105. package/tests/scenarios/o-task-errors.test.ts +497 -0
  106. package/tests/scenarios/p-remote-originated.test.ts +306 -0
  107. package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
  108. package/tests/scenarios/r-rename-stress.test.ts +208 -0
  109. package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
  110. package/tests/scenarios/t-type-change.test.ts +144 -0
  111. package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
  112. package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
  113. package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
  114. package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
  115. package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
  116. package/tests/scenarios/z-cross-ops.test.ts +285 -0
  117. package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
  118. package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
  119. package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
  120. package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
  121. package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
  122. package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
  123. package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
  124. package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
  125. package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
  126. package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
  127. package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
  128. package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
  129. package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
  130. package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
  131. package/tests/unit/collapse-deltas.test.ts +276 -0
  132. package/tests/unit/dir-tree.test.ts +159 -0
  133. package/tests/unit/icloud.test.ts +115 -0
  134. package/tests/unit/ignorer-cache-regression.test.ts +70 -0
  135. package/tests/unit/ignorer.test.ts +63 -0
  136. package/tests/unit/ipc-lock.test.ts +438 -0
  137. package/tests/unit/lock.test.ts +135 -0
  138. package/tests/unit/n-unit.test.ts +632 -0
  139. package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
  140. package/tests/unit/semaphore-regression.test.ts +140 -0
  141. package/tests/unit/state-refencode-regression.test.ts +224 -0
  142. package/tests/unit/state.test.ts +809 -0
  143. package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
  144. package/tests/unit/worker-api.test.ts +379 -0
  145. package/tsconfig.json +10 -1
  146. package/tsconfig.test.json +12 -0
  147. package/tsconfig.tsbuildinfo +1 -0
  148. package/vitest.bench.config.ts +32 -0
  149. package/vitest.config.ts +27 -0
  150. package/vitest.e2e.config.ts +68 -0
  151. package/.eslintrc +0 -16
  152. package/jest.config.js +0 -5
@@ -0,0 +1,354 @@
1
+ import { Volume, createFsFromVolume, type IFs } from "memfs"
2
+ import pathModule from "path"
3
+ import { type SyncFS, type SyncGlobFS } from "../../src/lib/environment"
4
+
5
+ /**
6
+ * Declarative description of an initial in-memory tree.
7
+ *
8
+ * Key = absolute path (e.g. "/local/a.txt").
9
+ * Value = `string` → a file with that UTF-8 content,
10
+ * `VfsFileSpec` → a file with content and/or an explicit mtime,
11
+ * `null` → an (empty) directory.
12
+ *
13
+ * Intermediate directories are created automatically.
14
+ */
15
+ export type VfsFileSpec = { content?: string; mtimeMs?: number }
16
+ export type VfsSpec = Record<string, string | VfsFileSpec | null>
17
+
18
+ export type VirtualFS = {
19
+ /** The {@link SyncFS} surface to inject into the engine's environment. */
20
+ fs: SyncFS
21
+ /** The fast-glob filesystem adapter to inject as `globFs`. */
22
+ globFs: SyncGlobFS
23
+ /** The underlying memfs volume (for advanced manipulation in tests). */
24
+ vol: InstanceType<typeof Volume>
25
+ /** The memfs fs implementation backing both {@link fs} and {@link globFs}. */
26
+ ifs: IFs
27
+ controls: {
28
+ /** Current inode of a path, or null if it does not exist. */
29
+ getInode(path: string): number | null
30
+ /**
31
+ * Force `stat`/`lstat` of `path` to report inode `ino`, simulating an OS (ext4) that recycles a
32
+ * freed inode number for the next-created file. memfs has no native reuse, so this is the only way
33
+ * to reproduce the inode-reuse rename misdetection deterministically. Use the real posix path the
34
+ * engine will stat (e.g. "/local/c.txt").
35
+ */
36
+ setInode(path: string, ino: number): void
37
+ /** Remove a previously forced inode for `path`. */
38
+ clearInode(path: string): void
39
+ /** Whether a path currently exists (no symlink following beyond stat). */
40
+ exists(path: string): boolean
41
+ /** Force the next fs operation that touches `path` to throw `error`. */
42
+ setError(path: string, error: NodeJS.ErrnoException): void
43
+ /** Remove a previously injected error for `path`. */
44
+ clearError(path: string): void
45
+ /** Remove all injected errors. */
46
+ clearAllErrors(): void
47
+ /**
48
+ * Register a callback invoked synchronously AFTER every `lstat`/`stat` returns, with the posix path
49
+ * just stat'd. Lets a test edit a file mid-scan (after the engine has already read it) to reproduce a
50
+ * read-during-scan race deterministically. The callback should gate itself (path match + a one-shot
51
+ * flag) and is cleared with {@link clearStatHook}. Only one hook is active at a time.
52
+ */
53
+ onStat(hook: (posixPath: string) => void): void
54
+ /** Remove the {@link onStat} hook. */
55
+ clearStatHook(): void
56
+ /** Flat JSON view of the volume (file path → content, dir → null). */
57
+ toJSON(): Record<string, string | null>
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Build a Node `ErrnoException` with a `code` (e.g. "ENOENT", "EACCES").
63
+ */
64
+ export function makeErrnoError(code: string, message?: string): NodeJS.ErrnoException {
65
+ const error = new Error(message ?? code) as NodeJS.ErrnoException
66
+
67
+ error.code = code
68
+
69
+ return error
70
+ }
71
+
72
+ /**
73
+ * Normalize a path the ENGINE produced into the posix form memfs understands.
74
+ *
75
+ * memfs is strictly posix (it rejects backslashes), and the virtual tree is seeded with `/`-paths. But
76
+ * the engine joins local paths with the host's real `path` module, so on a Windows runner it emits
77
+ * backslash separators — and FastGlob/@nodelib may resolve the posix `cwd` to a drive-rooted absolute
78
+ * like `C:\local\...`. Stripping a leading drive letter and converting `\`→`/` lets the in-memory fs
79
+ * accept whatever the host's path module emits, so the identical suite runs on win32/darwin/linux.
80
+ *
81
+ * On posix hosts this is a no-op (no drive letter, no backslashes). Only paths the engine passes in are
82
+ * normalized here; test-side `ifs`/`vol` access stays posix (tests always use `/`).
83
+ */
84
+ export function toPosixPath<T>(path: T): T {
85
+ return (typeof path === "string" ? path.replace(/^[a-zA-Z]:(?=[\\/])/, "").replace(/\\/g, "/") : path) as T
86
+ }
87
+
88
+ /**
89
+ * Materialize a {@link VfsSpec} into a memfs filesystem.
90
+ */
91
+ export function applyVfsSpec(ifs: IFs, spec: VfsSpec): void {
92
+ for (const [path, value] of Object.entries(spec)) {
93
+ if (value === null) {
94
+ ifs.mkdirSync(path, { recursive: true })
95
+
96
+ continue
97
+ }
98
+
99
+ const content = typeof value === "string" ? value : value.content ?? ""
100
+
101
+ ifs.mkdirSync(pathModule.posix.dirname(path), { recursive: true })
102
+ ifs.writeFileSync(path, content)
103
+
104
+ const mtimeMs = typeof value === "string" ? undefined : value.mtimeMs
105
+
106
+ if (typeof mtimeMs === "number") {
107
+ const seconds = mtimeMs / 1000
108
+
109
+ ifs.utimesSync(path, seconds, seconds)
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Create an in-memory filesystem that satisfies the engine's {@link SyncFS} and
116
+ * {@link SyncGlobFS} contracts. Backed by memfs (battle-tested), with the
117
+ * fs-extra conveniences the engine relies on (`ensureDir`, `exists`,
118
+ * `pathExists`, `move`) layered on top, plus a per-path error-injection map for
119
+ * resilience tests.
120
+ */
121
+ export function createVirtualFS(initial: VfsSpec = {}): VirtualFS {
122
+ const vol = new Volume()
123
+ const ifs = createFsFromVolume(vol)
124
+
125
+ applyVfsSpec(ifs, initial)
126
+
127
+ const errors = new Map<string, NodeJS.ErrnoException>()
128
+ // Forced inode numbers (posix path -> ino) so a test can reproduce ext4-style inode reuse, which
129
+ // memfs's allocator does not surface naturally.
130
+ const inodeOverrides = new Map<string, number>()
131
+ // Optional one-shot-friendly hook fired after each lstat/stat returns, so a test can mutate a file
132
+ // mid-scan (after the engine read it) to reproduce a read-during-scan race deterministically.
133
+ let statHook: ((posixPath: string) => void) | null = null
134
+ // `guard` is always called with an already-posix-normalized path (each method normalizes its inputs
135
+ // at entry), so the error map — keyed by the posix paths tests inject — matches on every host.
136
+ const guard = (path: string): void => {
137
+ const error = errors.get(path)
138
+
139
+ if (error) {
140
+ throw error
141
+ }
142
+ }
143
+
144
+ const promises = ifs.promises
145
+
146
+ const fs = {
147
+ constants: ifs.constants,
148
+ stat: async (path: string) => {
149
+ path = toPosixPath(path)
150
+ guard(path)
151
+
152
+ const stats = await promises.stat(path)
153
+ const overriddenInode = inodeOverrides.get(path)
154
+
155
+ if (overriddenInode !== undefined) {
156
+ stats.ino = overriddenInode
157
+ }
158
+
159
+ if (statHook) {
160
+ statHook(path)
161
+ }
162
+
163
+ return stats
164
+ },
165
+ lstat: async (path: string) => {
166
+ path = toPosixPath(path)
167
+ guard(path)
168
+
169
+ const stats = await promises.lstat(path)
170
+ const overriddenInode = inodeOverrides.get(path)
171
+
172
+ if (overriddenInode !== undefined) {
173
+ stats.ino = overriddenInode
174
+ }
175
+
176
+ if (statHook) {
177
+ statHook(path)
178
+ }
179
+
180
+ return stats
181
+ },
182
+ access: async (path: string, mode?: number) => {
183
+ path = toPosixPath(path)
184
+ guard(path)
185
+
186
+ return await promises.access(path, mode)
187
+ },
188
+ exists: async (path: string): Promise<boolean> => {
189
+ try {
190
+ path = toPosixPath(path)
191
+ guard(path)
192
+
193
+ await promises.access(path)
194
+
195
+ return true
196
+ } catch {
197
+ return false
198
+ }
199
+ },
200
+ pathExists: async (path: string): Promise<boolean> => {
201
+ try {
202
+ path = toPosixPath(path)
203
+ guard(path)
204
+
205
+ await promises.access(path)
206
+
207
+ return true
208
+ } catch {
209
+ return false
210
+ }
211
+ },
212
+ ensureDir: async (path: string): Promise<void> => {
213
+ path = toPosixPath(path)
214
+ guard(path)
215
+
216
+ await promises.mkdir(path, { recursive: true })
217
+ },
218
+ mkdir: async (path: string, options?: { recursive?: boolean }) => {
219
+ path = toPosixPath(path)
220
+ guard(path)
221
+
222
+ return await promises.mkdir(path, options)
223
+ },
224
+ rm: async (
225
+ path: string,
226
+ options?: { force?: boolean; maxRetries?: number; recursive?: boolean; retryDelay?: number }
227
+ ): Promise<void> => {
228
+ path = toPosixPath(path)
229
+ guard(path)
230
+
231
+ await promises.rm(path, options)
232
+ },
233
+ rename: async (src: string, dest: string): Promise<void> => {
234
+ src = toPosixPath(src)
235
+ dest = toPosixPath(dest)
236
+ guard(src)
237
+
238
+ await promises.rename(src, dest)
239
+ },
240
+ move: async (src: string, dest: string, options?: { overwrite?: boolean }): Promise<void> => {
241
+ src = toPosixPath(src)
242
+ dest = toPosixPath(dest)
243
+ guard(src)
244
+
245
+ await promises.mkdir(pathModule.posix.dirname(dest), { recursive: true })
246
+
247
+ if (options?.overwrite) {
248
+ try {
249
+ await promises.rm(dest, { recursive: true, force: true })
250
+ } catch {
251
+ // destination did not exist — nothing to overwrite
252
+ }
253
+ }
254
+
255
+ await promises.rename(src, dest)
256
+ },
257
+ utimes: async (path: string, atime: number | Date, mtime: number | Date): Promise<void> => {
258
+ path = toPosixPath(path)
259
+ guard(path)
260
+
261
+ await promises.utimes(path, atime, mtime)
262
+ },
263
+ readFile: async (path: string, options?: { encoding?: BufferEncoding }) => {
264
+ path = toPosixPath(path)
265
+ guard(path)
266
+
267
+ return await promises.readFile(path, options)
268
+ },
269
+ writeFile: async (path: string, data: string | Uint8Array, options?: { encoding?: BufferEncoding }): Promise<void> => {
270
+ path = toPosixPath(path)
271
+ guard(path)
272
+
273
+ await promises.writeFile(path, data, options)
274
+ },
275
+ createReadStream: (path: string, options?: Parameters<IFs["createReadStream"]>[1]) => {
276
+ path = toPosixPath(path)
277
+ guard(path)
278
+
279
+ return ifs.createReadStream(path, options)
280
+ },
281
+ createWriteStream: (path: string, options?: Parameters<IFs["createWriteStream"]>[1]) => {
282
+ path = toPosixPath(path)
283
+ guard(path)
284
+
285
+ return ifs.createWriteStream(path, options)
286
+ }
287
+ }
288
+
289
+ // FastGlob walks the tree through this adapter (lstat/stat/readdir + sync variants). On a Windows
290
+ // runner @nodelib builds child paths with the host separator and may resolve the posix `cwd` to a
291
+ // drive-rooted absolute, so every path it hands us is posix-normalized before reaching memfs. The
292
+ // returned glob entries are already forward-slash (fast-glob normalizes its output on all platforms).
293
+ const globFs = {
294
+ lstat: (path: string, ...rest: unknown[]) => (ifs.lstat as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
295
+ lstatSync: (path: string, ...rest: unknown[]) => (ifs.lstatSync as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
296
+ stat: (path: string, ...rest: unknown[]) => (ifs.stat as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
297
+ statSync: (path: string, ...rest: unknown[]) => (ifs.statSync as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
298
+ readdir: (path: string, ...rest: unknown[]) => (ifs.readdir as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
299
+ readdirSync: (path: string, ...rest: unknown[]) => (ifs.readdirSync as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest)
300
+ }
301
+
302
+ const controls: VirtualFS["controls"] = {
303
+ // Every path-keyed control normalizes with toPosixPath, exactly as the fs methods do before they
304
+ // consult these maps. Tests pass either posix literals (a no-op here) or engine-computed paths,
305
+ // which on Windows carry host backslash separators — without this an injected key would never
306
+ // match the posix path the engine actually stats/opens.
307
+ getInode: (path: string): number | null => {
308
+ try {
309
+ return Number(vol.statSync(toPosixPath(path)).ino)
310
+ } catch {
311
+ return null
312
+ }
313
+ },
314
+ exists: (path: string): boolean => {
315
+ try {
316
+ vol.statSync(toPosixPath(path))
317
+
318
+ return true
319
+ } catch {
320
+ return false
321
+ }
322
+ },
323
+ setInode: (path: string, ino: number): void => {
324
+ inodeOverrides.set(toPosixPath(path), ino)
325
+ },
326
+ clearInode: (path: string): void => {
327
+ inodeOverrides.delete(toPosixPath(path))
328
+ },
329
+ setError: (path: string, error: NodeJS.ErrnoException): void => {
330
+ errors.set(toPosixPath(path), error)
331
+ },
332
+ clearError: (path: string): void => {
333
+ errors.delete(toPosixPath(path))
334
+ },
335
+ clearAllErrors: (): void => {
336
+ errors.clear()
337
+ },
338
+ onStat: (hook: (posixPath: string) => void): void => {
339
+ statHook = hook
340
+ },
341
+ clearStatHook: (): void => {
342
+ statHook = null
343
+ },
344
+ toJSON: (): Record<string, string | null> => vol.toJSON()
345
+ }
346
+
347
+ return {
348
+ fs: fs as unknown as SyncFS,
349
+ globFs: globFs as unknown as SyncGlobFS,
350
+ vol,
351
+ ifs,
352
+ controls
353
+ }
354
+ }
@@ -0,0 +1,17 @@
1
+ import { it } from "vitest"
2
+
3
+ /**
4
+ * A scenario that encodes the CORRECT behavior the engine currently violates (catalogued in
5
+ * docs/sync-bug-catalog.md). Implemented with `it.fails` so the suite stays green while the bug
6
+ * exists: the body asserts correct behavior, the engine produces wrong behavior, the assertion
7
+ * throws, and `it.fails` treats that throw as a pass.
8
+ *
9
+ * When the corresponding Phase 2 fix lands, the assertion starts passing, `it.fails` then FAILS
10
+ * (the body no longer throws) — that is the signal to convert `knownBug(...)` to a plain `it(...)`.
11
+ *
12
+ * Keep the body focused so it fails for the RIGHT reason (the catalogued defect), not an unrelated
13
+ * harness error.
14
+ */
15
+ export function knownBug(bugId: string, name: string, fn: () => Promise<void>): void {
16
+ it.fails(`[KB ${bugId}] ${name}`, fn)
17
+ }
@@ -0,0 +1,65 @@
1
+ import pathModule from "path"
2
+ import { type World } from "./world"
3
+
4
+ /**
5
+ * Local filesystem mutations applied to a {@link World} between cycles. All paths are relative to the
6
+ * local sync root (e.g. "a.txt", "dir/b.txt"). Parent directories are created as needed. After a
7
+ * local mutation the runner fires the watcher so the next cycle rescans.
8
+ */
9
+
10
+ function localFull(world: World, relativePath: string): string {
11
+ return pathModule.posix.join(world.localPath, relativePath.replace(/^\/+/, ""))
12
+ }
13
+
14
+ export function writeLocal(world: World, relativePath: string, content: string): void {
15
+ const full = localFull(world, relativePath)
16
+
17
+ world.vfs.ifs.mkdirSync(pathModule.posix.dirname(full), { recursive: true })
18
+ world.vfs.ifs.writeFileSync(full, content)
19
+ }
20
+
21
+ /** Write a local file and force a specific mtime (whole-second precision drives conflict resolution). */
22
+ export function writeLocalAt(world: World, relativePath: string, content: string, mtimeMs: number): void {
23
+ writeLocal(world, relativePath, content)
24
+
25
+ const seconds = mtimeMs / 1000
26
+
27
+ world.vfs.ifs.utimesSync(localFull(world, relativePath), seconds, seconds)
28
+ }
29
+
30
+ /** Update a local file's mtime WITHOUT changing its content or inode (a pure touch). */
31
+ export function touchLocal(world: World, relativePath: string, mtimeMs: number): void {
32
+ const seconds = mtimeMs / 1000
33
+
34
+ world.vfs.ifs.utimesSync(localFull(world, relativePath), seconds, seconds)
35
+ }
36
+
37
+ export function mkdirLocal(world: World, relativePath: string): void {
38
+ world.vfs.ifs.mkdirSync(localFull(world, relativePath), { recursive: true })
39
+ }
40
+
41
+ export function rmLocal(world: World, relativePath: string): void {
42
+ world.vfs.ifs.rmSync(localFull(world, relativePath), { recursive: true, force: true })
43
+ }
44
+
45
+ export function renameLocal(world: World, fromRelativePath: string, toRelativePath: string): void {
46
+ const from = localFull(world, fromRelativePath)
47
+ const to = localFull(world, toRelativePath)
48
+
49
+ world.vfs.ifs.mkdirSync(pathModule.posix.dirname(to), { recursive: true })
50
+ world.vfs.ifs.renameSync(from, to)
51
+ }
52
+
53
+ export function readLocal(world: World, relativePath: string): string {
54
+ return world.vfs.ifs.readFileSync(localFull(world, relativePath), "utf-8") as string
55
+ }
56
+
57
+ export function existsLocal(world: World, relativePath: string): boolean {
58
+ try {
59
+ world.vfs.ifs.statSync(localFull(world, relativePath))
60
+
61
+ return true
62
+ } catch {
63
+ return false
64
+ }
65
+ }
@@ -0,0 +1,141 @@
1
+ import { vi, expect } from "vitest"
2
+ import { SYNC_INTERVAL } from "../../src/constants"
3
+ import { createWorld, restartSync, BASE_TIME, type CreateWorldOptions, type World } from "./world"
4
+ import { snapshotLocal, snapshotRemote, type WorldSnapshot } from "./snapshot"
5
+ import { type SyncMessage } from "../../src/types"
6
+
7
+ /**
8
+ * One step in a scenario. `runCycle` drives exactly one synchronization cycle; the mutate/control
9
+ * steps apply changes between cycles. `localMutate` automatically fires the watcher afterwards so the
10
+ * next cycle rescans the local tree.
11
+ */
12
+ export type Step =
13
+ | { type: "runCycle" }
14
+ | { type: "restart" }
15
+ | { type: "localMutate"; mutate: (world: World) => unknown }
16
+ | { type: "remoteMutate"; mutate: (world: World) => unknown }
17
+ | { type: "control"; control: (world: World) => unknown }
18
+
19
+ export function runCycle(): Step {
20
+ return { type: "runCycle" }
21
+ }
22
+
23
+ /** Simulate a process restart: reload persisted state and rebuild the engine over the same world. */
24
+ export function restart(): Step {
25
+ return { type: "restart" }
26
+ }
27
+
28
+ export function localMutate(mutate: (world: World) => unknown): Step {
29
+ return { type: "localMutate", mutate }
30
+ }
31
+
32
+ export function remoteMutate(mutate: (world: World) => unknown): Step {
33
+ return { type: "remoteMutate", mutate }
34
+ }
35
+
36
+ export function control(controlFn: (world: World) => unknown): Step {
37
+ return { type: "control", control: controlFn }
38
+ }
39
+
40
+ export type Scenario = CreateWorldOptions & {
41
+ name: string
42
+ steps: Step[]
43
+ }
44
+
45
+ export type RunResult = {
46
+ world: World
47
+ finalLocal: WorldSnapshot
48
+ finalRemote: WorldSnapshot
49
+ messages: SyncMessage[]
50
+ /** Per-`runCycle` snapshots and the messages emitted during that cycle. */
51
+ cycles: { local: WorldSnapshot; remote: WorldSnapshot; messages: SyncMessage[] }[]
52
+ }
53
+
54
+ async function applyStep(world: World, step: Step): Promise<void> {
55
+ switch (step.type) {
56
+ case "runCycle": {
57
+ // Advance past the local-change debounce window so waitForLocalDirectoryChanges returns
58
+ // immediately (its first synchronous check passes); during the cycle the clock is frozen,
59
+ // so the engine's internal progress timeouts and the lock-refresh interval never fire.
60
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
61
+
62
+ await world.sync.runCycle()
63
+
64
+ break
65
+ }
66
+
67
+ case "restart": {
68
+ await restartSync(world)
69
+
70
+ break
71
+ }
72
+
73
+ case "localMutate": {
74
+ await step.mutate(world)
75
+
76
+ world.triggerWatcher()
77
+
78
+ break
79
+ }
80
+
81
+ case "remoteMutate": {
82
+ await step.mutate(world)
83
+
84
+ break
85
+ }
86
+
87
+ case "control": {
88
+ await step.control(world)
89
+
90
+ break
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Build an in-memory world from the scenario spec, run its steps under fake timers, and return the
97
+ * final local/remote snapshots, the full message stream, and per-cycle snapshots.
98
+ */
99
+ export async function runScenario(scenario: Scenario): Promise<RunResult> {
100
+ // Fake only the timers the engine schedules on (debounce, lock refresh, cleanup, retries) and the
101
+ // clock. Leave setImmediate/microtasks real so async I/O (fast-glob traversal, streams) resolves
102
+ // while the fake clock is frozen mid-cycle.
103
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] })
104
+ vi.setSystemTime(BASE_TIME)
105
+
106
+ try {
107
+ const world = await createWorld(scenario)
108
+ const cycles: { local: WorldSnapshot; remote: WorldSnapshot; messages: SyncMessage[] }[] = []
109
+
110
+ for (const step of scenario.steps) {
111
+ const messagesBefore = world.messages.length
112
+
113
+ await applyStep(world, step)
114
+
115
+ if (step.type === "runCycle") {
116
+ cycles.push({
117
+ local: snapshotLocal(world),
118
+ remote: snapshotRemote(world),
119
+ messages: world.messages.slice(messagesBefore)
120
+ })
121
+ }
122
+ }
123
+
124
+ return {
125
+ world,
126
+ finalLocal: snapshotLocal(world),
127
+ finalRemote: snapshotRemote(world),
128
+ messages: world.messages,
129
+ cycles
130
+ }
131
+ } finally {
132
+ vi.useRealTimers()
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Assert the two normalized worlds are identical (used for twoWay equivalence: §2.3).
138
+ */
139
+ export function expectWorldsEqual(a: WorldSnapshot, b: WorldSnapshot): void {
140
+ expect(a).toEqual(b)
141
+ }
@@ -0,0 +1,113 @@
1
+ import pathModule from "path"
2
+ import crypto from "crypto"
3
+ import { LOCAL_TRASH_NAME } from "../../src/constants"
4
+ import { type SyncMessage } from "../../src/types"
5
+ import { type World } from "./world"
6
+
7
+ export type SnapshotEntry = { type: "file" | "directory"; size: number; mtimeSec: number; contentHash: string }
8
+
9
+ /**
10
+ * The normalized assertion unit: `path → { type, size, mtime(sec), contentHash }`, excluding the
11
+ * local trash directory. Local and remote snapshots are directly comparable — same content yields
12
+ * the same sha512 hash and the same whole-second mtime on both sides.
13
+ */
14
+ export type WorldSnapshot = Record<string, SnapshotEntry>
15
+
16
+ function sha512Hex(data: Buffer): string {
17
+ return crypto.createHash("sha512").update(Uint8Array.from(data)).digest("hex")
18
+ }
19
+
20
+ /**
21
+ * Normalized snapshot of the local sync directory (relative POSIX paths), excluding the local trash.
22
+ */
23
+ export function snapshotLocal(world: World): WorldSnapshot {
24
+ const result: WorldSnapshot = {}
25
+ const ifs = world.vfs.ifs
26
+ const root = world.localPath
27
+
28
+ const walk = (directory: string): void => {
29
+ let entries: string[]
30
+
31
+ try {
32
+ entries = ifs.readdirSync(directory) as string[]
33
+ } catch {
34
+ return
35
+ }
36
+
37
+ for (const entry of entries) {
38
+ if (entry === LOCAL_TRASH_NAME) {
39
+ continue
40
+ }
41
+
42
+ const full = pathModule.posix.join(directory, entry)
43
+ const relativePath = full.slice(root.length)
44
+ const stats = ifs.statSync(full)
45
+
46
+ if (stats.isDirectory()) {
47
+ result[relativePath] = { type: "directory", size: 0, mtimeSec: 0, contentHash: "" }
48
+
49
+ walk(full)
50
+ } else if (stats.isFile()) {
51
+ const content = ifs.readFileSync(full) as Buffer
52
+
53
+ result[relativePath] = {
54
+ type: "file",
55
+ size: stats.size,
56
+ mtimeSec: Math.floor(stats.mtimeMs / 1000),
57
+ contentHash: sha512Hex(content)
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ walk(root)
64
+
65
+ return result
66
+ }
67
+
68
+ /**
69
+ * Normalized snapshot of the live remote tree (relative POSIX paths).
70
+ */
71
+ export function snapshotRemote(world: World): WorldSnapshot {
72
+ return world.cloud.controls.snapshot()
73
+ }
74
+
75
+ /** All messages of a given `type`. */
76
+ export function messagesOfType<T extends SyncMessage["type"]>(messages: SyncMessage[], type: T): Extract<SyncMessage, { type: T }>[] {
77
+ return messages.filter((message): message is Extract<SyncMessage, { type: T }> => message.type === type)
78
+ }
79
+
80
+ /** Count messages of a given `type`. */
81
+ export function countMessages(messages: SyncMessage[], type: SyncMessage["type"]): number {
82
+ return messages.filter(message => message.type === type).length
83
+ }
84
+
85
+ /** The `of` discriminators of every "transfer" message (e.g. "upload", "downloadFile", ...). */
86
+ export function transferKinds(messages: SyncMessage[]): string[] {
87
+ return messagesOfType(messages, "transfer").map(message => message.data.of)
88
+ }
89
+
90
+ /** The transfer `of` discriminators that represent an actual file transfer (not a dir/rename op). */
91
+ export const FILE_TRANSFER_OPS = ["upload", "uploadFile", "download", "downloadFile"] as const
92
+
93
+ /** The actual file-transfer operations seen in a message stream (upload/download, queued..finished). */
94
+ export function transferOps(messages: SyncMessage[]): string[] {
95
+ return transferKinds(messages).filter(kind => (FILE_TRANSFER_OPS as readonly string[]).includes(kind))
96
+ }
97
+
98
+ /**
99
+ * Every operation kind in the stream — file transfers AND directory creates, deletes, and renames (the
100
+ * full set of `transfer.of` discriminators), deduplicated and sorted for readable failure output.
101
+ *
102
+ * `expect(allOps(messages)).toEqual([])` is the assertion for a COMPLETE no-op. transferOps() sees only
103
+ * file up/downloads, so a cycle that spuriously renamed, deleted, or mkdir'd a path slips past it; allOps
104
+ * catches it. Mirror of the e2e harness helper of the same name.
105
+ */
106
+ export function allOps(messages: SyncMessage[]): string[] {
107
+ return [...new Set(transferKinds(messages))].sort()
108
+ }
109
+
110
+ /** Whether any actual file transfer (upload/download) occurred in the message stream. */
111
+ export function hadTransfers(messages: SyncMessage[]): boolean {
112
+ return transferOps(messages).length > 0
113
+ }