@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,292 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, remoteMutate } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { transferKinds } from "../harness/snapshot"
5
+ import { writeLocalAt, renameLocal, rmLocal, readLocal, existsLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category Y — the cross-side conflict matrix for twoWay (behavioral spec §C/§D/§E intersection). Every
9
+ * case here is a SAME-PATH (or same-identity) conflict where both sides acted since the last sync, plus
10
+ * the single-cycle rename+modify race. The engine must always reach a fixed point (finalLocal ===
11
+ * finalRemote) with NO silent data loss. Resolution policies (confirmed with the maintainer):
12
+ *
13
+ * • newest-mtime wins for add-vs-add and modify-vs-modify (equal whole-second → local, §C6);
14
+ * • a real CONTENT modification on EITHER side beats the other side's delete (resurrect) — symmetric;
15
+ * • a rename whose source the other side deleted/modified/renamed degrades to keep-the-data (the
16
+ * renamed file is re-added under its new name; the other side's change is applied too) so the worlds
17
+ * still converge with both edits preserved;
18
+ * • rename + in-place modify of the SAME file in ONE cycle keeps BOTH (the new name AND the new bytes).
19
+ *
20
+ * These pin the fixes for F1–F4/F7 (docs/hardening-findings.md). Convergence + no-data-loss is the
21
+ * invariant; exact surviving paths are asserted where the policy fixes them.
22
+ */
23
+ const SECOND = 1000
24
+
25
+ describe("Category Y — cross-side conflict matrix (twoWay)", () => {
26
+ it("Y1: add(local) vs add(remote) same path, local newer → local content wins, converges", async () => {
27
+ const result = await runScenario({
28
+ name: "Y1",
29
+ mode: "twoWay",
30
+ steps: [
31
+ localMutate(world => writeLocalAt(world, "x.txt", "LOCAL-NEWER", BASE_TIME + 5 * SECOND)),
32
+ remoteMutate(world => world.cloud.controls.addFile("/x.txt", "remote-older", { mtimeMs: BASE_TIME + 2 * SECOND })),
33
+ runCycle(),
34
+ runCycle(),
35
+ runCycle()
36
+ ]
37
+ })
38
+
39
+ expect(result.finalLocal).toEqual(result.finalRemote)
40
+ expect(result.finalRemote["/x.txt"]).toMatchObject({ type: "file", size: "LOCAL-NEWER".length })
41
+ })
42
+
43
+ it("Y2: add(local) vs add(remote) same path, remote newer → remote content wins, converges", async () => {
44
+ const result = await runScenario({
45
+ name: "Y2",
46
+ mode: "twoWay",
47
+ steps: [
48
+ localMutate(world => writeLocalAt(world, "x.txt", "local-older", BASE_TIME + 2 * SECOND)),
49
+ remoteMutate(world => world.cloud.controls.addFile("/x.txt", "REMOTE-NEWER-LONGER", { mtimeMs: BASE_TIME + 5 * SECOND })),
50
+ runCycle(),
51
+ runCycle(),
52
+ runCycle()
53
+ ]
54
+ })
55
+
56
+ expect(result.finalLocal).toEqual(result.finalRemote)
57
+ expect(result.finalLocal["/x.txt"]).toMatchObject({ type: "file", size: "REMOTE-NEWER-LONGER".length })
58
+ })
59
+
60
+ it("Y3: delete(local) vs delete(remote) same path → converges to empty, no error", async () => {
61
+ const result = await runScenario({
62
+ name: "Y3",
63
+ mode: "twoWay",
64
+ initialLocal: { "/local/a.txt": "data" },
65
+ steps: [
66
+ runCycle(),
67
+ localMutate(world => rmLocal(world, "a.txt")),
68
+ remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
69
+ runCycle(),
70
+ runCycle()
71
+ ]
72
+ })
73
+
74
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
75
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
76
+ expect(result.finalLocal).toEqual(result.finalRemote)
77
+ })
78
+
79
+ // F7 — symmetric resurrect: a remote modification beats a local delete (mirror of OBS-001/D8).
80
+ it("Y4: delete(local) vs modify(remote, newer) → the remote modification wins, resurrected", async () => {
81
+ const result = await runScenario({
82
+ name: "Y4",
83
+ mode: "twoWay",
84
+ initialLocal: { "/local/a.txt": "v1" },
85
+ steps: [
86
+ runCycle(),
87
+ localMutate(world => rmLocal(world, "a.txt")),
88
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-MODIFIED-NEWER", { mtimeMs: BASE_TIME + 9 * SECOND })),
89
+ runCycle(),
90
+ runCycle()
91
+ ]
92
+ })
93
+
94
+ expect(result.finalLocal).toEqual(result.finalRemote)
95
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "REMOTE-MODIFIED-NEWER".length })
96
+ expect(readLocal(result.world, "a.txt")).toBe("REMOTE-MODIFIED-NEWER")
97
+ })
98
+
99
+ // The pre-existing direction (OBS-001) stays correct: a local modification beats a remote delete.
100
+ it("Y5: modify(local) vs delete(remote) → the local modification wins, resurrected", async () => {
101
+ const result = await runScenario({
102
+ name: "Y5",
103
+ mode: "twoWay",
104
+ initialLocal: { "/local/a.txt": "v1" },
105
+ steps: [
106
+ runCycle(),
107
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-MODIFIED-LONGER", BASE_TIME + 9 * SECOND)),
108
+ remoteMutate(world => world.cloud.controls.deletePath("/a.txt")),
109
+ runCycle(),
110
+ runCycle()
111
+ ]
112
+ })
113
+
114
+ expect(result.finalLocal).toEqual(result.finalRemote)
115
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "LOCAL-MODIFIED-LONGER".length })
116
+ })
117
+
118
+ // F1 — rename + in-place modify of the SAME file in ONE cycle: keep the new name AND the new bytes.
119
+ it("Y6: rename(local a→b) + modify b in one cycle → converges to b with the NEW content", async () => {
120
+ const result = await runScenario({
121
+ name: "Y6",
122
+ mode: "twoWay",
123
+ initialLocal: { "/local/a.txt": "original-content" },
124
+ steps: [
125
+ runCycle(),
126
+ localMutate(world => {
127
+ renameLocal(world, "a.txt", "b.txt")
128
+ writeLocalAt(world, "b.txt", "BRAND-NEW-CONTENT-X", BASE_TIME + 5 * SECOND)
129
+ }),
130
+ runCycle(),
131
+ runCycle(),
132
+ runCycle()
133
+ ]
134
+ })
135
+
136
+ expect(result.finalLocal).toEqual(result.finalRemote)
137
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
138
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "BRAND-NEW-CONTENT-X".length })
139
+ expect(readLocal(result.world, "b.txt")).toBe("BRAND-NEW-CONTENT-X")
140
+ })
141
+
142
+ // F2 — local rename a→b races a remote delete of a. The renamed file (b) is data the user kept, so
143
+ // it survives on both sides; the remote's delete of the old name is moot.
144
+ it("Y7: rename(local a→b) vs delete(remote a) → converges to {b}, data preserved", async () => {
145
+ const result = await runScenario({
146
+ name: "Y7",
147
+ mode: "twoWay",
148
+ initialLocal: { "/local/a.txt": "data" },
149
+ steps: [
150
+ runCycle(),
151
+ localMutate(world => renameLocal(world, "a.txt", "b.txt")),
152
+ remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
153
+ runCycle(),
154
+ runCycle(),
155
+ runCycle()
156
+ ]
157
+ })
158
+
159
+ expect(result.finalLocal).toEqual(result.finalRemote)
160
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
161
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "data".length })
162
+ expect(existsLocal(result.world, "b.txt")).toBe(true)
163
+ })
164
+
165
+ // F3 — local rename a→b races a remote modify of a. Keep-both: the remote's new content survives at
166
+ // a, the local rename result survives at b. No silent loss either way.
167
+ it("Y8: rename(local a→b) vs modify(remote a) → converges keeping both a and b", async () => {
168
+ const result = await runScenario({
169
+ name: "Y8",
170
+ mode: "twoWay",
171
+ initialLocal: { "/local/a.txt": "orig" },
172
+ steps: [
173
+ runCycle(),
174
+ localMutate(world => renameLocal(world, "a.txt", "b.txt")),
175
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-MOD", { mtimeMs: BASE_TIME + 9 * SECOND })),
176
+ runCycle(),
177
+ runCycle(),
178
+ runCycle()
179
+ ]
180
+ })
181
+
182
+ expect(result.finalLocal).toEqual(result.finalRemote)
183
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "REMOTE-MOD".length })
184
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "orig".length })
185
+ })
186
+
187
+ // F4 — both sides rename the same file to DIFFERENT names. Keep-both: each rename is preserved.
188
+ it("Y9: rename(local a→X) vs rename(remote a→Y) → converges keeping both X and Y", async () => {
189
+ const result = await runScenario({
190
+ name: "Y9",
191
+ mode: "twoWay",
192
+ initialLocal: { "/local/a.txt": "data" },
193
+ steps: [
194
+ runCycle(),
195
+ localMutate(world => renameLocal(world, "a.txt", "local-name.txt")),
196
+ remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/remote-name.txt")),
197
+ runCycle(),
198
+ runCycle(),
199
+ runCycle()
200
+ ]
201
+ })
202
+
203
+ expect(result.finalLocal).toEqual(result.finalRemote)
204
+ expect(result.finalRemote["/local-name.txt"]).toMatchObject({ type: "file" })
205
+ expect(result.finalRemote["/remote-name.txt"]).toMatchObject({ type: "file" })
206
+ })
207
+
208
+ // F2 symmetric — remote rename a→b races a local delete of a.
209
+ it("Y10: rename(remote a→b) vs delete(local a) → converges to {b}, data preserved", async () => {
210
+ const result = await runScenario({
211
+ name: "Y10",
212
+ mode: "twoWay",
213
+ initialRemote: { "/a.txt": "data" },
214
+ steps: [
215
+ runCycle(),
216
+ remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/b.txt")),
217
+ localMutate(world => rmLocal(world, "a.txt")),
218
+ runCycle(),
219
+ runCycle(),
220
+ runCycle()
221
+ ]
222
+ })
223
+
224
+ expect(result.finalLocal).toEqual(result.finalRemote)
225
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
226
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "data".length })
227
+ })
228
+
229
+ // F3 symmetric — remote rename a→b races a local modify of a.
230
+ it("Y11: rename(remote a→b) vs modify(local a) → converges keeping both a and b", async () => {
231
+ const result = await runScenario({
232
+ name: "Y11",
233
+ mode: "twoWay",
234
+ initialRemote: { "/a.txt": "orig" },
235
+ steps: [
236
+ runCycle(),
237
+ remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/b.txt")),
238
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-MOD", BASE_TIME + 9 * SECOND)),
239
+ runCycle(),
240
+ runCycle(),
241
+ runCycle()
242
+ ]
243
+ })
244
+
245
+ expect(result.finalLocal).toEqual(result.finalRemote)
246
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "LOCAL-MOD".length })
247
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "orig".length })
248
+ })
249
+
250
+ // Convergence under a same-cycle delete on one side and an unrelated add on the other (no false conflict).
251
+ it("Y12: delete(local a) + add(remote b) in one cycle → both applied, converges", async () => {
252
+ const result = await runScenario({
253
+ name: "Y12",
254
+ mode: "twoWay",
255
+ initialLocal: { "/local/a.txt": "data" },
256
+ steps: [
257
+ runCycle(),
258
+ localMutate(world => rmLocal(world, "a.txt")),
259
+ remoteMutate(world => world.cloud.controls.addFile("/b.txt", "new-remote", { mtimeMs: BASE_TIME + 3 * SECOND })),
260
+ runCycle(),
261
+ runCycle()
262
+ ]
263
+ })
264
+
265
+ expect(result.finalLocal).toEqual(result.finalRemote)
266
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
267
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file" })
268
+ })
269
+
270
+ // A second settling pass must be a true fixed point for a representative conflict (idempotence).
271
+ it("Y13: a resolved conflict is idempotent (no work on the next settled cycle)", async () => {
272
+ const result = await runScenario({
273
+ name: "Y13",
274
+ mode: "twoWay",
275
+ initialLocal: { "/local/a.txt": "v1" },
276
+ steps: [
277
+ runCycle(),
278
+ localMutate(world => rmLocal(world, "a.txt")),
279
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-WINS", { mtimeMs: BASE_TIME + 9 * SECOND })),
280
+ runCycle(),
281
+ runCycle(),
282
+ runCycle()
283
+ ]
284
+ })
285
+
286
+ expect(result.finalLocal).toEqual(result.finalRemote)
287
+ // The last cycle did no file transfers.
288
+ const last = result.cycles[result.cycles.length - 1]!
289
+
290
+ expect(transferKinds(last.messages).filter(k => ["upload", "uploadFile", "download", "downloadFile"].includes(k))).toEqual([])
291
+ })
292
+ })
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { allOps } from "../harness/snapshot"
5
+ import { writeLocal, writeLocalAt, mkdirLocal, rmLocal, renameLocal, readLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category Z — multiple operations composed in a SINGLE cycle (twoWay). These stress the phase-ordered
9
+ * runner and the rename/collapse/modify interplay together (the area that hid F1). Every case must reach
10
+ * a fixed point with the data intact. Distinct from Category E/R (rename mechanics), P (cross-dir moves),
11
+ * K (bulk), and Y (cross-side conflicts): here several DIFFERENT op types land in one beat on one side.
12
+ */
13
+ const SECOND = 1000
14
+
15
+ describe("Category Z — multi-op compositions in one cycle (twoWay)", () => {
16
+ it("Z1: move a file OUT of a directory and delete that directory in one cycle", async () => {
17
+ const result = await runScenario({
18
+ name: "Z1",
19
+ mode: "twoWay",
20
+ initialLocal: { "/local/dir/f.txt": "keepme", "/local/dir/other.txt": "other" },
21
+ steps: [
22
+ runCycle(),
23
+ localMutate(world => {
24
+ renameLocal(world, "dir/f.txt", "f.txt")
25
+ rmLocal(world, "dir")
26
+ }),
27
+ runCycle(),
28
+ runCycle()
29
+ ]
30
+ })
31
+
32
+ // The moved-out file survives at the root; the directory (and its remaining child) are gone.
33
+ expect(result.finalRemote["/f.txt"]).toMatchObject({ type: "file", size: "keepme".length })
34
+ expect(result.finalRemote["/dir"]).toBeUndefined()
35
+ expect(result.finalRemote["/dir/other.txt"]).toBeUndefined()
36
+ expect(result.finalLocal).toEqual(result.finalRemote)
37
+ })
38
+
39
+ it("Z2: rename a directory AND modify a child within it in one cycle (child's new content propagates)", async () => {
40
+ const result = await runScenario({
41
+ name: "Z2",
42
+ mode: "twoWay",
43
+ initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
44
+ steps: [
45
+ runCycle(),
46
+ localMutate(world => {
47
+ renameLocal(world, "dir", "dir2")
48
+ writeLocalAt(world, "dir2/child.txt", "NEW-CONTENT-LONGER", BASE_TIME + 5 * SECOND)
49
+ }),
50
+ runCycle(),
51
+ runCycle(),
52
+ runCycle()
53
+ ]
54
+ })
55
+
56
+ expect(result.finalRemote["/dir"]).toBeUndefined()
57
+ expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ type: "file", size: "NEW-CONTENT-LONGER".length })
58
+ expect(result.finalRemote["/dir2/sibling.txt"]).toMatchObject({ type: "file", size: "sib".length })
59
+ expect(result.finalLocal).toEqual(result.finalRemote)
60
+ })
61
+
62
+ it("Z3: move a subtree into another directory AND modify a file within the moved subtree", async () => {
63
+ const result = await runScenario({
64
+ name: "Z3",
65
+ mode: "twoWay",
66
+ initialLocal: { "/local/src/deep/data.txt": "old", "/local/dest/keep.txt": "k" },
67
+ steps: [
68
+ runCycle(),
69
+ localMutate(world => {
70
+ renameLocal(world, "src", "dest/src")
71
+ writeLocalAt(world, "dest/src/deep/data.txt", "MOVED-AND-CHANGED", BASE_TIME + 5 * SECOND)
72
+ }),
73
+ runCycle(),
74
+ runCycle(),
75
+ runCycle()
76
+ ]
77
+ })
78
+
79
+ expect(result.finalRemote["/src"]).toBeUndefined()
80
+ expect(result.finalRemote["/dest/src/deep/data.txt"]).toMatchObject({ type: "file", size: "MOVED-AND-CHANGED".length })
81
+ expect(result.finalRemote["/dest/keep.txt"]).toMatchObject({ type: "file" })
82
+ expect(result.finalLocal).toEqual(result.finalRemote)
83
+ })
84
+
85
+ it("Z4: rename a directory AND add a brand-new file into the renamed directory in one cycle", async () => {
86
+ const result = await runScenario({
87
+ name: "Z4",
88
+ mode: "twoWay",
89
+ initialLocal: { "/local/dir/existing.txt": "e" },
90
+ steps: [
91
+ runCycle(),
92
+ localMutate(world => {
93
+ renameLocal(world, "dir", "dir2")
94
+ writeLocal(world, "dir2/fresh.txt", "fresh")
95
+ }),
96
+ runCycle(),
97
+ runCycle()
98
+ ]
99
+ })
100
+
101
+ expect(result.finalRemote["/dir"]).toBeUndefined()
102
+ expect(result.finalRemote["/dir2/existing.txt"]).toMatchObject({ type: "file" })
103
+ expect(result.finalRemote["/dir2/fresh.txt"]).toMatchObject({ type: "file", size: "fresh".length })
104
+ expect(result.finalLocal).toEqual(result.finalRemote)
105
+ })
106
+
107
+ it("Z5: swap two directory names (a↔b) via a temp in one cycle", async () => {
108
+ const result = await runScenario({
109
+ name: "Z5",
110
+ mode: "twoWay",
111
+ initialLocal: { "/local/a/fa.txt": "AAA", "/local/b/fb.txt": "BBB" },
112
+ steps: [
113
+ runCycle(),
114
+ localMutate(world => {
115
+ renameLocal(world, "a", "tmp")
116
+ renameLocal(world, "b", "a")
117
+ renameLocal(world, "tmp", "b")
118
+ }),
119
+ runCycle(),
120
+ runCycle(),
121
+ runCycle()
122
+ ]
123
+ })
124
+
125
+ // a now holds B's content, b holds A's content.
126
+ expect(result.finalRemote["/a/fb.txt"]).toMatchObject({ type: "file", size: "BBB".length })
127
+ expect(result.finalRemote["/b/fa.txt"]).toMatchObject({ type: "file", size: "AAA".length })
128
+ expect(result.finalLocal).toEqual(result.finalRemote)
129
+ })
130
+
131
+ it("Z6: cross-directory move AND content modify of the same file in one cycle", async () => {
132
+ const result = await runScenario({
133
+ name: "Z6",
134
+ mode: "twoWay",
135
+ initialLocal: { "/local/from/f.txt": "original", "/local/to/keep.txt": "k" },
136
+ steps: [
137
+ runCycle(),
138
+ localMutate(world => {
139
+ renameLocal(world, "from/f.txt", "to/f.txt")
140
+ writeLocalAt(world, "to/f.txt", "MOVED-CONTENT", BASE_TIME + 5 * SECOND)
141
+ }),
142
+ runCycle(),
143
+ runCycle(),
144
+ runCycle()
145
+ ]
146
+ })
147
+
148
+ expect(result.finalRemote["/from/f.txt"]).toBeUndefined()
149
+ expect(result.finalRemote["/to/f.txt"]).toMatchObject({ type: "file", size: "MOVED-CONTENT".length })
150
+ expect(readLocal(result.world, "to/f.txt")).toBe("MOVED-CONTENT")
151
+ expect(result.finalLocal).toEqual(result.finalRemote)
152
+ })
153
+
154
+ it("Z7: delete one file, rename another, and add a third — all in one cycle", async () => {
155
+ const result = await runScenario({
156
+ name: "Z7",
157
+ mode: "twoWay",
158
+ initialLocal: { "/local/del.txt": "d", "/local/ren.txt": "r" },
159
+ steps: [
160
+ runCycle(),
161
+ localMutate(world => {
162
+ rmLocal(world, "del.txt")
163
+ renameLocal(world, "ren.txt", "renamed.txt")
164
+ writeLocal(world, "added.txt", "a")
165
+ }),
166
+ runCycle(),
167
+ runCycle()
168
+ ]
169
+ })
170
+
171
+ expect(result.finalRemote["/del.txt"]).toBeUndefined()
172
+ expect(result.finalRemote["/ren.txt"]).toBeUndefined()
173
+ expect(result.finalRemote["/renamed.txt"]).toMatchObject({ type: "file" })
174
+ expect(result.finalRemote["/added.txt"]).toMatchObject({ type: "file" })
175
+ expect(result.finalLocal).toEqual(result.finalRemote)
176
+ })
177
+
178
+ it("Z8: create a directory and move an existing file into it in one cycle", async () => {
179
+ const result = await runScenario({
180
+ name: "Z8",
181
+ mode: "twoWay",
182
+ initialLocal: { "/local/f.txt": "data" },
183
+ steps: [
184
+ runCycle(),
185
+ localMutate(world => {
186
+ mkdirLocal(world, "newdir")
187
+ renameLocal(world, "f.txt", "newdir/f.txt")
188
+ }),
189
+ runCycle(),
190
+ runCycle()
191
+ ]
192
+ })
193
+
194
+ expect(result.finalRemote["/newdir"]).toMatchObject({ type: "directory" })
195
+ expect(result.finalRemote["/newdir/f.txt"]).toMatchObject({ type: "file" })
196
+ expect(result.finalRemote["/f.txt"]).toBeUndefined()
197
+ expect(result.finalLocal).toEqual(result.finalRemote)
198
+ })
199
+
200
+ it("Z9: a rename round-trip across cycles (a→b→a) ends as an identity no-op", async () => {
201
+ let uuidBefore: string | undefined
202
+
203
+ const result = await runScenario({
204
+ name: "Z9",
205
+ mode: "twoWay",
206
+ initialLocal: { "/local/a.txt": "data" },
207
+ steps: [
208
+ runCycle(),
209
+ localMutate(world => {
210
+ uuidBefore = world.cloud.controls.getByPath("/a.txt")?.uuid
211
+ renameLocal(world, "a.txt", "b.txt")
212
+ }),
213
+ runCycle(),
214
+ localMutate(world => renameLocal(world, "b.txt", "a.txt")),
215
+ runCycle(),
216
+ runCycle()
217
+ ]
218
+ })
219
+
220
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "data".length })
221
+ expect(result.finalRemote["/b.txt"]).toBeUndefined()
222
+ // Identity preserved through the round trip (no re-upload, same remote node).
223
+ expect(result.world.cloud.controls.getByPath("/a.txt")?.uuid).toBe(uuidBefore)
224
+ expect(result.finalLocal).toEqual(result.finalRemote)
225
+ })
226
+
227
+ it("Z10: modify a file and rename its parent directory in one cycle", async () => {
228
+ const result = await runScenario({
229
+ name: "Z10",
230
+ mode: "twoWay",
231
+ initialLocal: { "/local/parent/data.txt": "old", "/local/parent/static.txt": "s" },
232
+ steps: [
233
+ runCycle(),
234
+ localMutate(world => {
235
+ writeLocalAt(world, "parent/data.txt", "EDITED-IN-PLACE", BASE_TIME + 5 * SECOND)
236
+ renameLocal(world, "parent", "parent-renamed")
237
+ }),
238
+ runCycle(),
239
+ runCycle(),
240
+ runCycle()
241
+ ]
242
+ })
243
+
244
+ expect(result.finalRemote["/parent"]).toBeUndefined()
245
+ expect(result.finalRemote["/parent-renamed/data.txt"]).toMatchObject({ type: "file", size: "EDITED-IN-PLACE".length })
246
+ expect(result.finalRemote["/parent-renamed/static.txt"]).toMatchObject({ type: "file" })
247
+ expect(readLocal(result.world, "parent-renamed/data.txt")).toBe("EDITED-IN-PLACE")
248
+ expect(result.finalLocal).toEqual(result.finalRemote)
249
+ // Settled.
250
+ expect(allOps(result.cycles[result.cycles.length - 1]!.messages)).toEqual([])
251
+ })
252
+
253
+ it("Z11: a deep mixed batch (adds, a delete, a move, a modify) converges and is idempotent", async () => {
254
+ const result = await runScenario({
255
+ name: "Z11",
256
+ mode: "twoWay",
257
+ initialLocal: {
258
+ "/local/keep/a.txt": "a",
259
+ "/local/keep/b.txt": "b",
260
+ "/local/gone/x.txt": "x",
261
+ "/local/move-me.txt": "m"
262
+ },
263
+ steps: [
264
+ runCycle(),
265
+ localMutate(world => {
266
+ writeLocal(world, "keep/c.txt", "c")
267
+ writeLocalAt(world, "keep/a.txt", "a-edited", BASE_TIME + 5 * SECOND)
268
+ rmLocal(world, "gone")
269
+ renameLocal(world, "move-me.txt", "keep/move-me.txt")
270
+ }),
271
+ runCycle(),
272
+ runCycle(),
273
+ runCycle()
274
+ ]
275
+ })
276
+
277
+ expect(result.finalRemote["/keep/c.txt"]).toMatchObject({ type: "file" })
278
+ expect(result.finalRemote["/keep/a.txt"]).toMatchObject({ type: "file", size: "a-edited".length })
279
+ expect(result.finalRemote["/gone"]).toBeUndefined()
280
+ expect(result.finalRemote["/keep/move-me.txt"]).toMatchObject({ type: "file" })
281
+ expect(result.finalRemote["/move-me.txt"]).toBeUndefined()
282
+ expect(result.finalLocal).toEqual(result.finalRemote)
283
+ expect(allOps(result.cycles[result.cycles.length - 1]!.messages)).toEqual([])
284
+ })
285
+ })