@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.
- package/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
|
@@ -0,0 +1,296 @@
|
|
|
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 { renameLocal, writeLocalAt, rmLocal } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cross-side directory rename + concurrent child change (BUG-A / BUG-B). A directory renamed on ONE side
|
|
9
|
+
* while a descendant is changed on the OTHER side is the hardest reconciliation case: the rename relocates
|
|
10
|
+
* the whole subtree, but the per-descendant passes compare current-vs-base by PATH, so a child still sitting
|
|
11
|
+
* at the pre-rename path on the other side is mis-attributed. Before the rename-aware rebase, this silently
|
|
12
|
+
* destroyed the other-side modification (BUG-A) or resurrected an other-side deletion (BUG-B). Every case
|
|
13
|
+
* must converge (finalLocal === finalRemote) with the NEWER change winning and no data loss.
|
|
14
|
+
*
|
|
15
|
+
* Category E covers same-side rename mechanics; Z covers same-side multi-op; Y covers same-path conflicts.
|
|
16
|
+
* This file is specifically the cross-side directory-subtree race.
|
|
17
|
+
*/
|
|
18
|
+
const SECOND = 1000
|
|
19
|
+
|
|
20
|
+
describe("Cross-side directory rename + concurrent child change (BUG-A / BUG-B)", () => {
|
|
21
|
+
it("ZB1: local dir rename + remote child MODIFY → the remote edit survives (BUG-A)", async () => {
|
|
22
|
+
const result = await runScenario({
|
|
23
|
+
name: "ZB1",
|
|
24
|
+
mode: "twoWay",
|
|
25
|
+
initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
|
|
26
|
+
steps: [
|
|
27
|
+
runCycle(),
|
|
28
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
29
|
+
remoteMutate(world =>
|
|
30
|
+
world.cloud.controls.updateFile("/dir/child.txt", "REMOTE-EDITED-NEW-CONTENT", { mtimeMs: BASE_TIME + 10 * SECOND })
|
|
31
|
+
),
|
|
32
|
+
runCycle(),
|
|
33
|
+
runCycle(),
|
|
34
|
+
runCycle()
|
|
35
|
+
]
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "REMOTE-EDITED-NEW-CONTENT".length })
|
|
39
|
+
expect(result.finalRemote["/dir2/sibling.txt"]).toMatchObject({ size: "sib".length })
|
|
40
|
+
expect(result.finalRemote["/dir/child.txt"]).toBeUndefined()
|
|
41
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("ZB2: remote dir rename + local child MODIFY → the local edit survives (BUG-A symmetric)", async () => {
|
|
45
|
+
const result = await runScenario({
|
|
46
|
+
name: "ZB2",
|
|
47
|
+
mode: "twoWay",
|
|
48
|
+
initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
|
|
49
|
+
steps: [
|
|
50
|
+
runCycle(),
|
|
51
|
+
remoteMutate(world => world.cloud.controls.movePath("/dir", "/dir2")),
|
|
52
|
+
localMutate(world => writeLocalAt(world, "dir/child.txt", "LOCAL-EDITED-NEW-CONTENT", BASE_TIME + 10 * SECOND)),
|
|
53
|
+
runCycle(),
|
|
54
|
+
runCycle(),
|
|
55
|
+
runCycle()
|
|
56
|
+
]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "LOCAL-EDITED-NEW-CONTENT".length })
|
|
60
|
+
expect(result.finalRemote["/dir2/sibling.txt"]).toMatchObject({ size: "sib".length })
|
|
61
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("ZB3: local dir rename + remote child DELETE → the child is deleted, not resurrected (BUG-B)", async () => {
|
|
65
|
+
const result = await runScenario({
|
|
66
|
+
name: "ZB3",
|
|
67
|
+
mode: "twoWay",
|
|
68
|
+
initialLocal: { "/local/dir/child.txt": "old", "/local/dir/keep.txt": "k" },
|
|
69
|
+
steps: [
|
|
70
|
+
runCycle(),
|
|
71
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
72
|
+
remoteMutate(world => world.cloud.controls.trashPath("/dir/child.txt")),
|
|
73
|
+
runCycle(),
|
|
74
|
+
runCycle(),
|
|
75
|
+
runCycle()
|
|
76
|
+
]
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// The renamed directory survives with its un-touched child; the remotely-deleted child stays deleted.
|
|
80
|
+
expect(result.finalRemote["/dir2/keep.txt"]).toMatchObject({ size: "k".length })
|
|
81
|
+
expect(result.finalRemote["/dir2/child.txt"]).toBeUndefined()
|
|
82
|
+
expect(result.finalLocal["/dir2/child.txt"]).toBeUndefined()
|
|
83
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("ZB4: remote dir rename + local child DELETE → the child is deleted, not resurrected (BUG-B symmetric)", async () => {
|
|
87
|
+
const result = await runScenario({
|
|
88
|
+
name: "ZB4",
|
|
89
|
+
mode: "twoWay",
|
|
90
|
+
initialLocal: { "/local/dir/child.txt": "old", "/local/dir/keep.txt": "k" },
|
|
91
|
+
steps: [
|
|
92
|
+
runCycle(),
|
|
93
|
+
remoteMutate(world => world.cloud.controls.movePath("/dir", "/dir2")),
|
|
94
|
+
// The user deletes a file inside a folder another device just renamed. Without the rebase the
|
|
95
|
+
// remote copy (now at /dir2/child.txt) would be resurrected back down instead of deleted.
|
|
96
|
+
localMutate(world => rmLocal(world, "dir/child.txt")),
|
|
97
|
+
runCycle(),
|
|
98
|
+
runCycle(),
|
|
99
|
+
runCycle()
|
|
100
|
+
]
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(result.finalRemote["/dir2/keep.txt"]).toMatchObject({ size: "k".length })
|
|
104
|
+
expect(result.finalRemote["/dir2/child.txt"]).toBeUndefined()
|
|
105
|
+
expect(result.finalLocal["/dir2/child.txt"]).toBeUndefined()
|
|
106
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("ZB5: NESTED — rename a top dir + remote modify of a DEEPLY nested child → edit survives at the new path", async () => {
|
|
110
|
+
const result = await runScenario({
|
|
111
|
+
name: "ZB5",
|
|
112
|
+
mode: "twoWay",
|
|
113
|
+
initialLocal: { "/local/top/mid/deep/child.txt": "old", "/local/top/other.txt": "o" },
|
|
114
|
+
steps: [
|
|
115
|
+
runCycle(),
|
|
116
|
+
localMutate(world => renameLocal(world, "top", "top2")),
|
|
117
|
+
remoteMutate(world =>
|
|
118
|
+
world.cloud.controls.updateFile("/top/mid/deep/child.txt", "DEEPLY-NESTED-NEW-CONTENT", {
|
|
119
|
+
mtimeMs: BASE_TIME + 10 * SECOND
|
|
120
|
+
})
|
|
121
|
+
),
|
|
122
|
+
runCycle(),
|
|
123
|
+
runCycle(),
|
|
124
|
+
runCycle()
|
|
125
|
+
]
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(result.finalRemote["/top2/mid/deep/child.txt"]).toMatchObject({ size: "DEEPLY-NESTED-NEW-CONTENT".length })
|
|
129
|
+
expect(result.finalRemote["/top2/other.txt"]).toMatchObject({ size: "o".length })
|
|
130
|
+
expect(result.finalRemote["/top/mid/deep/child.txt"]).toBeUndefined()
|
|
131
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("ZB6: MULTIPLE children — rename dir; remote modifies one, deletes one, leaves one", async () => {
|
|
135
|
+
const result = await runScenario({
|
|
136
|
+
name: "ZB6",
|
|
137
|
+
mode: "twoWay",
|
|
138
|
+
initialLocal: { "/local/dir/a.txt": "a-old", "/local/dir/b.txt": "b-old", "/local/dir/c.txt": "c-old" },
|
|
139
|
+
steps: [
|
|
140
|
+
runCycle(),
|
|
141
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
142
|
+
remoteMutate(world => {
|
|
143
|
+
world.cloud.controls.updateFile("/dir/a.txt", "A-REMOTE-EDITED-NEW", { mtimeMs: BASE_TIME + 10 * SECOND })
|
|
144
|
+
world.cloud.controls.trashPath("/dir/b.txt")
|
|
145
|
+
}),
|
|
146
|
+
runCycle(),
|
|
147
|
+
runCycle(),
|
|
148
|
+
runCycle()
|
|
149
|
+
]
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expect(result.finalRemote["/dir2/a.txt"]).toMatchObject({ size: "A-REMOTE-EDITED-NEW".length })
|
|
153
|
+
expect(result.finalRemote["/dir2/b.txt"]).toBeUndefined()
|
|
154
|
+
expect(result.finalRemote["/dir2/c.txt"]).toMatchObject({ size: "c-old".length })
|
|
155
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("ZB7: MOVE a dir into another dir + remote child modify → edit survives at the moved-into path", async () => {
|
|
159
|
+
const result = await runScenario({
|
|
160
|
+
name: "ZB7",
|
|
161
|
+
mode: "twoWay",
|
|
162
|
+
initialLocal: { "/local/src/data.txt": "old", "/local/dest/keep.txt": "k" },
|
|
163
|
+
steps: [
|
|
164
|
+
runCycle(),
|
|
165
|
+
localMutate(world => renameLocal(world, "src", "dest/src")),
|
|
166
|
+
remoteMutate(world =>
|
|
167
|
+
world.cloud.controls.updateFile("/src/data.txt", "MOVED-DIR-CHILD-NEW-CONTENT", { mtimeMs: BASE_TIME + 10 * SECOND })
|
|
168
|
+
),
|
|
169
|
+
runCycle(),
|
|
170
|
+
runCycle(),
|
|
171
|
+
runCycle()
|
|
172
|
+
]
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(result.finalRemote["/dest/src/data.txt"]).toMatchObject({ size: "MOVED-DIR-CHILD-NEW-CONTENT".length })
|
|
176
|
+
expect(result.finalRemote["/dest/keep.txt"]).toMatchObject({ size: "k".length })
|
|
177
|
+
expect(result.finalRemote["/src/data.txt"]).toBeUndefined()
|
|
178
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("ZB8: local dir rename + remote ADDS a new child under the old path → the new child lands in the renamed dir", async () => {
|
|
182
|
+
const result = await runScenario({
|
|
183
|
+
name: "ZB8",
|
|
184
|
+
mode: "twoWay",
|
|
185
|
+
initialLocal: { "/local/dir/child.txt": "old" },
|
|
186
|
+
steps: [
|
|
187
|
+
runCycle(),
|
|
188
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
189
|
+
remoteMutate(world => world.cloud.controls.addFile("/dir/new.txt", "REMOTE-ADDED-CHILD", { mtimeMs: BASE_TIME + 10 * SECOND })),
|
|
190
|
+
runCycle(),
|
|
191
|
+
runCycle(),
|
|
192
|
+
runCycle()
|
|
193
|
+
]
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "old".length })
|
|
197
|
+
expect(result.finalRemote["/dir2/new.txt"]).toMatchObject({ size: "REMOTE-ADDED-CHILD".length })
|
|
198
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("ZB9: a SIMPLE dir rename (no child change) converges with NO file transfer (regression guard)", async () => {
|
|
202
|
+
const result = await runScenario({
|
|
203
|
+
name: "ZB9",
|
|
204
|
+
mode: "twoWay",
|
|
205
|
+
initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
|
|
206
|
+
steps: [
|
|
207
|
+
runCycle(),
|
|
208
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
209
|
+
runCycle(),
|
|
210
|
+
runCycle()
|
|
211
|
+
]
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// The rename must NOT degrade into a re-upload/re-download of the unchanged children.
|
|
215
|
+
const renameCycleKinds = transferKinds(result.cycles[1]!.messages)
|
|
216
|
+
expect(renameCycleKinds).not.toContain("upload")
|
|
217
|
+
expect(renameCycleKinds).not.toContain("download")
|
|
218
|
+
expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "old".length })
|
|
219
|
+
expect(result.finalRemote["/dir2/sibling.txt"]).toMatchObject({ size: "sib".length })
|
|
220
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("ZB10: after a cross-side rename+modify converges, an extra cycle is a no-op (multi-cycle stability)", async () => {
|
|
224
|
+
const result = await runScenario({
|
|
225
|
+
name: "ZB10",
|
|
226
|
+
mode: "twoWay",
|
|
227
|
+
initialLocal: { "/local/dir/child.txt": "old" },
|
|
228
|
+
steps: [
|
|
229
|
+
runCycle(),
|
|
230
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
231
|
+
remoteMutate(world =>
|
|
232
|
+
world.cloud.controls.updateFile("/dir/child.txt", "REMOTE-EDITED-NEW-CONTENT", { mtimeMs: BASE_TIME + 10 * SECOND })
|
|
233
|
+
),
|
|
234
|
+
runCycle(),
|
|
235
|
+
runCycle(),
|
|
236
|
+
runCycle(),
|
|
237
|
+
// A final settled cycle must produce no transfers.
|
|
238
|
+
runCycle()
|
|
239
|
+
]
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const lastCycleKinds = transferKinds(result.cycles[result.cycles.length - 1]!.messages)
|
|
243
|
+
expect(lastCycleKinds).not.toContain("upload")
|
|
244
|
+
expect(lastCycleKinds).not.toContain("download")
|
|
245
|
+
expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "REMOTE-EDITED-NEW-CONTENT".length })
|
|
246
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// Mirror-mode coverage: BUG-A's DATA LOSS is twoWay-specific (a mirror's authoritative side always wins
|
|
250
|
+
// correctly), but the rename-aware rebase must still keep mirror modes CONVERGENT and the authoritative
|
|
251
|
+
// side's content winning at the renamed path.
|
|
252
|
+
|
|
253
|
+
it("ZB11: localToCloud — local dir rename + a foreign remote child edit → local content wins, converges", async () => {
|
|
254
|
+
const result = await runScenario({
|
|
255
|
+
name: "ZB11",
|
|
256
|
+
mode: "localToCloud",
|
|
257
|
+
initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
|
|
258
|
+
steps: [
|
|
259
|
+
runCycle(),
|
|
260
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
261
|
+
remoteMutate(world =>
|
|
262
|
+
world.cloud.controls.updateFile("/dir/child.txt", "FOREIGN-REMOTE-EDIT", { mtimeMs: BASE_TIME + 10 * SECOND })
|
|
263
|
+
),
|
|
264
|
+
runCycle(),
|
|
265
|
+
runCycle(),
|
|
266
|
+
runCycle()
|
|
267
|
+
]
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Local authoritative: the foreign remote edit is reverted to the local content at the renamed path.
|
|
271
|
+
expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "old".length })
|
|
272
|
+
expect(result.finalRemote["/dir/child.txt"]).toBeUndefined()
|
|
273
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it("ZB12: cloudToLocal — remote dir rename + a foreign local child edit → remote content wins, converges", async () => {
|
|
277
|
+
const result = await runScenario({
|
|
278
|
+
name: "ZB12",
|
|
279
|
+
mode: "cloudToLocal",
|
|
280
|
+
initialRemote: { "/dir/child.txt": "old", "/dir/sibling.txt": "sib" },
|
|
281
|
+
steps: [
|
|
282
|
+
runCycle(),
|
|
283
|
+
remoteMutate(world => world.cloud.controls.movePath("/dir", "/dir2")),
|
|
284
|
+
localMutate(world => writeLocalAt(world, "dir/child.txt", "FOREIGN-LOCAL-EDIT", BASE_TIME + 10 * SECOND)),
|
|
285
|
+
runCycle(),
|
|
286
|
+
runCycle(),
|
|
287
|
+
runCycle()
|
|
288
|
+
]
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// Remote authoritative: the foreign local edit is reverted to the remote content at the renamed path.
|
|
292
|
+
expect(result.finalLocal["/dir2/child.txt"]).toMatchObject({ size: "old".length })
|
|
293
|
+
expect(result.finalLocal["/dir/child.txt"]).toBeUndefined()
|
|
294
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, restart, localMutate, remoteMutate, control, type Step } from "../harness/runner"
|
|
3
|
+
import { writeLocalAt, renameLocal, rmLocal } from "../harness/mutations"
|
|
4
|
+
import { BASE_TIME } from "../harness/world"
|
|
5
|
+
|
|
6
|
+
const SECOND = 1000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Category ZC — crash / stop mid-run recovery.
|
|
10
|
+
*
|
|
11
|
+
* The engine has no write-ahead log; instead it relies on a single invariant: the persisted base
|
|
12
|
+
* (`previousLocalTree`/`previousRemoteTree`) is advanced ONLY at the end of a cycle that had ZERO task
|
|
13
|
+
* errors (`sync.ts` — `if (this.taskErrors.length === 0) { ... state.save() }`). So if the process is
|
|
14
|
+
* killed — or the user stops the sync — mid-cycle, the on-disk base stays at the LAST CLEAN cycle while
|
|
15
|
+
* the real filesystem / remote may already be partially advanced. On the next boot a fresh engine loads
|
|
16
|
+
* that stale base, re-scans both sides, and re-derives whatever work is still outstanding. The recovery
|
|
17
|
+
* is "at-least-once": an already-applied transfer may be re-derived, but it converges with no data loss
|
|
18
|
+
* and no duplication.
|
|
19
|
+
*
|
|
20
|
+
* A crash is modelled faithfully by (1) running a cycle in which one task fails — which gates the cycle
|
|
21
|
+
* and SKIPS the state save while OTHER tasks in the same cycle have already hit the fake cloud — then
|
|
22
|
+
* (2) `restart()`, which rebuilds the engine over the SAME virtual fs + cloud and reloads the (stale)
|
|
23
|
+
* persisted base, discarding all in-memory cycle progress. That on-disk outcome (base behind reality,
|
|
24
|
+
* in-memory state gone) is identical to a hard `kill -9` between task application and the state save.
|
|
25
|
+
*
|
|
26
|
+
* Distinct from Category S (restart between SETTLED cycles, where base == reality and the first cycle is
|
|
27
|
+
* a trivial no-op): here the base is deliberately BEHIND reality, exercising the self-heal path.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function settle(): Step[] {
|
|
31
|
+
return [runCycle(), runCycle()]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("Category ZC — crash / stop mid-run recovery", () => {
|
|
35
|
+
it("ZC1: a crash after a partially-applied cycle (upload landed, rename did not, state NOT saved) heals on restart", async () => {
|
|
36
|
+
const result = await runScenario({
|
|
37
|
+
name: "ZC1",
|
|
38
|
+
mode: "twoWay",
|
|
39
|
+
initialLocal: { "/local/a.txt": "a", "/local/dir/c.txt": "c" },
|
|
40
|
+
steps: [
|
|
41
|
+
...settle(),
|
|
42
|
+
// Two INDEPENDENT local changes in one cycle: add new.txt (its upload lands on the fake
|
|
43
|
+
// remote) and rename dir -> dir2 (the remote rename is forced to fail = the "crash" point).
|
|
44
|
+
localMutate(world => writeLocalAt(world, "new.txt", "fresh", BASE_TIME + 10 * SECOND)),
|
|
45
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
46
|
+
control(world => world.cloud.controls.setError("renameDirectory", new Error("crash: rename never reached the server"))),
|
|
47
|
+
// new.txt uploads; the dir rename throws -> taskErrors > 0 -> state.save() is SKIPPED.
|
|
48
|
+
runCycle(),
|
|
49
|
+
// Process dies here: in-memory progress is discarded and the on-disk base is still PRE-cycle.
|
|
50
|
+
restart(),
|
|
51
|
+
control(world => {
|
|
52
|
+
world.cloud.controls.clearError("renameDirectory")
|
|
53
|
+
world.triggerWatcher()
|
|
54
|
+
}),
|
|
55
|
+
runCycle(),
|
|
56
|
+
runCycle(),
|
|
57
|
+
runCycle()
|
|
58
|
+
]
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Guard the premise: the crash cycle (index 2, after the two settle cycles) genuinely left PARTIAL
|
|
62
|
+
// state — new.txt's upload reached the remote, but the rename did not. Without this the test could
|
|
63
|
+
// silently degrade into "everything just syncs after a restart".
|
|
64
|
+
const crashCycle = result.cycles[2]!
|
|
65
|
+
|
|
66
|
+
expect(crashCycle.remote["/new.txt"]).toMatchObject({ type: "file" })
|
|
67
|
+
expect(crashCycle.remote["/dir/c.txt"]).toMatchObject({ type: "file" })
|
|
68
|
+
expect(crashCycle.remote["/dir2"]).toBeUndefined()
|
|
69
|
+
|
|
70
|
+
// No loss, no duplication: the already-uploaded file survives exactly once, the lost rename
|
|
71
|
+
// completes, and the stale base is fully reconciled.
|
|
72
|
+
expect(result.finalRemote["/new.txt"]).toMatchObject({ type: "file", size: "fresh".length })
|
|
73
|
+
expect(result.finalRemote["/dir2/c.txt"]).toMatchObject({ type: "file" })
|
|
74
|
+
expect(result.finalRemote["/dir/c.txt"]).toBeUndefined()
|
|
75
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
76
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("ZC2: a crash with un-synced changes on BOTH sides (base behind reality both ways) converges on restart", async () => {
|
|
80
|
+
const result = await runScenario({
|
|
81
|
+
name: "ZC2",
|
|
82
|
+
mode: "twoWay",
|
|
83
|
+
initialLocal: { "/local/base.txt": "base" },
|
|
84
|
+
steps: [
|
|
85
|
+
...settle(),
|
|
86
|
+
// Both sides change, but the engine dies before ANY cycle syncs them: the on-disk base only
|
|
87
|
+
// knows base.txt, yet local has local-only.txt and the remote has remote-only.txt.
|
|
88
|
+
localMutate(world => writeLocalAt(world, "local-only.txt", "L", BASE_TIME + 10 * SECOND)),
|
|
89
|
+
remoteMutate(world => world.cloud.controls.addFile("/remote-only.txt", "R", { mtimeMs: BASE_TIME + 10 * SECOND })),
|
|
90
|
+
restart(),
|
|
91
|
+
control(world => world.triggerWatcher()),
|
|
92
|
+
runCycle(),
|
|
93
|
+
runCycle(),
|
|
94
|
+
runCycle()
|
|
95
|
+
]
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Both un-synced additions are picked up from the real fs/remote despite the stale base — neither
|
|
99
|
+
// is lost, and the deleted-detection gate does NOT mistake "present but not in base" for a deletion.
|
|
100
|
+
expect(result.finalLocal["/local-only.txt"]).toMatchObject({ type: "file" })
|
|
101
|
+
expect(result.finalLocal["/remote-only.txt"]).toMatchObject({ type: "file" })
|
|
102
|
+
expect(result.finalLocal["/base.txt"]).toMatchObject({ type: "file" })
|
|
103
|
+
expect(result.finalRemote["/local-only.txt"]).toMatchObject({ type: "file" })
|
|
104
|
+
expect(result.finalRemote["/remote-only.txt"]).toMatchObject({ type: "file" })
|
|
105
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("ZC3: a crash mid-deletion (one delete applied, one failed, state NOT saved) does NOT resurrect on restart", async () => {
|
|
109
|
+
const result = await runScenario({
|
|
110
|
+
name: "ZC3",
|
|
111
|
+
mode: "twoWay",
|
|
112
|
+
initialLocal: {
|
|
113
|
+
"/local/gone.txt": "g",
|
|
114
|
+
"/local/keepdir/k.txt": "k",
|
|
115
|
+
"/local/deldir/d.txt": "d"
|
|
116
|
+
},
|
|
117
|
+
steps: [
|
|
118
|
+
...settle(),
|
|
119
|
+
// Delete a file AND a directory locally in one cycle; the remote DIR-trash fails (crash),
|
|
120
|
+
// but the remote FILE-trash lands — so gone.txt is already removed remotely when we die.
|
|
121
|
+
localMutate(world => rmLocal(world, "gone.txt")),
|
|
122
|
+
localMutate(world => rmLocal(world, "deldir")),
|
|
123
|
+
control(world => world.cloud.controls.setError("trashDirectory", new Error("crash: dir delete never reached the server"))),
|
|
124
|
+
runCycle(),
|
|
125
|
+
// Reload the stale base: it still lists gone.txt AND deldir as present on BOTH sides.
|
|
126
|
+
restart(),
|
|
127
|
+
control(world => {
|
|
128
|
+
world.cloud.controls.clearError("trashDirectory")
|
|
129
|
+
world.triggerWatcher()
|
|
130
|
+
}),
|
|
131
|
+
runCycle(),
|
|
132
|
+
runCycle(),
|
|
133
|
+
runCycle()
|
|
134
|
+
]
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Guard the premise: the crash cycle genuinely left a HALF-APPLIED deletion — gone.txt was already
|
|
138
|
+
// trashed remotely, but deldir was not.
|
|
139
|
+
const crashCycle = result.cycles[2]!
|
|
140
|
+
|
|
141
|
+
expect(crashCycle.remote["/gone.txt"]).toBeUndefined()
|
|
142
|
+
expect(crashCycle.remote["/deldir/d.txt"]).toMatchObject({ type: "file" })
|
|
143
|
+
|
|
144
|
+
// The already-applied file deletion must NOT be resurrected from the stale base (both sides
|
|
145
|
+
// genuinely removed it); the pending dir deletion completes; untouched data is preserved.
|
|
146
|
+
expect(result.finalRemote["/gone.txt"]).toBeUndefined()
|
|
147
|
+
expect(result.finalLocal["/gone.txt"]).toBeUndefined()
|
|
148
|
+
expect(result.finalRemote["/deldir"]).toBeUndefined()
|
|
149
|
+
expect(result.finalRemote["/deldir/d.txt"]).toBeUndefined()
|
|
150
|
+
expect(result.finalRemote["/keepdir/k.txt"]).toMatchObject({ type: "file" })
|
|
151
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("ZC4: localToCloud — a crash after a partially-applied cycle heals on restart (mirror still converges)", async () => {
|
|
155
|
+
const result = await runScenario({
|
|
156
|
+
name: "ZC4",
|
|
157
|
+
mode: "localToCloud",
|
|
158
|
+
initialLocal: { "/local/a.txt": "a", "/local/dir/c.txt": "c" },
|
|
159
|
+
steps: [
|
|
160
|
+
...settle(),
|
|
161
|
+
localMutate(world => writeLocalAt(world, "new.txt", "fresh", BASE_TIME + 10 * SECOND)),
|
|
162
|
+
localMutate(world => renameLocal(world, "dir", "dir2")),
|
|
163
|
+
control(world => world.cloud.controls.setError("renameDirectory", new Error("crash"))),
|
|
164
|
+
runCycle(),
|
|
165
|
+
restart(),
|
|
166
|
+
control(world => {
|
|
167
|
+
world.cloud.controls.clearError("renameDirectory")
|
|
168
|
+
world.triggerWatcher()
|
|
169
|
+
}),
|
|
170
|
+
runCycle(),
|
|
171
|
+
runCycle(),
|
|
172
|
+
runCycle()
|
|
173
|
+
]
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Guard the premise: the crash cycle left partial state (upload landed, rename did not).
|
|
177
|
+
const crashCycle = result.cycles[2]!
|
|
178
|
+
|
|
179
|
+
expect(crashCycle.remote["/new.txt"]).toMatchObject({ type: "file" })
|
|
180
|
+
expect(crashCycle.remote["/dir2"]).toBeUndefined()
|
|
181
|
+
|
|
182
|
+
// The local mirror is authoritative: after the crash the remote is brought back into line with it,
|
|
183
|
+
// the dropped rename completes, and the partially-uploaded file is present exactly once.
|
|
184
|
+
expect(result.finalRemote["/new.txt"]).toMatchObject({ type: "file", size: "fresh".length })
|
|
185
|
+
expect(result.finalRemote["/dir2/c.txt"]).toMatchObject({ type: "file" })
|
|
186
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
187
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, control, type Step } from "../harness/runner"
|
|
3
|
+
import { writeLocalAt, rmLocal, renameLocal } from "../harness/mutations"
|
|
4
|
+
import { transferKinds } from "../harness/snapshot"
|
|
5
|
+
import { BASE_TIME } from "../harness/world"
|
|
6
|
+
|
|
7
|
+
const SECOND = 1000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Category ZD — inode reuse must not be misread as a rename.
|
|
11
|
+
*
|
|
12
|
+
* The local rename pass keys on inode: an inode that sat at path P in the base but now sits at path Q is
|
|
13
|
+
* treated as "P renamed to Q". But the OS RECYCLES inode numbers — ext4 hands a just-freed inode to the
|
|
14
|
+
* very next created file — so "delete a.txt + create c.txt" can put c.txt on a.txt's old inode and be
|
|
15
|
+
* misread as "rename a.txt -> c.txt". That phantom rename propagates as a REMOTE rename and deletes the
|
|
16
|
+
* original: silent data loss in modes that keep deletions (localBackup), and invisible in twoWay only
|
|
17
|
+
* because the stale path was going to be deleted anyway. This is exactly why the live `lifecycle.e2e` I6
|
|
18
|
+
* test flaked on Linux (ext4) while passing on macOS/Windows and in this fake-fs suite — memfs does not
|
|
19
|
+
* surface the reuse on its own, so these tests force it with the `setInode` control.
|
|
20
|
+
*
|
|
21
|
+
* The fix additionally requires the creation/birthtime to match: a genuine rename (even a rename+modify)
|
|
22
|
+
* preserves birthtime, whereas a reused inode belongs to a freshly-created file with a newer one.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function settle(): Step[] {
|
|
26
|
+
return [runCycle(), runCycle()]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("Category ZD — inode reuse is not a rename", () => {
|
|
30
|
+
it("ZD1: localBackup — a NEW file on a deleted file's recycled inode does NOT delete the original (no phantom rename)", async () => {
|
|
31
|
+
let reusedInode = 0
|
|
32
|
+
|
|
33
|
+
const result = await runScenario({
|
|
34
|
+
name: "ZD1",
|
|
35
|
+
mode: "localBackup",
|
|
36
|
+
initialLocal: { "/local/a.txt": "a", "/local/keep.txt": "k" },
|
|
37
|
+
steps: [
|
|
38
|
+
...settle(),
|
|
39
|
+
// Capture a.txt's inode, delete it (freeing the inode), create a brand-new c.txt, then force
|
|
40
|
+
// c.txt onto a.txt's freed inode — exactly what ext4 does and memfs will not do on its own.
|
|
41
|
+
control(world => {
|
|
42
|
+
reusedInode = world.vfs.controls.getInode("/local/a.txt")!
|
|
43
|
+
}),
|
|
44
|
+
localMutate(world => rmLocal(world, "a.txt")),
|
|
45
|
+
localMutate(world => writeLocalAt(world, "c.txt", "c", BASE_TIME + 30 * SECOND)),
|
|
46
|
+
control(world => world.vfs.controls.setInode("/local/c.txt", reusedInode)),
|
|
47
|
+
runCycle(),
|
|
48
|
+
runCycle()
|
|
49
|
+
]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const reuseCycle = result.cycles[2]!
|
|
53
|
+
|
|
54
|
+
// The reuse must NOT be propagated as a rename of the remote original.
|
|
55
|
+
expect(transferKinds(reuseCycle.messages)).not.toContain("renameRemoteFile")
|
|
56
|
+
// localBackup keeps remote-only files: the original survives, and the genuinely new file is uploaded.
|
|
57
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
58
|
+
expect(result.finalRemote["/c.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
59
|
+
expect(result.finalRemote["/keep.txt"]).toMatchObject({ type: "file" })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("ZD2: twoWay — inode reuse is a delete+add, never a phantom rename", async () => {
|
|
63
|
+
let reusedInode = 0
|
|
64
|
+
|
|
65
|
+
const result = await runScenario({
|
|
66
|
+
name: "ZD2",
|
|
67
|
+
mode: "twoWay",
|
|
68
|
+
initialLocal: { "/local/a.txt": "aaaa", "/local/keep.txt": "k" },
|
|
69
|
+
steps: [
|
|
70
|
+
...settle(),
|
|
71
|
+
control(world => {
|
|
72
|
+
reusedInode = world.vfs.controls.getInode("/local/a.txt")!
|
|
73
|
+
}),
|
|
74
|
+
localMutate(world => rmLocal(world, "a.txt")),
|
|
75
|
+
localMutate(world => writeLocalAt(world, "c.txt", "cc", BASE_TIME + 30 * SECOND)),
|
|
76
|
+
control(world => world.vfs.controls.setInode("/local/c.txt", reusedInode)),
|
|
77
|
+
runCycle(),
|
|
78
|
+
runCycle()
|
|
79
|
+
]
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const reuseCycle = result.cycles[2]!
|
|
83
|
+
|
|
84
|
+
// twoWay would also end up with a.txt gone and c.txt present even WITH the phantom rename (the rename
|
|
85
|
+
// + a follow-up content upload coincidentally lands the same end state), so the meaningful guarantee
|
|
86
|
+
// is the EMITTED intent: a delete + an add, never a rename of the unrelated original.
|
|
87
|
+
expect(transferKinds(reuseCycle.messages)).not.toContain("renameRemoteFile")
|
|
88
|
+
// The deletion genuinely propagated and the new file carries ITS OWN content ("cc" = 2 bytes), not
|
|
89
|
+
// the original's ("aaaa" = 4 bytes).
|
|
90
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
91
|
+
expect(result.finalRemote["/c.txt"]).toMatchObject({ type: "file", size: 2 })
|
|
92
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("ZD3: a genuine rename (inode AND birthtime preserved) is still detected as a rename, not a re-upload", async () => {
|
|
96
|
+
const result = await runScenario({
|
|
97
|
+
name: "ZD3",
|
|
98
|
+
mode: "twoWay",
|
|
99
|
+
initialLocal: { "/local/old.txt": "data", "/local/keep.txt": "k" },
|
|
100
|
+
steps: [
|
|
101
|
+
...settle(),
|
|
102
|
+
localMutate(world => renameLocal(world, "old.txt", "new.txt")),
|
|
103
|
+
runCycle(),
|
|
104
|
+
runCycle()
|
|
105
|
+
]
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const renameCycle = result.cycles[2]!
|
|
109
|
+
|
|
110
|
+
// The creation-match guard must NOT over-correct: a real move (memfs rename preserves inode AND
|
|
111
|
+
// birthtime, like a real fs) still propagates as a rename — no content re-upload.
|
|
112
|
+
expect(transferKinds(renameCycle.messages)).toContain("renameRemoteFile")
|
|
113
|
+
expect(transferKinds(renameCycle.messages)).not.toContain("upload")
|
|
114
|
+
expect(result.finalRemote["/new.txt"]).toMatchObject({ type: "file" })
|
|
115
|
+
expect(result.finalRemote["/old.txt"]).toBeUndefined()
|
|
116
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
117
|
+
})
|
|
118
|
+
})
|