@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,306 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, remoteMutate, localMutate, control } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { transferKinds, allOps } from "../harness/snapshot"
5
+ import { renameLocal, readLocal, existsLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category P — remote-originated propagation (the download direction) and cross-directory moves.
9
+ *
10
+ * The bulk of the suite is LOCAL-originated (uploads). This file deliberately drives the cloud→local
11
+ * direction (`mode:"cloudToLocal"`) so the engine has to CREATE/DOWNLOAD/DELETE/RENAME on the local
12
+ * side, plus the local→cloud cross-parent MOVE branches.
13
+ *
14
+ * Cache timing: the previous local/remote trees are observed at cycle START, and a just-downloaded
15
+ * file only enters the previous LOCAL tree on the following scan. So remote-originated mutations are
16
+ * applied after TWO settle cycles (the item is then present in both previous trees), which lands the
17
+ * mutation's propagation in `result.cycles[2]`. Local-originated moves settle in ONE cycle (the local
18
+ * inode is already known), landing in `result.cycles[1]`.
19
+ *
20
+ * Identity: a rename/move keeps the node identity (remote uuid / local inode) — it is NOT a
21
+ * delete+add — and a directory rename/move/delete collapses its children into the single parent op.
22
+ */
23
+ const SECOND = 1000
24
+
25
+ describe("Category P — remote-originated & cross-directory moves", () => {
26
+ // ===== DOWNLOAD direction (cloudToLocal) =====
27
+
28
+ it("P1: a new remote file downloads to local with its content (downloadFile)", async () => {
29
+ const result = await runScenario({
30
+ name: "P1",
31
+ mode: "cloudToLocal",
32
+ initialRemote: { "/a.txt": { content: "hello-remote", mtimeMs: BASE_TIME + 1 * SECOND } },
33
+ steps: [runCycle(), runCycle(), runCycle()]
34
+ })
35
+
36
+ expect(transferKinds(result.cycles[0]!.messages)).toContain("downloadFile")
37
+ expect(result.cycles[0]!.local["/a.txt"]).toMatchObject({ type: "file", size: "hello-remote".length })
38
+ // The content actually landed on disk, not just a tree entry.
39
+ expect(readLocal(result.world, "a.txt")).toBe("hello-remote")
40
+ // Idempotence: no further transfers once converged.
41
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
42
+ expect(result.finalLocal).toEqual(result.finalRemote)
43
+ })
44
+
45
+ it("P2: a remote directory tree creates the local dirs and downloads its files", async () => {
46
+ const result = await runScenario({
47
+ name: "P2",
48
+ mode: "cloudToLocal",
49
+ initialRemote: {
50
+ "/sub/a.txt": { content: "A", mtimeMs: BASE_TIME + 1 * SECOND },
51
+ "/sub/deep/b.txt": { content: "B", mtimeMs: BASE_TIME + 2 * SECOND }
52
+ },
53
+ steps: [runCycle(), runCycle(), runCycle()]
54
+ })
55
+
56
+ const kinds = transferKinds(result.cycles[0]!.messages)
57
+
58
+ // Both the intermediate directory and the nested directory are created locally…
59
+ expect(kinds).toContain("createLocalDirectory")
60
+ // …and the files are downloaded (not the dirs masquerading as files).
61
+ expect(kinds).toContain("downloadFile")
62
+
63
+ expect(result.finalLocal["/sub"]).toMatchObject({ type: "directory" })
64
+ expect(result.finalLocal["/sub/deep"]).toMatchObject({ type: "directory" })
65
+ expect(result.finalLocal["/sub/a.txt"]).toMatchObject({ type: "file", size: 1 })
66
+ expect(result.finalLocal["/sub/deep/b.txt"]).toMatchObject({ type: "file", size: 1 })
67
+ expect(readLocal(result.world, "sub/deep/b.txt")).toBe("B")
68
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
69
+ expect(result.finalLocal).toEqual(result.finalRemote)
70
+ })
71
+
72
+ it("P3: deleting a remote file deletes the local copy (to .filen.trash.local)", async () => {
73
+ const result = await runScenario({
74
+ name: "P3",
75
+ mode: "cloudToLocal",
76
+ initialRemote: { "/a.txt": { content: "content", mtimeMs: BASE_TIME + 1 * SECOND } },
77
+ steps: [
78
+ runCycle(),
79
+ runCycle(),
80
+ remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
81
+ runCycle(),
82
+ runCycle()
83
+ ]
84
+ })
85
+
86
+ expect(transferKinds(result.cycles[2]!.messages)).toContain("deleteLocalFile")
87
+ // Gone from the synced tree on both sides…
88
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
89
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
90
+ // …but moved to the local trash (no data loss), not hard-deleted.
91
+ expect(existsLocal(result.world, ".filen.trash.local/a.txt")).toBe(true)
92
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
93
+ expect(result.finalLocal).toEqual(result.finalRemote)
94
+ })
95
+
96
+ it("P4: deleting a remote directory subtree emits ONE local delete (children collapsed)", async () => {
97
+ const result = await runScenario({
98
+ name: "P4",
99
+ mode: "cloudToLocal",
100
+ initialRemote: {
101
+ "/d/a.txt": { content: "a", mtimeMs: BASE_TIME + 1 * SECOND },
102
+ "/d/deep/b.txt": { content: "b", mtimeMs: BASE_TIME + 2 * SECOND }
103
+ },
104
+ steps: [
105
+ runCycle(),
106
+ runCycle(),
107
+ remoteMutate(world => world.cloud.controls.trashPath("/d")),
108
+ runCycle(),
109
+ runCycle()
110
+ ]
111
+ })
112
+
113
+ const kinds = transferKinds(result.cycles[2]!.messages)
114
+
115
+ // Exercises local.ts' subtree cache-prune loop + the deltas collapse: only the parent dir is
116
+ // deleted, the per-child deletes are collapsed away.
117
+ expect(kinds.filter(kind => kind === "deleteLocalDirectory")).toHaveLength(1)
118
+ expect(kinds).not.toContain("deleteLocalFile")
119
+
120
+ expect(result.finalLocal["/d"]).toBeUndefined()
121
+ expect(result.finalLocal["/d/a.txt"]).toBeUndefined()
122
+ expect(result.finalLocal["/d/deep/b.txt"]).toBeUndefined()
123
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
124
+ expect(result.finalLocal).toEqual(result.finalRemote)
125
+ })
126
+
127
+ // ===== RENAME / MOVE direction =====
128
+
129
+ it("P5: a remote file rename renames the local copy (same identity, no re-download)", async () => {
130
+ let originalUUID: string | undefined
131
+ let originalInode: number | null = null
132
+
133
+ const result = await runScenario({
134
+ name: "P5",
135
+ mode: "cloudToLocal",
136
+ initialRemote: { "/a.txt": { content: "content", mtimeMs: BASE_TIME + 1 * SECOND } },
137
+ steps: [
138
+ runCycle(),
139
+ runCycle(),
140
+ control(world => {
141
+ originalUUID = world.cloud.controls.getByPath("/a.txt")?.uuid
142
+ originalInode = world.vfs.controls.getInode("/local/a.txt")
143
+ }),
144
+ remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/b.txt")),
145
+ runCycle(),
146
+ runCycle()
147
+ ]
148
+ })
149
+
150
+ expect(transferKinds(result.cycles[2]!.messages)).toContain("renameLocalFile")
151
+ // A rename is not a transfer: the content is not re-downloaded.
152
+ expect(transferKinds(result.cycles[2]!.messages)).not.toContain("downloadFile")
153
+
154
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
155
+ expect(result.finalLocal["/b.txt"]).toMatchObject({ type: "file", size: "content".length })
156
+ expect(readLocal(result.world, "b.txt")).toBe("content")
157
+
158
+ // The remote node kept its uuid (a move, not a re-create)…
159
+ expect(originalUUID).toBeDefined()
160
+ expect(result.world.cloud.controls.getByPath("/b.txt")?.uuid).toBe(originalUUID)
161
+ // …and the LOCAL file kept its inode (renamed in place, not deleted+re-downloaded).
162
+ expect(originalInode).not.toBeNull()
163
+ expect(result.world.vfs.controls.getInode("/local/b.txt")).toBe(originalInode)
164
+
165
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
166
+ expect(result.finalLocal).toEqual(result.finalRemote)
167
+ })
168
+
169
+ it("P6: a remote directory rename renames the subtree locally (ONE op, children reparented)", async () => {
170
+ const result = await runScenario({
171
+ name: "P6",
172
+ mode: "cloudToLocal",
173
+ initialRemote: {
174
+ "/d/a.txt": { content: "a", mtimeMs: BASE_TIME + 1 * SECOND },
175
+ "/d/deep/b.txt": { content: "b", mtimeMs: BASE_TIME + 2 * SECOND }
176
+ },
177
+ steps: [
178
+ runCycle(),
179
+ runCycle(),
180
+ remoteMutate(world => world.cloud.controls.movePath("/d", "/e")),
181
+ runCycle(),
182
+ runCycle()
183
+ ]
184
+ })
185
+
186
+ const kinds = transferKinds(result.cycles[2]!.messages)
187
+
188
+ // Exercises local.ts' subtree reparent loop + the deltas collapse: one parent rename carries
189
+ // the whole subtree — no per-child renames and (crucially) no re-downloads.
190
+ expect(kinds.filter(kind => kind === "renameLocalDirectory")).toHaveLength(1)
191
+ expect(kinds).not.toContain("renameLocalFile")
192
+ expect(kinds).not.toContain("downloadFile")
193
+
194
+ expect(result.finalLocal["/d"]).toBeUndefined()
195
+ expect(result.finalLocal["/d/a.txt"]).toBeUndefined()
196
+ expect(result.finalLocal["/e"]).toMatchObject({ type: "directory" })
197
+ expect(result.finalLocal["/e/a.txt"]).toMatchObject({ type: "file", size: 1 })
198
+ expect(result.finalLocal["/e/deep/b.txt"]).toMatchObject({ type: "file", size: 1 })
199
+ // The children were carried, not re-fetched — content intact.
200
+ expect(readLocal(result.world, "e/deep/b.txt")).toBe("b")
201
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
202
+ expect(result.finalLocal).toEqual(result.finalRemote)
203
+ })
204
+
205
+ it("P7: a local move into a subdirectory moves the remote node (same uuid, no re-upload)", async () => {
206
+ let originalUUID: string | undefined
207
+
208
+ const result = await runScenario({
209
+ name: "P7",
210
+ mode: "twoWay",
211
+ initialLocal: {
212
+ "/local/dir1/a.txt": "content",
213
+ // An already-settled empty destination directory, so the move's mkdir is a no-op.
214
+ "/local/dir2": null
215
+ },
216
+ steps: [
217
+ runCycle(),
218
+ control(world => {
219
+ originalUUID = world.cloud.controls.getByPath("/dir1/a.txt")?.uuid
220
+ }),
221
+ localMutate(world => renameLocal(world, "dir1/a.txt", "dir2/a.txt")),
222
+ runCycle(),
223
+ runCycle()
224
+ ]
225
+ })
226
+
227
+ // Move-into-subdir branch of remote.rename (basename unchanged → straight move).
228
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
229
+ expect(transferKinds(result.cycles[1]!.messages)).not.toContain("upload")
230
+
231
+ expect(result.finalRemote["/dir1/a.txt"]).toBeUndefined()
232
+ expect(result.finalRemote["/dir2/a.txt"]).toMatchObject({ type: "file", size: "content".length })
233
+
234
+ expect(originalUUID).toBeDefined()
235
+ expect(result.world.cloud.controls.getByPath("/dir2/a.txt")?.uuid).toBe(originalUUID)
236
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
237
+ expect(result.finalLocal).toEqual(result.finalRemote)
238
+ })
239
+
240
+ it("P8: a local move to the sync ROOT moves the remote node (same uuid)", async () => {
241
+ let originalUUID: string | undefined
242
+
243
+ const result = await runScenario({
244
+ name: "P8",
245
+ mode: "twoWay",
246
+ initialLocal: { "/local/dir/a.txt": "content" },
247
+ steps: [
248
+ runCycle(),
249
+ control(world => {
250
+ originalUUID = world.cloud.controls.getByPath("/dir/a.txt")?.uuid
251
+ }),
252
+ localMutate(world => renameLocal(world, "dir/a.txt", "a.txt")),
253
+ runCycle(),
254
+ runCycle()
255
+ ]
256
+ })
257
+
258
+ // Move-to-root branch of remote.rename.
259
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
260
+ expect(transferKinds(result.cycles[1]!.messages)).not.toContain("upload")
261
+
262
+ expect(result.finalRemote["/dir/a.txt"]).toBeUndefined()
263
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "content".length })
264
+ // The (now empty) source directory is untouched on both sides.
265
+ expect(result.finalRemote["/dir"]).toMatchObject({ type: "directory" })
266
+
267
+ expect(originalUUID).toBeDefined()
268
+ expect(result.world.cloud.controls.getByPath("/a.txt")?.uuid).toBe(originalUUID)
269
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
270
+ expect(result.finalLocal).toEqual(result.finalRemote)
271
+ })
272
+
273
+ it("P9: a local move that also renames across parents moves the remote node (same uuid)", async () => {
274
+ let originalUUID: string | undefined
275
+
276
+ const result = await runScenario({
277
+ name: "P9",
278
+ mode: "twoWay",
279
+ initialLocal: {
280
+ "/local/dir1/a.txt": "content",
281
+ "/local/dir2": null
282
+ },
283
+ steps: [
284
+ runCycle(),
285
+ control(world => {
286
+ originalUUID = world.cloud.controls.getByPath("/dir1/a.txt")?.uuid
287
+ }),
288
+ localMutate(world => renameLocal(world, "dir1/a.txt", "dir2/b.txt")),
289
+ runCycle(),
290
+ runCycle()
291
+ ]
292
+ })
293
+
294
+ // Rename-then-move branch of remote.rename (basename changes AND parent changes).
295
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
296
+ expect(transferKinds(result.cycles[1]!.messages)).not.toContain("upload")
297
+
298
+ expect(result.finalRemote["/dir1/a.txt"]).toBeUndefined()
299
+ expect(result.finalRemote["/dir2/b.txt"]).toMatchObject({ type: "file", size: "content".length })
300
+
301
+ expect(originalUUID).toBeDefined()
302
+ expect(result.world.cloud.controls.getByPath("/dir2/b.txt")?.uuid).toBe(originalUUID)
303
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
304
+ expect(result.finalLocal).toEqual(result.finalRemote)
305
+ })
306
+ })
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import pathModule from "path"
3
+ import { SYNC_INTERVAL, LOCAL_TRASH_NAME } from "../../src/constants"
4
+ import { createWorld, BASE_TIME, type CreateWorldOptions, type World } from "../harness/world"
5
+ import { snapshotRemote, messagesOfType, countMessages } from "../harness/snapshot"
6
+ import { rmLocal } from "../harness/mutations"
7
+ import { makeErrnoError } from "../fakes/virtual-fs"
8
+ import { type SyncMessage } from "../../src/types"
9
+
10
+ /**
11
+ * Category Q — cycle lifecycle internals (behavioral spec §I, §8). These pin the parts of
12
+ * `Sync.smokeTest` / `Sync.runCycle` / `Sync.cleanupLocalTrash` that the happy-path suite never
13
+ * reaches: the smoke-test retry loop (local writable/readable + remote-present), the per-cycle gate
14
+ * that refuses to proceed while an unresolved task error is pending, the catch-all `cycleError`, the
15
+ * deletion-confirmation re-prompt, and the 30-day local-trash eviction.
16
+ *
17
+ * Like Categories G and I these cycles are driven MANUALLY (the smoke-test and confirmation paths
18
+ * block on real timers), so fake timers are installed, the clock pinned to BASE_TIME, and the sync
19
+ * interval pumped before each {@link Sync.runCycle}.
20
+ */
21
+ const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
22
+ const DAY_MS = 86400000
23
+
24
+ async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
25
+ vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
26
+ vi.setSystemTime(BASE_TIME)
27
+
28
+ try {
29
+ const world = await createWorld(options)
30
+
31
+ await body(world)
32
+ } finally {
33
+ vi.useRealTimers()
34
+ }
35
+ }
36
+
37
+ /** Drive exactly one cycle (advance the interval so the local-change wait passes, then run). */
38
+ async function plainCycle(world: World): Promise<void> {
39
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
40
+
41
+ await world.sync.runCycle()
42
+ }
43
+
44
+ /** The messages appended to the world's stream while `body` runs (a per-cycle slice). */
45
+ async function capture(world: World, body: () => Promise<void>): Promise<SyncMessage[]> {
46
+ const mark = world.messages.length
47
+
48
+ await body()
49
+
50
+ return world.messages.slice(mark)
51
+ }
52
+
53
+ describe("Category Q — cycle lifecycle internals", () => {
54
+ it("Q1: a failed local writable smoke test emits cycleLocalSmokeTestFailed and retries until the path heals", async () => {
55
+ await withWorld({ mode: "twoWay" }, async world => {
56
+ // twoWay smoke-tests the local path for WRITABILITY; an injected EACCES makes isPathWritable false.
57
+ world.vfs.controls.setError(world.syncPair.localPath, makeErrnoError("EACCES"))
58
+
59
+ const messages = await capture(world, async () => {
60
+ const smoke = world.sync.smokeTest()
61
+
62
+ // Let the first attempt fail and schedule its SYNC_INTERVAL retry…
63
+ await vi.advanceTimersByTimeAsync(1)
64
+
65
+ // …then heal the fault so the scheduled retry passes and smokeTest resolves.
66
+ world.vfs.controls.clearError(world.syncPair.localPath)
67
+
68
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
69
+ await smoke
70
+ })
71
+
72
+ expect(messagesOfType(messages, "cycleLocalSmokeTestFailed").length).toBeGreaterThan(0)
73
+ // It recovered (the promise resolved) rather than failing the remote check.
74
+ expect(messagesOfType(messages, "cycleRemoteSmokeTestFailed").length).toBe(0)
75
+ })
76
+ })
77
+
78
+ it("Q2: a read-only mode smoke-tests readability instead, and a failed read also retries to recovery", async () => {
79
+ await withWorld({ mode: "localToCloud" }, async world => {
80
+ // localToCloud smoke-tests the local path for READABILITY (isPathReadable), a distinct branch.
81
+ world.vfs.controls.setError(world.syncPair.localPath, makeErrnoError("EACCES"))
82
+
83
+ const messages = await capture(world, async () => {
84
+ const smoke = world.sync.smokeTest()
85
+
86
+ await vi.advanceTimersByTimeAsync(1)
87
+
88
+ world.vfs.controls.clearError(world.syncPair.localPath)
89
+
90
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
91
+ await smoke
92
+ })
93
+
94
+ expect(messagesOfType(messages, "cycleLocalSmokeTestFailed").length).toBeGreaterThan(0)
95
+ })
96
+ })
97
+
98
+ it("Q3: a failed remote smoke test emits cycleRemoteSmokeTestFailed and retries until the remote is present", async () => {
99
+ await withWorld({ mode: "twoWay" }, async world => {
100
+ // The local check passes; the remote presence probe throws, so remoteDirPathExisting returns false.
101
+ world.cloud.controls.setError("present", new Error("present unavailable"))
102
+
103
+ const messages = await capture(world, async () => {
104
+ const smoke = world.sync.smokeTest()
105
+
106
+ await vi.advanceTimersByTimeAsync(1)
107
+
108
+ world.cloud.controls.clearError("present")
109
+
110
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
111
+ await smoke
112
+ })
113
+
114
+ expect(messagesOfType(messages, "cycleRemoteSmokeTestFailed").length).toBeGreaterThan(0)
115
+ // The local smoke test was never the problem here.
116
+ expect(messagesOfType(messages, "cycleLocalSmokeTestFailed").length).toBe(0)
117
+ })
118
+ })
119
+
120
+ it("Q4: cleanupLocalTrash evicts entries older than 30 days and keeps recent ones", async () => {
121
+ await withWorld({ mode: "twoWay" }, async world => {
122
+ const trashDir = pathModule.posix.join(world.syncPair.localPath, LOCAL_TRASH_NAME)
123
+
124
+ world.vfs.ifs.mkdirSync(trashDir, { recursive: true })
125
+ world.vfs.ifs.writeFileSync(pathModule.posix.join(trashDir, "old.txt"), "stale")
126
+ world.vfs.ifs.writeFileSync(pathModule.posix.join(trashDir, "fresh.txt"), "recent")
127
+
128
+ const now = Date.now()
129
+ // old.txt: last accessed 31 days ago (beyond the 30-day retention) → evicted.
130
+ const staleSeconds = (now - DAY_MS * 31) / 1000
131
+ // fresh.txt: accessed 1 day ago → retained.
132
+ const recentSeconds = (now - DAY_MS) / 1000
133
+
134
+ world.vfs.ifs.utimesSync(pathModule.posix.join(trashDir, "old.txt"), staleSeconds, staleSeconds)
135
+ world.vfs.ifs.utimesSync(pathModule.posix.join(trashDir, "fresh.txt"), recentSeconds, recentSeconds)
136
+
137
+ // Register the eviction interval, then fire one tick (the glob + rm settle within the advance).
138
+ world.sync.cleanupLocalTrash()
139
+
140
+ await vi.advanceTimersByTimeAsync(300000 + 1)
141
+ await vi.advanceTimersByTimeAsync(1)
142
+
143
+ expect(world.vfs.ifs.existsSync(pathModule.posix.join(trashDir, "old.txt"))).toBe(false)
144
+ expect(world.vfs.ifs.existsSync(pathModule.posix.join(trashDir, "fresh.txt"))).toBe(true)
145
+ })
146
+ })
147
+
148
+ it("Q5: a cycle that starts with an unresolved task error re-reports it and restarts without doing work", async () => {
149
+ await withWorld({ mode: "twoWay", initialRemote: { "/dir/a.txt": "content" } }, async world => {
150
+ // Settle twice so both previous trees + the engine's remote uuid cache are populated.
151
+ await plainCycle(world)
152
+ await plainCycle(world)
153
+
154
+ // Delete locally but force the remote trash to fail → the failing cycle records a task error.
155
+ rmLocal(world, "dir/a.txt")
156
+ world.cloud.controls.setError("trashFile", new Error("backend unavailable"))
157
+ world.triggerWatcher()
158
+
159
+ await plainCycle(world)
160
+
161
+ // The NEXT cycle is NOT reset, so it observes the pending task error at the very top and gates:
162
+ // it re-emits taskErrors + cycleRestarting and returns BEFORE starting the cycle body.
163
+ const gated = await capture(world, () => plainCycle(world))
164
+
165
+ expect(messagesOfType(gated, "taskErrors").length).toBeGreaterThan(0)
166
+ expect(messagesOfType(gated, "cycleRestarting").length).toBeGreaterThan(0)
167
+ expect(countMessages(gated, "cycleStarted")).toBe(0)
168
+ })
169
+ })
170
+
171
+ it("Q6: an error thrown while fetching the trees is caught and surfaced as cycleError", async () => {
172
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "x" } }, async world => {
173
+ await plainCycle(world)
174
+
175
+ // Make the remote tree fetch throw inside the cycle body (after the lock + smoke test).
176
+ world.cloud.controls.setError("tree", new Error("tree fetch boom"))
177
+ world.triggerWatcher()
178
+
179
+ const errored = await capture(world, () => plainCycle(world))
180
+
181
+ expect(messagesOfType(errored, "cycleError").length).toBeGreaterThan(0)
182
+ })
183
+ })
184
+
185
+ it("Q7: the deletion-confirmation prompt is re-emitted every second while it waits for a decision", async () => {
186
+ await withWorld(
187
+ {
188
+ mode: "twoWay",
189
+ requireConfirmationOnLargeDeletion: true,
190
+ initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" }
191
+ },
192
+ async world => {
193
+ await plainCycle(world)
194
+
195
+ rmLocal(world, "a.txt")
196
+ rmLocal(world, "b.txt")
197
+ world.triggerWatcher()
198
+
199
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
200
+
201
+ let settled = false
202
+ const cyclePromise = world.sync.runCycle().finally(() => {
203
+ settled = true
204
+ })
205
+
206
+ // Advance (without delivering a decision) until the prompt opens.
207
+ for (let tick = 0; tick < 30 && messagesOfType(world.messages, "confirmDeletion").length === 0; tick++) {
208
+ await vi.advanceTimersByTimeAsync(1000)
209
+ }
210
+
211
+ const afterOpen = messagesOfType(world.messages, "confirmDeletion").length
212
+
213
+ expect(afterOpen).toBeGreaterThan(0)
214
+
215
+ // One more second with no decision → the prompt is re-emitted (the waiting branch).
216
+ await vi.advanceTimersByTimeAsync(1000)
217
+
218
+ expect(messagesOfType(world.messages, "confirmDeletion").length).toBeGreaterThan(afterOpen)
219
+
220
+ // Now deliver the decision each tick until the cycle completes.
221
+ for (let tick = 0; tick < 30 && !settled; tick++) {
222
+ world.worker.confirmDeletion(world.syncPair.uuid, "delete")
223
+
224
+ await vi.advanceTimersByTimeAsync(1000)
225
+ }
226
+
227
+ await cyclePromise
228
+
229
+ // "delete" was confirmed, so the emptying proceeded.
230
+ expect(snapshotRemote(world)).toEqual({})
231
+ }
232
+ )
233
+ })
234
+ })