@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,497 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate, control } from "../harness/runner"
|
|
3
|
+
import { messagesOfType } from "../harness/snapshot"
|
|
4
|
+
import { mkdirLocal, rmLocal, renameLocal, writeLocalAt } from "../harness/mutations"
|
|
5
|
+
import { BASE_TIME } from "../harness/world"
|
|
6
|
+
import { makeErrnoError } from "../fakes/virtual-fs"
|
|
7
|
+
import { type SyncMessage, type TransferData } from "../../src/types"
|
|
8
|
+
|
|
9
|
+
const SECOND = 1000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Category O — per-task-type error paths (resilience / §H). This extends Category H to every task
|
|
13
|
+
* type whose `catch` branch H did not exercise. The guarantee under test for each task type: when its
|
|
14
|
+
* I/O fails with the target still present, the engine emits a `transfer`/`type:"error"` message of that
|
|
15
|
+
* task's `of`, records a task error (which gates the cycle and prevents state persistence), and a retry
|
|
16
|
+
* after the fault clears converges. The mirror guarantee: when the target has already vanished, the
|
|
17
|
+
* re-check skips the task silently with no error.
|
|
18
|
+
*
|
|
19
|
+
* H already covers `uploadFile` (surface) and `deleteRemoteFile` (surface + not_found swallow); those
|
|
20
|
+
* are not duplicated here.
|
|
21
|
+
*
|
|
22
|
+
* Deferred (noted, not faked): the REMOTE re-check SWALLOW branches — a deleteRemote/renameRemote/
|
|
23
|
+
* downloadFile task whose target vanishes strictly BETWEEN delta-computation and the task's
|
|
24
|
+
* existence re-check returning false. The fake cloud refreshes the engine's tree cache on every
|
|
25
|
+
* revision bump, so a genuinely-gone target leaves the cache too (→ the op early-returns inside
|
|
26
|
+
* RemoteFileSystem.unlink before the task catch, rather than throwing into it); holding the cache
|
|
27
|
+
* stale enough to reach the catch's `!fileExists`/`!directoryExists` branch would require forcing the
|
|
28
|
+
* existence probe to lie, i.e. faking the vanish. Those are true mid-I/O races, deferred to the live
|
|
29
|
+
* e2e suite (same framing as H). The LOCAL re-check swallows ARE genuine and covered here (O9, O10).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Total number of individual task errors reported across the message stream. */
|
|
33
|
+
function taskErrorCount(messages: SyncMessage[]): number {
|
|
34
|
+
return messagesOfType(messages, "taskErrors").reduce((sum, message) => sum + message.data.errors.length, 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether a `transfer` message with the given `of` discriminator and `type` exists in the stream. */
|
|
38
|
+
function hasTransfer(messages: SyncMessage[], of: TransferData["of"], type: "error" | "success"): boolean {
|
|
39
|
+
return messagesOfType(messages, "transfer").some(message => message.data.of === of && message.data.type === type)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("Category O — per-task-type error paths", () => {
|
|
43
|
+
// REMOTE error-surface: inject the mutation method; the node stays present, so the catch's
|
|
44
|
+
// existence re-check returns true and the error surfaces as a task error.
|
|
45
|
+
|
|
46
|
+
it("O1: a remote mkdir failure surfaces a createRemoteDirectory error, and a retry converges", async () => {
|
|
47
|
+
const result = await runScenario({
|
|
48
|
+
name: "O1",
|
|
49
|
+
mode: "twoWay",
|
|
50
|
+
steps: [
|
|
51
|
+
runCycle(),
|
|
52
|
+
localMutate(world => mkdirLocal(world, "newdir")),
|
|
53
|
+
control(world => world.cloud.controls.setError("createDirectory", new Error("mkdir boom"))),
|
|
54
|
+
runCycle(),
|
|
55
|
+
control(world => {
|
|
56
|
+
world.cloud.controls.clearError("createDirectory")
|
|
57
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
58
|
+
world.triggerWatcher()
|
|
59
|
+
}),
|
|
60
|
+
runCycle(),
|
|
61
|
+
runCycle()
|
|
62
|
+
]
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const failCycle = result.cycles[1]!
|
|
66
|
+
|
|
67
|
+
// createRemoteDirectory has no existence re-check — any error surfaces directly.
|
|
68
|
+
expect(hasTransfer(failCycle.messages, "createRemoteDirectory", "error")).toBe(true)
|
|
69
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
70
|
+
// After recovery the directory exists remotely and the worlds converge.
|
|
71
|
+
expect(result.finalRemote["/newdir"]).toMatchObject({ type: "directory" })
|
|
72
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("O2: a remote dir-trash failure surfaces a deleteRemoteDirectory error, and a retry converges", async () => {
|
|
76
|
+
// An EMPTY directory: deleting a non-empty one would also generate a child deleteRemoteFile,
|
|
77
|
+
// muddying the per-task assertion. The empty dir yields exactly one deleteRemoteDirectory task.
|
|
78
|
+
const result = await runScenario({
|
|
79
|
+
name: "O2",
|
|
80
|
+
mode: "twoWay",
|
|
81
|
+
initialLocal: { "/local/d": null },
|
|
82
|
+
steps: [
|
|
83
|
+
runCycle(),
|
|
84
|
+
localMutate(world => rmLocal(world, "d")),
|
|
85
|
+
control(world => world.cloud.controls.setError("trashDirectory", new Error("trash dir boom"))),
|
|
86
|
+
runCycle(),
|
|
87
|
+
control(world => {
|
|
88
|
+
world.cloud.controls.clearError("trashDirectory")
|
|
89
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
90
|
+
world.triggerWatcher()
|
|
91
|
+
}),
|
|
92
|
+
runCycle(),
|
|
93
|
+
runCycle()
|
|
94
|
+
]
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const failCycle = result.cycles[1]!
|
|
98
|
+
|
|
99
|
+
// trashDirectory threw a non-not_found error; the dir is still present, so directoryExists() is
|
|
100
|
+
// true and the error surfaces (a not_found would have been swallowed inside RemoteFileSystem.unlink).
|
|
101
|
+
expect(hasTransfer(failCycle.messages, "deleteRemoteDirectory", "error")).toBe(true)
|
|
102
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
103
|
+
expect(result.finalRemote["/d"]).toBeUndefined()
|
|
104
|
+
expect(result.finalLocal["/d"]).toBeUndefined()
|
|
105
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("O3: a remote renameFile failure surfaces a renameRemoteFile error, and a retry converges", async () => {
|
|
109
|
+
// The file lives in a subdirectory: the catch's fileExists(from) re-check resolves the parent from
|
|
110
|
+
// the tree cache, and the cache has no "/" entry — so a root-level file would re-check false and be
|
|
111
|
+
// swallowed rather than surfaced (matching H3/H4's subdirectory choice).
|
|
112
|
+
const result = await runScenario({
|
|
113
|
+
name: "O3",
|
|
114
|
+
mode: "twoWay",
|
|
115
|
+
initialLocal: { "/local/sub/a.txt": "x" },
|
|
116
|
+
steps: [
|
|
117
|
+
runCycle(),
|
|
118
|
+
localMutate(world => renameLocal(world, "sub/a.txt", "sub/b.txt")),
|
|
119
|
+
control(world => world.cloud.controls.setError("renameFile", new Error("rename file boom"))),
|
|
120
|
+
runCycle(),
|
|
121
|
+
control(world => {
|
|
122
|
+
world.cloud.controls.clearError("renameFile")
|
|
123
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
124
|
+
world.triggerWatcher()
|
|
125
|
+
}),
|
|
126
|
+
runCycle(),
|
|
127
|
+
runCycle()
|
|
128
|
+
]
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const failCycle = result.cycles[1]!
|
|
132
|
+
|
|
133
|
+
// The remote source (sub/a.txt) is still present, so fileExists(from) is true and the error surfaces.
|
|
134
|
+
expect(hasTransfer(failCycle.messages, "renameRemoteFile", "error")).toBe(true)
|
|
135
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
136
|
+
expect(result.finalRemote["/sub/b.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
137
|
+
expect(result.finalRemote["/sub/a.txt"]).toBeUndefined()
|
|
138
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("O4: a remote renameDirectory failure surfaces a renameRemoteDirectory error, and a retry converges", async () => {
|
|
142
|
+
const result = await runScenario({
|
|
143
|
+
name: "O4",
|
|
144
|
+
mode: "twoWay",
|
|
145
|
+
initialLocal: { "/local/d": null },
|
|
146
|
+
steps: [
|
|
147
|
+
runCycle(),
|
|
148
|
+
localMutate(world => renameLocal(world, "d", "e")),
|
|
149
|
+
control(world => world.cloud.controls.setError("renameDirectory", new Error("rename dir boom"))),
|
|
150
|
+
runCycle(),
|
|
151
|
+
control(world => {
|
|
152
|
+
world.cloud.controls.clearError("renameDirectory")
|
|
153
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
154
|
+
world.triggerWatcher()
|
|
155
|
+
}),
|
|
156
|
+
runCycle(),
|
|
157
|
+
runCycle()
|
|
158
|
+
]
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const failCycle = result.cycles[1]!
|
|
162
|
+
|
|
163
|
+
// The remote source (d) is still present, so directoryExists(from) is true and the error surfaces.
|
|
164
|
+
expect(hasTransfer(failCycle.messages, "renameRemoteDirectory", "error")).toBe(true)
|
|
165
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
166
|
+
expect(result.finalRemote["/e"]).toMatchObject({ type: "directory" })
|
|
167
|
+
expect(result.finalRemote["/d"]).toBeUndefined()
|
|
168
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("O5: a download failure surfaces both a download and a downloadFile error, and a retry converges", async () => {
|
|
172
|
+
// Subdirectory again, so the downloadFile catch's fileExists(path) re-check can resolve the parent.
|
|
173
|
+
const result = await runScenario({
|
|
174
|
+
name: "O5",
|
|
175
|
+
mode: "cloudToLocal",
|
|
176
|
+
initialRemote: { "/sub/a.txt": "x" },
|
|
177
|
+
steps: [
|
|
178
|
+
// Fail the very first cycle, while sub/a.txt must download.
|
|
179
|
+
control(world => world.cloud.controls.setError("downloadFileToLocal", new Error("dl boom"))),
|
|
180
|
+
runCycle(),
|
|
181
|
+
control(world => {
|
|
182
|
+
world.cloud.controls.clearError("downloadFileToLocal")
|
|
183
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
184
|
+
world.triggerWatcher()
|
|
185
|
+
}),
|
|
186
|
+
runCycle(),
|
|
187
|
+
runCycle()
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const failCycle = result.cycles[0]!
|
|
192
|
+
|
|
193
|
+
// RemoteFileSystem.download posts the `download` error before rethrowing; the downloadFile task then
|
|
194
|
+
// re-checks fileExists (still present → true) and posts the `downloadFile` error + records a task error.
|
|
195
|
+
expect(hasTransfer(failCycle.messages, "download", "error")).toBe(true)
|
|
196
|
+
expect(hasTransfer(failCycle.messages, "downloadFile", "error")).toBe(true)
|
|
197
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
198
|
+
expect(result.finalLocal["/sub/a.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
199
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("O5b: an aborted download that RESOLVES with a short file is discarded by the integrity guard, then a retry converges", async () => {
|
|
203
|
+
// Unlike O5 (where the SDK throws), the real SDK RESOLVES an aborted download — the read stream ends
|
|
204
|
+
// cleanly, so the pipeline reports no error even though the staged file is incomplete. The engine must
|
|
205
|
+
// NOT commit that short/0-byte file as synced (which would leave local & remote permanently diverged,
|
|
206
|
+
// since the cached size then matches the base). The remote.ts size guard discards it and the next cycle
|
|
207
|
+
// re-downloads in full. The fake's `simulateIncompleteDownload` reproduces the SDK's resolve-with-short
|
|
208
|
+
// behavior, which `setError` (a throw) does not — this pins the guard the live suite first surfaced.
|
|
209
|
+
const result = await runScenario({
|
|
210
|
+
name: "O5b",
|
|
211
|
+
mode: "cloudToLocal",
|
|
212
|
+
initialRemote: { "/r.txt": "remote-content" },
|
|
213
|
+
steps: [
|
|
214
|
+
control(world => world.cloud.controls.simulateIncompleteDownload("/r.txt")),
|
|
215
|
+
runCycle(),
|
|
216
|
+
control(world => {
|
|
217
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
218
|
+
world.triggerWatcher()
|
|
219
|
+
}),
|
|
220
|
+
runCycle(),
|
|
221
|
+
runCycle()
|
|
222
|
+
]
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const failCycle = result.cycles[0]!
|
|
226
|
+
|
|
227
|
+
// The incomplete download was detected and surfaced as a download error — not silently committed.
|
|
228
|
+
expect(hasTransfer(failCycle.messages, "download", "error")).toBe(true)
|
|
229
|
+
// The regression guard: WITHOUT the size check the 0-byte file is committed + cached as synced, so the
|
|
230
|
+
// base matches and it never re-downloads (finalLocal stays size 0, diverged). WITH the guard the short
|
|
231
|
+
// file is discarded and the engine re-fetches the full 14 bytes, converging.
|
|
232
|
+
expect(result.finalLocal["/r.txt"]).toMatchObject({ type: "file", size: 14 })
|
|
233
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// LOCAL error-surface: inject on a sub-path the op touches but the existence re-check does NOT.
|
|
237
|
+
|
|
238
|
+
it("O6: a local mkdir failure surfaces a createLocalDirectory error, and a retry converges", async () => {
|
|
239
|
+
const result = await runScenario({
|
|
240
|
+
name: "O6",
|
|
241
|
+
mode: "cloudToLocal",
|
|
242
|
+
initialRemote: { "/sub/keep.txt": "x" },
|
|
243
|
+
steps: [
|
|
244
|
+
// createLocalDirectory("/sub") does ensureDir("/local/sub"); inject EACCES there. This case has
|
|
245
|
+
// no existence re-check, so the error surfaces directly. (/local/sub is not in the local tree
|
|
246
|
+
// scan yet, so the injection only affects the mkdir.)
|
|
247
|
+
control(world => world.vfs.controls.setError("/local/sub", makeErrnoError("EACCES"))),
|
|
248
|
+
runCycle(),
|
|
249
|
+
control(world => {
|
|
250
|
+
world.vfs.controls.clearError("/local/sub")
|
|
251
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
252
|
+
world.triggerWatcher()
|
|
253
|
+
}),
|
|
254
|
+
runCycle(),
|
|
255
|
+
runCycle()
|
|
256
|
+
]
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const failCycle = result.cycles[0]!
|
|
260
|
+
|
|
261
|
+
expect(hasTransfer(failCycle.messages, "createLocalDirectory", "error")).toBe(true)
|
|
262
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
263
|
+
expect(result.finalLocal["/sub"]).toMatchObject({ type: "directory" })
|
|
264
|
+
expect(result.finalLocal["/sub/keep.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
265
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it("O7: a local delete I/O failure surfaces a deleteLocalFile error (not silently swallowed)", async () => {
|
|
269
|
+
// The deleteLocal catch re-checks `localFileSystem.pathExists(join(syncPair.localPath, delta.path))`,
|
|
270
|
+
// so when the unlink fails while the file is still present the re-check returns true and the error
|
|
271
|
+
// surfaces — a failed delete is no longer swallowed and mis-recorded as success. (BUG-007 fix: the
|
|
272
|
+
// re-check previously used the RELATIVE path, which never existed, so every failure was swallowed.)
|
|
273
|
+
const result = await runScenario({
|
|
274
|
+
name: "O7",
|
|
275
|
+
mode: "cloudToLocal",
|
|
276
|
+
initialRemote: { "/a.txt": "x" },
|
|
277
|
+
steps: [
|
|
278
|
+
runCycle(),
|
|
279
|
+
remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
|
|
280
|
+
// ensureDir(<localRoot>/.filen.trash.local) throws while /local/a.txt still exists → the unlink
|
|
281
|
+
// genuinely fails with the target present, which SHOULD surface a deleteLocalFile error.
|
|
282
|
+
control(world => world.vfs.controls.setError("/local/.filen.trash.local", makeErrnoError("EACCES"))),
|
|
283
|
+
runCycle()
|
|
284
|
+
]
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const failCycle = result.cycles[1]!
|
|
288
|
+
|
|
289
|
+
expect(hasTransfer(failCycle.messages, "deleteLocalFile", "error")).toBe(true)
|
|
290
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it("O8: a local rename failure surfaces a renameLocalFile error, and a retry converges", async () => {
|
|
294
|
+
const result = await runScenario({
|
|
295
|
+
name: "O8",
|
|
296
|
+
mode: "cloudToLocal",
|
|
297
|
+
initialRemote: { "/a.txt": "x" },
|
|
298
|
+
steps: [
|
|
299
|
+
runCycle(),
|
|
300
|
+
// Remote-move a.txt into a NEW subdir: the local rename's ensureDir(<dest parent>) is then
|
|
301
|
+
// "/local/sub" — a path the local tree scan never touches and which leaves the source
|
|
302
|
+
// "/local/a.txt" untouched. So ensureDir throws, the re-check pathExists(from) stays true, and
|
|
303
|
+
// the renameLocalFile error surfaces (rather than being swallowed as a vanished source).
|
|
304
|
+
remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/sub/b.txt")),
|
|
305
|
+
control(world => world.vfs.controls.setError("/local/sub", makeErrnoError("EACCES"))),
|
|
306
|
+
runCycle(),
|
|
307
|
+
control(world => {
|
|
308
|
+
world.vfs.controls.clearError("/local/sub")
|
|
309
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
310
|
+
world.triggerWatcher()
|
|
311
|
+
}),
|
|
312
|
+
runCycle(),
|
|
313
|
+
runCycle()
|
|
314
|
+
]
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const failCycle = result.cycles[1]!
|
|
318
|
+
|
|
319
|
+
expect(hasTransfer(failCycle.messages, "renameLocalFile", "error")).toBe(true)
|
|
320
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
321
|
+
expect(result.finalLocal["/sub/b.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
322
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
323
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// "Already-vanished" SWALLOW: the target is gone before the task runs → silent skip, no task error.
|
|
327
|
+
|
|
328
|
+
it("O9: a concurrent local+remote delete converges with no task error (deleteLocalFile vanish)", async () => {
|
|
329
|
+
const result = await runScenario({
|
|
330
|
+
name: "O9",
|
|
331
|
+
mode: "cloudToLocal",
|
|
332
|
+
initialRemote: { "/a.txt": "x" },
|
|
333
|
+
steps: [
|
|
334
|
+
runCycle(),
|
|
335
|
+
// The remote copy is trashed and the local copy is removed in the same beat: the fresh local
|
|
336
|
+
// scan no longer sees a.txt, so no deleteLocalFile is generated — a silent, error-free no-op.
|
|
337
|
+
remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
|
|
338
|
+
localMutate(world => rmLocal(world, "a.txt")),
|
|
339
|
+
runCycle(),
|
|
340
|
+
runCycle()
|
|
341
|
+
]
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
345
|
+
expect(hasTransfer(result.messages, "deleteLocalFile", "error")).toBe(false)
|
|
346
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
347
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
348
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("O10: cloudToLocal — a remote rename racing a local delete of the source converges (no doomed rename)", async () => {
|
|
352
|
+
const result = await runScenario({
|
|
353
|
+
name: "O10",
|
|
354
|
+
mode: "cloudToLocal",
|
|
355
|
+
initialRemote: { "/a.txt": "x" },
|
|
356
|
+
steps: [
|
|
357
|
+
runCycle(),
|
|
358
|
+
// The remote renames a.txt→b.txt while the local source a is removed in the same beat. In
|
|
359
|
+
// cloudToLocal the remote is authoritative: renaming a local source that the local side just
|
|
360
|
+
// changed (here: deleted) would target a stale/absent node, so the engine correctly does NOT
|
|
361
|
+
// emit the rename — it downloads the remote's b instead, so the worlds still converge. (F2)
|
|
362
|
+
remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/b.txt")),
|
|
363
|
+
localMutate(world => rmLocal(world, "a.txt")),
|
|
364
|
+
runCycle(),
|
|
365
|
+
runCycle()
|
|
366
|
+
]
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// No task error and no errored rename transfer; the remote's b is mirrored down and both sides agree.
|
|
370
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
371
|
+
expect(hasTransfer(result.messages, "renameLocalFile", "error")).toBe(false)
|
|
372
|
+
expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
373
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// Directory variants of the shared deleteLocal / renameLocal cases (the file variants are O7/O8/O10):
|
|
377
|
+
// these exercise the directory switch-cases and their success → state-applied paths.
|
|
378
|
+
|
|
379
|
+
it("O11: a remote directory deletion propagates to a local deleteLocalDirectory and converges", async () => {
|
|
380
|
+
const result = await runScenario({
|
|
381
|
+
name: "O11",
|
|
382
|
+
mode: "cloudToLocal",
|
|
383
|
+
initialRemote: { "/d": null },
|
|
384
|
+
steps: [
|
|
385
|
+
runCycle(),
|
|
386
|
+
remoteMutate(world => world.cloud.controls.trashPath("/d")),
|
|
387
|
+
runCycle(),
|
|
388
|
+
runCycle()
|
|
389
|
+
]
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
expect(hasTransfer(result.cycles[1]!.messages, "deleteLocalDirectory", "success")).toBe(true)
|
|
393
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
394
|
+
expect(result.finalLocal["/d"]).toBeUndefined()
|
|
395
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it("O12: a remote directory rename propagates to a local renameLocalDirectory and converges", async () => {
|
|
399
|
+
const result = await runScenario({
|
|
400
|
+
name: "O12",
|
|
401
|
+
mode: "cloudToLocal",
|
|
402
|
+
initialRemote: { "/d": null },
|
|
403
|
+
steps: [
|
|
404
|
+
runCycle(),
|
|
405
|
+
remoteMutate(world => world.cloud.controls.movePath("/d", "/e")),
|
|
406
|
+
runCycle(),
|
|
407
|
+
runCycle()
|
|
408
|
+
]
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
expect(hasTransfer(result.cycles[1]!.messages, "renameLocalDirectory", "success")).toBe(true)
|
|
412
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
413
|
+
expect(result.finalLocal["/e"]).toMatchObject({ type: "directory" })
|
|
414
|
+
expect(result.finalLocal["/d"]).toBeUndefined()
|
|
415
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// O13 (F1 regression): an upload that fails AFTER the file was already synced once must not poison the
|
|
419
|
+
// md5 dedup cache. The modify-branch upload (deltas.ts) writes localFileHashes[path] = newHash and only
|
|
420
|
+
// THEN calls uploadLocalFile; if the upload throws, the hash must be reverted (or written only on
|
|
421
|
+
// success). Otherwise, after the host clears the task error (resetTaskErrors — which does NOT clear
|
|
422
|
+
// localFileHashes), the next cycle recomputes the same newHash, finds it already equal to the poisoned
|
|
423
|
+
// cache entry, and SUPPRESSES the re-upload → the local edit is silently never pushed and the sides
|
|
424
|
+
// diverge permanently. This is the modify branch (file present on BOTH sides); a first upload goes
|
|
425
|
+
// through the additions branch which has no dedup, so only an edit-after-sync exposes it.
|
|
426
|
+
it("O13: an upload failure on a MODIFIED (already-synced) file does not suppress the retry (F1)", async () => {
|
|
427
|
+
const result = await runScenario({
|
|
428
|
+
name: "O13",
|
|
429
|
+
mode: "twoWay",
|
|
430
|
+
initialLocal: { "/local/a.txt": "v0-initial" },
|
|
431
|
+
steps: [
|
|
432
|
+
runCycle(),
|
|
433
|
+
// Edit the already-synced file: distinct size + newer whole-second mtime → modify branch, localWins.
|
|
434
|
+
localMutate(world => writeLocalAt(world, "a.txt", "v1-edited-longer-content", BASE_TIME + 5 * SECOND)),
|
|
435
|
+
control(world => world.cloud.controls.setError("uploadLocalFile", new Error("upload boom"))),
|
|
436
|
+
runCycle(),
|
|
437
|
+
// Host recovery: clear the injected fault and the task error. Crucially does NOT touch localFileHashes.
|
|
438
|
+
control(world => {
|
|
439
|
+
world.cloud.controls.clearError("uploadLocalFile")
|
|
440
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
441
|
+
world.triggerWatcher()
|
|
442
|
+
}),
|
|
443
|
+
runCycle(),
|
|
444
|
+
runCycle()
|
|
445
|
+
]
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
const failCycle = result.cycles[1]!
|
|
449
|
+
|
|
450
|
+
// The first attempt surfaced an upload error and gated the cycle.
|
|
451
|
+
expect(hasTransfer(failCycle.messages, "upload", "error")).toBe(true)
|
|
452
|
+
expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
|
|
453
|
+
// After recovery the edit IS pushed and the sides converge on the new content (not the stale v0).
|
|
454
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "v1-edited-longer-content".length })
|
|
455
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// O14 (F4 regression): a deleteLocalFile whose target already vanished must still evict the cache entry,
|
|
459
|
+
// otherwise that entry is persisted into the base tree as a PHANTOM. A later re-creation of the same path
|
|
460
|
+
// on the remote would then be read as a local deletion to propagate, and the engine would DELETE the
|
|
461
|
+
// re-created remote file instead of downloading it. The local unlink evicts the cache only AFTER a
|
|
462
|
+
// successful move/rm; when the source is already gone the move throws and the eviction is skipped — the
|
|
463
|
+
// fix makes unlink idempotent (evict when the source is confirmed gone). The remote unlink already does
|
|
464
|
+
// this via cleanItemEntry; this restores the symmetry on the local side.
|
|
465
|
+
it("O14: a deleteLocalFile on an already-vanished target evicts the cache, so a re-created remote file is not mis-deleted (F4)", async () => {
|
|
466
|
+
const result = await runScenario({
|
|
467
|
+
name: "O14",
|
|
468
|
+
mode: "twoWay",
|
|
469
|
+
initialLocal: { "/local/a.txt": "v0" },
|
|
470
|
+
steps: [
|
|
471
|
+
runCycle(),
|
|
472
|
+
// Remote trashes a.txt → next cycle mirrors the delete down to the local side.
|
|
473
|
+
remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
|
|
474
|
+
// The local copy vanishes concurrently WITHOUT a watcher event (a control step does not trigger
|
|
475
|
+
// the watcher), so the cached local scan still lists a.txt and a deleteLocalFile is generated —
|
|
476
|
+
// but the file is already gone when the task runs (move → ENOENT).
|
|
477
|
+
control(world => rmLocal(world, "a.txt")),
|
|
478
|
+
runCycle(),
|
|
479
|
+
// Another device re-creates a.txt remotely (new uuid). A phantom base entry for the old local
|
|
480
|
+
// a.txt would make the engine read this as a local deletion to propagate, deleting the new file.
|
|
481
|
+
remoteMutate(world => world.cloud.controls.addFile("/a.txt", "v2-recreated", { mtimeMs: BASE_TIME + 9 * SECOND })),
|
|
482
|
+
control(world => world.triggerWatcher()),
|
|
483
|
+
runCycle(),
|
|
484
|
+
runCycle()
|
|
485
|
+
]
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
489
|
+
// The re-created remote file must SURVIVE and be mirrored down — not deleted by a phantom base entry.
|
|
490
|
+
expect(result.finalRemote["/a.txt"], "re-created remote file must survive").toMatchObject({
|
|
491
|
+
type: "file",
|
|
492
|
+
size: "v2-recreated".length
|
|
493
|
+
})
|
|
494
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: "v2-recreated".length })
|
|
495
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
496
|
+
})
|
|
497
|
+
})
|