@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,208 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, type Step } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { allOps, transferKinds } from "../harness/snapshot"
|
|
5
|
+
import { renameLocal, writeLocalAt } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category R — rename/move stress (BUG-004 reproduction net). The delta engine collapses child
|
|
9
|
+
* rename/move/delete ops into their parent directory op (deltas.ts ~553-612). This pass composes
|
|
10
|
+
* nested and chained renames; these deterministic cases hammer that composition to surface any
|
|
11
|
+
* mis-collapse (a duplicated op, a dropped op, or a wrong from-path) that a black-box cycle CAN
|
|
12
|
+
* reproduce. The catalogued BUG-004 also has a pure mid-cycle timing component (mutations landing
|
|
13
|
+
* between tree-read and task-exec) which only the Phase 3 live e2e can exercise; this file pins the
|
|
14
|
+
* deterministic half so a regression there is caught here.
|
|
15
|
+
*
|
|
16
|
+
* Every case asserts convergence (worlds identical) and idempotence (a settled cycle does no work).
|
|
17
|
+
*/
|
|
18
|
+
const SECOND = 1000
|
|
19
|
+
|
|
20
|
+
function expectConverged(result: { finalLocal: Record<string, unknown>; finalRemote: Record<string, unknown>; cycles: { messages: unknown[] }[] }): void {
|
|
21
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
22
|
+
|
|
23
|
+
const lastCycle = result.cycles[result.cycles.length - 1]!
|
|
24
|
+
|
|
25
|
+
expect(allOps(lastCycle.messages as never)).toEqual([])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("Category R — rename/move stress (BUG-004 net)", () => {
|
|
29
|
+
it("RS1: a parent-dir rename composed with a child rename converges (child from-path rewritten)", async () => {
|
|
30
|
+
const result = await runScenario({
|
|
31
|
+
name: "RS1",
|
|
32
|
+
mode: "twoWay",
|
|
33
|
+
initialLocal: { "/local/a/c.txt": "x" },
|
|
34
|
+
steps: [
|
|
35
|
+
runCycle(),
|
|
36
|
+
runCycle(),
|
|
37
|
+
// Rename the parent dir, THEN rename the (now-moved) child to a new name in the same beat.
|
|
38
|
+
localMutate(world => {
|
|
39
|
+
renameLocal(world, "a", "b")
|
|
40
|
+
renameLocal(world, "b/c.txt", "b/d.txt")
|
|
41
|
+
}),
|
|
42
|
+
runCycle(),
|
|
43
|
+
runCycle()
|
|
44
|
+
]
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
expect(result.finalRemote["/b/d.txt"]).toMatchObject({ type: "file" })
|
|
48
|
+
expect(result.finalRemote["/a"]).toBeUndefined()
|
|
49
|
+
expect(result.finalRemote["/b/c.txt"]).toBeUndefined()
|
|
50
|
+
expectConverged(result)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("RS2: a chained directory rename within one beat (a -> b -> e) converges", async () => {
|
|
54
|
+
const result = await runScenario({
|
|
55
|
+
name: "RS2",
|
|
56
|
+
mode: "twoWay",
|
|
57
|
+
initialLocal: { "/local/a/c.txt": "x", "/local/a/deep/d.txt": "y" },
|
|
58
|
+
steps: [
|
|
59
|
+
runCycle(),
|
|
60
|
+
runCycle(),
|
|
61
|
+
localMutate(world => {
|
|
62
|
+
renameLocal(world, "a", "b")
|
|
63
|
+
renameLocal(world, "b", "e")
|
|
64
|
+
}),
|
|
65
|
+
runCycle(),
|
|
66
|
+
runCycle()
|
|
67
|
+
]
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(result.finalRemote["/e/c.txt"]).toMatchObject({ type: "file" })
|
|
71
|
+
expect(result.finalRemote["/e/deep/d.txt"]).toMatchObject({ type: "file" })
|
|
72
|
+
expect(result.finalRemote["/a"]).toBeUndefined()
|
|
73
|
+
expect(result.finalRemote["/b"]).toBeUndefined()
|
|
74
|
+
expectConverged(result)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("RS3: a three-level nested directory rename collapses to a single parent op", async () => {
|
|
78
|
+
const result = await runScenario({
|
|
79
|
+
name: "RS3",
|
|
80
|
+
mode: "twoWay",
|
|
81
|
+
initialLocal: { "/local/x/y/z/file.txt": "deep" },
|
|
82
|
+
steps: [
|
|
83
|
+
runCycle(),
|
|
84
|
+
runCycle(),
|
|
85
|
+
localMutate(world => renameLocal(world, "x", "x2")),
|
|
86
|
+
runCycle(),
|
|
87
|
+
runCycle()
|
|
88
|
+
]
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(result.finalRemote["/x2/y/z/file.txt"]).toMatchObject({ type: "file" })
|
|
92
|
+
expect(result.finalRemote["/x"]).toBeUndefined()
|
|
93
|
+
// Collapse: exactly one renameRemote op (the parent dir), not one per descendant.
|
|
94
|
+
const renameOps = transferKinds(result.cycles[2]!.messages).filter(kind => kind.startsWith("rename"))
|
|
95
|
+
|
|
96
|
+
expect(renameOps.length).toBeLessThanOrEqual(1)
|
|
97
|
+
expectConverged(result)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("RS4: moving a child OUT of a renamed directory converges", async () => {
|
|
101
|
+
const result = await runScenario({
|
|
102
|
+
name: "RS4",
|
|
103
|
+
mode: "twoWay",
|
|
104
|
+
initialLocal: { "/local/a/c.txt": "x", "/local/other": null },
|
|
105
|
+
steps: [
|
|
106
|
+
runCycle(),
|
|
107
|
+
runCycle(),
|
|
108
|
+
localMutate(world => {
|
|
109
|
+
// Move the child out to a sibling, THEN rename the now-empty-ish parent.
|
|
110
|
+
renameLocal(world, "a/c.txt", "other/c.txt")
|
|
111
|
+
renameLocal(world, "a", "b")
|
|
112
|
+
}),
|
|
113
|
+
runCycle(),
|
|
114
|
+
runCycle()
|
|
115
|
+
]
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(result.finalRemote["/other/c.txt"]).toMatchObject({ type: "file" })
|
|
119
|
+
expect(result.finalRemote["/b"]).toMatchObject({ type: "directory" })
|
|
120
|
+
expect(result.finalRemote["/a"]).toBeUndefined()
|
|
121
|
+
expect(result.finalRemote["/a/c.txt"]).toBeUndefined()
|
|
122
|
+
expectConverged(result)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("RS5: a rename concurrent with a sibling content change converges (both applied)", async () => {
|
|
126
|
+
const result = await runScenario({
|
|
127
|
+
name: "RS5",
|
|
128
|
+
mode: "twoWay",
|
|
129
|
+
initialLocal: { "/local/dir/a.txt": "a", "/local/keep.txt": "keep-v1" },
|
|
130
|
+
steps: [
|
|
131
|
+
runCycle(),
|
|
132
|
+
runCycle(),
|
|
133
|
+
localMutate(world => {
|
|
134
|
+
renameLocal(world, "dir", "dir-renamed")
|
|
135
|
+
writeLocalAt(world, "keep.txt", "keep-v2-longer", BASE_TIME + 10 * SECOND)
|
|
136
|
+
}),
|
|
137
|
+
runCycle(),
|
|
138
|
+
runCycle()
|
|
139
|
+
]
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(result.finalRemote["/dir-renamed/a.txt"]).toMatchObject({ type: "file" })
|
|
143
|
+
expect(result.finalRemote["/keep.txt"]).toMatchObject({ type: "file", size: "keep-v2-longer".length })
|
|
144
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
145
|
+
expectConverged(result)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("RS6: a deep subtree rename plus an in-subtree child rename converges", async () => {
|
|
149
|
+
const steps: Step[] = [
|
|
150
|
+
runCycle(),
|
|
151
|
+
runCycle(),
|
|
152
|
+
localMutate(world => {
|
|
153
|
+
// Rename the top dir, then rename a deep descendant to a new basename.
|
|
154
|
+
renameLocal(world, "root", "root2")
|
|
155
|
+
renameLocal(world, "root2/mid/leaf.txt", "root2/mid/leaf-renamed.txt")
|
|
156
|
+
}),
|
|
157
|
+
runCycle(),
|
|
158
|
+
runCycle()
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
const result = await runScenario({
|
|
162
|
+
name: "RS6",
|
|
163
|
+
mode: "twoWay",
|
|
164
|
+
initialLocal: { "/local/root/mid/leaf.txt": "L", "/local/root/mid/other.txt": "O" },
|
|
165
|
+
steps
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(result.finalRemote["/root2/mid/leaf-renamed.txt"]).toMatchObject({ type: "file" })
|
|
169
|
+
expect(result.finalRemote["/root2/mid/other.txt"]).toMatchObject({ type: "file" })
|
|
170
|
+
expect(result.finalRemote["/root"]).toBeUndefined()
|
|
171
|
+
expect(result.finalRemote["/root2/mid/leaf.txt"]).toBeUndefined()
|
|
172
|
+
expectConverged(result)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("RS7: a child under TWO overlapping renamed parents converges (sibling edit preserved)", async () => {
|
|
176
|
+
// Full-cycle guard for the multi-overlapping-parent collapse case (BUG-004's deterministic half).
|
|
177
|
+
// Two overlapping parent renames in one beat (/a -> /x then /x/b -> /x/y) make the child
|
|
178
|
+
// /a/b/c.txt match both, and a sibling edit adds an unrelated delta that must survive. NOTE: the
|
|
179
|
+
// engine re-runs and self-heals, so a corrupt FIRST cycle would still converge here — the
|
|
180
|
+
// deterministic corruption (dropped/duplicated op) is pinned directly at
|
|
181
|
+
// tests/unit/collapse-deltas.test.ts. This case proves the end-to-end path still converges.
|
|
182
|
+
const result = await runScenario({
|
|
183
|
+
name: "RS7",
|
|
184
|
+
mode: "twoWay",
|
|
185
|
+
initialLocal: {
|
|
186
|
+
"/local/a/b/c.txt": "child",
|
|
187
|
+
"/local/sibling.txt": "sib"
|
|
188
|
+
},
|
|
189
|
+
steps: [
|
|
190
|
+
runCycle(),
|
|
191
|
+
runCycle(),
|
|
192
|
+
localMutate(world => {
|
|
193
|
+
renameLocal(world, "a", "x")
|
|
194
|
+
renameLocal(world, "x/b", "x/y")
|
|
195
|
+
writeLocalAt(world, "sibling.txt", "sib-v2-longer", BASE_TIME + 10 * SECOND)
|
|
196
|
+
}),
|
|
197
|
+
runCycle(),
|
|
198
|
+
runCycle()
|
|
199
|
+
]
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(result.finalRemote["/x/y/c.txt"]).toMatchObject({ type: "file" })
|
|
203
|
+
expect(result.finalRemote["/sibling.txt"]).toMatchObject({ type: "file", size: "sib-v2-longer".length })
|
|
204
|
+
expect(result.finalRemote["/a"]).toBeUndefined()
|
|
205
|
+
expect(result.finalRemote["/x/b"]).toBeUndefined()
|
|
206
|
+
expectConverged(result)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, restart, localMutate, control, type Step } from "../harness/runner"
|
|
3
|
+
import { DB_ROOT } from "../harness/world"
|
|
4
|
+
import { transferKinds, messagesOfType } from "../harness/snapshot"
|
|
5
|
+
import pathModule from "path"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category S — upgrade transition (the backwards-compat safety net).
|
|
9
|
+
*
|
|
10
|
+
* The desktop client bundles this sync engine. When a user updates the client they keep the on-disk
|
|
11
|
+
* state the PREVIOUS engine wrote (`state/v2/<uuid>/…` trees, the `deviceId`, the local file hashes).
|
|
12
|
+
* The guarantee: the first cycle after the upgrade over an unchanged sync is a no-op — no re-sync the
|
|
13
|
+
* user notices, no spurious deletion, no data loss.
|
|
14
|
+
*
|
|
15
|
+
* `restart()` reloads that persisted v2 state over the SAME virtual fs + cloud (so inodes and uuids are
|
|
16
|
+
* preserved), faithfully simulating "a sync was settled, then a new engine boots on its state". These
|
|
17
|
+
* cases assert the Phase 2 behavior changes (empty-file handling, symlink skip, deletion gating, the
|
|
18
|
+
* msgpack tree fetch) do not disturb a tree that was already settled. The state FORMAT itself is pinned
|
|
19
|
+
* by S5 so a future serialization change that would silently break old state fails loudly here.
|
|
20
|
+
*/
|
|
21
|
+
function settle(): Step[] {
|
|
22
|
+
return [runCycle(), runCycle()]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const statePath = (uuid: string): string => pathModule.join(DB_ROOT, "state", "v2", uuid)
|
|
26
|
+
const deviceIdPath = (uuid: string): string => pathModule.join(DB_ROOT, "deviceId", "v1", uuid)
|
|
27
|
+
|
|
28
|
+
describe("Category S — upgrade transition (backwards compat)", () => {
|
|
29
|
+
it("S1: a settled two-way sync does ZERO work on the first cycle after an upgrade/restart", async () => {
|
|
30
|
+
const result = await runScenario({
|
|
31
|
+
name: "S1",
|
|
32
|
+
mode: "twoWay",
|
|
33
|
+
initialLocal: {
|
|
34
|
+
"/local/a.txt": "alpha",
|
|
35
|
+
"/local/dir/b.txt": "bravo",
|
|
36
|
+
"/local/dir/sub/c.txt": "charlie"
|
|
37
|
+
},
|
|
38
|
+
steps: [...settle(), restart(), runCycle()]
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const settled = result.cycles[1]!
|
|
42
|
+
const afterUpgrade = result.cycles[2]!
|
|
43
|
+
|
|
44
|
+
// The post-upgrade cycle moved no bytes and prompted no deletion.
|
|
45
|
+
expect(transferKinds(afterUpgrade.messages)).toEqual([])
|
|
46
|
+
expect(messagesOfType(afterUpgrade.messages, "confirmDeletion")).toEqual([])
|
|
47
|
+
// Nothing added or removed on either side; both sides still match the settled state exactly.
|
|
48
|
+
expect(afterUpgrade.local).toEqual(settled.local)
|
|
49
|
+
expect(afterUpgrade.remote).toEqual(settled.remote)
|
|
50
|
+
expect(afterUpgrade.local).toEqual(afterUpgrade.remote)
|
|
51
|
+
// Sanity: a non-empty tree was actually synced.
|
|
52
|
+
expect(Object.keys(settled.local).length).toBeGreaterThan(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("S2: the deviceId is reused across a restart (server-side tree cache stays valid)", async () => {
|
|
56
|
+
let before: string | null = null
|
|
57
|
+
let after: string | null = null
|
|
58
|
+
|
|
59
|
+
const result = await runScenario({
|
|
60
|
+
name: "S2",
|
|
61
|
+
mode: "twoWay",
|
|
62
|
+
initialLocal: { "/local/keep.txt": "k" },
|
|
63
|
+
steps: [
|
|
64
|
+
...settle(),
|
|
65
|
+
control(async world => {
|
|
66
|
+
before = (await world.vfs.fs.readFile(deviceIdPath(world.syncPair.uuid), { encoding: "utf-8" })) as unknown as string
|
|
67
|
+
}),
|
|
68
|
+
restart(),
|
|
69
|
+
control(async world => {
|
|
70
|
+
after = (await world.vfs.fs.readFile(deviceIdPath(world.syncPair.uuid), { encoding: "utf-8" })) as unknown as string
|
|
71
|
+
}),
|
|
72
|
+
runCycle()
|
|
73
|
+
]
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(before).toBeTruthy()
|
|
77
|
+
// A regenerated deviceId would invalidate the server's per-device tree cache and force a full
|
|
78
|
+
// re-download storm on every client update — it must survive the restart unchanged.
|
|
79
|
+
expect(after).toBe(before)
|
|
80
|
+
expect(transferKinds(result.cycles[2]!.messages)).toEqual([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("S3: a settled sync with large-deletion confirmation enabled raises NO deletion prompt after upgrade (BUG-001)", async () => {
|
|
84
|
+
// The deletion-gating fix must not misfire when a fresh engine reloads a settled tree: the
|
|
85
|
+
// previous trees are non-empty and the current trees match them, so nothing looks deleted.
|
|
86
|
+
const result = await runScenario({
|
|
87
|
+
name: "S3",
|
|
88
|
+
mode: "twoWay",
|
|
89
|
+
requireConfirmationOnLargeDeletion: true,
|
|
90
|
+
initialLocal: {
|
|
91
|
+
"/local/x.txt": "x",
|
|
92
|
+
"/local/y.txt": "y",
|
|
93
|
+
"/local/z/w.txt": "w"
|
|
94
|
+
},
|
|
95
|
+
steps: [...settle(), restart(), runCycle()]
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const afterUpgrade = result.cycles[2]!
|
|
99
|
+
|
|
100
|
+
expect(messagesOfType(afterUpgrade.messages, "confirmDeletion")).toEqual([])
|
|
101
|
+
expect(transferKinds(afterUpgrade.messages)).toEqual([])
|
|
102
|
+
expect(afterUpgrade.local).toEqual(afterUpgrade.remote)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("S4: after upgrade a newly-visible empty file uploads WITHOUT disturbing already-synced files (BUG-002)", async () => {
|
|
106
|
+
// The old engine ignored 0-byte files, so they were never in its state. The new engine includes
|
|
107
|
+
// them — uploading the empty file is the intended new behavior, but it must be purely additive:
|
|
108
|
+
// the previously-synced file is neither re-uploaded nor deleted.
|
|
109
|
+
const result = await runScenario({
|
|
110
|
+
name: "S4",
|
|
111
|
+
mode: "twoWay",
|
|
112
|
+
initialLocal: { "/local/keep.txt": "content" },
|
|
113
|
+
steps: [
|
|
114
|
+
...settle(),
|
|
115
|
+
restart(),
|
|
116
|
+
localMutate(world => world.vfs.ifs.writeFileSync("/local/empty.txt", "")),
|
|
117
|
+
runCycle(),
|
|
118
|
+
runCycle()
|
|
119
|
+
]
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(result.finalRemote["/empty.txt"]).toMatchObject({ type: "file", size: 0 })
|
|
123
|
+
expect(result.finalRemote["/keep.txt"]).toMatchObject({ type: "file" })
|
|
124
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("S5: persisted state is the stable v2 line-delimited {prop,data} JSON (old state stays readable)", async () => {
|
|
128
|
+
let localTreeRaw = ""
|
|
129
|
+
let remoteTreeRaw = ""
|
|
130
|
+
|
|
131
|
+
await runScenario({
|
|
132
|
+
name: "S5",
|
|
133
|
+
mode: "twoWay",
|
|
134
|
+
initialLocal: { "/local/a.txt": "alpha", "/local/d/b.txt": "bravo" },
|
|
135
|
+
steps: [
|
|
136
|
+
...settle(),
|
|
137
|
+
control(async world => {
|
|
138
|
+
const base = statePath(world.syncPair.uuid)
|
|
139
|
+
|
|
140
|
+
localTreeRaw = (await world.vfs.fs.readFile(pathModule.join(base, "previousLocalTree"), { encoding: "utf-8" })) as unknown as string
|
|
141
|
+
remoteTreeRaw = (await world.vfs.fs.readFile(pathModule.join(base, "previousRemoteTree"), { encoding: "utf-8" })) as unknown as string
|
|
142
|
+
})
|
|
143
|
+
]
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const localLines = localTreeRaw.trim().split("\n").filter(Boolean)
|
|
147
|
+
|
|
148
|
+
expect(localLines.length).toBeGreaterThan(0)
|
|
149
|
+
|
|
150
|
+
for (const line of localLines) {
|
|
151
|
+
const parsed = JSON.parse(line)
|
|
152
|
+
|
|
153
|
+
expect(parsed).toHaveProperty("prop")
|
|
154
|
+
expect(parsed.data).toHaveProperty("path")
|
|
155
|
+
expect(parsed.data).toHaveProperty("inode")
|
|
156
|
+
expect(parsed.data).toHaveProperty("type")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const remoteLines = remoteTreeRaw.trim().split("\n").filter(Boolean)
|
|
160
|
+
|
|
161
|
+
expect(remoteLines.length).toBeGreaterThan(0)
|
|
162
|
+
|
|
163
|
+
for (const line of remoteLines) {
|
|
164
|
+
const parsed = JSON.parse(line)
|
|
165
|
+
|
|
166
|
+
expect(parsed).toHaveProperty("prop")
|
|
167
|
+
expect(parsed.data).toHaveProperty("uuid")
|
|
168
|
+
expect(parsed.data).toHaveProperty("path")
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate } from "../harness/runner"
|
|
3
|
+
import { writeLocal, rmLocal } from "../harness/mutations"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Category T — type changes at a path (file ↔ directory). The path exists on BOTH sides but as
|
|
7
|
+
* different types, so the rename pass (inode/uuid differ), the deletion passes (path still present),
|
|
8
|
+
* and the addition passes (other side's same-path item exists) all miss it. Without dedicated
|
|
9
|
+
* handling the stale-type item lingers and its replacement can't be created (E2E-BUG-001).
|
|
10
|
+
*
|
|
11
|
+
* The fix attributes the change against the last-synced base (whoever's type diverged from base is
|
|
12
|
+
* authoritative; local wins a tie/both), deletes the stale-type item, and creates the new type from
|
|
13
|
+
* the authoritative side. The phase-ordered executor guarantees the delete runs before the create.
|
|
14
|
+
*/
|
|
15
|
+
describe("Category T — file ↔ directory type changes", () => {
|
|
16
|
+
it("T1: local file → directory (remote still a file) replaces the remote file with the directory", async () => {
|
|
17
|
+
const result = await runScenario({
|
|
18
|
+
name: "T1",
|
|
19
|
+
mode: "twoWay",
|
|
20
|
+
initialLocal: { "/local/thing": "i am a file" },
|
|
21
|
+
steps: [
|
|
22
|
+
runCycle(),
|
|
23
|
+
localMutate(world => {
|
|
24
|
+
rmLocal(world, "thing")
|
|
25
|
+
writeLocal(world, "thing/inside.txt", "now a dir")
|
|
26
|
+
}),
|
|
27
|
+
runCycle(),
|
|
28
|
+
runCycle()
|
|
29
|
+
]
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
expect(result.finalRemote["/thing"]).toMatchObject({ type: "directory" })
|
|
33
|
+
expect(result.finalRemote["/thing/inside.txt"]).toMatchObject({ type: "file" })
|
|
34
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("T2: local directory → file (remote still a directory with children) replaces the tree with the file", async () => {
|
|
38
|
+
const result = await runScenario({
|
|
39
|
+
name: "T2",
|
|
40
|
+
mode: "twoWay",
|
|
41
|
+
initialLocal: { "/local/thing/a.txt": "child a", "/local/thing/b.txt": "child b" },
|
|
42
|
+
steps: [
|
|
43
|
+
runCycle(),
|
|
44
|
+
localMutate(world => {
|
|
45
|
+
rmLocal(world, "thing")
|
|
46
|
+
writeLocal(world, "thing", "now a file")
|
|
47
|
+
}),
|
|
48
|
+
runCycle(),
|
|
49
|
+
runCycle()
|
|
50
|
+
]
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(result.finalRemote["/thing"]).toMatchObject({ type: "file" })
|
|
54
|
+
expect(result.finalRemote["/thing/a.txt"]).toBeUndefined()
|
|
55
|
+
expect(result.finalRemote["/thing/b.txt"]).toBeUndefined()
|
|
56
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("T3: remote file → directory (local still a file) replaces the local file with the directory", async () => {
|
|
60
|
+
const result = await runScenario({
|
|
61
|
+
name: "T3",
|
|
62
|
+
mode: "twoWay",
|
|
63
|
+
initialLocal: { "/local/thing": "i am a file" },
|
|
64
|
+
steps: [
|
|
65
|
+
runCycle(),
|
|
66
|
+
remoteMutate(world => {
|
|
67
|
+
world.cloud.controls.deletePath("/thing")
|
|
68
|
+
world.cloud.controls.addDir("/thing")
|
|
69
|
+
world.cloud.controls.addFile("/thing/inside.txt", "remote dir now")
|
|
70
|
+
}),
|
|
71
|
+
runCycle(),
|
|
72
|
+
runCycle()
|
|
73
|
+
]
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(result.finalLocal["/thing"]).toMatchObject({ type: "directory" })
|
|
77
|
+
expect(result.finalLocal["/thing/inside.txt"]).toMatchObject({ type: "file" })
|
|
78
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("T4: remote directory → file (local still a directory with children) replaces the tree with the file", async () => {
|
|
82
|
+
const result = await runScenario({
|
|
83
|
+
name: "T4",
|
|
84
|
+
mode: "twoWay",
|
|
85
|
+
initialLocal: { "/local/thing/a.txt": "child a", "/local/thing/b.txt": "child b" },
|
|
86
|
+
steps: [
|
|
87
|
+
runCycle(),
|
|
88
|
+
remoteMutate(world => {
|
|
89
|
+
world.cloud.controls.deletePath("/thing")
|
|
90
|
+
world.cloud.controls.addFile("/thing", "now a file")
|
|
91
|
+
}),
|
|
92
|
+
runCycle(),
|
|
93
|
+
runCycle()
|
|
94
|
+
]
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(result.finalLocal["/thing"]).toMatchObject({ type: "file" })
|
|
98
|
+
expect(result.finalLocal["/thing/a.txt"]).toBeUndefined()
|
|
99
|
+
expect(result.finalLocal["/thing/b.txt"]).toBeUndefined()
|
|
100
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("T5: localToCloud — local file → directory replaces the remote file", async () => {
|
|
104
|
+
const result = await runScenario({
|
|
105
|
+
name: "T5",
|
|
106
|
+
mode: "localToCloud",
|
|
107
|
+
initialLocal: { "/local/thing": "i am a file" },
|
|
108
|
+
steps: [
|
|
109
|
+
runCycle(),
|
|
110
|
+
localMutate(world => {
|
|
111
|
+
rmLocal(world, "thing")
|
|
112
|
+
writeLocal(world, "thing/inside.txt", "now a dir")
|
|
113
|
+
}),
|
|
114
|
+
runCycle(),
|
|
115
|
+
runCycle()
|
|
116
|
+
]
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(result.finalRemote["/thing"]).toMatchObject({ type: "directory" })
|
|
120
|
+
expect(result.finalRemote["/thing/inside.txt"]).toMatchObject({ type: "file" })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("T6: cloudToLocal — remote file → directory replaces the local file", async () => {
|
|
124
|
+
const result = await runScenario({
|
|
125
|
+
name: "T6",
|
|
126
|
+
mode: "cloudToLocal",
|
|
127
|
+
initialLocal: {},
|
|
128
|
+
initialRemote: { "/thing": "i am a file" },
|
|
129
|
+
steps: [
|
|
130
|
+
runCycle(),
|
|
131
|
+
remoteMutate(world => {
|
|
132
|
+
world.cloud.controls.deletePath("/thing")
|
|
133
|
+
world.cloud.controls.addDir("/thing")
|
|
134
|
+
world.cloud.controls.addFile("/thing/inside.txt", "remote dir now")
|
|
135
|
+
}),
|
|
136
|
+
runCycle(),
|
|
137
|
+
runCycle()
|
|
138
|
+
]
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(result.finalLocal["/thing"]).toMatchObject({ type: "directory" })
|
|
142
|
+
expect(result.finalLocal["/thing/inside.txt"]).toMatchObject({ type: "file" })
|
|
143
|
+
})
|
|
144
|
+
})
|