@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,215 @@
|
|
|
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 { snapshotLocalReal, snapshotRemoteReal } from "./harness/assert"
|
|
7
|
+
import { writeLocal, modifyLocal, rmLocal, renameLocal, renameRemoteDir, readLocal, existsLocal, uploadRemote, deleteRemote } from "./harness/mutations"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Phase 3 e2e — one-way mode semantics against the live backend. localToCloud pushes local changes up
|
|
11
|
+
* and ignores remote-only changes; cloudToLocal pulls remote changes down and ignores local-only
|
|
12
|
+
* changes. (The brand-new-file boundary is in sync.e2e.test.ts; here the ignored side acts on
|
|
13
|
+
* already-synced items.)
|
|
14
|
+
*/
|
|
15
|
+
describe.skipIf(!E2E_ENABLED)("E2E — one-way mode semantics", () => {
|
|
16
|
+
let sdk: FilenSDK
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
sdk = await loginTestSDK()
|
|
20
|
+
}, 300_000)
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await teardownTestSDK()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// ---- localToCloud (local is authoritative) ----------------------------------------------------
|
|
27
|
+
|
|
28
|
+
it("localToCloud: a local content modification is pushed to the remote", async () => {
|
|
29
|
+
await withE2EWorld({ sdk, mode: "localToCloud" }, async world => {
|
|
30
|
+
await writeLocal(world, "doc.txt", "v1aa")
|
|
31
|
+
await settle(world)
|
|
32
|
+
|
|
33
|
+
await modifyLocal(world, "doc.txt", "v2bb")
|
|
34
|
+
await settle(world)
|
|
35
|
+
|
|
36
|
+
const local = await snapshotLocalReal(world, { withContent: true })
|
|
37
|
+
const remote = await snapshotRemoteReal(world, { withContent: true })
|
|
38
|
+
|
|
39
|
+
expect(remote["/doc.txt"]!.contentHash).toBe(local["/doc.txt"]!.contentHash)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("localToCloud: a local deletion is pushed to the remote", async () => {
|
|
44
|
+
await withE2EWorld({ sdk, mode: "localToCloud" }, async world => {
|
|
45
|
+
await writeLocal(world, "drop.txt", "x")
|
|
46
|
+
await settle(world)
|
|
47
|
+
|
|
48
|
+
await rmLocal(world, "drop.txt")
|
|
49
|
+
await settle(world)
|
|
50
|
+
|
|
51
|
+
expect((await snapshotRemoteReal(world))["/drop.txt"]).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("localToCloud: a local rename is pushed to the remote", async () => {
|
|
56
|
+
await withE2EWorld({ sdk, mode: "localToCloud" }, async world => {
|
|
57
|
+
await writeLocal(world, "before.txt", "data")
|
|
58
|
+
await settle(world)
|
|
59
|
+
|
|
60
|
+
await renameLocal(world, "before.txt", "after.txt")
|
|
61
|
+
await settle(world)
|
|
62
|
+
|
|
63
|
+
const remote = await snapshotRemoteReal(world)
|
|
64
|
+
|
|
65
|
+
expect(remote["/before.txt"]).toBeUndefined()
|
|
66
|
+
expect(remote["/after.txt"]).toMatchObject({ type: "file" })
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ---- cloudToLocal (remote is authoritative) ---------------------------------------------------
|
|
71
|
+
|
|
72
|
+
it("cloudToLocal: a remote deletion is pulled down to local", async () => {
|
|
73
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal" }, async world => {
|
|
74
|
+
await uploadRemote(world, "remote-drop.txt", "x")
|
|
75
|
+
await settle(world)
|
|
76
|
+
|
|
77
|
+
expect(await existsLocal(world, "remote-drop.txt")).toBe(true)
|
|
78
|
+
|
|
79
|
+
await deleteRemote(world, "remote-drop.txt")
|
|
80
|
+
await settle(world)
|
|
81
|
+
|
|
82
|
+
expect(await existsLocal(world, "remote-drop.txt")).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("cloudToLocal: a remote subtree is pulled down to local", async () => {
|
|
87
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal" }, async world => {
|
|
88
|
+
await uploadRemote(world, "tree/a.txt", "a")
|
|
89
|
+
await uploadRemote(world, "tree/deep/b.txt", "b")
|
|
90
|
+
await settle(world)
|
|
91
|
+
|
|
92
|
+
expect(await existsLocal(world, "tree/a.txt")).toBe(true)
|
|
93
|
+
expect(await existsLocal(world, "tree/deep/b.txt")).toBe(true)
|
|
94
|
+
expect(await readLocal(world, "tree/deep/b.txt")).toBe("b")
|
|
95
|
+
|
|
96
|
+
await expectConverged(world)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("cloudToLocal: a local modification to a synced file is NOT pushed up", async () => {
|
|
101
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal" }, async world => {
|
|
102
|
+
await uploadRemote(world, "shared.txt", "remote-original")
|
|
103
|
+
await settle(world)
|
|
104
|
+
|
|
105
|
+
// Local edit on the pulled-down copy (clearly-newer mtime) must still NOT propagate upward.
|
|
106
|
+
await modifyLocal(world, "shared.txt", "locally-edited")
|
|
107
|
+
await settle(world)
|
|
108
|
+
|
|
109
|
+
const remote = await snapshotRemoteReal(world, { withContent: true })
|
|
110
|
+
|
|
111
|
+
// Remote still holds the original content (local edit was ignored upward).
|
|
112
|
+
expect(Buffer.from("remote-original").length).toBe(remote["/shared.txt"]!.size)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ---- strict mirror: the foreign side is forced to match the authoritative side ----------------
|
|
117
|
+
|
|
118
|
+
it("localToCloud: a remote-only file is mirror-DELETED", async () => {
|
|
119
|
+
await withE2EWorld({ sdk, mode: "localToCloud" }, async world => {
|
|
120
|
+
await writeLocal(world, "mine.txt", "mine")
|
|
121
|
+
await settle(world)
|
|
122
|
+
|
|
123
|
+
// Another device adds a file the local side never had; the mirror must remove it.
|
|
124
|
+
await uploadRemote(world, "foreign.txt", "theirs")
|
|
125
|
+
await settle(world)
|
|
126
|
+
|
|
127
|
+
const remote = await snapshotRemoteReal(world)
|
|
128
|
+
|
|
129
|
+
expect(remote["/foreign.txt"]).toBeUndefined()
|
|
130
|
+
expect(remote["/mine.txt"]).toMatchObject({ type: "file" })
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("cloudToLocal: a local-only file is mirror-DELETED", async () => {
|
|
135
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal" }, async world => {
|
|
136
|
+
await uploadRemote(world, "remote.txt", "remote")
|
|
137
|
+
await settle(world)
|
|
138
|
+
|
|
139
|
+
// A local-only file the remote never had; the mirror must remove it.
|
|
140
|
+
await writeLocal(world, "local-only.txt", "mine")
|
|
141
|
+
await settle(world)
|
|
142
|
+
|
|
143
|
+
expect(await existsLocal(world, "local-only.txt")).toBe(false)
|
|
144
|
+
expect(await existsLocal(world, "remote.txt")).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("localToCloud: a foreign remote edit to a synced file is REVERTED (F6)", async () => {
|
|
149
|
+
await withE2EWorld({ sdk, mode: "localToCloud" }, async world => {
|
|
150
|
+
await writeLocal(world, "a.txt", "local-content")
|
|
151
|
+
await settle(world)
|
|
152
|
+
|
|
153
|
+
await uploadRemote(world, "a.txt", "FOREIGN")
|
|
154
|
+
await settle(world)
|
|
155
|
+
|
|
156
|
+
expect((await snapshotRemoteReal(world, { withContent: true }))["/a.txt"]!.size).toBe("local-content".length)
|
|
157
|
+
expect(await readLocal(world, "a.txt")).toBe("local-content")
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("cloudToLocal: a foreign local edit to a synced file is REVERTED (F6)", async () => {
|
|
162
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal" }, async world => {
|
|
163
|
+
await uploadRemote(world, "a.txt", "remote-content")
|
|
164
|
+
await settle(world)
|
|
165
|
+
|
|
166
|
+
await modifyLocal(world, "a.txt", "LOCAL-EDIT")
|
|
167
|
+
await settle(world)
|
|
168
|
+
|
|
169
|
+
expect(await readLocal(world, "a.txt")).toBe("remote-content")
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ---- cross-side directory rename + a concurrent child change in a MIRROR mode (BUG-A parity) ------
|
|
174
|
+
// In twoWay this was the critical data-loss bug; in a mirror the authoritative side always wins, but
|
|
175
|
+
// the rename-aware rebase must still keep the dir rename and the child change correctly aligned and
|
|
176
|
+
// the worlds convergent. (Live counterparts of mocked ZB11/ZB12.)
|
|
177
|
+
|
|
178
|
+
it("localToCloud: a local dir rename + a foreign remote child edit → local content wins, converges", async () => {
|
|
179
|
+
await withE2EWorld({ sdk, mode: "localToCloud" }, async world => {
|
|
180
|
+
await writeLocal(world, "dir/child.txt", "old")
|
|
181
|
+
await writeLocal(world, "dir/sibling.txt", "sib")
|
|
182
|
+
await settle(world)
|
|
183
|
+
|
|
184
|
+
// Rename the directory locally while another device concurrently edits a child at the old path.
|
|
185
|
+
await renameLocal(world, "dir", "dir2")
|
|
186
|
+
await uploadRemote(world, "dir/child.txt", "FOREIGN-REMOTE-EDIT")
|
|
187
|
+
await settle(world)
|
|
188
|
+
|
|
189
|
+
const remote = await snapshotRemoteReal(world, { withContent: true })
|
|
190
|
+
|
|
191
|
+
// Local authoritative: the renamed dir wins and the foreign edit is reverted to local content.
|
|
192
|
+
expect(remote["/dir2/child.txt"]!.size).toBe("old".length)
|
|
193
|
+
expect(remote["/dir/child.txt"]).toBeUndefined()
|
|
194
|
+
await expectConverged(world)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("cloudToLocal: a remote dir rename + a foreign local child edit → remote content wins, converges", async () => {
|
|
199
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal" }, async world => {
|
|
200
|
+
await uploadRemote(world, "dir/child.txt", "old")
|
|
201
|
+
await uploadRemote(world, "dir/sibling.txt", "sib")
|
|
202
|
+
await settle(world)
|
|
203
|
+
|
|
204
|
+
// Another device renames the directory remotely while we edit a child locally at the old path.
|
|
205
|
+
await renameRemoteDir(world, "dir", "dir2")
|
|
206
|
+
await modifyLocal(world, "dir/child.txt", "FOREIGN-LOCAL-EDIT")
|
|
207
|
+
await settle(world)
|
|
208
|
+
|
|
209
|
+
// Remote authoritative: the renamed dir wins and the foreign local edit is reverted.
|
|
210
|
+
expect(await readLocal(world, "dir2/child.txt")).toBe("old")
|
|
211
|
+
expect(await existsLocal(world, "dir/child.txt")).toBe(false)
|
|
212
|
+
await expectConverged(world)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
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, allOps } from "./harness/drive"
|
|
6
|
+
import { snapshotLocalReal, snapshotRemoteReal } from "./harness/assert"
|
|
7
|
+
import { uploadRemote, writeLocal } from "./harness/mutations"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Phase 3 e2e — cross-platform path rules against the live backend, validated on the REAL OS of each
|
|
11
|
+
* matrix runner (ubuntu / macos / windows). A "foreign" client (a direct SDK upload) creates a file
|
|
12
|
+
* whose name or length is illegal on SOME platforms; syncing it down must behave per-OS:
|
|
13
|
+
*
|
|
14
|
+
* - colon `:` → illegal on win32 + darwin, legal on linux.
|
|
15
|
+
* - reserved `com1.txt` → illegal on win32 only, legal on darwin + linux.
|
|
16
|
+
* - very long path → illegal on win32 (>512), legal on darwin (<1024) + linux (<4096).
|
|
17
|
+
*
|
|
18
|
+
* On a platform where the name is illegal the engine SKIPS it (never downloads it, never crashes the
|
|
19
|
+
* cycle, never deletes it from the remote that legitimately holds it). On a platform where it is legal
|
|
20
|
+
* the file syncs down normally. This is the live counterpart to the mocked Category AC suite — same
|
|
21
|
+
* rules, asserted against the real filesystem + backend instead of stubbed `process.platform`.
|
|
22
|
+
*
|
|
23
|
+
* Unlike most e2e cases these do NOT call `expectConverged`: when a name is illegal locally the two
|
|
24
|
+
* sides are SUPPOSED to differ (remote keeps it, local can't hold it), so each side is asserted directly.
|
|
25
|
+
*/
|
|
26
|
+
describe.skipIf(!E2E_ENABLED)("E2E — cross-platform path rules", () => {
|
|
27
|
+
let sdk: FilenSDK
|
|
28
|
+
|
|
29
|
+
// A path long enough to exceed win32's 512-char limit on its own (so the tmp-dir prefix is
|
|
30
|
+
// irrelevant) while staying under darwin's 1024 limit once prefixed; every NAME is ≤ 255 (the
|
|
31
|
+
// uniform name limit) so only `pathLength`, not `nameLength`, is in play.
|
|
32
|
+
const longPath = `/${"d".repeat(250)}/${"s".repeat(250)}/${"f".repeat(240)}.txt`
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
sdk = await loginTestSDK()
|
|
36
|
+
}, 300_000)
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await teardownTestSDK()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("a colon name syncs down only on linux; elsewhere it is skipped but kept on the remote", async () => {
|
|
43
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
44
|
+
// Foreign client (e.g. a linux desktop) created these.
|
|
45
|
+
await uploadRemote(world, "report:v2.txt", "colon — legal on linux only")
|
|
46
|
+
await uploadRemote(world, "ok.txt", "legal everywhere")
|
|
47
|
+
await settle(world)
|
|
48
|
+
|
|
49
|
+
const [local, remote] = await Promise.all([snapshotLocalReal(world), snapshotRemoteReal(world)])
|
|
50
|
+
|
|
51
|
+
// The remote ALWAYS keeps both files (skipping is never deletion).
|
|
52
|
+
expect(remote["/report:v2.txt"]).toMatchObject({ type: "file" })
|
|
53
|
+
expect(remote["/ok.txt"]).toMatchObject({ type: "file" })
|
|
54
|
+
// The valid sibling always lands locally.
|
|
55
|
+
expect(local["/ok.txt"]).toMatchObject({ type: "file" })
|
|
56
|
+
|
|
57
|
+
// The colon file lands locally ONLY on linux (where a colon is a legal filename).
|
|
58
|
+
if (process.platform === "linux") {
|
|
59
|
+
expect(local["/report:v2.txt"]).toMatchObject({ type: "file" })
|
|
60
|
+
} else {
|
|
61
|
+
expect(local["/report:v2.txt"]).toBeUndefined()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Long-lived stability: with the world already settled, a fresh confirming cycle does zero work
|
|
65
|
+
// (no transfer, rename, delete, or mkdir) — the perpetually-skipped file never re-churns.
|
|
66
|
+
const confirming = await cycle(world)
|
|
67
|
+
|
|
68
|
+
expect(allOps(confirming)).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("a Windows reserved device name (com1.txt) syncs down everywhere except win32", async () => {
|
|
73
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
74
|
+
await uploadRemote(world, "com1.txt", "reserved on windows, ordinary elsewhere")
|
|
75
|
+
await uploadRemote(world, "normal.txt", "fine everywhere")
|
|
76
|
+
await settle(world)
|
|
77
|
+
|
|
78
|
+
const [local, remote] = await Promise.all([snapshotLocalReal(world), snapshotRemoteReal(world)])
|
|
79
|
+
|
|
80
|
+
expect(remote["/com1.txt"]).toMatchObject({ type: "file" })
|
|
81
|
+
expect(remote["/normal.txt"]).toMatchObject({ type: "file" })
|
|
82
|
+
expect(local["/normal.txt"]).toMatchObject({ type: "file" })
|
|
83
|
+
|
|
84
|
+
if (process.platform === "win32") {
|
|
85
|
+
expect(local["/com1.txt"]).toBeUndefined()
|
|
86
|
+
} else {
|
|
87
|
+
expect(local["/com1.txt"]).toMatchObject({ type: "file" })
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("a path over the win32 length limit syncs down on darwin/linux but is skipped on win32", async () => {
|
|
93
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
94
|
+
await uploadRemote(world, longPath, "long path — over 512 chars")
|
|
95
|
+
await uploadRemote(world, "short.txt", "fine everywhere")
|
|
96
|
+
await settle(world)
|
|
97
|
+
|
|
98
|
+
const [local, remote] = await Promise.all([snapshotLocalReal(world), snapshotRemoteReal(world)])
|
|
99
|
+
|
|
100
|
+
expect(remote[longPath]).toMatchObject({ type: "file" })
|
|
101
|
+
expect(local["/short.txt"]).toMatchObject({ type: "file" })
|
|
102
|
+
|
|
103
|
+
if (process.platform === "win32") {
|
|
104
|
+
expect(local[longPath]).toBeUndefined()
|
|
105
|
+
} else {
|
|
106
|
+
expect(local[longPath]).toMatchObject({ type: "file" })
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("a name over 255 BYTES but under 255 UTF-16 units syncs down everywhere except linux (byte NAME_MAX)", async () => {
|
|
112
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
113
|
+
// "あ" is 1 UTF-16 code unit but 3 UTF-8 bytes (and has no NFC/NFD ambiguity). 100 of them is
|
|
114
|
+
// 100 units — under the 255-unit macOS/NTFS NAME limit — yet 300 bytes, over linux's 255-BYTE
|
|
115
|
+
// NAME_MAX. A foreign macOS/Windows peer legitimately created it.
|
|
116
|
+
const multibyteName = `${"あ".repeat(100)}.txt`
|
|
117
|
+
|
|
118
|
+
await uploadRemote(world, multibyteName, "three hundred bytes of name")
|
|
119
|
+
await uploadRemote(world, "plain.txt", "fine everywhere")
|
|
120
|
+
await settle(world)
|
|
121
|
+
|
|
122
|
+
const [local, remote] = await Promise.all([snapshotLocalReal(world), snapshotRemoteReal(world)])
|
|
123
|
+
|
|
124
|
+
// The remote always keeps it; the valid sibling always lands locally.
|
|
125
|
+
expect(remote[`/${multibyteName}`]).toMatchObject({ type: "file" })
|
|
126
|
+
expect(local["/plain.txt"]).toMatchObject({ type: "file" })
|
|
127
|
+
|
|
128
|
+
// Lands locally on darwin + win32 (they count UTF-16 units: 100 < 255). Skipped on linux, whose
|
|
129
|
+
// NAME_MAX is 255 BYTES (300 > 255) — a graceful skip, NOT a per-cycle ENAMETOOLONG retry loop.
|
|
130
|
+
if (process.platform === "linux") {
|
|
131
|
+
expect(local[`/${multibyteName}`]).toBeUndefined()
|
|
132
|
+
} else {
|
|
133
|
+
expect(local[`/${multibyteName}`]).toMatchObject({ type: "file" })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// With the world settled, a confirming cycle does zero work — on linux the perpetually
|
|
137
|
+
// byte-over-long name never re-churns.
|
|
138
|
+
const confirming = await cycle(world)
|
|
139
|
+
|
|
140
|
+
expect(allOps(confirming)).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("a LOCAL file legal here but illegal elsewhere uploads normally (the foreign client will skip it)", async () => {
|
|
145
|
+
// The mirror direction: whatever this OS lets us create locally, the engine uploads. A peer on a
|
|
146
|
+
// stricter OS is the one that will skip it on the way down — proven by the inbound cases above.
|
|
147
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
148
|
+
// A name every platform can create locally, to keep this test host-agnostic on the upload side.
|
|
149
|
+
await writeLocal(world, "everywhere-legal.txt", "content")
|
|
150
|
+
await settle(world)
|
|
151
|
+
|
|
152
|
+
const remote = await snapshotRemoteReal(world)
|
|
153
|
+
|
|
154
|
+
expect(remote["/everywhere-legal.txt"]).toMatchObject({ type: "file" })
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
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, allOps } from "./harness/drive"
|
|
6
|
+
import { writeLocal, rmLocal, uploadRemote, deleteRemote } from "./harness/mutations"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Phase 3 e2e — property/fuzz convergence against the live backend, the live counterpart of mocked
|
|
10
|
+
* Category L (twoWay meta-invariants) AND Category AB (directional mirror invariants). A seeded PRNG
|
|
11
|
+
* builds a random add/modify/delete history over several rounds (settling between rounds), then asserts
|
|
12
|
+
* on the REAL backend: convergence (both sides end byte-for-byte identical, content hashes included) and
|
|
13
|
+
* idempotence (a settled cycle does no transfers). Seeded, so any failure reproduces deterministically.
|
|
14
|
+
*
|
|
15
|
+
* For the directional modes the convergence assertion IS the mirror invariant: in localToCloud the remote
|
|
16
|
+
* must end identical to the (authoritative) local side, in cloudToLocal the local must mirror the remote.
|
|
17
|
+
* So those runs only mutate the AUTHORITATIVE side — the pure "a random source history is always mirrored"
|
|
18
|
+
* property (foreign-edit reversion is covered by modes.e2e's fixed scenarios). twoWay mutates both sides.
|
|
19
|
+
*
|
|
20
|
+
* "Well-behaved" like the mocked property suites, and ENFORCED (not just asserted): each path is touched
|
|
21
|
+
* at most once per round (no same-cycle same-path ambiguity), rounds are separated by a real settle, and —
|
|
22
|
+
* crucially — every write carries a UNIQUE SIZE (a strictly-growing payload, see `nextContent`). The engine
|
|
23
|
+
* detects changes by (whole-second mtime, size), never by re-hashing content every cycle (that per-cycle
|
|
24
|
+
* cost is the exact thing the perf/memory budget forbids), so two same-size edits that land in the same
|
|
25
|
+
* whole second are — by design — indistinguishable to it (the documented §C11 blind spot; an accepted
|
|
26
|
+
* tradeoff, not a bug, avoided by construction in the mocked suites too). Unique sizes keep the fuzz off
|
|
27
|
+
* that blind spot: the size delta always reveals the edit, so a divergence here is a REAL convergence bug.
|
|
28
|
+
* Renames are covered exhaustively by conflict.e2e/edge.e2e, so the fuzz stays on add/modify/delete.
|
|
29
|
+
*/
|
|
30
|
+
function mulberry32(seed: number): () => number {
|
|
31
|
+
let state = seed >>> 0
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
state = (state + 0x6d2b79f5) >>> 0
|
|
35
|
+
|
|
36
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state)
|
|
37
|
+
|
|
38
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
39
|
+
|
|
40
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FILE_POOL = ["p0.txt", "p1.txt", "p2.txt", "p3.txt", "p4.txt", "p5.txt"]
|
|
45
|
+
const ROUNDS = 6
|
|
46
|
+
const MAX_PER_ROUND = 3
|
|
47
|
+
|
|
48
|
+
type FuzzMode = "twoWay" | "localToCloud" | "cloudToLocal"
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Drive a seeded random history in `mode`, then assert convergence + idempotence. `mutate` selects which
|
|
52
|
+
* side a given mutation targets: "both" (random) for twoWay, or the fixed authoritative side for a mirror
|
|
53
|
+
* mode. The seed file is created on whichever side we mutate, so it is never reverted before the run starts.
|
|
54
|
+
*/
|
|
55
|
+
async function runFuzz(sdk: FilenSDK, mode: FuzzMode, seed: number, mutate: "both" | "local" | "remote"): Promise<void> {
|
|
56
|
+
await withE2EWorld({ sdk, mode }, async world => {
|
|
57
|
+
const random = mulberry32(seed)
|
|
58
|
+
const pick = <T>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
|
|
59
|
+
const exists = new Set<string>()
|
|
60
|
+
let mutationCount = 0
|
|
61
|
+
|
|
62
|
+
// Every write gets a strictly-growing payload, so no two writes ever share a size — the engine's
|
|
63
|
+
// (whole-second mtime, size) detector sees every edit even when a fast settle ties the mtimes,
|
|
64
|
+
// steering clear of the documented same-size/same-second blind spot (§C11) so divergence means a bug.
|
|
65
|
+
let writeSeq = 0
|
|
66
|
+
const nextContent = (): string => `s${seed}-w${writeSeq}-${"x".repeat(writeSeq++)}`
|
|
67
|
+
const pickSide = (): "local" | "remote" => (mutate === "both" ? (random() < 0.5 ? "local" : "remote") : mutate)
|
|
68
|
+
|
|
69
|
+
// Seed a file on the authoritative side so early deletes/modifies have something to act on.
|
|
70
|
+
if (mutate === "remote") {
|
|
71
|
+
await uploadRemote(world, "p0.txt", nextContent())
|
|
72
|
+
} else {
|
|
73
|
+
await writeLocal(world, "p0.txt", nextContent())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
exists.add("p0.txt")
|
|
77
|
+
await settle(world)
|
|
78
|
+
|
|
79
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
80
|
+
const touched = new Set<string>()
|
|
81
|
+
const mutations = 1 + Math.floor(random() * MAX_PER_ROUND)
|
|
82
|
+
|
|
83
|
+
for (let m = 0; m < mutations; m++) {
|
|
84
|
+
const path = pick(FILE_POOL)
|
|
85
|
+
|
|
86
|
+
if (touched.has(path)) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
touched.add(path)
|
|
91
|
+
|
|
92
|
+
const side = pickSide()
|
|
93
|
+
const doDelete = exists.has(path) && random() < 0.35
|
|
94
|
+
|
|
95
|
+
if (doDelete) {
|
|
96
|
+
exists.delete(path)
|
|
97
|
+
|
|
98
|
+
if (side === "local") {
|
|
99
|
+
await rmLocal(world, path)
|
|
100
|
+
} else {
|
|
101
|
+
await deleteRemote(world, path)
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
exists.add(path)
|
|
105
|
+
|
|
106
|
+
const content = nextContent()
|
|
107
|
+
|
|
108
|
+
if (side === "local") {
|
|
109
|
+
await writeLocal(world, path, content)
|
|
110
|
+
} else {
|
|
111
|
+
await uploadRemote(world, path, content)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
mutationCount++
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Settle between rounds so each round starts converged and the next round's writes carry a
|
|
119
|
+
// strictly-newer real mtime than anything before them.
|
|
120
|
+
await settle(world)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Convergence (§2.3) — for a mirror mode this IS the mirror invariant (target == authoritative side).
|
|
124
|
+
await settle(world)
|
|
125
|
+
await expectConverged(world)
|
|
126
|
+
|
|
127
|
+
// Idempotence (§2.2): a further settled cycle performs no operations at all — not just no transfers,
|
|
128
|
+
// but no spurious rename/delete/mkdir either (allOps, so an idempotence violation can't hide).
|
|
129
|
+
const messages = await cycle(world)
|
|
130
|
+
|
|
131
|
+
expect(allOps(messages), `seed=${seed} mode=${mode} was not idempotent`).toEqual([])
|
|
132
|
+
// Sanity: the history actually exercised the engine.
|
|
133
|
+
expect(mutationCount).toBeGreaterThan(0)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe.skipIf(!E2E_ENABLED)("E2E — property/fuzz (live convergence)", () => {
|
|
138
|
+
let sdk: FilenSDK
|
|
139
|
+
|
|
140
|
+
beforeAll(async () => {
|
|
141
|
+
sdk = await loginTestSDK()
|
|
142
|
+
}, 300_000)
|
|
143
|
+
|
|
144
|
+
afterAll(async () => {
|
|
145
|
+
await teardownTestSDK()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Category L — twoWay, both sides mutated.
|
|
149
|
+
for (const seed of [0xc0ffee, 0x5eed01, 0x1337beef]) {
|
|
150
|
+
it(`twoWay seed=${seed}: a random two-sided history converges and is idempotent`, async () => {
|
|
151
|
+
await runFuzz(sdk, "twoWay", seed, "both")
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Category AB — directional mirror: a random authoritative-side history is always mirrored to the target.
|
|
156
|
+
it("localToCloud: a random local history is always mirrored to the remote (idempotent)", async () => {
|
|
157
|
+
await runFuzz(sdk, "localToCloud", 0xfeed42, "local")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("cloudToLocal: a random remote history is always mirrored to the local (idempotent)", async () => {
|
|
161
|
+
await runFuzz(sdk, "cloudToLocal", 0x0b00b1, "remote")
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import type FilenSDK from "@filen/sdk"
|
|
3
|
+
import { v4 as uuidv4 } from "uuid"
|
|
4
|
+
import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
|
|
5
|
+
import { withE2EWorld } from "./harness/world"
|
|
6
|
+
import { settle, expectConverged, messagesOfType } from "./harness/drive"
|
|
7
|
+
import { snapshotRemoteReal } from "./harness/assert"
|
|
8
|
+
import { writeLocal, deleteRemote } from "./harness/mutations"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Phase 3 e2e — concurrency / multi-device races against the live backend, the live counterpart of mocked
|
|
12
|
+
* Category AA. Unlike the fake cloud (whose contended lock makes the cycle error out), the real engine
|
|
13
|
+
* acquires the sync resource lock with `maxTries: Infinity`, so a device that already holds the lock makes
|
|
14
|
+
* the cycle WAIT — the important production property: two devices syncing the same folder never corrupt
|
|
15
|
+
* each other, the loser simply blocks until the lock frees. We hold the real lock from a second SDK
|
|
16
|
+
* "device" to prove this, then prove a concurrent delete+recreate still converges with no task errors.
|
|
17
|
+
*/
|
|
18
|
+
describe.skipIf(!E2E_ENABLED)("E2E — races & multi-device contention", () => {
|
|
19
|
+
let sdk: FilenSDK
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
sdk = await loginTestSDK()
|
|
23
|
+
}, 300_000)
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await teardownTestSDK()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("a cycle waits for a contending device's lock, does no work, then converges once released (AA1/AA2)", async () => {
|
|
30
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
31
|
+
await writeLocal(world, "a.txt", "data")
|
|
32
|
+
|
|
33
|
+
// The exact resource the engine locks (lib/sync.ts) — a second device grabs it first.
|
|
34
|
+
const resource = `sync-remoteParentUUID-${world.remoteParentUUID}`
|
|
35
|
+
const otherDevice = uuidv4()
|
|
36
|
+
|
|
37
|
+
await sdk.user().acquireResourceLock({ resource, lockUUID: otherDevice, maxTries: 1, tryTimeout: 1000 })
|
|
38
|
+
|
|
39
|
+
world.worker.resetCache(world.syncPair.uuid)
|
|
40
|
+
|
|
41
|
+
let cycleDone = false
|
|
42
|
+
const cyclePromise = world.sync.runCycle().finally(() => {
|
|
43
|
+
cycleDone = true
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// While the other device holds the lock the engine keeps retrying acquisition and does NO sync
|
|
48
|
+
// work — nothing is uploaded, no state is touched.
|
|
49
|
+
await new Promise<void>(resolve => setTimeout(resolve, 5000))
|
|
50
|
+
|
|
51
|
+
expect(cycleDone, "cycle should still be blocked on the contended lock").toBe(false)
|
|
52
|
+
expect((await snapshotRemoteReal(world))["/a.txt"], "no work performed while contended").toBeUndefined()
|
|
53
|
+
// The engine signalled it is waiting for the lock (posted after 3s of waiting).
|
|
54
|
+
expect(messagesOfType(world.messages, "cycleAcquiringLockStarted").length).toBeGreaterThan(0)
|
|
55
|
+
} finally {
|
|
56
|
+
// Release so the engine's pending acquire succeeds on its next try, and drain the now-unblocked
|
|
57
|
+
// cycle before leaving (no orphaned async work during teardown) regardless of how we exit.
|
|
58
|
+
await sdk.user().releaseResourceLock({ resource, lockUUID: otherDevice }).catch(() => {})
|
|
59
|
+
await cyclePromise.catch(() => {})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Once released the cycle proceeds; the world converges with the file uploaded — no corruption.
|
|
63
|
+
await settle(world)
|
|
64
|
+
await expectConverged(world)
|
|
65
|
+
|
|
66
|
+
expect((await snapshotRemoteReal(world))["/a.txt"]).toMatchObject({ type: "file" })
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("a file deleted on one side and re-created on the other converges with no task errors (AA8)", async () => {
|
|
71
|
+
await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
|
|
72
|
+
await writeLocal(world, "a.txt", "v1")
|
|
73
|
+
await settle(world)
|
|
74
|
+
await expectConverged(world)
|
|
75
|
+
|
|
76
|
+
// The remote trashes a.txt while local re-creates it with new content before the next cycle. The
|
|
77
|
+
// engine resolves the delete-vs-recreate without erroring and settles on the surviving content.
|
|
78
|
+
await deleteRemote(world, "a.txt")
|
|
79
|
+
await writeLocal(world, "a.txt", "v2-local-recreated-longer")
|
|
80
|
+
|
|
81
|
+
await settle(world)
|
|
82
|
+
await expectConverged(world)
|
|
83
|
+
|
|
84
|
+
const anyTaskErrored = messagesOfType(world.messages, "taskErrors").some(message => message.data.errors.length > 0)
|
|
85
|
+
|
|
86
|
+
expect(anyTaskErrored, "no task should surface an error across the reconciliation").toBe(false)
|
|
87
|
+
expect((await snapshotRemoteReal(world))["/a.txt"]).toMatchObject({ type: "file" })
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
})
|