@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,187 @@
|
|
|
1
|
+
import SyncWorker from "../../src/index"
|
|
2
|
+
import Sync from "../../src/lib/sync"
|
|
3
|
+
import { type SyncPair, type SyncMode, type SyncMessage } from "../../src/types"
|
|
4
|
+
import { type SyncEnvironment, type SyncWatcher } from "../../src/lib/environment"
|
|
5
|
+
import { createVirtualFS, type VfsSpec, type VirtualFS } from "../fakes/virtual-fs"
|
|
6
|
+
import { createFakeCloud, type CloudSpec, type FakeCloud } from "../fakes/fake-cloud"
|
|
7
|
+
import { v4 as uuidv4 } from "uuid"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A fixed, realistic base time so file mtimes are large ms values (the engine's
|
|
11
|
+
* `convertTimestampToMs` treats them as already-ms) and the fake clock is deterministic.
|
|
12
|
+
*/
|
|
13
|
+
export const BASE_TIME = new Date("2024-06-01T00:00:00.000Z").getTime()
|
|
14
|
+
|
|
15
|
+
export const LOCAL_ROOT = "/local"
|
|
16
|
+
export const DB_ROOT = "/db"
|
|
17
|
+
|
|
18
|
+
export type CreateWorldOptions = {
|
|
19
|
+
mode: SyncMode
|
|
20
|
+
initialLocal?: VfsSpec
|
|
21
|
+
initialRemote?: CloudSpec
|
|
22
|
+
excludeDotFiles?: boolean
|
|
23
|
+
localTrashDisabled?: boolean
|
|
24
|
+
requireConfirmationOnLargeDeletion?: boolean
|
|
25
|
+
paused?: boolean
|
|
26
|
+
uuid?: string
|
|
27
|
+
filenIgnore?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type World = {
|
|
31
|
+
worker: SyncWorker
|
|
32
|
+
sync: Sync
|
|
33
|
+
vfs: VirtualFS
|
|
34
|
+
cloud: FakeCloud
|
|
35
|
+
messages: SyncMessage[]
|
|
36
|
+
syncPair: SyncPair
|
|
37
|
+
localPath: string
|
|
38
|
+
/** Simulate the directory watcher firing (a local change was observed). */
|
|
39
|
+
triggerWatcher: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A manual watcher whose `onChange` callbacks are invoked on demand, so tests deterministically
|
|
44
|
+
* tell the engine "the local directory changed" instead of relying on a real fs watcher.
|
|
45
|
+
*/
|
|
46
|
+
function createManualWatcher(): { factory: SyncEnvironment["createWatcher"]; trigger: () => void } {
|
|
47
|
+
const callbacks = new Set<() => void>()
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
factory: async (_path: string, onChange: () => void): Promise<SyncWatcher> => {
|
|
51
|
+
callbacks.add(onChange)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
close: async (): Promise<void> => {
|
|
55
|
+
callbacks.delete(onChange)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
trigger: (): void => {
|
|
60
|
+
for (const callback of callbacks) {
|
|
61
|
+
callback()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a fully wired, in-memory sync world: a virtual filesystem + a stateful fake cloud injected
|
|
69
|
+
* into a real {@link SyncWorker}/{@link Sync}, with all engine I/O routed through the fakes and the
|
|
70
|
+
* scheduling loop bypassed (the runner drives {@link Sync.runCycle} directly).
|
|
71
|
+
*
|
|
72
|
+
* Must be called with vitest fake timers already installed and the system time set, so memfs and the
|
|
73
|
+
* engine share a deterministic clock.
|
|
74
|
+
*/
|
|
75
|
+
export async function createWorld(options: CreateWorldOptions): Promise<World> {
|
|
76
|
+
const localSpec: VfsSpec = { [LOCAL_ROOT]: null, [DB_ROOT]: null }
|
|
77
|
+
|
|
78
|
+
for (const [path, value] of Object.entries(options.initialLocal ?? {})) {
|
|
79
|
+
localSpec[path] = value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof options.filenIgnore === "string") {
|
|
83
|
+
localSpec[`${LOCAL_ROOT}/.filenignore`] = options.filenIgnore
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const vfs = createVirtualFS(localSpec)
|
|
87
|
+
const cloud = createFakeCloud(options.initialRemote ?? {}, { localFs: vfs.fs })
|
|
88
|
+
const watcher = createManualWatcher()
|
|
89
|
+
|
|
90
|
+
const environment: SyncEnvironment = {
|
|
91
|
+
fs: vfs.fs,
|
|
92
|
+
globFs: vfs.globFs,
|
|
93
|
+
writeFileAtomic: async (filename, data, writeOptions): Promise<void> => {
|
|
94
|
+
await vfs.fs.writeFile(filename, data, writeOptions)
|
|
95
|
+
},
|
|
96
|
+
createWatcher: watcher.factory,
|
|
97
|
+
// Route the tree fetch back through the fake cloud's SDK so every scenario exercises the real
|
|
98
|
+
// engine logic without HTTP/msgpack (the production msgpack path is unit-tested separately).
|
|
99
|
+
fetchDirTree: (_sdk, request) => cloud.sdk.api(3).dir().tree(request)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const messages: SyncMessage[] = []
|
|
103
|
+
const syncPair: SyncPair = {
|
|
104
|
+
name: "test-pair",
|
|
105
|
+
uuid: options.uuid ?? uuidv4(),
|
|
106
|
+
localPath: LOCAL_ROOT,
|
|
107
|
+
remotePath: "/",
|
|
108
|
+
remoteParentUUID: cloud.controls.rootUUID,
|
|
109
|
+
mode: options.mode,
|
|
110
|
+
excludeDotFiles: options.excludeDotFiles ?? false,
|
|
111
|
+
paused: options.paused ?? false,
|
|
112
|
+
localTrashDisabled: options.localTrashDisabled ?? false,
|
|
113
|
+
requireConfirmationOnLargeDeletion: options.requireConfirmationOnLargeDeletion ?? false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const worker = new SyncWorker({
|
|
117
|
+
syncPairs: [syncPair],
|
|
118
|
+
dbPath: DB_ROOT,
|
|
119
|
+
sdk: cloud.sdk,
|
|
120
|
+
onMessage: message => {
|
|
121
|
+
messages.push(message)
|
|
122
|
+
},
|
|
123
|
+
disableLogging: true,
|
|
124
|
+
environment
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const sync = new Sync({ syncPair, worker })
|
|
128
|
+
|
|
129
|
+
worker.syncs[syncPair.uuid] = sync
|
|
130
|
+
|
|
131
|
+
// The engine's initialize() would also call run() (the self-scheduling loop); we skip it and
|
|
132
|
+
// load persisted state directly so the runner can drive discrete cycles.
|
|
133
|
+
await sync.state.initialize()
|
|
134
|
+
await sync.ignorer.initialize()
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
worker,
|
|
138
|
+
sync,
|
|
139
|
+
vfs,
|
|
140
|
+
cloud,
|
|
141
|
+
messages,
|
|
142
|
+
syncPair,
|
|
143
|
+
localPath: LOCAL_ROOT,
|
|
144
|
+
triggerWatcher: watcher.trigger
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Simulate a process restart: rebuild the worker/sync over the SAME virtual filesystem and cloud,
|
|
150
|
+
* so persisted state (previous trees, deviceId, ignorer) is reloaded from disk. Mutates `world` in
|
|
151
|
+
* place (new worker/sync/watcher) and keeps appending to the same message stream.
|
|
152
|
+
*/
|
|
153
|
+
export async function restartSync(world: World): Promise<void> {
|
|
154
|
+
const watcher = createManualWatcher()
|
|
155
|
+
|
|
156
|
+
const environment: SyncEnvironment = {
|
|
157
|
+
fs: world.vfs.fs,
|
|
158
|
+
globFs: world.vfs.globFs,
|
|
159
|
+
writeFileAtomic: async (filename, data, writeOptions): Promise<void> => {
|
|
160
|
+
await world.vfs.fs.writeFile(filename, data, writeOptions)
|
|
161
|
+
},
|
|
162
|
+
createWatcher: watcher.factory,
|
|
163
|
+
fetchDirTree: (_sdk, request) => world.cloud.sdk.api(3).dir().tree(request)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const worker = new SyncWorker({
|
|
167
|
+
syncPairs: [world.syncPair],
|
|
168
|
+
dbPath: DB_ROOT,
|
|
169
|
+
sdk: world.cloud.sdk,
|
|
170
|
+
onMessage: message => {
|
|
171
|
+
world.messages.push(message)
|
|
172
|
+
},
|
|
173
|
+
disableLogging: true,
|
|
174
|
+
environment
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const sync = new Sync({ syncPair: world.syncPair, worker })
|
|
178
|
+
|
|
179
|
+
worker.syncs[world.syncPair.uuid] = sync
|
|
180
|
+
|
|
181
|
+
await sync.state.initialize()
|
|
182
|
+
await sync.ignorer.initialize()
|
|
183
|
+
|
|
184
|
+
world.worker = worker
|
|
185
|
+
world.sync = sync
|
|
186
|
+
world.triggerWatcher = watcher.trigger
|
|
187
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, restart } from "../harness/runner"
|
|
3
|
+
import { countMessages, transferKinds, allOps, hadTransfers } from "../harness/snapshot"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Category A — baseline & convergence (behavioral spec §A).
|
|
7
|
+
*
|
|
8
|
+
* The exact cycle in which the steady-state `cycleNoChanges` message appears depends on the
|
|
9
|
+
* deviceId-cache / cache-reset settling mechanics, so these assert the robust invariants: the
|
|
10
|
+
* expected transfer happens on the first cycle, NO transfers happen afterwards, the worlds converge
|
|
11
|
+
* to equality, and steady state is eventually reported.
|
|
12
|
+
*/
|
|
13
|
+
describe("Category A — baseline & convergence", () => {
|
|
14
|
+
it("A1: empty ↔ empty converges with no operations", async () => {
|
|
15
|
+
const result = await runScenario({
|
|
16
|
+
name: "A1",
|
|
17
|
+
mode: "twoWay",
|
|
18
|
+
steps: [runCycle(), runCycle()]
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(hadTransfers(result.messages)).toBe(false)
|
|
22
|
+
expect(countMessages(result.messages, "cycleNoChanges")).toBeGreaterThanOrEqual(1)
|
|
23
|
+
expect(result.finalLocal).toEqual({})
|
|
24
|
+
expect(result.finalRemote).toEqual({})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("A2: a local-only file uploads on the first cycle, then converges (twoWay)", async () => {
|
|
28
|
+
const result = await runScenario({
|
|
29
|
+
name: "A2",
|
|
30
|
+
mode: "twoWay",
|
|
31
|
+
initialLocal: { "/local/a.txt": "0123456789" },
|
|
32
|
+
steps: [runCycle(), runCycle(), runCycle()]
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
expect(result.cycles[0]!.remote["/a.txt"]).toMatchObject({ type: "file", size: 10 })
|
|
36
|
+
expect(transferKinds(result.cycles[0]!.messages)).toContain("upload")
|
|
37
|
+
expect(allOps(result.cycles[1]!.messages)).toEqual([])
|
|
38
|
+
expect(allOps(result.cycles[2]!.messages)).toEqual([])
|
|
39
|
+
expect(countMessages(result.messages, "cycleNoChanges")).toBeGreaterThanOrEqual(1)
|
|
40
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 10 })
|
|
41
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("A3: a remote-only file downloads on the first cycle, then converges (twoWay)", async () => {
|
|
45
|
+
const result = await runScenario({
|
|
46
|
+
name: "A3",
|
|
47
|
+
mode: "twoWay",
|
|
48
|
+
initialRemote: { "/a.txt": "hello" },
|
|
49
|
+
steps: [runCycle(), runCycle(), runCycle()]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(result.cycles[0]!.local["/a.txt"]).toMatchObject({ type: "file", size: 5 })
|
|
53
|
+
expect(transferKinds(result.cycles[0]!.messages)).toContain("download")
|
|
54
|
+
expect(allOps(result.cycles[1]!.messages)).toEqual([])
|
|
55
|
+
expect(allOps(result.cycles[2]!.messages)).toEqual([])
|
|
56
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 5 })
|
|
57
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("A4: identical content on both sides converges without any transfer", async () => {
|
|
61
|
+
const result = await runScenario({
|
|
62
|
+
name: "A4",
|
|
63
|
+
mode: "twoWay",
|
|
64
|
+
initialLocal: { "/local/same.txt": "identical" },
|
|
65
|
+
initialRemote: { "/same.txt": "identical" },
|
|
66
|
+
steps: [runCycle(), runCycle(), runCycle()]
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(hadTransfers(result.messages)).toBe(false)
|
|
70
|
+
expect(result.finalLocal["/same.txt"]).toMatchObject({ type: "file", size: 9 })
|
|
71
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("A5: a converged world reports no operations after a restart (state persistence)", async () => {
|
|
75
|
+
const result = await runScenario({
|
|
76
|
+
name: "A5",
|
|
77
|
+
mode: "twoWay",
|
|
78
|
+
initialLocal: { "/local/a.txt": "persisted" },
|
|
79
|
+
steps: [runCycle(), runCycle(), runCycle(), restart(), runCycle(), runCycle()]
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// The two cycles after the restart (indices 3 and 4) perform no transfers.
|
|
83
|
+
expect(allOps(result.cycles[3]!.messages)).toEqual([])
|
|
84
|
+
expect(allOps(result.cycles[4]!.messages)).toEqual([])
|
|
85
|
+
expect(countMessages(result.messages, "cycleError")).toBe(0)
|
|
86
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 9 })
|
|
87
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("A6: a deeply nested directory tree converges (twoWay)", async () => {
|
|
91
|
+
const result = await runScenario({
|
|
92
|
+
name: "A6",
|
|
93
|
+
mode: "twoWay",
|
|
94
|
+
initialLocal: {
|
|
95
|
+
"/local/a/b/c/d/e/deep.txt": "deep",
|
|
96
|
+
"/local/a/b/sibling.txt": "sibling",
|
|
97
|
+
"/local/a/top.txt": "top"
|
|
98
|
+
},
|
|
99
|
+
steps: [runCycle(), runCycle(), runCycle(), runCycle()]
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(result.finalRemote["/a/b/c/d/e/deep.txt"]).toMatchObject({ type: "file", size: 4 })
|
|
103
|
+
expect(result.finalRemote["/a/b/c/d/e"]).toMatchObject({ type: "directory" })
|
|
104
|
+
expect(result.finalRemote["/a/top.txt"]).toMatchObject({ type: "file", size: 3 })
|
|
105
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,258 @@
|
|
|
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 { writeLocal, rmLocal } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category AA — concurrency / race conditions and the large-deletion confirmation across modes. The
|
|
9
|
+
* lock cases prove a cycle survives another device holding the remote resource lock (it reports a
|
|
10
|
+
* cycleError and recovers when released, never corrupting state). The confirmation cases extend the
|
|
11
|
+
* Category G prompt — which only covered twoWay — to the directional modes: the prompt fires for the
|
|
12
|
+
* side a mode is allowed to delete (localToCloud→local, cloudToLocal→remote) and NEVER fires for the
|
|
13
|
+
* additive backup modes (which never delete the target).
|
|
14
|
+
*
|
|
15
|
+
* Cycles that block on a confirmation prompt are driven manually (a timer pump delivers the decision).
|
|
16
|
+
*/
|
|
17
|
+
const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
|
|
18
|
+
|
|
19
|
+
async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
|
|
20
|
+
vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
|
|
21
|
+
vi.setSystemTime(BASE_TIME)
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const world = await createWorld(options)
|
|
25
|
+
|
|
26
|
+
await body(world)
|
|
27
|
+
} finally {
|
|
28
|
+
vi.useRealTimers()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function plainCycle(world: World): Promise<void> {
|
|
33
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
34
|
+
|
|
35
|
+
await world.sync.runCycle()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function cycleWithDecision(world: World, decision: "delete" | "restart"): Promise<void> {
|
|
39
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
40
|
+
|
|
41
|
+
let settled = false
|
|
42
|
+
const cyclePromise = world.sync.runCycle().finally(() => {
|
|
43
|
+
settled = true
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
for (let tick = 0; tick < 30 && !settled; tick++) {
|
|
47
|
+
world.worker.confirmDeletion(world.syncPair.uuid, decision)
|
|
48
|
+
|
|
49
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await cyclePromise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drive one cycle that begins while another device holds the remote lock. With maxTries:Infinity the
|
|
57
|
+
* acquire BLOCKS (retrying every second) instead of failing, so the cycle does no work and reports no
|
|
58
|
+
* cycleError until the lock frees. Mirrors cycleWithDecision's timer pump so the blocked acquire and the
|
|
59
|
+
* rest of the (now unblocked) cycle both drain deterministically. `assertWhileBlocked` runs at the point
|
|
60
|
+
* the cycle is provably still waiting on the lock, before it is released.
|
|
61
|
+
*/
|
|
62
|
+
async function cycleBlockedByContendedLock(world: World, assertWhileBlocked: () => void): Promise<void> {
|
|
63
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
64
|
+
|
|
65
|
+
let settled = false
|
|
66
|
+
const cyclePromise = world.sync.runCycle().finally(() => {
|
|
67
|
+
settled = true
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Spin several retry windows (past the 3s "acquiring lock" notice); the cycle stays blocked.
|
|
71
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
72
|
+
|
|
73
|
+
expect(settled, "the cycle must block on the contended lock, not fail").toBe(false)
|
|
74
|
+
|
|
75
|
+
assertWhileBlocked()
|
|
76
|
+
|
|
77
|
+
// Another device releases the lock; the acquire succeeds on its next retry and the cycle finishes.
|
|
78
|
+
world.cloud.controls.releaseLockContention(lockResource(world))
|
|
79
|
+
|
|
80
|
+
for (let tick = 0; tick < 30 && !settled; tick++) {
|
|
81
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await cyclePromise
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lockResource(world: World): string {
|
|
88
|
+
return `sync-remoteParentUUID-${world.cloud.controls.rootUUID}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function confirmDeletionCount(world: World): number {
|
|
92
|
+
return messagesOfType(world.messages, "confirmDeletion").length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("Category AA — races & cross-mode deletion confirmation", () => {
|
|
96
|
+
it("AA1: a contended remote lock makes the cycle wait, then it recovers when released", async () => {
|
|
97
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "data" } }, async world => {
|
|
98
|
+
world.cloud.controls.contendLock(lockResource(world))
|
|
99
|
+
|
|
100
|
+
await cycleBlockedByContendedLock(world, () => {
|
|
101
|
+
// Blocked on the lock (the engine acquires with maxTries:Infinity): it announced it is waiting,
|
|
102
|
+
// reported NO error, and did NO work (no corruption) — the previous fake threw here instead.
|
|
103
|
+
expect(messagesOfType(world.messages, "cycleAcquiringLockStarted").length).toBeGreaterThan(0)
|
|
104
|
+
expect(messagesOfType(world.messages, "cycleError").length).toBe(0)
|
|
105
|
+
expect(snapshotRemote(world)["/a.txt"]).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// The now-unblocked cycle (plus a follow-up to settle both sides) syncs to convergence.
|
|
109
|
+
await plainCycle(world)
|
|
110
|
+
|
|
111
|
+
expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
|
|
112
|
+
expect(snapshotLocal(world)).toEqual(snapshotRemote(world))
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("AA2: a cycle blocked by lock contention applies ALL pending adds and deletes once released", async () => {
|
|
117
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/keep.txt": "k", "/local/remove.txt": "r" } }, async world => {
|
|
118
|
+
await plainCycle(world)
|
|
119
|
+
await plainCycle(world)
|
|
120
|
+
|
|
121
|
+
// Queue an add and a delete, then block the next cycle on a contended lock.
|
|
122
|
+
writeLocal(world, "added.txt", "added")
|
|
123
|
+
rmLocal(world, "remove.txt")
|
|
124
|
+
world.triggerWatcher()
|
|
125
|
+
world.cloud.controls.contendLock(lockResource(world))
|
|
126
|
+
|
|
127
|
+
await cycleBlockedByContendedLock(world, () => {
|
|
128
|
+
// Nothing applied while the cycle is blocked on the lock.
|
|
129
|
+
expect(snapshotRemote(world)["/added.txt"]).toBeUndefined()
|
|
130
|
+
expect(snapshotRemote(world)["/remove.txt"]).toMatchObject({ type: "file" })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// After release the queued work all lands and the worlds converge.
|
|
134
|
+
await plainCycle(world)
|
|
135
|
+
|
|
136
|
+
expect(snapshotRemote(world)["/added.txt"]).toMatchObject({ type: "file" })
|
|
137
|
+
expect(snapshotRemote(world)["/remove.txt"]).toBeUndefined()
|
|
138
|
+
expect(snapshotLocal(world)).toEqual(snapshotRemote(world))
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("AA3: localToCloud — emptying the local side prompts a local-deletion confirmation", async () => {
|
|
143
|
+
await withWorld(
|
|
144
|
+
{ mode: "localToCloud", requireConfirmationOnLargeDeletion: true, initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" } },
|
|
145
|
+
async world => {
|
|
146
|
+
await plainCycle(world)
|
|
147
|
+
|
|
148
|
+
rmLocal(world, "a.txt")
|
|
149
|
+
rmLocal(world, "b.txt")
|
|
150
|
+
world.triggerWatcher()
|
|
151
|
+
|
|
152
|
+
await cycleWithDecision(world, "delete")
|
|
153
|
+
|
|
154
|
+
expect(confirmDeletionCount(world)).toBeGreaterThan(0)
|
|
155
|
+
expect(messagesOfType(world.messages, "confirmDeletion")[0]!.data.where).toBe("local")
|
|
156
|
+
// "delete" confirmed → the remote mirror is emptied.
|
|
157
|
+
expect(snapshotRemote(world)).toEqual({})
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("AA4: cloudToLocal — emptying the remote side prompts a remote-deletion confirmation", async () => {
|
|
163
|
+
await withWorld(
|
|
164
|
+
{ mode: "cloudToLocal", requireConfirmationOnLargeDeletion: true, initialRemote: { "/a.txt": "a", "/b.txt": "b" } },
|
|
165
|
+
async world => {
|
|
166
|
+
await plainCycle(world)
|
|
167
|
+
|
|
168
|
+
world.cloud.controls.trashPath("/a.txt")
|
|
169
|
+
world.cloud.controls.trashPath("/b.txt")
|
|
170
|
+
|
|
171
|
+
await cycleWithDecision(world, "delete")
|
|
172
|
+
|
|
173
|
+
expect(confirmDeletionCount(world)).toBeGreaterThan(0)
|
|
174
|
+
expect(messagesOfType(world.messages, "confirmDeletion")[0]!.data.where).toBe("remote")
|
|
175
|
+
// "delete" confirmed → the local mirror is emptied.
|
|
176
|
+
expect(snapshotLocal(world)).toEqual({})
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("AA5: localToCloud — answering 'restart' to the prompt skips the deletions", async () => {
|
|
182
|
+
await withWorld(
|
|
183
|
+
{ mode: "localToCloud", requireConfirmationOnLargeDeletion: true, initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" } },
|
|
184
|
+
async world => {
|
|
185
|
+
await plainCycle(world)
|
|
186
|
+
|
|
187
|
+
rmLocal(world, "a.txt")
|
|
188
|
+
rmLocal(world, "b.txt")
|
|
189
|
+
world.triggerWatcher()
|
|
190
|
+
|
|
191
|
+
await cycleWithDecision(world, "restart")
|
|
192
|
+
|
|
193
|
+
expect(confirmDeletionCount(world)).toBeGreaterThan(0)
|
|
194
|
+
// "restart" → the remote still holds both files (deletions not applied).
|
|
195
|
+
expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
|
|
196
|
+
expect(snapshotRemote(world)["/b.txt"]).toMatchObject({ type: "file" })
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("AA6: localBackup — emptying the local side NEVER prompts (backups don't delete the target)", async () => {
|
|
202
|
+
await withWorld(
|
|
203
|
+
{ mode: "localBackup", requireConfirmationOnLargeDeletion: true, initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" } },
|
|
204
|
+
async world => {
|
|
205
|
+
await plainCycle(world)
|
|
206
|
+
|
|
207
|
+
rmLocal(world, "a.txt")
|
|
208
|
+
rmLocal(world, "b.txt")
|
|
209
|
+
world.triggerWatcher()
|
|
210
|
+
|
|
211
|
+
await plainCycle(world)
|
|
212
|
+
|
|
213
|
+
expect(confirmDeletionCount(world)).toBe(0)
|
|
214
|
+
// The remote backup is untouched.
|
|
215
|
+
expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
|
|
216
|
+
expect(snapshotRemote(world)["/b.txt"]).toMatchObject({ type: "file" })
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("AA7: cloudBackup — emptying the remote side NEVER prompts (backups don't delete the target)", async () => {
|
|
222
|
+
await withWorld(
|
|
223
|
+
{ mode: "cloudBackup", requireConfirmationOnLargeDeletion: true, initialRemote: { "/a.txt": "a", "/b.txt": "b" } },
|
|
224
|
+
async world => {
|
|
225
|
+
await plainCycle(world)
|
|
226
|
+
|
|
227
|
+
world.cloud.controls.trashPath("/a.txt")
|
|
228
|
+
world.cloud.controls.trashPath("/b.txt")
|
|
229
|
+
|
|
230
|
+
await plainCycle(world)
|
|
231
|
+
|
|
232
|
+
expect(confirmDeletionCount(world)).toBe(0)
|
|
233
|
+
// The local backup is untouched.
|
|
234
|
+
expect(snapshotLocal(world)["/a.txt"]).toMatchObject({ type: "file" })
|
|
235
|
+
expect(snapshotLocal(world)["/b.txt"]).toMatchObject({ type: "file" })
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("AA8: a file replaced (vanished + re-created) mid-flight is handled without error and converges", async () => {
|
|
241
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "v1" } }, async world => {
|
|
242
|
+
await plainCycle(world)
|
|
243
|
+
|
|
244
|
+
// Remote deletes a.txt while local re-creates it with new content in the same beat — the delete
|
|
245
|
+
// task re-checks existence and the engine still settles on the surviving local content.
|
|
246
|
+
world.cloud.controls.trashPath("/a.txt")
|
|
247
|
+
writeLocal(world, "a.txt", "v2-local-recreated")
|
|
248
|
+
world.triggerWatcher()
|
|
249
|
+
|
|
250
|
+
await plainCycle(world)
|
|
251
|
+
await plainCycle(world)
|
|
252
|
+
await plainCycle(world)
|
|
253
|
+
|
|
254
|
+
expect(messagesOfType(world.messages, "taskErrors").some(message => message.data.errors.length > 0)).toBe(false)
|
|
255
|
+
expect(snapshotLocal(world)).toEqual(snapshotRemote(world))
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
})
|