@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,261 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import type FilenSDK from "@filen/sdk"
|
|
3
|
+
import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
|
|
4
|
+
import { withE2EWorld } from "./harness/world"
|
|
5
|
+
import { settle, expectConverged } from "./harness/drive"
|
|
6
|
+
import { snapshotRemoteReal } from "./harness/assert"
|
|
7
|
+
import {
|
|
8
|
+
writeLocal,
|
|
9
|
+
modifyLocal,
|
|
10
|
+
rmLocal,
|
|
11
|
+
renameLocal,
|
|
12
|
+
readLocal,
|
|
13
|
+
uploadRemote,
|
|
14
|
+
deleteRemote,
|
|
15
|
+
renameRemote,
|
|
16
|
+
setLocalMtime,
|
|
17
|
+
existsLocal
|
|
18
|
+
} from "./harness/mutations"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Phase 3 e2e — twoWay conflict resolution against the live backend (both sides changed since the last
|
|
22
|
+
* sync). The mtime helper makes the local side deterministically newer where "newest wins" applies.
|
|
23
|
+
*/
|
|
24
|
+
describe.skipIf(!E2E_ENABLED)("E2E — twoWay conflict resolution", () => {
|
|
25
|
+
let sdk: FilenSDK
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
sdk = await loginTestSDK()
|
|
29
|
+
}, 300_000)
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await teardownTestSDK()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("both sides create the same path; the newer (local) copy wins", async () => {
|
|
36
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
37
|
+
// Local copy stamped clearly-newer than the remote upload that follows.
|
|
38
|
+
await modifyLocal(world, "c.txt", "LOCAL-WINS")
|
|
39
|
+
await uploadRemote(world, "c.txt", "remote-loses")
|
|
40
|
+
|
|
41
|
+
await settle(world)
|
|
42
|
+
|
|
43
|
+
await expectConverged(world)
|
|
44
|
+
expect(await readLocal(world, "c.txt")).toBe("LOCAL-WINS")
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("local modify vs remote delete: the newer local modification wins, resurrected (E2E-OBS-001)", async () => {
|
|
49
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
50
|
+
await writeLocal(world, "f.txt", "v1")
|
|
51
|
+
await settle(world)
|
|
52
|
+
|
|
53
|
+
// The local copy is edited (content changed) while the remote deletes it. Per newer-modify-wins
|
|
54
|
+
// the local modification survives the deletion: the file is re-uploaded (resurrected) remotely
|
|
55
|
+
// and kept locally with the local content, rather than being removed on both sides.
|
|
56
|
+
await modifyLocal(world, "f.txt", "v2-modified")
|
|
57
|
+
await deleteRemote(world, "f.txt")
|
|
58
|
+
|
|
59
|
+
await settle(world)
|
|
60
|
+
|
|
61
|
+
await expectConverged(world)
|
|
62
|
+
expect(await existsLocal(world, "f.txt")).toBe(true)
|
|
63
|
+
expect(await readLocal(world, "f.txt")).toBe("v2-modified")
|
|
64
|
+
expect((await snapshotRemoteReal(world))["/f.txt"]).toMatchObject({ type: "file" })
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("local delete vs remote-unchanged: the delete propagates", async () => {
|
|
69
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
70
|
+
await writeLocal(world, "g.txt", "data")
|
|
71
|
+
await settle(world)
|
|
72
|
+
|
|
73
|
+
await rmLocal(world, "g.txt")
|
|
74
|
+
|
|
75
|
+
await settle(world)
|
|
76
|
+
|
|
77
|
+
expect((await snapshotRemoteReal(world))["/g.txt"]).toBeUndefined()
|
|
78
|
+
await expectConverged(world)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("remote modify vs local delete: the newer remote modification wins, resurrected (F7)", async () => {
|
|
83
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
84
|
+
await writeLocal(world, "h.txt", "v1")
|
|
85
|
+
await settle(world)
|
|
86
|
+
|
|
87
|
+
// Symmetric to the local-modify-vs-remote-delete case: the local copy is deleted while the remote
|
|
88
|
+
// modifies it (a new version). The newer modification wins on EITHER side, so the file survives.
|
|
89
|
+
await rmLocal(world, "h.txt")
|
|
90
|
+
await uploadRemote(world, "h.txt", "REMOTE-MODIFIED")
|
|
91
|
+
|
|
92
|
+
await settle(world)
|
|
93
|
+
|
|
94
|
+
await expectConverged(world)
|
|
95
|
+
expect(await existsLocal(world, "h.txt")).toBe(true)
|
|
96
|
+
expect(await readLocal(world, "h.txt")).toBe("REMOTE-MODIFIED")
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("rename + in-place modify in one beat keeps the new name AND the new content (F1)", async () => {
|
|
101
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
102
|
+
await writeLocal(world, "a.txt", "original-content")
|
|
103
|
+
await settle(world)
|
|
104
|
+
|
|
105
|
+
// Rename, then immediately overwrite the renamed file before the next sync. The rename must not
|
|
106
|
+
// mask the content change — the remote ends with the NEW bytes under the new name.
|
|
107
|
+
await renameLocal(world, "a.txt", "b.txt")
|
|
108
|
+
await writeLocal(world, "b.txt", "BRAND-NEW-CONTENT")
|
|
109
|
+
|
|
110
|
+
await settle(world)
|
|
111
|
+
|
|
112
|
+
await expectConverged(world)
|
|
113
|
+
|
|
114
|
+
const remote = await snapshotRemoteReal(world)
|
|
115
|
+
|
|
116
|
+
expect(remote["/a.txt"]).toBeUndefined()
|
|
117
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
118
|
+
expect(await readLocal(world, "b.txt")).toBe("BRAND-NEW-CONTENT")
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// --- Conflict-matrix parity with mocked Category Y (the trickiest rename-vs-other-side cases) ---
|
|
123
|
+
|
|
124
|
+
it("add(local) vs add(remote) same path, remote newer → the remote copy wins (Y2)", async () => {
|
|
125
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
126
|
+
await writeLocal(world, "addboth.txt", "LOCAL-OLDER")
|
|
127
|
+
// Age the local copy so the remote upload that follows is unambiguously newer.
|
|
128
|
+
await setLocalMtime(world, "addboth.txt", Date.now() - 60_000)
|
|
129
|
+
await uploadRemote(world, "addboth.txt", "REMOTE-NEWER")
|
|
130
|
+
|
|
131
|
+
await settle(world)
|
|
132
|
+
|
|
133
|
+
await expectConverged(world)
|
|
134
|
+
expect(await readLocal(world, "addboth.txt")).toBe("REMOTE-NEWER")
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("delete(local) vs delete(remote) same path → converges to empty, no error (Y3)", async () => {
|
|
139
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
140
|
+
await writeLocal(world, "doomed.txt", "bye")
|
|
141
|
+
await settle(world)
|
|
142
|
+
|
|
143
|
+
await rmLocal(world, "doomed.txt")
|
|
144
|
+
await deleteRemote(world, "doomed.txt")
|
|
145
|
+
|
|
146
|
+
await settle(world)
|
|
147
|
+
|
|
148
|
+
await expectConverged(world)
|
|
149
|
+
expect((await snapshotRemoteReal(world))["/doomed.txt"]).toBeUndefined()
|
|
150
|
+
expect(await existsLocal(world, "doomed.txt")).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("rename(local a→b) vs delete(remote a) → converges to {b}, data preserved (Y7)", async () => {
|
|
155
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
156
|
+
await writeLocal(world, "a.txt", "data")
|
|
157
|
+
await settle(world)
|
|
158
|
+
|
|
159
|
+
await renameLocal(world, "a.txt", "b.txt")
|
|
160
|
+
await deleteRemote(world, "a.txt")
|
|
161
|
+
|
|
162
|
+
await settle(world)
|
|
163
|
+
|
|
164
|
+
await expectConverged(world)
|
|
165
|
+
const remote = await snapshotRemoteReal(world)
|
|
166
|
+
expect(remote["/a.txt"]).toBeUndefined()
|
|
167
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
168
|
+
expect(await existsLocal(world, "b.txt")).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("rename(local a→b) vs modify(remote a) → converges keeping BOTH a and b (Y8)", async () => {
|
|
173
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
174
|
+
await writeLocal(world, "a.txt", "orig")
|
|
175
|
+
await settle(world)
|
|
176
|
+
|
|
177
|
+
await renameLocal(world, "a.txt", "b.txt")
|
|
178
|
+
await uploadRemote(world, "a.txt", "REMOTE-MOD")
|
|
179
|
+
|
|
180
|
+
await settle(world)
|
|
181
|
+
|
|
182
|
+
await expectConverged(world)
|
|
183
|
+
const remote = await snapshotRemoteReal(world)
|
|
184
|
+
expect(remote["/a.txt"]).toMatchObject({ type: "file" })
|
|
185
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
186
|
+
expect(await readLocal(world, "a.txt")).toBe("REMOTE-MOD")
|
|
187
|
+
expect(await readLocal(world, "b.txt")).toBe("orig")
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("rename(local a→X) vs rename(remote a→Y) → converges keeping BOTH X and Y (Y9)", async () => {
|
|
192
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
193
|
+
await writeLocal(world, "a.txt", "data")
|
|
194
|
+
await settle(world)
|
|
195
|
+
|
|
196
|
+
await renameLocal(world, "a.txt", "local-name.txt")
|
|
197
|
+
await renameRemote(world, "a.txt", "remote-name.txt")
|
|
198
|
+
|
|
199
|
+
await settle(world)
|
|
200
|
+
|
|
201
|
+
await expectConverged(world)
|
|
202
|
+
const remote = await snapshotRemoteReal(world)
|
|
203
|
+
expect(remote["/local-name.txt"]).toMatchObject({ type: "file" })
|
|
204
|
+
expect(remote["/remote-name.txt"]).toMatchObject({ type: "file" })
|
|
205
|
+
expect(remote["/a.txt"]).toBeUndefined()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("rename(remote a→b) vs delete(local a) → converges to {b}, data preserved (Y10)", async () => {
|
|
210
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
211
|
+
await writeLocal(world, "a.txt", "data")
|
|
212
|
+
await settle(world)
|
|
213
|
+
|
|
214
|
+
await renameRemote(world, "a.txt", "b.txt")
|
|
215
|
+
await rmLocal(world, "a.txt")
|
|
216
|
+
|
|
217
|
+
await settle(world)
|
|
218
|
+
|
|
219
|
+
await expectConverged(world)
|
|
220
|
+
const remote = await snapshotRemoteReal(world)
|
|
221
|
+
expect(remote["/a.txt"]).toBeUndefined()
|
|
222
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it("rename(remote a→b) vs modify(local a) → converges keeping BOTH a and b (Y11)", async () => {
|
|
227
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
228
|
+
await writeLocal(world, "a.txt", "orig")
|
|
229
|
+
await settle(world)
|
|
230
|
+
|
|
231
|
+
await renameRemote(world, "a.txt", "b.txt")
|
|
232
|
+
await modifyLocal(world, "a.txt", "LOCAL-MOD")
|
|
233
|
+
|
|
234
|
+
await settle(world)
|
|
235
|
+
|
|
236
|
+
await expectConverged(world)
|
|
237
|
+
const remote = await snapshotRemoteReal(world)
|
|
238
|
+
expect(remote["/a.txt"]).toMatchObject({ type: "file" })
|
|
239
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
240
|
+
expect(await readLocal(world, "a.txt")).toBe("LOCAL-MOD")
|
|
241
|
+
expect(await readLocal(world, "b.txt")).toBe("orig")
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("delete(local a) + add(remote b) in one cycle → both applied, converges (Y12)", async () => {
|
|
246
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
247
|
+
await writeLocal(world, "a.txt", "data")
|
|
248
|
+
await settle(world)
|
|
249
|
+
|
|
250
|
+
await rmLocal(world, "a.txt")
|
|
251
|
+
await uploadRemote(world, "b.txt", "new-remote")
|
|
252
|
+
|
|
253
|
+
await settle(world)
|
|
254
|
+
|
|
255
|
+
await expectConverged(world)
|
|
256
|
+
const remote = await snapshotRemoteReal(world)
|
|
257
|
+
expect(remote["/a.txt"]).toBeUndefined()
|
|
258
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import type FilenSDK from "@filen/sdk"
|
|
3
|
+
import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
|
|
4
|
+
import { withE2EWorld } from "./harness/world"
|
|
5
|
+
import { cycle, settle, expectConverged, transferKinds } from "./harness/drive"
|
|
6
|
+
import { snapshotRemoteReal, snapshotLocalReal } from "./harness/assert"
|
|
7
|
+
import {
|
|
8
|
+
writeLocal,
|
|
9
|
+
mkdirLocal,
|
|
10
|
+
rmLocal,
|
|
11
|
+
renameLocal,
|
|
12
|
+
uploadRemote,
|
|
13
|
+
existsLocal,
|
|
14
|
+
renameRemoteDir,
|
|
15
|
+
deleteRemote,
|
|
16
|
+
modifyLocal,
|
|
17
|
+
readLocal
|
|
18
|
+
} from "./harness/mutations"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Phase 3 e2e — structural and naming edge cases against the live backend. Heavy on directory
|
|
22
|
+
* renames/moves (the delta-collapse path, BUG-004) and unusual names. All tiny files.
|
|
23
|
+
*/
|
|
24
|
+
describe.skipIf(!E2E_ENABLED)("E2E — structural & naming edge cases", () => {
|
|
25
|
+
let sdk: FilenSDK
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
sdk = await loginTestSDK()
|
|
29
|
+
}, 300_000)
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await teardownTestSDK()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("renames a directory and carries its children (collapse to one parent op)", async () => {
|
|
36
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
37
|
+
await writeLocal(world, "parent/a.txt", "a")
|
|
38
|
+
await writeLocal(world, "parent/sub/b.txt", "b")
|
|
39
|
+
await settle(world)
|
|
40
|
+
|
|
41
|
+
await renameLocal(world, "parent", "parent2")
|
|
42
|
+
await settle(world)
|
|
43
|
+
|
|
44
|
+
const remote = await snapshotRemoteReal(world)
|
|
45
|
+
|
|
46
|
+
expect(remote["/parent"]).toBeUndefined()
|
|
47
|
+
expect(remote["/parent2/a.txt"]).toMatchObject({ type: "file" })
|
|
48
|
+
expect(remote["/parent2/sub/b.txt"]).toMatchObject({ type: "file" })
|
|
49
|
+
|
|
50
|
+
await expectConverged(world)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("renames a parent dir AND a child within it in one beat (BUG-004 composition)", async () => {
|
|
55
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
56
|
+
await writeLocal(world, "p/child.txt", "x")
|
|
57
|
+
await settle(world)
|
|
58
|
+
|
|
59
|
+
// Parent rename + child rename composed in a single cycle.
|
|
60
|
+
await renameLocal(world, "p", "p2")
|
|
61
|
+
await renameLocal(world, "p2/child.txt", "p2/child2.txt")
|
|
62
|
+
await settle(world)
|
|
63
|
+
|
|
64
|
+
const remote = await snapshotRemoteReal(world)
|
|
65
|
+
|
|
66
|
+
expect(remote["/p"]).toBeUndefined()
|
|
67
|
+
expect(remote["/p2/child.txt"]).toBeUndefined()
|
|
68
|
+
expect(remote["/p2/child2.txt"]).toMatchObject({ type: "file" })
|
|
69
|
+
|
|
70
|
+
await expectConverged(world)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("moves a subtree into another directory", async () => {
|
|
75
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
76
|
+
await writeLocal(world, "src/inner/file.txt", "data")
|
|
77
|
+
await mkdirLocal(world, "dest")
|
|
78
|
+
await settle(world)
|
|
79
|
+
|
|
80
|
+
await renameLocal(world, "src", "dest/src")
|
|
81
|
+
await settle(world)
|
|
82
|
+
|
|
83
|
+
const remote = await snapshotRemoteReal(world)
|
|
84
|
+
|
|
85
|
+
expect(remote["/src"]).toBeUndefined()
|
|
86
|
+
expect(remote["/dest/src/inner/file.txt"]).toMatchObject({ type: "file" })
|
|
87
|
+
|
|
88
|
+
await expectConverged(world)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("deletes an entire directory tree", async () => {
|
|
93
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
94
|
+
await writeLocal(world, "tree/a.txt", "a")
|
|
95
|
+
await writeLocal(world, "tree/deep/b.txt", "b")
|
|
96
|
+
await writeLocal(world, "keep.txt", "k")
|
|
97
|
+
await settle(world)
|
|
98
|
+
|
|
99
|
+
await rmLocal(world, "tree")
|
|
100
|
+
await settle(world)
|
|
101
|
+
|
|
102
|
+
const remote = await snapshotRemoteReal(world)
|
|
103
|
+
|
|
104
|
+
expect(remote["/tree"]).toBeUndefined()
|
|
105
|
+
expect(remote["/tree/a.txt"]).toBeUndefined()
|
|
106
|
+
expect(remote["/tree/deep/b.txt"]).toBeUndefined()
|
|
107
|
+
expect(remote["/keep.txt"]).toMatchObject({ type: "file" })
|
|
108
|
+
|
|
109
|
+
await expectConverged(world)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("syncs files with spaces, unicode, and emoji in their names", async () => {
|
|
114
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
115
|
+
await writeLocal(world, "a file with spaces.txt", "1")
|
|
116
|
+
await writeLocal(world, "ünïcödé/naïve café.txt", "2")
|
|
117
|
+
await writeLocal(world, "emoji 🚀 rocket.txt", "3")
|
|
118
|
+
|
|
119
|
+
await settle(world)
|
|
120
|
+
|
|
121
|
+
const remote = await snapshotRemoteReal(world)
|
|
122
|
+
|
|
123
|
+
expect(remote["/a file with spaces.txt"]).toMatchObject({ type: "file" })
|
|
124
|
+
expect(remote["/ünïcödé/naïve café.txt"]).toMatchObject({ type: "file" })
|
|
125
|
+
expect(remote["/emoji 🚀 rocket.txt"]).toMatchObject({ type: "file" })
|
|
126
|
+
|
|
127
|
+
await expectConverged(world)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// E2E-BUG-001 (FIXED): a same-path type change (file -> directory). The delta engine now attributes
|
|
132
|
+
// the change against the last-synced base, deletes the stale-type remote item, then creates the new
|
|
133
|
+
// type — the phase-ordered runner guarantees the delete lands before the create.
|
|
134
|
+
it("replaces a file with a directory of the same name", async () => {
|
|
135
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
136
|
+
await writeLocal(world, "thing", "i am a file")
|
|
137
|
+
await settle(world)
|
|
138
|
+
|
|
139
|
+
await rmLocal(world, "thing")
|
|
140
|
+
await writeLocal(world, "thing/inside.txt", "now a dir")
|
|
141
|
+
await settle(world)
|
|
142
|
+
|
|
143
|
+
const remote = await snapshotRemoteReal(world)
|
|
144
|
+
|
|
145
|
+
expect(remote["/thing"]).toMatchObject({ type: "directory" })
|
|
146
|
+
expect(remote["/thing/inside.txt"]).toMatchObject({ type: "file" })
|
|
147
|
+
|
|
148
|
+
await expectConverged(world)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("replaces a directory (with children) with a file of the same name", async () => {
|
|
153
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
154
|
+
await writeLocal(world, "thing/a.txt", "child a")
|
|
155
|
+
await writeLocal(world, "thing/b.txt", "child b")
|
|
156
|
+
await settle(world)
|
|
157
|
+
|
|
158
|
+
await rmLocal(world, "thing")
|
|
159
|
+
await writeLocal(world, "thing", "now a file")
|
|
160
|
+
await settle(world)
|
|
161
|
+
|
|
162
|
+
const remote = await snapshotRemoteReal(world)
|
|
163
|
+
|
|
164
|
+
expect(remote["/thing"]).toMatchObject({ type: "file" })
|
|
165
|
+
expect(remote["/thing/a.txt"]).toBeUndefined()
|
|
166
|
+
expect(remote["/thing/b.txt"]).toBeUndefined()
|
|
167
|
+
|
|
168
|
+
await expectConverged(world)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("syncs a deeply nested tree", async () => {
|
|
173
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
174
|
+
await writeLocal(world, "l1/l2/l3/l4/l5/deep.txt", "deep")
|
|
175
|
+
await settle(world)
|
|
176
|
+
|
|
177
|
+
expect((await snapshotRemoteReal(world))["/l1/l2/l3/l4/l5/deep.txt"]).toMatchObject({ type: "file" })
|
|
178
|
+
|
|
179
|
+
await expectConverged(world)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("syncs many files in a single directory", async () => {
|
|
184
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
185
|
+
for (let i = 0; i < 20; i++) {
|
|
186
|
+
await writeLocal(world, `bulk/file-${i}.txt`, `content-${i}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await settle(world)
|
|
190
|
+
|
|
191
|
+
const remote = await snapshotRemoteReal(world)
|
|
192
|
+
|
|
193
|
+
for (let i = 0; i < 20; i++) {
|
|
194
|
+
expect(remote[`/bulk/file-${i}.txt`]).toMatchObject({ type: "file" })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await expectConverged(world)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("moves a file from one directory to another", async () => {
|
|
202
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
203
|
+
await writeLocal(world, "from/doc.txt", "data")
|
|
204
|
+
await mkdirLocal(world, "to")
|
|
205
|
+
await settle(world)
|
|
206
|
+
|
|
207
|
+
await renameLocal(world, "from/doc.txt", "to/doc.txt")
|
|
208
|
+
await settle(world)
|
|
209
|
+
|
|
210
|
+
const remote = await snapshotRemoteReal(world)
|
|
211
|
+
|
|
212
|
+
expect(remote["/from/doc.txt"]).toBeUndefined()
|
|
213
|
+
expect(remote["/to/doc.txt"]).toMatchObject({ type: "file", size: 4 })
|
|
214
|
+
|
|
215
|
+
await expectConverged(world)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("merges independent additions from both sides (twoWay, no conflict)", async () => {
|
|
220
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
221
|
+
await writeLocal(world, "local-add.txt", "local")
|
|
222
|
+
await uploadRemote(world, "remote-add.txt", "remote")
|
|
223
|
+
await settle(world)
|
|
224
|
+
|
|
225
|
+
const remote = await snapshotRemoteReal(world)
|
|
226
|
+
|
|
227
|
+
expect(remote["/local-add.txt"]).toMatchObject({ type: "file" })
|
|
228
|
+
expect(remote["/remote-add.txt"]).toMatchObject({ type: "file" })
|
|
229
|
+
expect(await existsLocal(world, "local-add.txt")).toBe(true)
|
|
230
|
+
expect(await existsLocal(world, "remote-add.txt")).toBe(true)
|
|
231
|
+
|
|
232
|
+
await expectConverged(world)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("a settled directory rename does no further work after the move", async () => {
|
|
237
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
238
|
+
await writeLocal(world, "dir/a.txt", "a")
|
|
239
|
+
await settle(world)
|
|
240
|
+
await renameLocal(world, "dir", "dir-renamed")
|
|
241
|
+
await settle(world)
|
|
242
|
+
|
|
243
|
+
// One more cycle must be a complete no-op (no leftover per-child churn).
|
|
244
|
+
const messages = await cycle(world)
|
|
245
|
+
|
|
246
|
+
expect(transferKinds(messages)).toEqual([])
|
|
247
|
+
await expectConverged(world)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("case-insensitive name collision: only one variant survives and the sync converges (F11)", async () => {
|
|
252
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
253
|
+
// The backend is case-insensitive per parent, so two names differing only in case collide. The
|
|
254
|
+
// collision is created on the REMOTE (a case-sensitive local FS isn't guaranteed on the test host)
|
|
255
|
+
// and synced down — the live counterpart of mocked F11.
|
|
256
|
+
await uploadRemote(world, "Report.txt", "one")
|
|
257
|
+
await uploadRemote(world, "report.txt", "two")
|
|
258
|
+
|
|
259
|
+
// The backend kept exactly one of the two case variants.
|
|
260
|
+
const remoteAfterUpload = await snapshotRemoteReal(world)
|
|
261
|
+
const survivingRemote = ["/Report.txt", "/report.txt"].filter(path => remoteAfterUpload[path] !== undefined)
|
|
262
|
+
|
|
263
|
+
expect(survivingRemote).toHaveLength(1)
|
|
264
|
+
|
|
265
|
+
// The sync converges without crashing or churning; the local side ends matching the single copy.
|
|
266
|
+
await settle(world)
|
|
267
|
+
await expectConverged(world)
|
|
268
|
+
|
|
269
|
+
expect(Object.keys(await snapshotLocalReal(world))).toHaveLength(1)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Cross-side directory rename + concurrent child change (BUG-A / BUG-B): a folder renamed on one side
|
|
274
|
+
// while a file inside it is edited/deleted on the OTHER side. Before the rename-aware rebase the rename
|
|
275
|
+
// relocated the subtree while a stale same-path op clobbered the change (data loss) or resurrected a
|
|
276
|
+
// deletion. Live mirror of the mocked ZB suite.
|
|
277
|
+
describe("cross-side directory rename + concurrent child change", () => {
|
|
278
|
+
it("a local dir rename + a remote child edit keeps the remote edit (BUG-A)", async () => {
|
|
279
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
280
|
+
await mkdirLocal(world, "dir")
|
|
281
|
+
await writeLocal(world, "dir/child.txt", "old")
|
|
282
|
+
await writeLocal(world, "dir/sibling.txt", "sib")
|
|
283
|
+
await settle(world)
|
|
284
|
+
await expectConverged(world)
|
|
285
|
+
|
|
286
|
+
// One device renames the folder; another edits a file still at the old path, same window.
|
|
287
|
+
await renameLocal(world, "dir", "dir2")
|
|
288
|
+
await uploadRemote(world, "dir/child.txt", "REMOTE-EDITED-NEW-CONTENT")
|
|
289
|
+
|
|
290
|
+
await settle(world)
|
|
291
|
+
await expectConverged(world)
|
|
292
|
+
|
|
293
|
+
expect(await readLocal(world, "dir2/child.txt")).toBe("REMOTE-EDITED-NEW-CONTENT")
|
|
294
|
+
const remote = await snapshotRemoteReal(world)
|
|
295
|
+
expect(remote["/dir2/child.txt"]).toMatchObject({ size: "REMOTE-EDITED-NEW-CONTENT".length })
|
|
296
|
+
expect(remote["/dir/child.txt"]).toBeUndefined()
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("a remote dir rename + a local child edit keeps the local edit (BUG-A symmetric)", async () => {
|
|
301
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
302
|
+
await mkdirLocal(world, "dir")
|
|
303
|
+
await writeLocal(world, "dir/child.txt", "old")
|
|
304
|
+
await settle(world)
|
|
305
|
+
await expectConverged(world)
|
|
306
|
+
|
|
307
|
+
await renameRemoteDir(world, "dir", "dir2")
|
|
308
|
+
await modifyLocal(world, "dir/child.txt", "LOCAL-EDITED-NEW-CONTENT")
|
|
309
|
+
|
|
310
|
+
await settle(world)
|
|
311
|
+
await expectConverged(world)
|
|
312
|
+
|
|
313
|
+
const remote = await snapshotRemoteReal(world)
|
|
314
|
+
expect(remote["/dir2/child.txt"]).toMatchObject({ size: "LOCAL-EDITED-NEW-CONTENT".length })
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it("a local dir rename + a remote child delete does not resurrect the child (BUG-B)", async () => {
|
|
319
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
320
|
+
await mkdirLocal(world, "dir")
|
|
321
|
+
await writeLocal(world, "dir/child.txt", "old")
|
|
322
|
+
await writeLocal(world, "dir/keep.txt", "k")
|
|
323
|
+
await settle(world)
|
|
324
|
+
await expectConverged(world)
|
|
325
|
+
|
|
326
|
+
await renameLocal(world, "dir", "dir2")
|
|
327
|
+
await deleteRemote(world, "dir/child.txt")
|
|
328
|
+
|
|
329
|
+
await settle(world)
|
|
330
|
+
await expectConverged(world)
|
|
331
|
+
|
|
332
|
+
expect(await existsLocal(world, "dir2/child.txt")).toBe(false)
|
|
333
|
+
const remote = await snapshotRemoteReal(world)
|
|
334
|
+
expect(remote["/dir2/child.txt"]).toBeUndefined()
|
|
335
|
+
expect(remote["/dir2/keep.txt"]).toMatchObject({ size: "k".length })
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
})
|