@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,276 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { collapseDeltas, directoriesWithSurvivingChildren, type Delta } from "../../src/lib/deltas"
3
+
4
+ /**
5
+ * Direct unit coverage for the delta-collapse pass (deltas.ts). The full-cycle scenario net
6
+ * (tests/scenarios/r-rename-stress.test.ts) can only observe convergence after the engine re-runs and
7
+ * self-heals, which masks a corrupt FIRST cycle. These tests assert the collapse output directly, so a
8
+ * dropped or duplicated op is caught even when a later cycle would have papered over it.
9
+ *
10
+ * The headline case is BUG-004's deterministic half: a child under TWO overlapping renamed parents, in
11
+ * the array ordering that made the previous in-place splice clobber an unrelated delta.
12
+ */
13
+ const empty = {
14
+ renamedLocalDirectories: [] as Delta[],
15
+ renamedRemoteDirectories: [] as Delta[],
16
+ deletedLocalDirectories: [] as Delta[],
17
+ deletedRemoteDirectories: [] as Delta[]
18
+ }
19
+
20
+ describe("collapseDeltas", () => {
21
+ it("drops a child carried by its most-specific parent without clobbering an unrelated delta (specific-first ordering)", () => {
22
+ // /a -> /x AND /a/b -> /x/y both cover /a/b/c.txt. /unrelated.txt is an independent rename that
23
+ // must survive untouched. The most-specific parent is listed FIRST — the exact ordering under
24
+ // which the old splice-in-place loop removed slot i then overwrote the delta that shifted into it,
25
+ // silently dropping /unrelated.txt and emitting a bogus duplicate rename of the child.
26
+ const deltas: Delta[] = [
27
+ { type: "renameLocalDirectory", path: "/a", from: "/a", to: "/x" },
28
+ { type: "renameLocalDirectory", path: "/a/b", from: "/a/b", to: "/x/y" },
29
+ { type: "renameLocalFile", path: "/a/b/c.txt", from: "/a/b/c.txt", to: "/x/y/c.txt" },
30
+ { type: "renameLocalFile", path: "/unrelated.txt", from: "/unrelated.txt", to: "/renamed.txt" }
31
+ ]
32
+ const renamedLocalDirectories: Delta[] = [
33
+ { type: "renameLocalDirectory", path: "/a/b", from: "/a/b", to: "/x/y" },
34
+ { type: "renameLocalDirectory", path: "/a", from: "/a", to: "/x" }
35
+ ]
36
+
37
+ const out = collapseDeltas({ ...empty, deltas, renamedLocalDirectories })
38
+
39
+ // The unrelated rename survives byte-for-byte.
40
+ expect(out).toContainEqual({ type: "renameLocalFile", path: "/unrelated.txt", from: "/unrelated.txt", to: "/renamed.txt" })
41
+ // The child is fully carried by its immediate parent dir rename — dropped, not duplicated.
42
+ expect(out.find(delta => delta.path === "/a/b/c.txt")).toBeUndefined()
43
+ expect(out.filter(delta => "to" in delta && delta.to === "/x/y/c.txt")).toEqual([])
44
+ // The nested dir rename is rewritten to its post-/a-rename source.
45
+ expect(out).toContainEqual({ type: "renameLocalDirectory", path: "/a/b", from: "/x/b", to: "/x/y" })
46
+ // Exactly three ops remain: the top rename, the rewritten nested rename, the unrelated rename.
47
+ expect(out).toHaveLength(3)
48
+ })
49
+
50
+ it("is order-independent: general-first parent ordering yields the identical collapse", () => {
51
+ const deltas: Delta[] = [
52
+ { type: "renameLocalDirectory", path: "/a", from: "/a", to: "/x" },
53
+ { type: "renameLocalDirectory", path: "/a/b", from: "/a/b", to: "/x/y" },
54
+ { type: "renameLocalFile", path: "/a/b/c.txt", from: "/a/b/c.txt", to: "/x/y/c.txt" },
55
+ { type: "renameLocalFile", path: "/unrelated.txt", from: "/unrelated.txt", to: "/renamed.txt" }
56
+ ]
57
+ const renamedLocalDirectories: Delta[] = [
58
+ { type: "renameLocalDirectory", path: "/a", from: "/a", to: "/x" },
59
+ { type: "renameLocalDirectory", path: "/a/b", from: "/a/b", to: "/x/y" }
60
+ ]
61
+
62
+ const out = collapseDeltas({ ...empty, deltas, renamedLocalDirectories })
63
+
64
+ expect(out).toContainEqual({ type: "renameLocalFile", path: "/unrelated.txt", from: "/unrelated.txt", to: "/renamed.txt" })
65
+ expect(out.find(delta => delta.path === "/a/b/c.txt")).toBeUndefined()
66
+ expect(out).toContainEqual({ type: "renameLocalDirectory", path: "/a/b", from: "/x/b", to: "/x/y" })
67
+ expect(out).toHaveLength(3)
68
+ })
69
+
70
+ it("rewrites (does not drop) a child rename whose target differs from the parent-implied location", () => {
71
+ // The parent dir rename carries the file to /x/c.txt, but the user ALSO renamed the file itself,
72
+ // so an explicit rename from the post-parent-rename source must remain.
73
+ const deltas: Delta[] = [
74
+ { type: "renameLocalDirectory", path: "/a", from: "/a", to: "/x" },
75
+ { type: "renameLocalFile", path: "/a/c.txt", from: "/a/c.txt", to: "/x/renamed.txt" }
76
+ ]
77
+ const renamedLocalDirectories: Delta[] = [{ type: "renameLocalDirectory", path: "/a", from: "/a", to: "/x" }]
78
+
79
+ const out = collapseDeltas({ ...empty, deltas, renamedLocalDirectories })
80
+
81
+ expect(out).toContainEqual({ type: "renameLocalFile", path: "/a/c.txt", from: "/x/c.txt", to: "/x/renamed.txt" })
82
+ expect(out).toHaveLength(2)
83
+ })
84
+
85
+ it("leaves a rename with no covering parent untouched", () => {
86
+ const deltas: Delta[] = [{ type: "renameLocalFile", path: "/loose.txt", from: "/loose.txt", to: "/moved.txt" }]
87
+
88
+ const out = collapseDeltas({ ...empty, deltas })
89
+
90
+ expect(out).toEqual(deltas)
91
+ })
92
+
93
+ it("collapses the remote rename side symmetrically", () => {
94
+ const deltas: Delta[] = [
95
+ { type: "renameRemoteDirectory", path: "/a", from: "/a", to: "/x" },
96
+ { type: "renameRemoteDirectory", path: "/a/b", from: "/a/b", to: "/x/y" },
97
+ { type: "renameRemoteFile", path: "/a/b/c.txt", from: "/a/b/c.txt", to: "/x/y/c.txt" }
98
+ ]
99
+ const renamedRemoteDirectories: Delta[] = [
100
+ { type: "renameRemoteDirectory", path: "/a/b", from: "/a/b", to: "/x/y" },
101
+ { type: "renameRemoteDirectory", path: "/a", from: "/a", to: "/x" }
102
+ ]
103
+
104
+ const out = collapseDeltas({ ...empty, deltas, renamedRemoteDirectories })
105
+
106
+ expect(out.find(delta => delta.path === "/a/b/c.txt")).toBeUndefined()
107
+ expect(out).toContainEqual({ type: "renameRemoteDirectory", path: "/a/b", from: "/x/b", to: "/x/y" })
108
+ expect(out).toHaveLength(2)
109
+ })
110
+
111
+ it("drops a child delete covered by an ancestor directory delete, keeping unrelated deletes", () => {
112
+ const deltas: Delta[] = [
113
+ { type: "deleteLocalDirectory", path: "/gone" },
114
+ { type: "deleteLocalFile", path: "/gone/inner.txt" },
115
+ { type: "deleteLocalDirectory", path: "/gone/sub" },
116
+ { type: "deleteLocalFile", path: "/gone/sub/deep.txt" },
117
+ { type: "deleteLocalFile", path: "/keep-deleted.txt" }
118
+ ]
119
+ const deletedLocalDirectories: Delta[] = [
120
+ { type: "deleteLocalDirectory", path: "/gone" },
121
+ { type: "deleteLocalDirectory", path: "/gone/sub" }
122
+ ]
123
+
124
+ const out = collapseDeltas({ ...empty, deltas, deletedLocalDirectories })
125
+
126
+ // Only the top-level dir delete and the unrelated file delete survive.
127
+ expect(out).toEqual([
128
+ { type: "deleteLocalDirectory", path: "/gone" },
129
+ { type: "deleteLocalFile", path: "/keep-deleted.txt" }
130
+ ])
131
+ })
132
+
133
+ it("drops a child remote delete covered by an ancestor remote directory delete", () => {
134
+ const deltas: Delta[] = [
135
+ { type: "deleteRemoteDirectory", path: "/gone" },
136
+ { type: "deleteRemoteFile", path: "/gone/inner.txt" },
137
+ { type: "deleteRemoteFile", path: "/keep.txt" }
138
+ ]
139
+ const deletedRemoteDirectories: Delta[] = [{ type: "deleteRemoteDirectory", path: "/gone" }]
140
+
141
+ const out = collapseDeltas({ ...empty, deltas, deletedRemoteDirectories })
142
+
143
+ expect(out).toEqual([
144
+ { type: "deleteRemoteDirectory", path: "/gone" },
145
+ { type: "deleteRemoteFile", path: "/keep.txt" }
146
+ ])
147
+ })
148
+
149
+ it("passes non-rename / non-delete deltas through unchanged", () => {
150
+ const deltas: Delta[] = [
151
+ { type: "uploadFile", path: "/up.txt", size: 12 },
152
+ { type: "downloadFile", path: "/down.txt", size: 34 },
153
+ { type: "createRemoteDirectory", path: "/newdir" },
154
+ { type: "createLocalDirectory", path: "/newlocal" }
155
+ ]
156
+ const renamedLocalDirectories: Delta[] = [{ type: "renameLocalDirectory", path: "/a", from: "/a", to: "/x" }]
157
+
158
+ const out = collapseDeltas({ ...empty, deltas, renamedLocalDirectories })
159
+
160
+ expect(out).toEqual(deltas)
161
+ })
162
+
163
+ it("does not treat a sibling-prefix path as a child (no false prefix match)", () => {
164
+ // "/abc" must NOT be collapsed under a rename of "/ab" — the boundary "/" guards against this.
165
+ const deltas: Delta[] = [{ type: "renameLocalFile", path: "/abc.txt", from: "/abc.txt", to: "/abc2.txt" }]
166
+ const renamedLocalDirectories: Delta[] = [{ type: "renameLocalDirectory", path: "/ab", from: "/ab", to: "/zz" }]
167
+
168
+ const out = collapseDeltas({ ...empty, deltas, renamedLocalDirectories })
169
+
170
+ expect(out).toEqual(deltas)
171
+ })
172
+
173
+ it("collapses a large wide directory move to the single parent rename (ancestor-walk correctness + scale)", () => {
174
+ // A realistic large move: /src -> /dst with many subdirectories, each holding many files. Every child
175
+ // must fold into its nearest renamed-directory ancestor, leaving ONLY the top-level rename. This both
176
+ // checks the indexed ancestor-walk on a large input and acts as a tripwire for a return to the old
177
+ // O(deltas * directories * pathLength) scan, which on this input ran for MINUTES — far past the test
178
+ // runner's timeout — where the indexed version finishes in milliseconds. (P1)
179
+ const deltas: Delta[] = []
180
+ const renamedLocalDirectories: Delta[] = []
181
+ const top: Delta = { type: "renameLocalDirectory", path: "/src", from: "/src", to: "/dst" }
182
+
183
+ deltas.push(top)
184
+ renamedLocalDirectories.push(top)
185
+
186
+ for (let s = 0; s < 300; s++) {
187
+ const fromDir = `/src/sub${s}`
188
+ const toDir = `/dst/sub${s}`
189
+ const dirRename: Delta = { type: "renameLocalDirectory", path: fromDir, from: fromDir, to: toDir }
190
+
191
+ deltas.push(dirRename)
192
+ renamedLocalDirectories.push(dirRename)
193
+
194
+ for (let f = 0; f < 100; f++) {
195
+ const from = `${fromDir}/f${f}.txt`
196
+ const to = `${toDir}/f${f}.txt`
197
+
198
+ deltas.push({ type: "renameLocalFile", path: from, from, to })
199
+ }
200
+ }
201
+
202
+ // 1 top rename + 300 subdir renames + 30000 file renames.
203
+ expect(deltas.length).toBe(30301)
204
+
205
+ const out = collapseDeltas({ ...empty, deltas, renamedLocalDirectories })
206
+
207
+ // Everything collapses into /src -> /dst: each subdir rename rewrites to /dst/subN (== its target, so
208
+ // redundant) and each file folds into its subdir which folds into /src.
209
+ expect(out).toEqual([{ type: "renameLocalDirectory", path: "/src", from: "/src", to: "/dst" }])
210
+ })
211
+ })
212
+
213
+ /**
214
+ * Direct coverage for the dir-delete-vs-live-child guard (H5). A directory slated for deletion must be
215
+ * KEPT (its delete dropped) when this side still holds a child that is neither deleted nor renamed away.
216
+ */
217
+ describe("directoriesWithSurvivingChildren", () => {
218
+ const tree = (paths: string[]): Record<string, unknown> => Object.fromEntries(paths.map(path => [path, {}]))
219
+
220
+ const keepFor = (deltas: Delta[], paths: string[]): Set<string> =>
221
+ directoriesWithSurvivingChildren(deltas, "deleteRemoteDirectory", "deleteRemoteFile", "renameRemoteDirectory", "renameRemoteFile", tree(paths))
222
+
223
+ it("keeps a deleted directory that still holds a brand-new (non-deleted) child", () => {
224
+ const deltas: Delta[] = [{ type: "deleteRemoteDirectory", path: "/dir" }]
225
+ // /dir/new.txt exists in the tree and is NOT being deleted → it keeps /dir alive.
226
+ expect([...keepFor(deltas, ["/dir", "/dir/new.txt", "/other.txt"])]).toEqual(["/dir"])
227
+ })
228
+
229
+ it("does NOT keep a directory whose only child is being renamed out", () => {
230
+ const deltas: Delta[] = [
231
+ { type: "deleteRemoteDirectory", path: "/dir" },
232
+ { type: "renameRemoteFile", path: "/dir2/c.txt", from: "/dir/c.txt", to: "/dir2/c.txt" }
233
+ ]
234
+ // /dir/c.txt is leaving via a rename, so /dir has nothing live → it is deleted (empty set).
235
+ expect([...keepFor(deltas, ["/dir", "/dir/c.txt"])]).toEqual([])
236
+ })
237
+
238
+ it("does NOT keep a directory whose children are all being deleted", () => {
239
+ const deltas: Delta[] = [
240
+ { type: "deleteRemoteDirectory", path: "/dir" },
241
+ { type: "deleteRemoteFile", path: "/dir/a.txt" }
242
+ ]
243
+ expect([...keepFor(deltas, ["/dir", "/dir/a.txt"])]).toEqual([])
244
+ })
245
+
246
+ it("keeps an ancestor when a deep descendant survives, but not when an ancestor is renamed away", () => {
247
+ const survivingDeep: Delta[] = [{ type: "deleteRemoteDirectory", path: "/dir" }]
248
+ // A surviving grandchild keeps the top directory.
249
+ expect([...keepFor(survivingDeep, ["/dir", "/dir/sub/leaf.txt"])]).toEqual(["/dir"])
250
+
251
+ // But if the intermediate dir is renamed away, the whole subtree leaves and keeps nothing.
252
+ const renamedAway: Delta[] = [
253
+ { type: "deleteRemoteDirectory", path: "/dir" },
254
+ { type: "renameRemoteDirectory", path: "/moved", from: "/dir/sub", to: "/moved" }
255
+ ]
256
+ expect([...keepFor(renamedAway, ["/dir", "/dir/sub/leaf.txt"])]).toEqual([])
257
+ })
258
+
259
+ it("returns an empty set when nothing is being deleted", () => {
260
+ expect(keepFor([{ type: "renameRemoteFile", path: "/b", from: "/a", to: "/b" }], ["/a"]).size).toBe(0)
261
+ })
262
+
263
+ it("scopes to the requested direction (a remote delete is invisible to the local-typed query)", () => {
264
+ const deltas: Delta[] = [{ type: "deleteRemoteDirectory", path: "/dir" }]
265
+ const localKeep = directoriesWithSurvivingChildren(
266
+ deltas,
267
+ "deleteLocalDirectory",
268
+ "deleteLocalFile",
269
+ "renameLocalDirectory",
270
+ "renameLocalFile",
271
+ tree(["/dir", "/dir/new.txt"])
272
+ )
273
+
274
+ expect(localKeep.size).toBe(0)
275
+ })
276
+ })
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { pack } from "msgpackr"
3
+ import { fetchDirTreeMsgpack, DIR_TREE_GATEWAY_URLS, type DirTreeSDK } from "../../src/lib/filesystems/dirTree"
4
+
5
+ /**
6
+ * Direct unit coverage for the production msgpack reimplementation of `/v3/dir/tree`. Every sync
7
+ * scenario routes the fetch through the fake cloud's SDK, so this is the ONLY place the real transport
8
+ * (JSON body, msgpack response decode, envelope handling, gateway failover, backoff, abort) is
9
+ * exercised — Phase 3 then validates it end-to-end against the live API.
10
+ */
11
+ function makeSDK(post: (...args: unknown[]) => unknown, apiKey: string | undefined = "test-key"): DirTreeSDK {
12
+ return { axiosInstance: { post }, config: { apiKey } } as unknown as DirTreeSDK
13
+ }
14
+
15
+ // A 200 response carrying the standard { status, data } envelope, msgpack-encoded as the server sends it.
16
+ function ok(data: unknown): { status: number; data: Buffer } {
17
+ return { status: 200, data: pack({ status: true, data }) }
18
+ }
19
+
20
+ // Deterministic transport: no real waits, fixed gateway start index (0), no jitter.
21
+ const deterministic = { sleep: async (): Promise<void> => {}, random: (): number => 0, baseRetryDelayMs: 0, maxRetryDelayMs: 0 }
22
+
23
+ const sampleTree = {
24
+ files: [["fuuid", "bucket", "region", 1, "puuid", "fmeta", 2, 0]],
25
+ folders: [["root", "rmeta", "base"]]
26
+ }
27
+
28
+ describe("fetchDirTreeMsgpack", () => {
29
+ it("POSTs a JSON body and decodes the msgpack response into the same shape the SDK returns", async () => {
30
+ const post = vi.fn().mockResolvedValue(ok(sampleTree))
31
+ const sdk = makeSDK(post)
32
+
33
+ const result = await fetchDirTreeMsgpack(sdk, { uuid: "parent-uuid", deviceId: "dev-1", skipCache: false }, deterministic)
34
+
35
+ expect(result.files).toEqual(sampleTree.files)
36
+ expect(result.folders).toEqual(sampleTree.folders)
37
+
38
+ const [url, body, config] = post.mock.calls[0]!
39
+
40
+ expect(url).toBe(`${DIR_TREE_GATEWAY_URLS[0]}/v3/dir/tree`)
41
+ // Body stays JSON; skipCache encoded as 0/1 exactly like the SDK.
42
+ expect(body).toEqual({ uuid: "parent-uuid", deviceId: "dev-1", skipCache: 0 })
43
+ expect(config.responseType).toBe("arraybuffer")
44
+ expect(config.headers.Authorization).toBe("Bearer test-key")
45
+ expect(config.headers.Accept).toBe("application/msgpack")
46
+ expect(config.headers["Content-Type"]).toBe("application/json")
47
+ // The response transform must be a pure passthrough so the binary body is never copied/parsed.
48
+ const raw = Buffer.from([1, 2, 3])
49
+ expect(config.transformResponse(raw)).toBe(raw)
50
+ })
51
+
52
+ it("encodes skipCache:true as 1", async () => {
53
+ const post = vi.fn().mockResolvedValue(ok(sampleTree))
54
+
55
+ await fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: true }, deterministic)
56
+
57
+ expect(post.mock.calls[0]![1]).toEqual({ uuid: "u", deviceId: "d", skipCache: 1 })
58
+ })
59
+
60
+ it("falls back to the anonymous API key when none is configured", async () => {
61
+ const post = vi.fn().mockResolvedValue(ok(sampleTree))
62
+ // Build the SDK with no apiKey at all (a default param can't model "explicitly absent").
63
+ const sdk = { axiosInstance: { post }, config: {} } as unknown as DirTreeSDK
64
+
65
+ await fetchDirTreeMsgpack(sdk, { uuid: "u", deviceId: "d", skipCache: false }, deterministic)
66
+
67
+ expect(post.mock.calls[0]![2].headers.Authorization).toBe("Bearer anonymous")
68
+ })
69
+
70
+ it("preserves the deviceId-cache contract: an unchanged tree (empty arrays) passes straight through", async () => {
71
+ // The server returns empty files/folders when nothing changed for this deviceId; the fetcher must
72
+ // surface that verbatim so remote.ts can read it as "reuse my cache".
73
+ const post = vi.fn().mockResolvedValue(ok({ files: [], folders: [] }))
74
+
75
+ const result = await fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: false }, deterministic)
76
+
77
+ expect(result.files).toEqual([])
78
+ expect(result.folders).toEqual([])
79
+ })
80
+
81
+ it("accepts a bare (un-enveloped) decoded object", async () => {
82
+ const post = vi.fn().mockResolvedValue({ status: 200, data: pack(sampleTree) })
83
+
84
+ const result = await fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: false }, deterministic)
85
+
86
+ expect(result.folders).toEqual(sampleTree.folders)
87
+ })
88
+
89
+ it("normalizes ArrayBuffer and Uint8Array response payloads", async () => {
90
+ const packed = pack({ status: true, data: sampleTree })
91
+ const asArrayBuffer = packed.buffer.slice(packed.byteOffset, packed.byteOffset + packed.byteLength)
92
+
93
+ const postAB = vi.fn().mockResolvedValue({ status: 200, data: asArrayBuffer })
94
+ const resultAB = await fetchDirTreeMsgpack(makeSDK(postAB), { uuid: "u", deviceId: "d", skipCache: false }, deterministic)
95
+ expect(resultAB.folders).toEqual(sampleTree.folders)
96
+
97
+ const postU8 = vi.fn().mockResolvedValue({ status: 200, data: new Uint8Array(packed) })
98
+ const resultU8 = await fetchDirTreeMsgpack(makeSDK(postU8), { uuid: "u", deviceId: "d", skipCache: false }, deterministic)
99
+ expect(resultU8.folders).toEqual(sampleTree.folders)
100
+ })
101
+
102
+ it("throws a DirTreeAPIError WITHOUT retrying on a status:false envelope", async () => {
103
+ const post = vi.fn().mockResolvedValue({ status: 200, data: pack({ status: false, code: "folder_not_found", message: "gone" }) })
104
+
105
+ await expect(fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: false }, deterministic)).rejects.toMatchObject({
106
+ name: "DirTreeAPIError",
107
+ code: "folder_not_found"
108
+ })
109
+ // Definitive error => exactly one attempt.
110
+ expect(post).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ it("retries a transient failure on the NEXT gateway and succeeds", async () => {
114
+ const post = vi
115
+ .fn()
116
+ .mockRejectedValueOnce(new Error("ECONNRESET"))
117
+ .mockResolvedValueOnce(ok(sampleTree))
118
+
119
+ const result = await fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: false }, deterministic)
120
+
121
+ expect(result.folders).toEqual(sampleTree.folders)
122
+ expect(post).toHaveBeenCalledTimes(2)
123
+ // Gateway rotation: the retry must target a different host than the failed attempt.
124
+ expect(post.mock.calls[0]![0]).toBe(`${DIR_TREE_GATEWAY_URLS[0]}/v3/dir/tree`)
125
+ expect(post.mock.calls[1]![0]).toBe(`${DIR_TREE_GATEWAY_URLS[1]}/v3/dir/tree`)
126
+ })
127
+
128
+ it("treats a non-200 status as retryable", async () => {
129
+ const post = vi
130
+ .fn()
131
+ .mockResolvedValueOnce({ status: 503, data: null })
132
+ .mockResolvedValueOnce(ok(sampleTree))
133
+
134
+ const result = await fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: false }, deterministic)
135
+
136
+ expect(result.folders).toEqual(sampleTree.folders)
137
+ expect(post).toHaveBeenCalledTimes(2)
138
+ })
139
+
140
+ it("gives up after maxTries and throws the last error", async () => {
141
+ const post = vi.fn().mockRejectedValue(new Error("network down"))
142
+
143
+ await expect(
144
+ fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: false }, { ...deterministic, maxTries: 3 })
145
+ ).rejects.toThrow("network down")
146
+ expect(post).toHaveBeenCalledTimes(3)
147
+ })
148
+
149
+ it("aborts immediately without making a request when the signal is already aborted", async () => {
150
+ const post = vi.fn().mockResolvedValue(ok(sampleTree))
151
+ const controller = new AbortController()
152
+ controller.abort()
153
+
154
+ await expect(
155
+ fetchDirTreeMsgpack(makeSDK(post), { uuid: "u", deviceId: "d", skipCache: false }, { ...deterministic, abortSignal: controller.signal })
156
+ ).rejects.toThrow("Aborted")
157
+ expect(post).not.toHaveBeenCalled()
158
+ })
159
+ })
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest"
2
+
3
+ /**
4
+ * Unit coverage for the iCloud-backed-path detection in `src/utils.ts`
5
+ * (`pathSyncedByICloud` / `isPathSyncedByICloud`). These warn the user when a sync pair points at a
6
+ * folder iCloud is also managing. The darwin path shells out to `xattr` via execFile; we mock
7
+ * `child_process.execFile` so the probe is deterministic (no real `xattr`, no real filesystem) and
8
+ * exercise both the provider-attribute match and the ancestry walk.
9
+ */
10
+ const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() }))
11
+
12
+ vi.mock("child_process", async importOriginal => {
13
+ const actual = await importOriginal<typeof import("child_process")>()
14
+
15
+ return { ...actual, execFile: execMock }
16
+ })
17
+
18
+ import { pathSyncedByICloud, isPathSyncedByICloud } from "../../src/utils"
19
+
20
+ /** Run `fn` with `process.platform` temporarily forced to `platform`, restoring it afterwards. */
21
+ async function withPlatform(platform: NodeJS.Platform, fn: () => Promise<void>): Promise<void> {
22
+ const original = Object.getOwnPropertyDescriptor(process, "platform")
23
+
24
+ Object.defineProperty(process, "platform", { value: platform, configurable: true })
25
+
26
+ try {
27
+ await fn()
28
+ } finally {
29
+ if (original) {
30
+ Object.defineProperty(process, "platform", original)
31
+ }
32
+ }
33
+ }
34
+
35
+ /** Drive the mocked `execFile` callback with a fixed `(err, stdout, stderr)` result for every invocation. */
36
+ function execYields(err: Error | null, stdout: string, stderr: string): void {
37
+ execMock.mockImplementation((_file: string, _args: string[], callback: (e: Error | null, o: string, s: string) => void) => {
38
+ callback(err, stdout, stderr)
39
+ })
40
+ }
41
+
42
+ afterEach(() => {
43
+ execMock.mockReset()
44
+ })
45
+
46
+ describe("iCloud detection (utils)", () => {
47
+ it("pathSyncedByICloud short-circuits to false off darwin without probing xattr", async () => {
48
+ await withPlatform("linux", async () => {
49
+ expect(await pathSyncedByICloud("/home/user/docs")).toBe(false)
50
+ })
51
+
52
+ expect(execMock).not.toHaveBeenCalled()
53
+ })
54
+
55
+ it("pathSyncedByICloud is true when xattr lists a file-provider / iCloud attribute", async () => {
56
+ await withPlatform("darwin", async () => {
57
+ execYields(null, "com.apple.quarantine\ncom.apple.fileprovider.fpfs#P", "")
58
+
59
+ expect(await pathSyncedByICloud("/Users/me/Mobile Documents")).toBe(true)
60
+ })
61
+ })
62
+
63
+ it("pathSyncedByICloud is false when xattr lists only non-cloud attributes", async () => {
64
+ await withPlatform("darwin", async () => {
65
+ execYields(null, "com.apple.quarantine\nuser.custom", "")
66
+
67
+ expect(await pathSyncedByICloud("/Users/me/local")).toBe(false)
68
+ })
69
+ })
70
+
71
+ it("pathSyncedByICloud resolves false when xattr errors", async () => {
72
+ await withPlatform("darwin", async () => {
73
+ execYields(new Error("xattr: No such file"), "", "")
74
+
75
+ expect(await pathSyncedByICloud("/missing")).toBe(false)
76
+ })
77
+ })
78
+
79
+ it("pathSyncedByICloud resolves false when xattr writes to stderr", async () => {
80
+ await withPlatform("darwin", async () => {
81
+ execYields(null, "", "xattr: permission denied")
82
+
83
+ expect(await pathSyncedByICloud("/denied")).toBe(false)
84
+ })
85
+ })
86
+
87
+ it("isPathSyncedByICloud is false off darwin", async () => {
88
+ await withPlatform("win32", async () => {
89
+ expect(await isPathSyncedByICloud("/Users/me/x/y")).toBe(false)
90
+ })
91
+
92
+ expect(execMock).not.toHaveBeenCalled()
93
+ })
94
+
95
+ it("isPathSyncedByICloud walks up the ancestry and is true when an ANCESTOR is iCloud-backed", async () => {
96
+ await withPlatform("darwin", async () => {
97
+ // Only the grandparent directory carries the marker; the leaf and its parent do not.
98
+ execMock.mockImplementation((_file: string, args: string[], callback: (e: Error | null, o: string, s: string) => void) => {
99
+ const target = args[0]
100
+
101
+ callback(null, target === "/Users/me/iCloudDrive" ? "com.apple.icloud.marker" : "com.apple.quarantine", "")
102
+ })
103
+
104
+ expect(await isPathSyncedByICloud("/Users/me/iCloudDrive/docs/file.txt")).toBe(true)
105
+ })
106
+ })
107
+
108
+ it("isPathSyncedByICloud walks to the root and is false when nothing in the ancestry is iCloud-backed", async () => {
109
+ await withPlatform("darwin", async () => {
110
+ execYields(null, "com.apple.quarantine", "")
111
+
112
+ expect(await isPathSyncedByICloud("/Users/me/local/docs/file.txt")).toBe(false)
113
+ })
114
+ })
115
+ })
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { createWorld } from "../harness/world"
3
+
4
+ /**
5
+ * Added regression tests pinning the ignorer cache-preservation optimization: a per-cycle re-init
6
+ * (no passed content) with byte-for-byte unchanged rules must NOT wipe the filesystem ignoredCaches
7
+ * (that wipe defeated the cache and forced the whole tree's per-file ignore checks to recompute every
8
+ * cycle), while a real rule change MUST still clear them and update the decisions.
9
+ *
10
+ * NEW FILE — does not touch the existing Ignorer tests in tests/unit/ignorer.test.ts.
11
+ */
12
+ describe("Ignorer — ignoredCache preservation across unchanged re-init (perf guard)", () => {
13
+ it("keeps the filesystem ignoredCache populated when re-init finds unchanged rules", async () => {
14
+ const world = await createWorld({ mode: "twoWay", filenIgnore: "*.log" })
15
+
16
+ // Populate the local FS ignore cache for a path.
17
+ world.sync.localFileSystem.isPathIgnored("/keep.txt", "/local/keep.txt", "file")
18
+
19
+ expect(world.sync.localFileSystem.ignoredCache.has("/keep.txt")).toBe(true)
20
+
21
+ // The per-cycle re-init path (no passed content) with identical rules must leave the cache intact.
22
+ await world.sync.ignorer.initialize()
23
+
24
+ expect(world.sync.localFileSystem.ignoredCache.has("/keep.txt")).toBe(true)
25
+ expect(world.sync.remoteFileSystem.ignoredCache).toBeDefined()
26
+ })
27
+
28
+ it("clears the cache and updates decisions when the rules change (explicit update)", async () => {
29
+ const world = await createWorld({ mode: "twoWay", filenIgnore: "*.log" })
30
+
31
+ world.sync.localFileSystem.isPathIgnored("/a.txt", "/local/a.txt", "file")
32
+
33
+ expect(world.sync.localFileSystem.ignoredCache.has("/a.txt")).toBe(true)
34
+ expect(world.sync.ignorer.ignores("a.txt")).toBe(false)
35
+
36
+ await world.sync.ignorer.update("*.txt")
37
+
38
+ // The cache was wiped (re-evaluation forced) and the new rule now applies.
39
+ expect(world.sync.localFileSystem.ignoredCache.has("/a.txt")).toBe(false)
40
+ expect(world.sync.ignorer.ignores("a.txt")).toBe(true)
41
+ })
42
+
43
+ it("picks up a physical .filenignore change on the next no-arg initialize", async () => {
44
+ const world = await createWorld({ mode: "twoWay", filenIgnore: "*.log" })
45
+
46
+ expect(world.sync.ignorer.ignores("a.txt")).toBe(false)
47
+
48
+ await world.vfs.fs.writeFile("/local/.filenignore", "*.txt", { encoding: "utf-8" })
49
+
50
+ // No passed content — exactly the per-cycle path — must still detect the on-disk change.
51
+ await world.sync.ignorer.initialize()
52
+
53
+ expect(world.sync.ignorer.ignores("a.txt")).toBe(true)
54
+ })
55
+
56
+ it("clear() then a same-content initialize() rebuilds the matcher (no stale empty matcher)", async () => {
57
+ const world = await createWorld({ mode: "twoWay", filenIgnore: "*.log" })
58
+
59
+ expect(world.sync.ignorer.ignores("a.log")).toBe(true)
60
+
61
+ world.sync.ignorer.clear()
62
+
63
+ expect(world.sync.ignorer.ignores("a.log")).toBe(false)
64
+
65
+ // Same content as before — the baseline reset in clear() must force a rebuild, not a skip.
66
+ await world.sync.ignorer.initialize()
67
+
68
+ expect(world.sync.ignorer.ignores("a.log")).toBe(true)
69
+ })
70
+ })