@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,277 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { SYNC_INTERVAL } from "../../src/constants"
|
|
3
|
+
import { createWorld, BASE_TIME, type CreateWorldOptions, type World } from "../harness/world"
|
|
4
|
+
import { snapshotLocal, snapshotRemote, messagesOfType } from "../harness/snapshot"
|
|
5
|
+
import { rmLocal } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category G — large-deletion confirmation (behavioral spec §G, §6). When
|
|
9
|
+
* requireConfirmationOnLargeDeletion is set and an entire side is emptied, the engine emits a
|
|
10
|
+
* `confirmDeletion` prompt every second and blocks the cycle until `confirmDeletion(uuid, decision)`
|
|
11
|
+
* arrives. "delete" proceeds; "restart" (or timeout) skips the cycle's deletions.
|
|
12
|
+
*
|
|
13
|
+
* These cycles block mid-run on the prompt, so they are driven manually (not via runScenario): the
|
|
14
|
+
* timer pump below both fires the 1s prompt interval and delivers the user's decision.
|
|
15
|
+
*/
|
|
16
|
+
const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
|
|
17
|
+
|
|
18
|
+
async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
|
|
19
|
+
vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
|
|
20
|
+
vi.setSystemTime(BASE_TIME)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const world = await createWorld(options)
|
|
24
|
+
|
|
25
|
+
await body(world)
|
|
26
|
+
} finally {
|
|
27
|
+
vi.useRealTimers()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Drive one cycle that is NOT expected to block on a confirmation prompt. */
|
|
32
|
+
async function plainCycle(world: World): Promise<void> {
|
|
33
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
34
|
+
|
|
35
|
+
await world.sync.runCycle()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Drive one cycle, delivering `decision` to any confirmation prompt so the cycle can complete. */
|
|
39
|
+
async function cycleWithDecision(world: World, decision: "delete" | "restart"): Promise<void> {
|
|
40
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
41
|
+
|
|
42
|
+
let settled = false
|
|
43
|
+
const cyclePromise = world.sync.runCycle().finally(() => {
|
|
44
|
+
settled = true
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// The prompt resets the decision to "waiting" when it opens, so re-deliver each tick until the
|
|
48
|
+
// 1s interval observes it and the cycle moves on.
|
|
49
|
+
for (let tick = 0; tick < 30 && !settled; tick++) {
|
|
50
|
+
world.worker.confirmDeletion(world.syncPair.uuid, decision)
|
|
51
|
+
|
|
52
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await cyclePromise
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function confirmDeletionCount(world: World): number {
|
|
59
|
+
return messagesOfType(world.messages, "confirmDeletion").length
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("Category G — large-deletion confirmation", () => {
|
|
63
|
+
it("G1: emptying the local side and confirming the deletion proceeds", async () => {
|
|
64
|
+
await withWorld(
|
|
65
|
+
{
|
|
66
|
+
mode: "twoWay",
|
|
67
|
+
requireConfirmationOnLargeDeletion: true,
|
|
68
|
+
initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" }
|
|
69
|
+
},
|
|
70
|
+
async world => {
|
|
71
|
+
await plainCycle(world)
|
|
72
|
+
|
|
73
|
+
rmLocal(world, "a.txt")
|
|
74
|
+
rmLocal(world, "b.txt")
|
|
75
|
+
world.triggerWatcher()
|
|
76
|
+
|
|
77
|
+
await cycleWithDecision(world, "delete")
|
|
78
|
+
|
|
79
|
+
expect(confirmDeletionCount(world)).toBeGreaterThan(0)
|
|
80
|
+
expect(messagesOfType(world.messages, "confirmDeletion")[0]!.data.where).toBe("local")
|
|
81
|
+
// "delete" was given, so the remote is emptied to match.
|
|
82
|
+
expect(snapshotRemote(world)).toEqual({})
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("G2: emptying the local side and answering restart skips the cycle (no deletions)", async () => {
|
|
88
|
+
await withWorld(
|
|
89
|
+
{
|
|
90
|
+
mode: "twoWay",
|
|
91
|
+
requireConfirmationOnLargeDeletion: true,
|
|
92
|
+
initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" }
|
|
93
|
+
},
|
|
94
|
+
async world => {
|
|
95
|
+
await plainCycle(world)
|
|
96
|
+
|
|
97
|
+
rmLocal(world, "a.txt")
|
|
98
|
+
rmLocal(world, "b.txt")
|
|
99
|
+
world.triggerWatcher()
|
|
100
|
+
|
|
101
|
+
await cycleWithDecision(world, "restart")
|
|
102
|
+
|
|
103
|
+
expect(confirmDeletionCount(world)).toBeGreaterThan(0)
|
|
104
|
+
// "restart" was given, so the deletions are NOT applied — the remote still has both files.
|
|
105
|
+
expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
|
|
106
|
+
expect(snapshotRemote(world)["/b.txt"]).toMatchObject({ type: "file" })
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// G3: confirmRemoteDeletion is gated by BOTH the mode and the `previousRemote.size <= deleteCount`
|
|
112
|
+
// threshold (symmetric to confirmLocalDeletion). Here the remote is emptied but only part of it is
|
|
113
|
+
// attributable to remote-side deletions (the rest was deleted locally), so the threshold is NOT met
|
|
114
|
+
// and there is NO prompt. (BUG-001 fix: the missing `&&` that dropped the threshold + mode gate is
|
|
115
|
+
// restored, so confirmRemoteDeletion is now symmetric with confirmLocalDeletion.)
|
|
116
|
+
it("G3: a sub-threshold remote emptying does not trigger a confirmation prompt", async () => {
|
|
117
|
+
await withWorld(
|
|
118
|
+
{
|
|
119
|
+
mode: "twoWay",
|
|
120
|
+
requireConfirmationOnLargeDeletion: true,
|
|
121
|
+
// Start from the remote so the previous remote tree is observed non-empty (size 3) — an
|
|
122
|
+
// upload-only cycle would leave previousRemote.size at 0 and the prompt could never fire.
|
|
123
|
+
initialRemote: { "/a.txt": "a", "/b.txt": "b", "/c.txt": "c" }
|
|
124
|
+
},
|
|
125
|
+
async world => {
|
|
126
|
+
await plainCycle(world)
|
|
127
|
+
|
|
128
|
+
// Remote loses everything, but one of those removals is also a LOCAL deletion — so it is
|
|
129
|
+
// attributed to a remote-delete, leaving deleteLocalCount (2) < previousRemote.size (3). The
|
|
130
|
+
// correct (threshold-gated) confirmRemoteDeletion is therefore false.
|
|
131
|
+
world.cloud.controls.trashPath("/a.txt")
|
|
132
|
+
world.cloud.controls.trashPath("/b.txt")
|
|
133
|
+
world.cloud.controls.trashPath("/c.txt")
|
|
134
|
+
rmLocal(world, "a.txt")
|
|
135
|
+
world.triggerWatcher()
|
|
136
|
+
|
|
137
|
+
await cycleWithDecision(world, "delete")
|
|
138
|
+
|
|
139
|
+
// TARGET: the threshold is not met, so the engine proceeds without prompting.
|
|
140
|
+
expect(confirmDeletionCount(world)).toBe(0)
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("G4: with confirmation disabled, a full emptying deletes without prompting", async () => {
|
|
146
|
+
await withWorld(
|
|
147
|
+
{
|
|
148
|
+
mode: "twoWay",
|
|
149
|
+
requireConfirmationOnLargeDeletion: false,
|
|
150
|
+
initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" }
|
|
151
|
+
},
|
|
152
|
+
async world => {
|
|
153
|
+
await plainCycle(world)
|
|
154
|
+
|
|
155
|
+
rmLocal(world, "a.txt")
|
|
156
|
+
rmLocal(world, "b.txt")
|
|
157
|
+
world.triggerWatcher()
|
|
158
|
+
|
|
159
|
+
await plainCycle(world)
|
|
160
|
+
|
|
161
|
+
expect(confirmDeletionCount(world)).toBe(0)
|
|
162
|
+
expect(snapshotRemote(world)).toEqual({})
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("G5: a partial deletion (side not emptied) does not trigger a prompt", async () => {
|
|
168
|
+
await withWorld(
|
|
169
|
+
{
|
|
170
|
+
mode: "twoWay",
|
|
171
|
+
requireConfirmationOnLargeDeletion: true,
|
|
172
|
+
initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b", "/local/c.txt": "c" }
|
|
173
|
+
},
|
|
174
|
+
async world => {
|
|
175
|
+
await plainCycle(world)
|
|
176
|
+
|
|
177
|
+
rmLocal(world, "a.txt")
|
|
178
|
+
world.triggerWatcher()
|
|
179
|
+
|
|
180
|
+
await plainCycle(world)
|
|
181
|
+
|
|
182
|
+
expect(confirmDeletionCount(world)).toBe(0)
|
|
183
|
+
// Only the deleted file is gone; the rest remain.
|
|
184
|
+
expect(snapshotRemote(world)["/a.txt"]).toBeUndefined()
|
|
185
|
+
expect(snapshotRemote(world)["/b.txt"]).toMatchObject({ type: "file" })
|
|
186
|
+
expect(snapshotRemote(world)["/c.txt"]).toMatchObject({ type: "file" })
|
|
187
|
+
expect(snapshotLocal(world)["/a.txt"]).toBeUndefined()
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Drive a cycle that opens the confirmation prompt, then STOP the pair (pause/remove) mid-wait instead
|
|
193
|
+
// of answering. Returns whether the cycle settled — with the bail-out fix it does (the cycle skips and
|
|
194
|
+
// the finally releases the lock); without it the wait spins forever holding the lock. The tick loop is
|
|
195
|
+
// capped so a regression fails fast (settled === false) instead of hanging the suite.
|
|
196
|
+
async function runCycleThenStopMidConfirmation(world: World, stop: (world: World) => void): Promise<boolean> {
|
|
197
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
198
|
+
|
|
199
|
+
let settled = false
|
|
200
|
+
const cyclePromise = world.sync.runCycle().finally(() => {
|
|
201
|
+
settled = true
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Let the prompt open, then stop the pair while the wait loop is still polling.
|
|
205
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
206
|
+
|
|
207
|
+
stop(world)
|
|
208
|
+
|
|
209
|
+
for (let tick = 0; tick < 5 && !settled; tick++) {
|
|
210
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (settled) {
|
|
214
|
+
await cyclePromise
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return settled
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
it("G6: pausing the pair while awaiting confirmation bails out — no wedge, lock released, no deletion", async () => {
|
|
221
|
+
await withWorld(
|
|
222
|
+
{
|
|
223
|
+
mode: "twoWay",
|
|
224
|
+
requireConfirmationOnLargeDeletion: true,
|
|
225
|
+
initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" }
|
|
226
|
+
},
|
|
227
|
+
async world => {
|
|
228
|
+
await plainCycle(world)
|
|
229
|
+
|
|
230
|
+
rmLocal(world, "a.txt")
|
|
231
|
+
rmLocal(world, "b.txt")
|
|
232
|
+
world.triggerWatcher()
|
|
233
|
+
|
|
234
|
+
const settled = await runCycleThenStopMidConfirmation(world, w => {
|
|
235
|
+
w.sync.paused = true
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// The cycle stopped waiting (it did not hold the lock forever) and applied NO deletion.
|
|
239
|
+
expect(settled, "the cycle must stop waiting once the pair is paused").toBe(true)
|
|
240
|
+
expect(confirmDeletionCount(world)).toBeGreaterThan(0)
|
|
241
|
+
expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
|
|
242
|
+
expect(snapshotRemote(world)["/b.txt"]).toMatchObject({ type: "file" })
|
|
243
|
+
|
|
244
|
+
// The lock was released (the finally ran), so once un-paused a normal cycle proceeds to
|
|
245
|
+
// completion — it would block forever on lock.acquire() if the previous cycle still held it.
|
|
246
|
+
world.sync.paused = false
|
|
247
|
+
|
|
248
|
+
await cycleWithDecision(world, "restart")
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it("G7: removing the pair while awaiting confirmation bails out — no wedge", async () => {
|
|
254
|
+
await withWorld(
|
|
255
|
+
{
|
|
256
|
+
mode: "twoWay",
|
|
257
|
+
requireConfirmationOnLargeDeletion: true,
|
|
258
|
+
initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" }
|
|
259
|
+
},
|
|
260
|
+
async world => {
|
|
261
|
+
await plainCycle(world)
|
|
262
|
+
|
|
263
|
+
rmLocal(world, "a.txt")
|
|
264
|
+
rmLocal(world, "b.txt")
|
|
265
|
+
world.triggerWatcher()
|
|
266
|
+
|
|
267
|
+
const settled = await runCycleThenStopMidConfirmation(world, w => {
|
|
268
|
+
w.sync.removed = true
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
expect(settled, "the cycle must stop waiting once the pair is removed").toBe(true)
|
|
272
|
+
// No deletion was applied — the wait was abandoned, not confirmed.
|
|
273
|
+
expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { APIError } from "@filen/sdk"
|
|
3
|
+
import { runScenario, runCycle, localMutate, control } from "../harness/runner"
|
|
4
|
+
import { BASE_TIME } from "../harness/world"
|
|
5
|
+
import { messagesOfType, transferKinds } from "../harness/snapshot"
|
|
6
|
+
import { writeLocalAt, rmLocal, mkdirLocal, writeLocal } from "../harness/mutations"
|
|
7
|
+
import { type SyncMessage } from "../../src/types"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Category H — resilience / races (behavioral spec §H, §7). A task whose source vanished before it
|
|
11
|
+
* runs is skipped without error; an `APIError{file_not_found|folder_not_found}` on a remote unlink is
|
|
12
|
+
* treated as already-deleted; a real task failure is recorded and the cycle restarts WITHOUT
|
|
13
|
+
* persisting new state, so a retry recovers. Several true mid-I/O races (modify-during-upload, a
|
|
14
|
+
* vanish strictly between delta-computation and the task) are only fully reproducible against real
|
|
15
|
+
* I/O — those are deferred to the Phase 3 live e2e suite; here we pin the deterministic guarantees.
|
|
16
|
+
*/
|
|
17
|
+
const SECOND = 1000
|
|
18
|
+
|
|
19
|
+
/** Total number of individual task errors reported across the message stream. */
|
|
20
|
+
function taskErrorCount(messages: SyncMessage[]): number {
|
|
21
|
+
return messagesOfType(messages, "taskErrors").reduce((sum, message) => sum + message.data.errors.length, 0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("Category H — resilience / races", () => {
|
|
25
|
+
it("H1: a delete whose remote target already vanished is a silent no-op (no task error)", async () => {
|
|
26
|
+
const result = await runScenario({
|
|
27
|
+
name: "H1",
|
|
28
|
+
mode: "twoWay",
|
|
29
|
+
initialLocal: { "/local/a.txt": "content" },
|
|
30
|
+
steps: [
|
|
31
|
+
runCycle(),
|
|
32
|
+
// The local delete produces a deleteRemote delta, but the remote copy is ALSO already gone
|
|
33
|
+
// (trashed externally in the same beat) — the unlink must find nothing and skip cleanly.
|
|
34
|
+
localMutate(world => rmLocal(world, "a.txt")),
|
|
35
|
+
control(world => world.cloud.controls.trashPath("/a.txt")),
|
|
36
|
+
runCycle(),
|
|
37
|
+
runCycle()
|
|
38
|
+
]
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
42
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
43
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
44
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("H2: successive edits to a file always converge to the latest content (no lost update)", async () => {
|
|
48
|
+
const result = await runScenario({
|
|
49
|
+
name: "H2",
|
|
50
|
+
mode: "twoWay",
|
|
51
|
+
initialLocal: { "/local/a.txt": "v1" },
|
|
52
|
+
steps: [
|
|
53
|
+
runCycle(),
|
|
54
|
+
localMutate(world => writeLocalAt(world, "a.txt", "v2", BASE_TIME + 2 * SECOND)),
|
|
55
|
+
runCycle(),
|
|
56
|
+
localMutate(world => writeLocalAt(world, "a.txt", "v3-final", BASE_TIME + 4 * SECOND)),
|
|
57
|
+
runCycle(),
|
|
58
|
+
runCycle()
|
|
59
|
+
]
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
63
|
+
// The remote reflects the LAST write, not an earlier one — no edit was lost across the overlap.
|
|
64
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "v3-final".length })
|
|
65
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("H3: an APIError file_not_found on the remote unlink is treated as already-deleted (no error)", async () => {
|
|
69
|
+
// The file lives in a subdirectory: the delete task's existence re-check resolves a parent, and
|
|
70
|
+
// the two settle cycles seed both previous trees + the engine's remote uuid cache before the delete.
|
|
71
|
+
const result = await runScenario({
|
|
72
|
+
name: "H3",
|
|
73
|
+
mode: "twoWay",
|
|
74
|
+
initialRemote: { "/dir/a.txt": "content" },
|
|
75
|
+
steps: [
|
|
76
|
+
runCycle(),
|
|
77
|
+
runCycle(),
|
|
78
|
+
localMutate(world => rmLocal(world, "dir/a.txt")),
|
|
79
|
+
// The backend reports the file as already gone when we try to trash it.
|
|
80
|
+
control(world =>
|
|
81
|
+
world.cloud.controls.setError("trashFile", new APIError({ code: "file_not_found", message: "File not found." }))
|
|
82
|
+
),
|
|
83
|
+
runCycle(),
|
|
84
|
+
runCycle()
|
|
85
|
+
]
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// file_not_found is swallowed and the unlink is reported as a SUCCESSFUL delete — never an error.
|
|
89
|
+
// (We assert the success signal rather than final convergence: injecting not_found leaves the node
|
|
90
|
+
// live in the fake — the backend would actually have removed it — so the two would otherwise
|
|
91
|
+
// re-diverge. The guarantee under test is purely that not_found does not surface as a failure.)
|
|
92
|
+
const deleteMessages = messagesOfType(result.cycles[2]!.messages, "transfer").filter(message => message.data.of === "deleteRemoteFile")
|
|
93
|
+
|
|
94
|
+
expect(deleteMessages.some(message => message.data.type === "success")).toBe(true)
|
|
95
|
+
expect(deleteMessages.some(message => message.data.type === "error")).toBe(false)
|
|
96
|
+
expect(taskErrorCount(result.messages)).toBe(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("H4: a real (non-not_found) error on a delete is recorded, the cycle restarts, and a retry recovers", async () => {
|
|
100
|
+
const result = await runScenario({
|
|
101
|
+
name: "H4",
|
|
102
|
+
mode: "twoWay",
|
|
103
|
+
initialRemote: { "/dir/a.txt": "content" },
|
|
104
|
+
steps: [
|
|
105
|
+
runCycle(),
|
|
106
|
+
runCycle(),
|
|
107
|
+
localMutate(world => rmLocal(world, "dir/a.txt")),
|
|
108
|
+
control(world => world.cloud.controls.setError("trashFile", new Error("backend unavailable"))),
|
|
109
|
+
runCycle(),
|
|
110
|
+
// Recover: clear the fault and the gating task error, then re-run.
|
|
111
|
+
control(world => {
|
|
112
|
+
world.cloud.controls.clearError("trashFile")
|
|
113
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
114
|
+
world.triggerWatcher()
|
|
115
|
+
}),
|
|
116
|
+
runCycle(),
|
|
117
|
+
runCycle()
|
|
118
|
+
]
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// The failed cycle surfaced the error (a deleteRemoteFile that errored) and recorded a task error.
|
|
122
|
+
expect(taskErrorCount(result.cycles[2]!.messages)).toBeGreaterThan(0)
|
|
123
|
+
expect(transferKinds(result.cycles[2]!.messages)).toContain("deleteRemoteFile")
|
|
124
|
+
// After recovery the delete completes and the worlds converge.
|
|
125
|
+
expect(result.finalRemote["/dir/a.txt"]).toBeUndefined()
|
|
126
|
+
expect(result.finalLocal["/dir/a.txt"]).toBeUndefined()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("H5: when one upload fails, an independent op still proceeds and the failure is retried to convergence", async () => {
|
|
130
|
+
const result = await runScenario({
|
|
131
|
+
name: "H5",
|
|
132
|
+
mode: "twoWay",
|
|
133
|
+
steps: [
|
|
134
|
+
runCycle(),
|
|
135
|
+
localMutate(world => {
|
|
136
|
+
// A new empty directory (createRemoteDirectory — succeeds) alongside a file whose upload
|
|
137
|
+
// is forced to fail. The independent directory op must not be blocked by the failure.
|
|
138
|
+
mkdirLocal(world, "survives")
|
|
139
|
+
writeLocal(world, "bad.txt", "payload")
|
|
140
|
+
}),
|
|
141
|
+
control(world => world.cloud.controls.setError("uploadLocalFile", new Error("upload boom"))),
|
|
142
|
+
runCycle(),
|
|
143
|
+
control(world => {
|
|
144
|
+
world.cloud.controls.clearError("uploadLocalFile")
|
|
145
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
146
|
+
world.triggerWatcher()
|
|
147
|
+
}),
|
|
148
|
+
runCycle(),
|
|
149
|
+
runCycle()
|
|
150
|
+
]
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// The upload failed and was recorded…
|
|
154
|
+
expect(taskErrorCount(result.cycles[1]!.messages)).toBeGreaterThan(0)
|
|
155
|
+
const failedKinds = transferKinds(result.cycles[1]!.messages)
|
|
156
|
+
|
|
157
|
+
expect(failedKinds).toContain("uploadFile")
|
|
158
|
+
// …but the independent directory creation still went through in that same cycle.
|
|
159
|
+
expect(result.cycles[1]!.remote["/survives"]).toMatchObject({ type: "directory" })
|
|
160
|
+
expect(result.cycles[1]!.remote["/bad.txt"]).toBeUndefined()
|
|
161
|
+
|
|
162
|
+
// After recovery the previously-failed upload completes and everything converges.
|
|
163
|
+
expect(result.finalRemote["/bad.txt"]).toMatchObject({ type: "file", size: "payload".length })
|
|
164
|
+
expect(result.finalRemote["/survives"]).toMatchObject({ type: "directory" })
|
|
165
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
166
|
+
})
|
|
167
|
+
})
|