@filen/sync 0.2.1 → 0.3.1
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,229 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import type FilenSDK from "@filen/sdk"
|
|
3
|
+
import pathModule from "path"
|
|
4
|
+
import fs from "fs-extra"
|
|
5
|
+
import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
|
|
6
|
+
import { withE2EWorld, restartE2EWorld } from "./harness/world"
|
|
7
|
+
import { cycle, settle, expectConverged, allOps, messagesOfType } from "./harness/drive"
|
|
8
|
+
import { snapshotRemoteReal } from "./harness/assert"
|
|
9
|
+
import { writeLocal, rmLocal, renameLocal, existsLocal, uploadRemote } from "./harness/mutations"
|
|
10
|
+
import { DEVICE_ID_VERSION } from "../../src/lib/filesystems/remote"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Phase 3 e2e — state persistence across restarts against the live backend. Proves the on-disk state
|
|
14
|
+
* (previous trees, deviceId, hashes) survives a process restart / client upgrade and drives correct
|
|
15
|
+
* incremental syncs: settled trees no-op, and only genuine post-restart changes are transferred.
|
|
16
|
+
*/
|
|
17
|
+
describe.skipIf(!E2E_ENABLED)("E2E — state persistence across restarts", () => {
|
|
18
|
+
let sdk: FilenSDK
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
sdk = await loginTestSDK()
|
|
22
|
+
}, 300_000)
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await teardownTestSDK()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("a settled tree no-ops on the first cycle after a restart", async () => {
|
|
29
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
30
|
+
await writeLocal(world, "a.txt", "a")
|
|
31
|
+
await writeLocal(world, "dir/b.txt", "b")
|
|
32
|
+
await settle(world)
|
|
33
|
+
|
|
34
|
+
await restartE2EWorld(world)
|
|
35
|
+
|
|
36
|
+
const messages = await cycle(world, { resetCache: false })
|
|
37
|
+
|
|
38
|
+
expect(allOps(messages)).toEqual([])
|
|
39
|
+
await expectConverged(world)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("a settled rename no-ops after a restart", async () => {
|
|
44
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
45
|
+
await writeLocal(world, "dir/file.txt", "x")
|
|
46
|
+
await settle(world)
|
|
47
|
+
await renameLocal(world, "dir", "dir2")
|
|
48
|
+
await settle(world)
|
|
49
|
+
|
|
50
|
+
await restartE2EWorld(world)
|
|
51
|
+
|
|
52
|
+
const messages = await cycle(world, { resetCache: false })
|
|
53
|
+
|
|
54
|
+
expect(allOps(messages)).toEqual([])
|
|
55
|
+
expect((await snapshotRemoteReal(world))["/dir2/file.txt"]).toMatchObject({ type: "file" })
|
|
56
|
+
await expectConverged(world)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("a settled deletion no-ops after a restart (no resurrection)", async () => {
|
|
61
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
62
|
+
await writeLocal(world, "keep.txt", "k")
|
|
63
|
+
await writeLocal(world, "gone.txt", "g")
|
|
64
|
+
await settle(world)
|
|
65
|
+
await rmLocal(world, "gone.txt")
|
|
66
|
+
await settle(world)
|
|
67
|
+
|
|
68
|
+
await restartE2EWorld(world)
|
|
69
|
+
|
|
70
|
+
const messages = await cycle(world, { resetCache: false })
|
|
71
|
+
|
|
72
|
+
expect(allOps(messages)).toEqual([])
|
|
73
|
+
// The deleted file must NOT come back from stale state.
|
|
74
|
+
expect((await snapshotRemoteReal(world))["/gone.txt"]).toBeUndefined()
|
|
75
|
+
expect(await existsLocal(world, "gone.txt")).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("a change made after a restart syncs incrementally (only the change moves)", async () => {
|
|
80
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
81
|
+
await writeLocal(world, "existing.txt", "old")
|
|
82
|
+
await settle(world)
|
|
83
|
+
|
|
84
|
+
await restartE2EWorld(world)
|
|
85
|
+
|
|
86
|
+
// A brand-new file after the restart...
|
|
87
|
+
await writeLocal(world, "new-after-restart.txt", "fresh")
|
|
88
|
+
await settle(world)
|
|
89
|
+
|
|
90
|
+
const remote = await snapshotRemoteReal(world)
|
|
91
|
+
|
|
92
|
+
expect(remote["/new-after-restart.txt"]).toMatchObject({ type: "file" })
|
|
93
|
+
expect(remote["/existing.txt"]).toMatchObject({ type: "file" })
|
|
94
|
+
await expectConverged(world)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("survives two restarts in a row with no work", async () => {
|
|
99
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
100
|
+
await writeLocal(world, "stable.txt", "s")
|
|
101
|
+
await settle(world)
|
|
102
|
+
|
|
103
|
+
await restartE2EWorld(world)
|
|
104
|
+
expect(allOps(await cycle(world, { resetCache: false }))).toEqual([])
|
|
105
|
+
|
|
106
|
+
await restartE2EWorld(world)
|
|
107
|
+
expect(allOps(await cycle(world, { resetCache: false }))).toEqual([])
|
|
108
|
+
|
|
109
|
+
await expectConverged(world)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("the deviceId is reused across a restart, keeping the server-side tree cache valid (S2)", async () => {
|
|
114
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
115
|
+
await writeLocal(world, "keep.txt", "k")
|
|
116
|
+
await settle(world)
|
|
117
|
+
|
|
118
|
+
const deviceIdPath = pathModule.join(world.dbPath, "deviceId", `v${DEVICE_ID_VERSION}`, world.syncPair.uuid)
|
|
119
|
+
const before = await fs.readFile(deviceIdPath, { encoding: "utf-8" })
|
|
120
|
+
|
|
121
|
+
expect(before.length).toBeGreaterThan(0)
|
|
122
|
+
|
|
123
|
+
await restartE2EWorld(world)
|
|
124
|
+
|
|
125
|
+
// A regenerated deviceId would invalidate the server's per-device tree cache and force a
|
|
126
|
+
// re-download storm on every client update — it must survive the restart unchanged.
|
|
127
|
+
const after = await fs.readFile(deviceIdPath, { encoding: "utf-8" })
|
|
128
|
+
|
|
129
|
+
expect(after).toBe(before)
|
|
130
|
+
expect(allOps(await cycle(world, { resetCache: false }))).toEqual([])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("a settled sync with large-deletion confirmation enabled raises NO prompt after a restart (S3/BUG-001)", async () => {
|
|
135
|
+
await withE2EWorld({ sdk, mode: "twoWay", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
136
|
+
await writeLocal(world, "x.txt", "x")
|
|
137
|
+
await writeLocal(world, "y.txt", "y")
|
|
138
|
+
await writeLocal(world, "z/w.txt", "w")
|
|
139
|
+
await settle(world)
|
|
140
|
+
|
|
141
|
+
await restartE2EWorld(world)
|
|
142
|
+
|
|
143
|
+
// The deletion gate must not misfire when a fresh engine reloads a settled tree: the previous
|
|
144
|
+
// trees are non-empty and the current trees match them, so nothing looks deleted.
|
|
145
|
+
const messages = await cycle(world, { resetCache: false })
|
|
146
|
+
|
|
147
|
+
expect(messagesOfType(messages, "confirmDeletion")).toEqual([])
|
|
148
|
+
expect(allOps(messages)).toEqual([])
|
|
149
|
+
await expectConverged(world)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("persisted state is the stable v2 line-delimited {prop,data} JSON on disk (S5)", async () => {
|
|
154
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
155
|
+
await writeLocal(world, "a.txt", "alpha")
|
|
156
|
+
await writeLocal(world, "d/b.txt", "bravo")
|
|
157
|
+
await settle(world)
|
|
158
|
+
|
|
159
|
+
const localTreeRaw = await fs.readFile(world.sync.state.previousLocalTreePath, { encoding: "utf-8" })
|
|
160
|
+
const remoteTreeRaw = await fs.readFile(world.sync.state.previousRemoteTreePath, { encoding: "utf-8" })
|
|
161
|
+
|
|
162
|
+
const assertLines = (raw: string): void => {
|
|
163
|
+
const lines = raw.trim().split("\n").filter(Boolean)
|
|
164
|
+
|
|
165
|
+
expect(lines.length).toBeGreaterThan(0)
|
|
166
|
+
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
const parsed = JSON.parse(line)
|
|
169
|
+
|
|
170
|
+
expect(parsed).toHaveProperty("prop")
|
|
171
|
+
expect(parsed.data).toHaveProperty("path")
|
|
172
|
+
expect(parsed.data).toHaveProperty("type")
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
assertLines(localTreeRaw)
|
|
177
|
+
assertLines(remoteTreeRaw)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// ---- crash / stop mid-run: the persisted base is BEHIND reality on restart ----------------------
|
|
182
|
+
// The state save runs only after a fully clean cycle, so a kill (or a user stop) mid-run leaves the
|
|
183
|
+
// on-disk base at the last clean cycle while the fs/remote already moved on. A fresh engine must
|
|
184
|
+
// re-derive the outstanding work from that stale base and converge — no loss, no resurrection. These
|
|
185
|
+
// are the live counterparts of mocked Category ZC (which additionally injects a partial-cycle fault).
|
|
186
|
+
|
|
187
|
+
it("un-synced changes on BOTH sides survive a restart and converge (crash before the first sync)", async () => {
|
|
188
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
189
|
+
await writeLocal(world, "base.txt", "base")
|
|
190
|
+
await settle(world)
|
|
191
|
+
|
|
192
|
+
// Both sides change, then the process dies before any cycle syncs them: the persisted base
|
|
193
|
+
// still only knows base.txt.
|
|
194
|
+
await writeLocal(world, "local-only.txt", "L")
|
|
195
|
+
await uploadRemote(world, "remote-only.txt", "R")
|
|
196
|
+
|
|
197
|
+
await restartE2EWorld(world)
|
|
198
|
+
await settle(world)
|
|
199
|
+
|
|
200
|
+
const remote = await snapshotRemoteReal(world)
|
|
201
|
+
|
|
202
|
+
expect(remote["/local-only.txt"]).toMatchObject({ type: "file" })
|
|
203
|
+
expect(remote["/remote-only.txt"]).toMatchObject({ type: "file" })
|
|
204
|
+
expect(await existsLocal(world, "local-only.txt")).toBe(true)
|
|
205
|
+
expect(await existsLocal(world, "remote-only.txt")).toBe(true)
|
|
206
|
+
await expectConverged(world)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("an un-synced local rename survives a restart and applies (no resurrection of the old path)", async () => {
|
|
211
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
212
|
+
await writeLocal(world, "dir/file.txt", "x")
|
|
213
|
+
await settle(world)
|
|
214
|
+
|
|
215
|
+
// Rename locally, then die before the rename is ever synced — the base still has the old path.
|
|
216
|
+
await renameLocal(world, "dir", "dir2")
|
|
217
|
+
|
|
218
|
+
await restartE2EWorld(world)
|
|
219
|
+
await settle(world)
|
|
220
|
+
|
|
221
|
+
const remote = await snapshotRemoteReal(world)
|
|
222
|
+
|
|
223
|
+
expect(remote["/dir2/file.txt"]).toMatchObject({ type: "file" })
|
|
224
|
+
expect(remote["/dir/file.txt"]).toBeUndefined()
|
|
225
|
+
expect(remote["/dir"]).toBeUndefined()
|
|
226
|
+
await expectConverged(world)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
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, restartE2EWorld } from "./harness/world"
|
|
5
|
+
import { cycle, settle, expectConverged, allOps } from "./harness/drive"
|
|
6
|
+
import { snapshotLocalReal, snapshotRemoteReal } from "./harness/assert"
|
|
7
|
+
import { writeLocal, modifyLocal, mkdirLocal, rmLocal, renameLocal, readLocal, uploadRemote, mkdirRemote, existsLocal } from "./harness/mutations"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Phase 3 — real end-to-end against the live SDK + backend. Each case runs a real {@link SyncWorker}
|
|
11
|
+
* against a fresh `/<uuid>` remote dir and a fresh local tmp dir, then permanently cleans both sides.
|
|
12
|
+
* All files are tiny (bytes) — this exercises the sync ALGORITHM end-to-end, not transfer throughput.
|
|
13
|
+
*
|
|
14
|
+
* Skips entirely when FILEN_TEST_EMAIL / FILEN_TEST_PASSWORD are unset.
|
|
15
|
+
*/
|
|
16
|
+
describe.skipIf(!E2E_ENABLED)("E2E — core sync against live backend", () => {
|
|
17
|
+
let sdk: FilenSDK
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
sdk = await loginTestSDK()
|
|
21
|
+
}, 300_000)
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await teardownTestSDK()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// ---- twoWay ------------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
it("twoWay: uploads new local files and directories to the remote", async () => {
|
|
30
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
31
|
+
await writeLocal(world, "a.txt", "alpha")
|
|
32
|
+
await writeLocal(world, "dir/b.txt", "bravo")
|
|
33
|
+
await mkdirLocal(world, "dir/empty-dir")
|
|
34
|
+
|
|
35
|
+
await settle(world)
|
|
36
|
+
|
|
37
|
+
const remote = await snapshotRemoteReal(world)
|
|
38
|
+
|
|
39
|
+
expect(remote["/a.txt"]).toMatchObject({ type: "file", size: 5 })
|
|
40
|
+
expect(remote["/dir"]).toMatchObject({ type: "directory" })
|
|
41
|
+
expect(remote["/dir/b.txt"]).toMatchObject({ type: "file" })
|
|
42
|
+
expect(remote["/dir/empty-dir"]).toMatchObject({ type: "directory" })
|
|
43
|
+
|
|
44
|
+
await expectConverged(world)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("twoWay: propagates creation AND deletion of a standalone empty directory", async () => {
|
|
49
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
50
|
+
// Create a standalone empty directory and let it sync up.
|
|
51
|
+
await mkdirLocal(world, "empty")
|
|
52
|
+
await settle(world)
|
|
53
|
+
|
|
54
|
+
expect((await snapshotRemoteReal(world))["/empty"]).toMatchObject({ type: "directory" })
|
|
55
|
+
await expectConverged(world)
|
|
56
|
+
|
|
57
|
+
// Deleting the still-empty directory must propagate too — no leaked empty dir on the remote.
|
|
58
|
+
await rmLocal(world, "empty")
|
|
59
|
+
await settle(world)
|
|
60
|
+
|
|
61
|
+
expect((await snapshotRemoteReal(world))["/empty"]).toBeUndefined()
|
|
62
|
+
await expectConverged(world)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("twoWay: downloads new remote files and directories to local", async () => {
|
|
67
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
68
|
+
await uploadRemote(world, "from-cloud.txt", "cloud-content")
|
|
69
|
+
await mkdirRemote(world, "cloud-dir")
|
|
70
|
+
await uploadRemote(world, "cloud-dir/nested.txt", "nested")
|
|
71
|
+
|
|
72
|
+
await settle(world)
|
|
73
|
+
|
|
74
|
+
expect(await existsLocal(world, "from-cloud.txt")).toBe(true)
|
|
75
|
+
expect(await readLocal(world, "from-cloud.txt")).toBe("cloud-content")
|
|
76
|
+
expect(await existsLocal(world, "cloud-dir/nested.txt")).toBe(true)
|
|
77
|
+
|
|
78
|
+
await expectConverged(world)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("twoWay: propagates a content modification (same size, different bytes)", async () => {
|
|
83
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
84
|
+
await writeLocal(world, "note.txt", "aaaa")
|
|
85
|
+
await settle(world)
|
|
86
|
+
|
|
87
|
+
// Same byte length, different content — size-only checks would miss this; content hash won't.
|
|
88
|
+
await modifyLocal(world, "note.txt", "bbbb")
|
|
89
|
+
await settle(world)
|
|
90
|
+
|
|
91
|
+
await expectConverged(world)
|
|
92
|
+
|
|
93
|
+
const remote = await snapshotRemoteReal(world, { withContent: true })
|
|
94
|
+
const local = await snapshotLocalReal(world, { withContent: true })
|
|
95
|
+
|
|
96
|
+
expect(remote["/note.txt"]!.contentHash).toBe(local["/note.txt"]!.contentHash)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("twoWay: propagates a local deletion to the remote", async () => {
|
|
101
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
102
|
+
await writeLocal(world, "keep.txt", "keep")
|
|
103
|
+
await writeLocal(world, "remove-me.txt", "gone")
|
|
104
|
+
await settle(world)
|
|
105
|
+
|
|
106
|
+
await rmLocal(world, "remove-me.txt")
|
|
107
|
+
await settle(world)
|
|
108
|
+
|
|
109
|
+
const remote = await snapshotRemoteReal(world)
|
|
110
|
+
|
|
111
|
+
expect(remote["/remove-me.txt"]).toBeUndefined()
|
|
112
|
+
expect(remote["/keep.txt"]).toMatchObject({ type: "file" })
|
|
113
|
+
|
|
114
|
+
await expectConverged(world)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("twoWay: propagates a rename and a move", async () => {
|
|
119
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
120
|
+
await writeLocal(world, "original.txt", "data")
|
|
121
|
+
await mkdirLocal(world, "target")
|
|
122
|
+
await settle(world)
|
|
123
|
+
|
|
124
|
+
await renameLocal(world, "original.txt", "renamed.txt")
|
|
125
|
+
await settle(world)
|
|
126
|
+
await renameLocal(world, "renamed.txt", "target/renamed.txt")
|
|
127
|
+
await settle(world)
|
|
128
|
+
|
|
129
|
+
const remote = await snapshotRemoteReal(world)
|
|
130
|
+
|
|
131
|
+
expect(remote["/original.txt"]).toBeUndefined()
|
|
132
|
+
expect(remote["/renamed.txt"]).toBeUndefined()
|
|
133
|
+
expect(remote["/target/renamed.txt"]).toMatchObject({ type: "file", size: 4 })
|
|
134
|
+
|
|
135
|
+
await expectConverged(world)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("twoWay: syncs a multi-level nested directory tree", async () => {
|
|
140
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
141
|
+
await writeLocal(world, "x/y/z/deep.txt", "deep")
|
|
142
|
+
await writeLocal(world, "x/y/sibling.txt", "sib")
|
|
143
|
+
await writeLocal(world, "x/top.txt", "top")
|
|
144
|
+
|
|
145
|
+
await settle(world)
|
|
146
|
+
|
|
147
|
+
const remote = await snapshotRemoteReal(world)
|
|
148
|
+
|
|
149
|
+
expect(remote["/x/y/z/deep.txt"]).toMatchObject({ type: "file" })
|
|
150
|
+
expect(remote["/x/y/sibling.txt"]).toMatchObject({ type: "file" })
|
|
151
|
+
expect(remote["/x/top.txt"]).toMatchObject({ type: "file" })
|
|
152
|
+
|
|
153
|
+
await expectConverged(world)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("twoWay: a 0-byte file syncs (BUG-002)", async () => {
|
|
158
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
159
|
+
await writeLocal(world, "empty.txt", "")
|
|
160
|
+
await writeLocal(world, "nonempty.txt", "x")
|
|
161
|
+
|
|
162
|
+
await settle(world)
|
|
163
|
+
|
|
164
|
+
const remote = await snapshotRemoteReal(world)
|
|
165
|
+
|
|
166
|
+
expect(remote["/empty.txt"]).toMatchObject({ type: "file", size: 0 })
|
|
167
|
+
expect(remote["/nonempty.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
168
|
+
|
|
169
|
+
await expectConverged(world)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("twoWay: a settled sync does ZERO transfers on the next cycle after a restart (backwards-compat)", async () => {
|
|
174
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
175
|
+
await writeLocal(world, "a.txt", "alpha")
|
|
176
|
+
await writeLocal(world, "dir/b.txt", "bravo")
|
|
177
|
+
await settle(world)
|
|
178
|
+
|
|
179
|
+
await restartE2EWorld(world)
|
|
180
|
+
|
|
181
|
+
// Warm-cache path (no resetCache): the reloaded state must see nothing to do.
|
|
182
|
+
const messages = await cycle(world, { resetCache: false })
|
|
183
|
+
|
|
184
|
+
expect(allOps(messages)).toEqual([])
|
|
185
|
+
await expectConverged(world)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ---- one-way modes -----------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
it("localToCloud: uploads local changes but does NOT pull remote-only files down", async () => {
|
|
192
|
+
await withE2EWorld({ sdk, mode: "localToCloud" }, async world => {
|
|
193
|
+
await writeLocal(world, "local-only.txt", "mine")
|
|
194
|
+
await settle(world)
|
|
195
|
+
|
|
196
|
+
// Appears on the remote (push works)...
|
|
197
|
+
expect((await snapshotRemoteReal(world))["/local-only.txt"]).toMatchObject({ type: "file" })
|
|
198
|
+
|
|
199
|
+
// ...but a remote-only file must NOT be downloaded in localToCloud.
|
|
200
|
+
await uploadRemote(world, "cloud-only.txt", "theirs")
|
|
201
|
+
await settle(world)
|
|
202
|
+
|
|
203
|
+
expect(await existsLocal(world, "cloud-only.txt")).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("cloudToLocal: downloads remote changes but does NOT push local-only files up", async () => {
|
|
208
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal" }, async world => {
|
|
209
|
+
await uploadRemote(world, "cloud-only.txt", "theirs")
|
|
210
|
+
await settle(world)
|
|
211
|
+
|
|
212
|
+
// Pulled down (download works)...
|
|
213
|
+
expect(await existsLocal(world, "cloud-only.txt")).toBe(true)
|
|
214
|
+
|
|
215
|
+
// ...but a local-only file must NOT be uploaded in cloudToLocal.
|
|
216
|
+
await writeLocal(world, "local-only.txt", "mine")
|
|
217
|
+
await settle(world)
|
|
218
|
+
|
|
219
|
+
expect((await snapshotRemoteReal(world))["/local-only.txt"]).toBeUndefined()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|