@filen/sync 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, expectTypeOf } from "vitest"
|
|
2
|
+
import SyncWorker, * as rootExports from "../../src/index"
|
|
3
|
+
import { type SyncMessage, type SyncPair, type SyncMode } from "../../src/types"
|
|
4
|
+
import { runScenario, runCycle, localMutate } from "../harness/runner"
|
|
5
|
+
import { messagesOfType } from "../harness/snapshot"
|
|
6
|
+
import { renameLocal, rmLocal } from "../harness/mutations"
|
|
7
|
+
import { DB_ROOT } from "../harness/world"
|
|
8
|
+
import { IGNORER_VERSION } from "../../src/ignorer"
|
|
9
|
+
import { DEVICE_ID_VERSION } from "../../src/lib/filesystems/remote"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Category M — golden / compatibility pins (behavioral spec §M).
|
|
13
|
+
*
|
|
14
|
+
* These freeze the *public contract* downstream consumers (filen-desktop, @filen/web) depend on:
|
|
15
|
+
* the {@link SyncMessage} shapes streamed over IPC, the on-disk persistence layout (a migration
|
|
16
|
+
* contract), the {@link SyncWorker} constructor + method surface, and the root module's named
|
|
17
|
+
* exports. They are deliberately STRUCTURAL (`toMatchObject` / `toHaveProperty` / `typeof`) rather
|
|
18
|
+
* than giant value snapshots, so they document intent and survive incidental field-order changes.
|
|
19
|
+
*
|
|
20
|
+
* Only happy-path message families are pinned — error/`cycleError`/smoke-test shapes need error
|
|
21
|
+
* injection and are covered elsewhere. The two `setTimeout`-gated lifecycle signals
|
|
22
|
+
* (`cycleAcquiringLockStarted`, `cycleGettingTreesStarted`) never fire under the harness's frozen
|
|
23
|
+
* clock and are intentionally NOT pinned here.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The documented {@link SyncPair} fields every message carries under `message.syncPair`. */
|
|
27
|
+
const syncPairShape = {
|
|
28
|
+
name: expect.any(String),
|
|
29
|
+
uuid: expect.any(String),
|
|
30
|
+
localPath: expect.any(String),
|
|
31
|
+
remotePath: expect.any(String),
|
|
32
|
+
remoteParentUUID: expect.any(String),
|
|
33
|
+
mode: expect.any(String),
|
|
34
|
+
excludeDotFiles: expect.any(Boolean),
|
|
35
|
+
paused: expect.any(Boolean),
|
|
36
|
+
localTrashDisabled: expect.any(Boolean)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Pull the first message of `type`, asserting at least one was emitted. */
|
|
40
|
+
function requireMessage<T extends SyncMessage["type"]>(messages: SyncMessage[], type: T): Extract<SyncMessage, { type: T }> {
|
|
41
|
+
const found = messagesOfType(messages, type)[0]
|
|
42
|
+
|
|
43
|
+
expect(found, `expected at least one "${type}" message`).toBeDefined()
|
|
44
|
+
|
|
45
|
+
return found as Extract<SyncMessage, { type: T }>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pull the first `transfer` message with the given `data.of` / `data.type`, asserting it exists. */
|
|
49
|
+
function requireTransfer(messages: SyncMessage[], of: string, type: string): Extract<SyncMessage, { type: "transfer" }> {
|
|
50
|
+
const found = messagesOfType(messages, "transfer").find(message => message.data.of === of && message.data.type === type)
|
|
51
|
+
|
|
52
|
+
expect(found, `expected a transfer message of="${of}" type="${type}"`).toBeDefined()
|
|
53
|
+
|
|
54
|
+
return found as Extract<SyncMessage, { type: "transfer" }>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("Category M — golden / compatibility pins", () => {
|
|
58
|
+
describe("M1 — SyncMessage shape pins", () => {
|
|
59
|
+
let messages: SyncMessage[] = []
|
|
60
|
+
|
|
61
|
+
// One rich twoWay scenario that exercises every happy-path op family: uploads + a created
|
|
62
|
+
// remote dir, downloads + a created local dir, then a file/dir rename, then a file/dir delete.
|
|
63
|
+
// All emitted messages are aggregated; each family is asserted to appear at least once with the
|
|
64
|
+
// documented shape (robust to deviceId-cache settling, which only shifts WHICH cycle emits what).
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
const result = await runScenario({
|
|
67
|
+
name: "M1",
|
|
68
|
+
mode: "twoWay",
|
|
69
|
+
uuid: "11111111-1111-4111-8111-111111111111",
|
|
70
|
+
initialLocal: {
|
|
71
|
+
"/local/up.txt": "upload-me",
|
|
72
|
+
"/local/ldir/nested.txt": "nested-content"
|
|
73
|
+
},
|
|
74
|
+
initialRemote: {
|
|
75
|
+
"/down.txt": "download-me",
|
|
76
|
+
"/rdir/rnested.txt": "rnested-content"
|
|
77
|
+
},
|
|
78
|
+
steps: [
|
|
79
|
+
runCycle(),
|
|
80
|
+
runCycle(),
|
|
81
|
+
localMutate(world => {
|
|
82
|
+
renameLocal(world, "up.txt", "up2.txt")
|
|
83
|
+
renameLocal(world, "ldir", "ldir2")
|
|
84
|
+
}),
|
|
85
|
+
runCycle(),
|
|
86
|
+
runCycle(),
|
|
87
|
+
localMutate(world => {
|
|
88
|
+
rmLocal(world, "up2.txt")
|
|
89
|
+
rmLocal(world, "ldir2")
|
|
90
|
+
}),
|
|
91
|
+
runCycle(),
|
|
92
|
+
runCycle()
|
|
93
|
+
]
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
messages = result.messages
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("M1a: pins the upload / download progress transfer families (queued → started → progress → finished)", () => {
|
|
100
|
+
for (const of of ["upload", "download"] as const) {
|
|
101
|
+
expect(requireTransfer(messages, of, "queued")).toMatchObject({
|
|
102
|
+
type: "transfer",
|
|
103
|
+
syncPair: syncPairShape,
|
|
104
|
+
data: { of, type: "queued", relativePath: expect.any(String), localPath: expect.any(String), size: expect.any(Number) }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(requireTransfer(messages, of, "started")).toMatchObject({
|
|
108
|
+
type: "transfer",
|
|
109
|
+
syncPair: syncPairShape,
|
|
110
|
+
data: { of, type: "started", relativePath: expect.any(String), localPath: expect.any(String), size: expect.any(Number) }
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(requireTransfer(messages, of, "progress")).toMatchObject({
|
|
114
|
+
type: "transfer",
|
|
115
|
+
syncPair: syncPairShape,
|
|
116
|
+
data: {
|
|
117
|
+
of,
|
|
118
|
+
type: "progress",
|
|
119
|
+
relativePath: expect.any(String),
|
|
120
|
+
localPath: expect.any(String),
|
|
121
|
+
bytes: expect.any(Number),
|
|
122
|
+
size: expect.any(Number)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
expect(requireTransfer(messages, of, "finished")).toMatchObject({
|
|
127
|
+
type: "transfer",
|
|
128
|
+
syncPair: syncPairShape,
|
|
129
|
+
data: { of, type: "finished", relativePath: expect.any(String), localPath: expect.any(String), size: expect.any(Number) }
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("M1b: pins the per-op success transfer families (uploadFile/downloadFile/create/rename/delete)", () => {
|
|
135
|
+
const successOps = [
|
|
136
|
+
"uploadFile",
|
|
137
|
+
"downloadFile",
|
|
138
|
+
"createRemoteDirectory",
|
|
139
|
+
"createLocalDirectory",
|
|
140
|
+
"renameRemoteFile",
|
|
141
|
+
"renameRemoteDirectory",
|
|
142
|
+
"deleteRemoteFile",
|
|
143
|
+
"deleteRemoteDirectory"
|
|
144
|
+
] as const
|
|
145
|
+
|
|
146
|
+
for (const of of successOps) {
|
|
147
|
+
expect(requireTransfer(messages, of, "success")).toMatchObject({
|
|
148
|
+
type: "transfer",
|
|
149
|
+
syncPair: syncPairShape,
|
|
150
|
+
data: { of, type: "success", relativePath: expect.any(String), localPath: expect.any(String) }
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("M1c: pins the cycle lifecycle signal families (each is a bare { type, syncPair }, no data)", () => {
|
|
156
|
+
// Only signals that actually fire under the harness's frozen clock; the setTimeout-gated
|
|
157
|
+
// `cycleAcquiringLockStarted` / `cycleGettingTreesStarted` are deliberately excluded.
|
|
158
|
+
const lifecycleTypes = [
|
|
159
|
+
"cycleStarted",
|
|
160
|
+
"cycleAcquiringLockDone",
|
|
161
|
+
"cycleWaitingForLocalDirectoryChangesDone",
|
|
162
|
+
"cycleGettingTreesDone",
|
|
163
|
+
"cycleProcessingDeltasStarted",
|
|
164
|
+
"cycleProcessingDeltasDone",
|
|
165
|
+
"cycleProcessingTasksStarted",
|
|
166
|
+
"cycleProcessingTasksDone",
|
|
167
|
+
"cycleApplyingStateStarted",
|
|
168
|
+
"cycleApplyingStateDone",
|
|
169
|
+
"cycleSavingStateStarted",
|
|
170
|
+
"cycleSavingStateDone",
|
|
171
|
+
"cycleReleasingLockStarted",
|
|
172
|
+
"cycleReleasingLockDone",
|
|
173
|
+
"cycleSuccess"
|
|
174
|
+
] as const
|
|
175
|
+
|
|
176
|
+
for (const type of lifecycleTypes) {
|
|
177
|
+
const message = requireMessage(messages, type)
|
|
178
|
+
|
|
179
|
+
expect(message).toMatchObject({ type, syncPair: syncPairShape })
|
|
180
|
+
expect(message).not.toHaveProperty("data")
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("M1e: pins the cycleNoChanges signal emitted by a converged (idempotent) world", async () => {
|
|
185
|
+
// `cycleNoChanges` is the steady-state quiet signal; capture it from its own converged world
|
|
186
|
+
// (which reliably quiesces) rather than the busy fixture above (§A documents the settling).
|
|
187
|
+
const result = await runScenario({
|
|
188
|
+
name: "M1-noop",
|
|
189
|
+
mode: "twoWay",
|
|
190
|
+
uuid: "55555555-5555-4555-8555-555555555555",
|
|
191
|
+
initialLocal: { "/local/stable.txt": "stable" },
|
|
192
|
+
steps: [runCycle(), runCycle(), runCycle(), runCycle()]
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const noChanges = requireMessage(result.messages, "cycleNoChanges")
|
|
196
|
+
|
|
197
|
+
expect(noChanges).toMatchObject({ type: "cycleNoChanges", syncPair: syncPairShape })
|
|
198
|
+
expect(noChanges).not.toHaveProperty("data")
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("M1d: pins the data-carrying delta / tree / error report families", () => {
|
|
202
|
+
expect(requireMessage(messages, "deltasCount")).toMatchObject({
|
|
203
|
+
type: "deltasCount",
|
|
204
|
+
syncPair: syncPairShape,
|
|
205
|
+
data: { count: expect.any(Number) }
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
expect(requireMessage(messages, "deltasSize")).toMatchObject({
|
|
209
|
+
type: "deltasSize",
|
|
210
|
+
syncPair: syncPairShape,
|
|
211
|
+
data: { size: expect.any(Number) }
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(requireMessage(messages, "localTreeIgnored")).toMatchObject({
|
|
215
|
+
type: "localTreeIgnored",
|
|
216
|
+
syncPair: syncPairShape,
|
|
217
|
+
data: { ignored: expect.any(Array) }
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(requireMessage(messages, "remoteTreeIgnored")).toMatchObject({
|
|
221
|
+
type: "remoteTreeIgnored",
|
|
222
|
+
syncPair: syncPairShape,
|
|
223
|
+
data: { ignored: expect.any(Array) }
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
expect(requireMessage(messages, "localTreeErrors")).toMatchObject({
|
|
227
|
+
type: "localTreeErrors",
|
|
228
|
+
syncPair: syncPairShape,
|
|
229
|
+
data: { errors: expect.any(Array) }
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(requireMessage(messages, "taskErrors")).toMatchObject({
|
|
233
|
+
type: "taskErrors",
|
|
234
|
+
syncPair: syncPairShape,
|
|
235
|
+
data: { errors: expect.any(Array) }
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe("M2 — on-disk format pins", () => {
|
|
241
|
+
it("M2: pins the state/v2 + deviceId/v1 + ignorer/v1 persistence layout and serialization", async () => {
|
|
242
|
+
const uuid = "22222222-2222-4222-8222-222222222222"
|
|
243
|
+
const result = await runScenario({
|
|
244
|
+
name: "M2",
|
|
245
|
+
mode: "twoWay",
|
|
246
|
+
uuid,
|
|
247
|
+
initialLocal: { "/local/golden.txt": "golden-content" },
|
|
248
|
+
steps: [runCycle(), runCycle(), runCycle()]
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const { world } = result
|
|
252
|
+
const ifs = world.vfs.ifs
|
|
253
|
+
const exists = (path: string): boolean => world.vfs.controls.exists(path)
|
|
254
|
+
const readLines = (path: string): string[] =>
|
|
255
|
+
(ifs.readFileSync(path, "utf-8") as string).split("\n").filter(line => line.trim().length > 0)
|
|
256
|
+
|
|
257
|
+
// --- State directory layout: the engine's own public paths must live under `state/v2/<uuid>`.
|
|
258
|
+
// The engine composes these paths with the host `path.join`, so on Windows they use backslash
|
|
259
|
+
// separators; memfs and the layout pins below are posix, so normalize before touching the fs.
|
|
260
|
+
const state = world.sync.state
|
|
261
|
+
const toPosix = (path: string): string => path.replace(/\\/g, "/")
|
|
262
|
+
const statePath = toPosix(state.statePath)
|
|
263
|
+
const stateFilePaths = [
|
|
264
|
+
state.previousLocalTreePath,
|
|
265
|
+
state.previousLocalINodesPath,
|
|
266
|
+
state.previousRemoteTreePath,
|
|
267
|
+
state.previousRemoteUUIDsPath,
|
|
268
|
+
state.localFileHashesPath
|
|
269
|
+
].map(toPosix)
|
|
270
|
+
|
|
271
|
+
expect(statePath).toContain("/state/v2/")
|
|
272
|
+
expect(statePath.endsWith(`/${uuid}`)).toBe(true)
|
|
273
|
+
|
|
274
|
+
for (const filePath of stateFilePaths) {
|
|
275
|
+
expect(statePath.length, `${filePath} must live under statePath`).toBeGreaterThan(0)
|
|
276
|
+
expect(filePath.startsWith(statePath)).toBe(true)
|
|
277
|
+
expect(exists(filePath), `expected persisted state file ${filePath}`).toBe(true)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- Line-delimited JSON format: every line is `{"prop":<key>,"data":<value>}` + "\n".
|
|
281
|
+
const localTreeLines = readLines(toPosix(state.previousLocalTreePath))
|
|
282
|
+
|
|
283
|
+
expect(localTreeLines.length).toBeGreaterThan(0)
|
|
284
|
+
|
|
285
|
+
const localEntry = JSON.parse(localTreeLines[0]!) as { prop: string; data: Record<string, unknown> }
|
|
286
|
+
|
|
287
|
+
expect(typeof localEntry.prop).toBe("string")
|
|
288
|
+
expect(localEntry.data).toBeTypeOf("object")
|
|
289
|
+
// A serialized LocalItem keeps `path` + `type` (the delta engine's identity fields).
|
|
290
|
+
expect(localEntry.data).toHaveProperty("path")
|
|
291
|
+
expect(localEntry.data).toHaveProperty("type")
|
|
292
|
+
|
|
293
|
+
const remoteTreeLines = readLines(toPosix(state.previousRemoteTreePath))
|
|
294
|
+
|
|
295
|
+
expect(remoteTreeLines.length).toBeGreaterThan(0)
|
|
296
|
+
|
|
297
|
+
const remoteEntry = JSON.parse(remoteTreeLines[0]!) as { prop: string; data: Record<string, unknown> }
|
|
298
|
+
|
|
299
|
+
expect(typeof remoteEntry.prop).toBe("string")
|
|
300
|
+
// A serialized RemoteItem keeps its cloud `uuid` (remote identity) + `path` + `type`.
|
|
301
|
+
expect(remoteEntry.data).toHaveProperty("uuid")
|
|
302
|
+
expect(remoteEntry.data).toHaveProperty("path")
|
|
303
|
+
expect(remoteEntry.data).toHaveProperty("type")
|
|
304
|
+
|
|
305
|
+
// localFileHashes maps a path → md5 hex STRING (not an object).
|
|
306
|
+
const hashLines = readLines(toPosix(state.localFileHashesPath))
|
|
307
|
+
|
|
308
|
+
expect(hashLines.length).toBeGreaterThan(0)
|
|
309
|
+
|
|
310
|
+
const hashEntry = JSON.parse(hashLines[0]!) as { prop: string; data: unknown }
|
|
311
|
+
|
|
312
|
+
expect(typeof hashEntry.prop).toBe("string")
|
|
313
|
+
expect(typeof hashEntry.data).toBe("string")
|
|
314
|
+
|
|
315
|
+
// --- deviceId file: `deviceId/v1/<uuid>` holds a non-empty uuid string (drives the tree cache).
|
|
316
|
+
expect(DEVICE_ID_VERSION).toBe(1)
|
|
317
|
+
|
|
318
|
+
const deviceIdPath = `${DB_ROOT}/deviceId/v${DEVICE_ID_VERSION}/${uuid}`
|
|
319
|
+
|
|
320
|
+
expect(deviceIdPath).toContain("/deviceId/v1/")
|
|
321
|
+
expect(exists(deviceIdPath), `expected persisted deviceId file ${deviceIdPath}`).toBe(true)
|
|
322
|
+
|
|
323
|
+
const deviceId = (ifs.readFileSync(deviceIdPath, "utf-8") as string).trim()
|
|
324
|
+
|
|
325
|
+
expect(deviceId.length).toBeGreaterThan(0)
|
|
326
|
+
expect(deviceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
|
327
|
+
|
|
328
|
+
// --- Ignorer layout: `<dbPath>/ignorer/v${IGNORER_VERSION}/<uuid>/filenIgnore`. The engine only
|
|
329
|
+
// materializes this dbPath copy lazily (the physical `<localPath>/.filenignore` is the source of
|
|
330
|
+
// truth), so the migration contract is pinned via the version constant + the dir-name field that
|
|
331
|
+
// together compose the path, rather than via an on-disk file.
|
|
332
|
+
expect(IGNORER_VERSION).toBe(1)
|
|
333
|
+
expect(world.sync.ignorer.name).toBe("ignorer")
|
|
334
|
+
|
|
335
|
+
const ignorerPath = `${DB_ROOT}/${world.sync.ignorer.name}/v${IGNORER_VERSION}/${uuid}/filenIgnore`
|
|
336
|
+
|
|
337
|
+
expect(ignorerPath).toBe(`${DB_ROOT}/ignorer/v1/${uuid}/filenIgnore`)
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe("M3 — public surface pins", () => {
|
|
342
|
+
it("M3: SyncWorker is constructible with the documented options and exposes the public methods", async () => {
|
|
343
|
+
// Reuse the harness to obtain a wired fake SDK; construct a worker directly to pin that the
|
|
344
|
+
// documented constructor options object is accepted (sdk branch → no socket connection).
|
|
345
|
+
const result = await runScenario({
|
|
346
|
+
name: "M3",
|
|
347
|
+
mode: "twoWay",
|
|
348
|
+
uuid: "33333333-3333-4333-8333-333333333333",
|
|
349
|
+
steps: [runCycle()]
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
expect(typeof SyncWorker).toBe("function")
|
|
353
|
+
|
|
354
|
+
const pair: SyncPair = {
|
|
355
|
+
name: "m3-pair",
|
|
356
|
+
uuid: "44444444-4444-4444-8444-444444444444",
|
|
357
|
+
localPath: "/local",
|
|
358
|
+
remotePath: "/",
|
|
359
|
+
remoteParentUUID: result.world.cloud.controls.rootUUID,
|
|
360
|
+
mode: "twoWay",
|
|
361
|
+
excludeDotFiles: false,
|
|
362
|
+
paused: false,
|
|
363
|
+
localTrashDisabled: false,
|
|
364
|
+
requireConfirmationOnLargeDeletion: false
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const worker = new SyncWorker({
|
|
368
|
+
syncPairs: [pair],
|
|
369
|
+
dbPath: DB_ROOT,
|
|
370
|
+
sdk: result.world.cloud.sdk,
|
|
371
|
+
onMessage: () => {},
|
|
372
|
+
runOnce: true,
|
|
373
|
+
disableLogging: true
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
expect(worker).toBeInstanceOf(SyncWorker)
|
|
377
|
+
|
|
378
|
+
// Documented public readonly properties.
|
|
379
|
+
expect(Array.isArray(worker.syncPairs)).toBe(true)
|
|
380
|
+
expect(typeof worker.dbPath).toBe("string")
|
|
381
|
+
expect(worker.sdk).toBeDefined()
|
|
382
|
+
expect(typeof worker.syncs).toBe("object")
|
|
383
|
+
expect(typeof worker.runOnce).toBe("boolean")
|
|
384
|
+
|
|
385
|
+
// Documented public methods (config updates, deletion confirmation, ignorer, transfers, lifecycle).
|
|
386
|
+
const methods = [
|
|
387
|
+
"updateMode",
|
|
388
|
+
"updateExcludeDotFiles",
|
|
389
|
+
"updateIgnorerContent",
|
|
390
|
+
"updateRequireConfirmationOnLargeDeletion",
|
|
391
|
+
"confirmDeletion",
|
|
392
|
+
"fetchIgnorerContent",
|
|
393
|
+
"updatePaused",
|
|
394
|
+
"pauseTransfer",
|
|
395
|
+
"resumeTransfer",
|
|
396
|
+
"stopTransfer",
|
|
397
|
+
"updateSyncPairs",
|
|
398
|
+
"updateRemoved",
|
|
399
|
+
"resetCache",
|
|
400
|
+
"resetTaskErrors",
|
|
401
|
+
"resetLocalTreeErrors",
|
|
402
|
+
"toggleLocalTrash",
|
|
403
|
+
"initialize"
|
|
404
|
+
] as const
|
|
405
|
+
|
|
406
|
+
for (const method of methods) {
|
|
407
|
+
expect(typeof worker[method], `worker.${method} should be a function`).toBe("function")
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Type-level signature pins (validated by tsc, no-op at runtime).
|
|
411
|
+
expectTypeOf(worker.updateMode).parameters.toEqualTypeOf<[string, SyncMode]>()
|
|
412
|
+
expectTypeOf(worker.confirmDeletion).parameters.toEqualTypeOf<[string, "delete" | "restart"]>()
|
|
413
|
+
expectTypeOf(worker.updateExcludeDotFiles).parameters.toEqualTypeOf<[string, boolean]>()
|
|
414
|
+
expectTypeOf(worker.fetchIgnorerContent).returns.toEqualTypeOf<Promise<string>>()
|
|
415
|
+
expectTypeOf(worker.updateIgnorerContent).returns.toEqualTypeOf<Promise<void>>()
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
describe("M4 — root exports pin", () => {
|
|
420
|
+
it("M4: serializeError/deserializeError/tryingToSyncDesktop/isPathSyncedByICloud are exported functions", () => {
|
|
421
|
+
// Default export is the SyncWorker class; the four utils are re-exported via `export * from "./utils"`.
|
|
422
|
+
expect(typeof rootExports.default).toBe("function")
|
|
423
|
+
expect(rootExports.default).toBe(SyncWorker)
|
|
424
|
+
|
|
425
|
+
for (const name of ["serializeError", "deserializeError", "tryingToSyncDesktop", "isPathSyncedByICloud"] as const) {
|
|
426
|
+
expect(rootExports, `root export "${name}" is missing`).toHaveProperty(name)
|
|
427
|
+
expect(typeof rootExports[name], `root export "${name}" should be a function`).toBe("function")
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// serializeError / deserializeError round-trip pins the SerializedError IPC shape.
|
|
431
|
+
const original = new Error("boom")
|
|
432
|
+
|
|
433
|
+
original.name = "GoldenError"
|
|
434
|
+
|
|
435
|
+
const serialized = rootExports.serializeError(original)
|
|
436
|
+
|
|
437
|
+
expect(serialized).toMatchObject({ name: "GoldenError", message: "boom", stringified: "GoldenError: boom" })
|
|
438
|
+
expect(typeof serialized.stringified).toBe("string")
|
|
439
|
+
|
|
440
|
+
const restored = rootExports.deserializeError(serialized)
|
|
441
|
+
|
|
442
|
+
expect(restored).toBeInstanceOf(Error)
|
|
443
|
+
expect(restored.message).toBe("boom")
|
|
444
|
+
expect(restored.name).toBe("GoldenError")
|
|
445
|
+
|
|
446
|
+
// tryingToSyncDesktop is a pure, synchronous predicate. isPathSyncedByICloud spawns `xattr` on
|
|
447
|
+
// darwin, so it is only pinned as a callable function (not invoked) to keep the suite hermetic.
|
|
448
|
+
expect(typeof rootExports.tryingToSyncDesktop("/definitely/not/the/desktop")).toBe("boolean")
|
|
449
|
+
expectTypeOf(rootExports.isPathSyncedByICloud).returns.toEqualTypeOf<Promise<boolean>>()
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
})
|