@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.
Files changed (152) hide show
  1. package/.node-version +1 -1
  2. package/dist/ignorer.d.ts +6 -0
  3. package/dist/ignorer.js +43 -24
  4. package/dist/ignorer.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deltas.d.ts +58 -2
  9. package/dist/lib/deltas.js +693 -108
  10. package/dist/lib/deltas.js.map +1 -1
  11. package/dist/lib/environment.d.ts +47 -0
  12. package/dist/lib/environment.js +71 -0
  13. package/dist/lib/environment.js.map +1 -0
  14. package/dist/lib/filesystems/dirTree.d.ts +70 -0
  15. package/dist/lib/filesystems/dirTree.js +157 -0
  16. package/dist/lib/filesystems/dirTree.js.map +1 -0
  17. package/dist/lib/filesystems/local.d.ts +18 -8
  18. package/dist/lib/filesystems/local.js +166 -160
  19. package/dist/lib/filesystems/local.js.map +1 -1
  20. package/dist/lib/filesystems/remote.d.ts +12 -5
  21. package/dist/lib/filesystems/remote.js +226 -172
  22. package/dist/lib/filesystems/remote.js.map +1 -1
  23. package/dist/lib/ipc.js +1 -2
  24. package/dist/lib/ipc.js.map +1 -1
  25. package/dist/lib/lock.js +19 -12
  26. package/dist/lib/lock.js.map +1 -1
  27. package/dist/lib/logger.js +9 -7
  28. package/dist/lib/logger.js.map +1 -1
  29. package/dist/lib/state.js +159 -63
  30. package/dist/lib/state.js.map +1 -1
  31. package/dist/lib/sync.d.ts +18 -0
  32. package/dist/lib/sync.js +165 -96
  33. package/dist/lib/sync.js.map +1 -1
  34. package/dist/lib/tasks.d.ts +7 -8
  35. package/dist/lib/tasks.js +38 -45
  36. package/dist/lib/tasks.js.map +1 -1
  37. package/dist/semaphore.d.ts +1 -0
  38. package/dist/semaphore.js +22 -5
  39. package/dist/semaphore.js.map +1 -1
  40. package/dist/utils.js +51 -35
  41. package/dist/utils.js.map +1 -1
  42. package/eslint.config.mjs +36 -0
  43. package/package.json +19 -15
  44. package/tests/bench/collapse.bench.ts +114 -0
  45. package/tests/bench/cycle.bench.ts +111 -0
  46. package/tests/bench/deltas.bench.ts +151 -0
  47. package/tests/bench/harness/fake-sync.ts +32 -0
  48. package/tests/bench/harness/measure.ts +276 -0
  49. package/tests/bench/harness/scale-world.ts +160 -0
  50. package/tests/bench/harness/trees.ts +275 -0
  51. package/tests/bench/local-scan.bench.ts +74 -0
  52. package/tests/bench/longrun.bench.ts +130 -0
  53. package/tests/bench/profile-incremental.ts +90 -0
  54. package/tests/bench/remote-build.bench.ts +104 -0
  55. package/tests/bench/render.ts +14 -0
  56. package/tests/bench/semaphore.bench.ts +79 -0
  57. package/tests/bench/state.bench.ts +85 -0
  58. package/tests/bench/tasks-dispatch.bench.ts +156 -0
  59. package/tests/conformance/virtual-fs.test.ts +213 -0
  60. package/tests/e2e/backup.e2e.test.ts +130 -0
  61. package/tests/e2e/confirm.e2e.test.ts +191 -0
  62. package/tests/e2e/conflict.e2e.test.ts +261 -0
  63. package/tests/e2e/edge.e2e.test.ts +339 -0
  64. package/tests/e2e/harness/account.ts +104 -0
  65. package/tests/e2e/harness/assert.ts +127 -0
  66. package/tests/e2e/harness/drive.ts +88 -0
  67. package/tests/e2e/harness/mutations.ts +249 -0
  68. package/tests/e2e/harness/world.ts +222 -0
  69. package/tests/e2e/ignore.e2e.test.ts +123 -0
  70. package/tests/e2e/lifecycle.e2e.test.ts +290 -0
  71. package/tests/e2e/modes.e2e.test.ts +215 -0
  72. package/tests/e2e/platform.e2e.test.ts +157 -0
  73. package/tests/e2e/property.e2e.test.ts +163 -0
  74. package/tests/e2e/races.e2e.test.ts +90 -0
  75. package/tests/e2e/regressions.e2e.test.ts +212 -0
  76. package/tests/e2e/resilience.e2e.test.ts +231 -0
  77. package/tests/e2e/special.e2e.test.ts +185 -0
  78. package/tests/e2e/state.e2e.test.ts +229 -0
  79. package/tests/e2e/sync.e2e.test.ts +222 -0
  80. package/tests/fakes/fake-cloud.test.ts +267 -0
  81. package/tests/fakes/fake-cloud.ts +1094 -0
  82. package/tests/fakes/virtual-fs.ts +354 -0
  83. package/tests/harness/known-bug.ts +17 -0
  84. package/tests/harness/mutations.ts +65 -0
  85. package/tests/harness/runner.ts +141 -0
  86. package/tests/harness/snapshot.ts +113 -0
  87. package/tests/harness/world.ts +187 -0
  88. package/tests/scenarios/a-baseline.test.ts +107 -0
  89. package/tests/scenarios/aa-races.test.ts +258 -0
  90. package/tests/scenarios/ab-mode-property.test.ts +189 -0
  91. package/tests/scenarios/ac-platform.test.ts +320 -0
  92. package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
  93. package/tests/scenarios/b-additions.test.ts +160 -0
  94. package/tests/scenarios/c-modifications.test.ts +194 -0
  95. package/tests/scenarios/d-deletions.test.ts +259 -0
  96. package/tests/scenarios/e-rename-move.test.ts +288 -0
  97. package/tests/scenarios/f-ignore-filter.test.ts +346 -0
  98. package/tests/scenarios/g-large-deletion.test.ts +277 -0
  99. package/tests/scenarios/h-resilience.test.ts +167 -0
  100. package/tests/scenarios/i-lifecycle.test.ts +353 -0
  101. package/tests/scenarios/j-state-cache.test.ts +264 -0
  102. package/tests/scenarios/k-scale.test.ts +202 -0
  103. package/tests/scenarios/l-property.test.ts +145 -0
  104. package/tests/scenarios/m-golden.test.ts +452 -0
  105. package/tests/scenarios/o-task-errors.test.ts +497 -0
  106. package/tests/scenarios/p-remote-originated.test.ts +306 -0
  107. package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
  108. package/tests/scenarios/r-rename-stress.test.ts +208 -0
  109. package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
  110. package/tests/scenarios/t-type-change.test.ts +144 -0
  111. package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
  112. package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
  113. package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
  114. package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
  115. package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
  116. package/tests/scenarios/z-cross-ops.test.ts +285 -0
  117. package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
  118. package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
  119. package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
  120. package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
  121. package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
  122. package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
  123. package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
  124. package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
  125. package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
  126. package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
  127. package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
  128. package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
  129. package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
  130. package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
  131. package/tests/unit/collapse-deltas.test.ts +276 -0
  132. package/tests/unit/dir-tree.test.ts +159 -0
  133. package/tests/unit/icloud.test.ts +115 -0
  134. package/tests/unit/ignorer-cache-regression.test.ts +70 -0
  135. package/tests/unit/ignorer.test.ts +63 -0
  136. package/tests/unit/ipc-lock.test.ts +438 -0
  137. package/tests/unit/lock.test.ts +135 -0
  138. package/tests/unit/n-unit.test.ts +632 -0
  139. package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
  140. package/tests/unit/semaphore-regression.test.ts +140 -0
  141. package/tests/unit/state-refencode-regression.test.ts +224 -0
  142. package/tests/unit/state.test.ts +809 -0
  143. package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
  144. package/tests/unit/worker-api.test.ts +379 -0
  145. package/tsconfig.json +10 -1
  146. package/tsconfig.test.json +12 -0
  147. package/tsconfig.tsbuildinfo +1 -0
  148. package/vitest.bench.config.ts +32 -0
  149. package/vitest.config.ts +27 -0
  150. package/vitest.e2e.config.ts +68 -0
  151. package/.eslintrc +0 -16
  152. package/jest.config.js +0 -5
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate } from "../harness/runner"
3
+ import { messagesOfType } from "../harness/snapshot"
4
+ import { writeLocal, rmLocal } from "../harness/mutations"
5
+
6
+ /**
7
+ * Added regression test pinning the tasks.process dispatch ORDER after the bucketing optimization (the
8
+ * 12 filter-passes were replaced with a single partition-then-drain). The fixed type order — deletes
9
+ * before directory creations before uploads — is load-bearing: an upload into a not-yet-created
10
+ * directory would fail. This drives a mixed one-cycle batch and asserts the completion order, on top of
11
+ * convergence.
12
+ *
13
+ * NEW FILE — does not touch the existing scenario tests.
14
+ */
15
+ describe("tasks.process — dispatch order preserved by bucketing", () => {
16
+ it("completes deleteRemoteFile before createRemoteDirectory before uploadFile in one cycle", async () => {
17
+ const result = await runScenario({
18
+ name: "dispatch-order",
19
+ mode: "twoWay",
20
+ initialLocal: { "/local/old.txt": "old", "/local/keep.txt": "k" },
21
+ steps: [
22
+ runCycle(),
23
+ localMutate(world => {
24
+ // One cycle that produces a delete, a directory creation, and an upload into it.
25
+ rmLocal(world, "old.txt")
26
+ writeLocal(world, "newdir/new.txt", "new-content")
27
+ }),
28
+ runCycle(),
29
+ runCycle()
30
+ ]
31
+ })
32
+
33
+ const opCycle = result.cycles[1]!.messages
34
+ const completions: string[] = messagesOfType(opCycle, "transfer")
35
+ .filter(message => message.data.type === "success")
36
+ .map(message => message.data.of)
37
+
38
+ const firstIndex = (of: string): number => completions.indexOf(of)
39
+
40
+ expect(firstIndex("deleteRemoteFile")).toBeGreaterThanOrEqual(0)
41
+ expect(firstIndex("createRemoteDirectory")).toBeGreaterThanOrEqual(0)
42
+ expect(firstIndex("uploadFile")).toBeGreaterThanOrEqual(0)
43
+
44
+ // Deletes run before directory creations, which run before uploads.
45
+ expect(firstIndex("deleteRemoteFile")).toBeLessThan(firstIndex("createRemoteDirectory"))
46
+ expect(firstIndex("createRemoteDirectory")).toBeLessThan(firstIndex("uploadFile"))
47
+
48
+ // And the cycle still converges.
49
+ expect(result.finalLocal).toEqual(result.finalRemote)
50
+ expect(result.finalRemote["/newdir/new.txt"]).toMatchObject({ type: "file" })
51
+ expect(result.finalRemote["/old.txt"]).toBeUndefined()
52
+ })
53
+ })
@@ -0,0 +1,379 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { PauseSignal } from "@filen/sdk"
3
+ import SyncWorker from "../../src/index"
4
+ import Sync from "../../src/lib/sync"
5
+ import { promiseAllChunked, promiseAllSettledChunked, isPathSyncedByICloud, pathSyncedByICloud } from "../../src/utils"
6
+ import { type LocalTreeError } from "../../src/lib/filesystems/local"
7
+ import { createWorld, BASE_TIME, DB_ROOT, type CreateWorldOptions, type World } from "../harness/world"
8
+ import { makeErrnoError } from "../fakes/virtual-fs"
9
+
10
+ /**
11
+ * Targeted unit tests for the {@link SyncWorker} public control surface in `src/index.ts` and the
12
+ * remaining uncovered helpers in `src/utils.ts`. The behavioral scenario suite (Category I in
13
+ * particular) already drives the match/effect paths of the lifecycle methods; this file fills the
14
+ * gaps: the constructor option handling, the methods the scenarios do not touch
15
+ * (`resetLocalTreeErrors`, `toggleLocalTrash`, `updateRequireConfirmationOnLargeDeletion`,
16
+ * `fetchIgnorerContent`), the per-transfer pause-signal fan-out inside `updatePaused`, and the
17
+ * "unknown uuid / absent Sync" no-op branches shared by the whole control surface.
18
+ *
19
+ * World-backed tests run under vitest fake timers (matching {@link createWorld}'s contract); the
20
+ * pure helper tests do not need them.
21
+ */
22
+ const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
23
+
24
+ async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
25
+ vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
26
+ vi.setSystemTime(BASE_TIME)
27
+
28
+ try {
29
+ const world = await createWorld(options)
30
+
31
+ await body(world)
32
+ } finally {
33
+ vi.useRealTimers()
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Run `fn` with `process.platform` temporarily stubbed (async variant of the helper used in
39
+ * `n-unit.test.ts`). `process.platform` is non-writable but configurable, so it is replaced via
40
+ * `defineProperty` and restored from its original descriptor.
41
+ */
42
+ async function withPlatformAsync(platform: NodeJS.Platform, fn: () => Promise<void>): Promise<void> {
43
+ const original = Object.getOwnPropertyDescriptor(process, "platform")
44
+
45
+ Object.defineProperty(process, "platform", { value: platform, configurable: true })
46
+
47
+ try {
48
+ await fn()
49
+ } finally {
50
+ if (original) {
51
+ Object.defineProperty(process, "platform", original)
52
+ }
53
+ }
54
+ }
55
+
56
+ describe("SyncWorker public API — constructor", () => {
57
+ it("throws when neither an sdk instance nor an sdkConfig is provided", () => {
58
+ expect(() => new SyncWorker({ syncPairs: [], dbPath: DB_ROOT })).toThrow(
59
+ "Either pass a configured SDK instance OR a SDKConfig object."
60
+ )
61
+ })
62
+
63
+ it("falls back to the default environment, defaults runOnce to false, and tolerates a missing onMessage", async () => {
64
+ await withWorld({ mode: "twoWay" }, async world => {
65
+ // No `environment` => defaultEnvironment(); no `onMessage` => the process.onMessage assignment
66
+ // is skipped; `runOnce` is omitted => defaults to false.
67
+ const worker = new SyncWorker({
68
+ syncPairs: [world.syncPair],
69
+ dbPath: DB_ROOT,
70
+ sdk: world.cloud.sdk,
71
+ disableLogging: true
72
+ })
73
+
74
+ expect(worker.runOnce).toBe(false)
75
+ expect(worker.environment.fs).toBeDefined()
76
+ expect(worker.environment.globFs).toBeDefined()
77
+ expect(typeof worker.environment.writeFileAtomic).toBe("function")
78
+ expect(typeof worker.environment.createWatcher).toBe("function")
79
+
80
+ // It registered nothing and started nothing: initialize() was never called.
81
+ expect(Object.keys(worker.syncs)).toEqual([])
82
+ })
83
+ })
84
+ })
85
+
86
+ describe("SyncWorker public API — updateSyncPairs error handling", () => {
87
+ it("surfaces and rethrows an initialization failure for a newly registered pair", async () => {
88
+ await withWorld({ mode: "twoWay" }, async world => {
89
+ // A brand-new pair (not yet in `syncs`) makes updateSyncPairs construct a Sync and initialize
90
+ // it. A throwaway Sync over the same pair exposes the exact state directory the real one will
91
+ // `ensureDir()` during initialize(); failing that fs op makes initialize() reject and forces
92
+ // updateSyncPairs into its catch/rethrow.
93
+ const newPair = { ...world.syncPair, uuid: "worker-api-second-pair" }
94
+ const probe = new Sync({ syncPair: newPair, worker: world.worker })
95
+
96
+ world.vfs.controls.setError(probe.state.statePath, makeErrnoError("EACCES", "EACCES: permission denied"))
97
+
98
+ await expect(world.worker.updateSyncPairs([newPair])).rejects.toThrow("EACCES")
99
+ })
100
+ })
101
+ })
102
+
103
+ describe("SyncWorker public API — per-pair control methods", () => {
104
+ it("resetLocalTreeErrors clears the matching pair's local-tree errors and ignores an unknown uuid", async () => {
105
+ await withWorld({ mode: "twoWay" }, async world => {
106
+ const uuid = world.syncPair.uuid
107
+ const treeError: LocalTreeError = {
108
+ localPath: `${world.localPath}/broken.txt`,
109
+ relativePath: "/broken.txt",
110
+ error: new Error("stat failed"),
111
+ uuid: "node-uuid"
112
+ }
113
+
114
+ world.sync.localTreeErrors = [treeError]
115
+
116
+ // Unknown uuid: the loop `continue`s past the (mismatched) pair, leaving the errors intact.
117
+ world.worker.resetLocalTreeErrors("not-this-pair")
118
+ expect(world.sync.localTreeErrors).toEqual([treeError])
119
+
120
+ // Matching uuid: the errors are reset to an empty array.
121
+ world.worker.resetLocalTreeErrors(uuid)
122
+ expect(world.sync.localTreeErrors).toEqual([])
123
+ })
124
+ })
125
+
126
+ it("toggleLocalTrash flips localTrashDisabled for the matching pair and ignores an unknown uuid", async () => {
127
+ await withWorld({ mode: "twoWay" }, async world => {
128
+ const uuid = world.syncPair.uuid
129
+
130
+ expect(world.sync.localTrashDisabled).toBe(false)
131
+
132
+ // Unknown uuid: no change.
133
+ world.worker.toggleLocalTrash("not-this-pair", true)
134
+ expect(world.sync.localTrashDisabled).toBe(false)
135
+
136
+ // Matching uuid: disable, then re-enable.
137
+ world.worker.toggleLocalTrash(uuid, true)
138
+ expect(world.sync.localTrashDisabled).toBe(true)
139
+
140
+ world.worker.toggleLocalTrash(uuid, false)
141
+ expect(world.sync.localTrashDisabled).toBe(false)
142
+ })
143
+ })
144
+
145
+ it("updateRequireConfirmationOnLargeDeletion toggles the flag for the matching pair and ignores an unknown uuid", async () => {
146
+ await withWorld({ mode: "twoWay" }, async world => {
147
+ const uuid = world.syncPair.uuid
148
+
149
+ expect(world.sync.requireConfirmationOnLargeDeletion).toBe(false)
150
+
151
+ world.worker.updateRequireConfirmationOnLargeDeletion("not-this-pair", true)
152
+ expect(world.sync.requireConfirmationOnLargeDeletion).toBe(false)
153
+
154
+ world.worker.updateRequireConfirmationOnLargeDeletion(uuid, true)
155
+ expect(world.sync.requireConfirmationOnLargeDeletion).toBe(true)
156
+ })
157
+ })
158
+
159
+ it("fetchIgnorerContent returns the matching pair's ignorer content and an empty string for an unknown uuid", async () => {
160
+ await withWorld({ mode: "twoWay" }, async world => {
161
+ const uuid = world.syncPair.uuid
162
+
163
+ // Unknown uuid short-circuits to an empty string (no pair matched).
164
+ expect(await world.worker.fetchIgnorerContent("not-this-pair")).toBe("")
165
+
166
+ // After setting the ignorer content it round-trips through the physical .filenignore.
167
+ await world.worker.updateIgnorerContent(uuid, "node_modules\n*.log")
168
+ expect(await world.worker.fetchIgnorerContent(uuid)).toBe("node_modules\n*.log")
169
+ })
170
+ })
171
+
172
+ it("updatePaused pauses and resumes each registered per-transfer signal, skipping ones already in the target state", async () => {
173
+ await withWorld({ mode: "twoWay" }, async world => {
174
+ const uuid = world.syncPair.uuid
175
+ const running = new PauseSignal()
176
+ const alreadyPaused = new PauseSignal()
177
+
178
+ alreadyPaused.pause()
179
+
180
+ world.sync.pauseSignals["upload:/running.txt"] = running
181
+ world.sync.pauseSignals["upload:/already.txt"] = alreadyPaused
182
+
183
+ // Pausing the pair pauses the running signal and leaves the already-paused one untouched.
184
+ world.worker.updatePaused(uuid, true)
185
+
186
+ expect(world.sync.paused).toBe(true)
187
+ expect(running.isPaused()).toBe(true)
188
+ expect(alreadyPaused.isPaused()).toBe(true)
189
+
190
+ // Add a freshly-registered, still-running signal: the resume pass must resume the paused ones
191
+ // and skip the running one.
192
+ const stillRunning = new PauseSignal()
193
+
194
+ world.sync.pauseSignals["download:/fresh.txt"] = stillRunning
195
+
196
+ world.worker.updatePaused(uuid, false)
197
+
198
+ expect(world.sync.paused).toBe(false)
199
+ expect(running.isPaused()).toBe(false)
200
+ expect(alreadyPaused.isPaused()).toBe(false)
201
+ expect(stillRunning.isPaused()).toBe(false)
202
+ })
203
+ })
204
+
205
+ it("updateRemoved with removed=false clears the removed flag without running cleanup", async () => {
206
+ await withWorld({ mode: "twoWay" }, async world => {
207
+ const uuid = world.syncPair.uuid
208
+
209
+ world.sync.removed = true
210
+
211
+ const mark = world.messages.length
212
+
213
+ await world.worker.updateRemoved(uuid, false)
214
+
215
+ expect(world.sync.removed).toBe(false)
216
+ // The removed=false branch does NOT call cleanup(), so no cycleExited is emitted.
217
+ expect(world.messages.slice(mark).some(message => message.type === "cycleExited")).toBe(false)
218
+ })
219
+ })
220
+ })
221
+
222
+ describe("SyncWorker public API — unknown uuid / absent Sync no-ops", () => {
223
+ it("resetCache and resetTaskErrors do nothing for an unknown uuid", async () => {
224
+ await withWorld({ mode: "twoWay" }, async world => {
225
+ const cacheBefore = world.sync.localFileSystem.getDirectoryTreeCache
226
+ const timestampBefore = world.sync.localFileSystem.lastDirectoryChangeTimestamp
227
+ const taskErrorsBefore = world.sync.taskErrors
228
+
229
+ world.worker.resetCache("not-this-pair")
230
+ world.worker.resetTaskErrors("not-this-pair")
231
+
232
+ // The mismatched-uuid branch `continue`s without touching any state (same references/values).
233
+ expect(world.sync.localFileSystem.getDirectoryTreeCache).toBe(cacheBefore)
234
+ expect(world.sync.localFileSystem.lastDirectoryChangeTimestamp).toBe(timestampBefore)
235
+ expect(world.sync.taskErrors).toBe(taskErrorsBefore)
236
+ })
237
+ })
238
+
239
+ it("update* controls leave the pair untouched when called with an unknown uuid", async () => {
240
+ await withWorld({ mode: "twoWay" }, async world => {
241
+ const ignorePath = `${world.localPath}/.filenignore`
242
+
243
+ expect(world.sync.paused).toBe(false)
244
+ expect(world.sync.excludeDotFiles).toBe(false)
245
+ expect(world.sync.mode).toBe("twoWay")
246
+ expect(world.sync.deletionConfirmationResult).toBe("waiting")
247
+ expect(world.vfs.controls.exists(ignorePath)).toBe(false)
248
+
249
+ world.worker.updatePaused("not-this-pair", true)
250
+ world.worker.updateExcludeDotFiles("not-this-pair", true)
251
+ world.worker.updateMode("not-this-pair", "localBackup")
252
+ world.worker.confirmDeletion("not-this-pair", "delete")
253
+ await world.worker.updateIgnorerContent("not-this-pair", "secret")
254
+ await world.worker.updateRemoved("not-this-pair", true)
255
+
256
+ expect(world.sync.paused).toBe(false)
257
+ expect(world.sync.excludeDotFiles).toBe(false)
258
+ expect(world.sync.mode).toBe("twoWay")
259
+ expect(world.sync.deletionConfirmationResult).toBe("waiting")
260
+ expect(world.sync.removed).toBe(false)
261
+ // updateIgnorerContent for an unknown uuid wrote nothing to the physical .filenignore.
262
+ expect(world.vfs.controls.exists(ignorePath)).toBe(false)
263
+ })
264
+ })
265
+
266
+ it("transfer controls are safe no-ops for unknown uuids and unknown transfer keys", async () => {
267
+ await withWorld({ mode: "twoWay" }, async world => {
268
+ const uuid = world.syncPair.uuid
269
+
270
+ // Unknown uuid: the per-sync lookup never matches, so nothing is registered.
271
+ world.worker.stopTransfer("not-this-pair", "upload", "/a.txt")
272
+ world.worker.pauseTransfer("not-this-pair", "upload", "/a.txt")
273
+ world.worker.resumeTransfer("not-this-pair", "upload", "/a.txt")
274
+
275
+ // Known uuid but a transfer key that was never registered: still a no-op, no controller or
276
+ // signal is conjured into existence.
277
+ world.worker.stopTransfer(uuid, "download", "/missing.txt")
278
+ world.worker.resumeTransfer(uuid, "download", "/missing.txt")
279
+ world.worker.pauseTransfer(uuid, "download", "/missing.txt")
280
+
281
+ expect(world.sync.abortControllers["upload:/a.txt"]).toBeUndefined()
282
+ expect(world.sync.abortControllers["download:/missing.txt"]).toBeUndefined()
283
+ expect(world.sync.pauseSignals["upload:/a.txt"]).toBeUndefined()
284
+ expect(world.sync.pauseSignals["download:/missing.txt"]).toBeUndefined()
285
+ })
286
+ })
287
+
288
+ it("control methods skip a registered pair that has no active Sync instance", async () => {
289
+ await withWorld({ mode: "twoWay" }, async world => {
290
+ const uuid = world.syncPair.uuid
291
+
292
+ // A pair present in `syncPairs` but with no instantiated Sync (e.g. before initialize() or after
293
+ // a teardown): the `!sync` guard must skip it safely rather than dereferencing undefined.
294
+ Reflect.deleteProperty(world.worker.syncs, uuid)
295
+
296
+ expect(() => {
297
+ world.worker.resetCache(uuid)
298
+ world.worker.resetTaskErrors(uuid)
299
+ world.worker.resetLocalTreeErrors(uuid)
300
+ world.worker.toggleLocalTrash(uuid, true)
301
+ }).not.toThrow()
302
+
303
+ // None of the calls recreated a Sync for the pair.
304
+ expect(world.worker.syncs[uuid]).toBeUndefined()
305
+ })
306
+ })
307
+ })
308
+
309
+ describe("utils — promiseAllChunked", () => {
310
+ it("withResults=true returns the fulfilled values in order across a chunk boundary", async () => {
311
+ const promises = [1, 2, 3, 4, 5].map(n => Promise.resolve(n * 10))
312
+
313
+ // chunkSize=2 < 5 forces multiple chunks; the concatenated result preserves input order.
314
+ const result = await promiseAllChunked(promises, 2, true)
315
+
316
+ expect(result).toEqual([10, 20, 30, 40, 50])
317
+ })
318
+
319
+ it("withResults=false awaits every promise but resolves to an empty array", async () => {
320
+ const order: number[] = []
321
+ const make = (n: number): Promise<void> =>
322
+ Promise.resolve().then(() => {
323
+ order.push(n)
324
+ })
325
+
326
+ const result = await promiseAllChunked([make(1), make(2), make(3), make(4), make(5)], 2, false)
327
+
328
+ expect(result).toEqual([])
329
+ // Every promise still ran even though no results were collected.
330
+ expect([...order].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5])
331
+ })
332
+ })
333
+
334
+ describe("utils — promiseAllSettledChunked", () => {
335
+ it("withResults=true keeps fulfilled values in order and swallows rejections in the reducer", async () => {
336
+ const promises = [
337
+ Promise.resolve("a"),
338
+ Promise.reject(new Error("nope")),
339
+ Promise.resolve("b"),
340
+ Promise.reject(new Error("nope2")),
341
+ Promise.resolve("c")
342
+ ]
343
+
344
+ const result = await promiseAllSettledChunked(promises, 2, true)
345
+
346
+ // Rejections are dropped by the reduce; only fulfilled values survive, in order.
347
+ expect(result).toEqual(["a", "b", "c"])
348
+ })
349
+
350
+ it("withResults=false awaits all settlements but resolves to an empty array", async () => {
351
+ const order: number[] = []
352
+ const make = (n: number): Promise<number> =>
353
+ Promise.resolve().then(() => {
354
+ order.push(n)
355
+
356
+ return n
357
+ })
358
+
359
+ const result = await promiseAllSettledChunked([make(1), make(2), make(3)], 2, false)
360
+
361
+ expect(result).toEqual([])
362
+ expect([...order].sort((a, b) => a - b)).toEqual([1, 2, 3])
363
+ })
364
+ })
365
+
366
+ describe("utils — isPathSyncedByICloud / pathSyncedByICloud", () => {
367
+ it("both resolve to false immediately on a non-darwin platform (no xattr exec, no dirname walk)", async () => {
368
+ await withPlatformAsync("linux", async () => {
369
+ expect(await pathSyncedByICloud("/home/me/Documents/file.txt")).toBe(false)
370
+ // isPathSyncedByICloud returns at the platform guard, never entering the dirname-walk loop.
371
+ expect(await isPathSyncedByICloud("/home/me/Documents/deeply/nested/file.txt")).toBe(false)
372
+ })
373
+
374
+ await withPlatformAsync("win32", async () => {
375
+ expect(await pathSyncedByICloud("C:/Users/me/file.txt")).toBe(false)
376
+ expect(await isPathSyncedByICloud("C:/Users/me/file.txt")).toBe(false)
377
+ })
378
+ })
379
+ })
package/tsconfig.json CHANGED
@@ -14,7 +14,16 @@
14
14
  "skipLibCheck": true,
15
15
  "declaration": true,
16
16
  "resolveJsonModule": true,
17
- "noUncheckedIndexedAccess": true
17
+ "noUncheckedIndexedAccess": true,
18
+ "noImplicitOverride": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "noImplicitReturns": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "exactOptionalPropertyTypes": true,
24
+ "noPropertyAccessFromIndexSignature": true,
25
+ "allowUnreachableCode": false,
26
+ "allowUnusedLabels": false
18
27
  },
19
28
  "ts-node": {
20
29
  "files": true
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "noEmit": true,
6
+ "declaration": false,
7
+ "sourceMap": false,
8
+ "types": ["node", "vitest/globals"]
9
+ },
10
+ "include": ["src/**/*.ts", "tests/**/*.ts", "index.d.ts"],
11
+ "exclude": ["node_modules", "dist", "docs", ".github", "dev", ".vscode"]
12
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/constants.ts","./src/ignorer.ts","./src/index.ts","./src/semaphore.ts","./src/types.ts","./src/utils.ts","./src/lib/deltas.ts","./src/lib/environment.ts","./src/lib/ipc.ts","./src/lib/lock.ts","./src/lib/logger.ts","./src/lib/state.ts","./src/lib/sync.ts","./src/lib/tasks.ts","./src/lib/filesystems/dirTree.ts","./src/lib/filesystems/local.ts","./src/lib/filesystems/remote.ts","./index.d.ts"],"version":"5.9.3"}
@@ -0,0 +1,32 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ /**
4
+ * Benchmark config — completely separate from the deterministic mocked suite (vitest.config.ts) and the
5
+ * live e2e suite (vitest.e2e.config.ts). Bench files are `tests/bench/**\/*.bench.ts`, which the mocked
6
+ * suite's include (`tests/**\/*.test.ts`) does NOT match, so they never run in `npm test` / CI.
7
+ *
8
+ * Run with NODE_OPTIONS so the measure harness can force GC for stable retained-heap numbers and large
9
+ * trees fit:
10
+ * NODE_OPTIONS='--expose-gc --max-old-space-size=120000' npx vitest run --config vitest.bench.config.ts
11
+ * then render the report (see docs/perf/02-benchmarks.md). NODE_OPTIONS is inherited by the worker forks
12
+ * (verified) — Vitest 4 removed per-pool execArgv config.
13
+ *
14
+ * - REAL timers + REAL clock (we measure wall time).
15
+ * - Serial, no file parallelism: clean memory measurements + ordered JSONL appends.
16
+ * - Huge timeout: million-node trees take a while to build + process.
17
+ */
18
+ export default defineConfig({
19
+ test: {
20
+ globals: true,
21
+ environment: "node",
22
+ include: ["tests/bench/**/*.bench.ts"],
23
+ testTimeout: 3_600_000,
24
+ hookTimeout: 3_600_000,
25
+ teardownTimeout: 600_000,
26
+ pool: "forks",
27
+ fileParallelism: false,
28
+ sequence: {
29
+ concurrent: false
30
+ }
31
+ }
32
+ })
@@ -0,0 +1,27 @@
1
+ import { defineConfig, configDefaults } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["tests/**/*.test.ts"],
8
+ // The live e2e suite has its own config (vitest.e2e.config.ts): real network, real clock, no
9
+ // coverage gate. Keep it out of the deterministic suite + its coverage gate.
10
+ exclude: [...configDefaults.exclude, "tests/e2e/**"],
11
+ coverage: {
12
+ provider: "v8",
13
+ include: ["src/**/*.ts"],
14
+ // Excluded from the gate: the real-I/O DI seams the suite intentionally bypasses
15
+ // (environment wiring, the enabled-logger path that writes to the user filesystem) and the
16
+ // declaration-only modules. The engine logic itself is gated below.
17
+ exclude: ["src/lib/environment.ts", "src/lib/logger.ts", "src/constants.ts", "src/types.ts"],
18
+ reporter: ["text", "html", "lcov"],
19
+ thresholds: {
20
+ statements: 90,
21
+ branches: 85,
22
+ functions: 90,
23
+ lines: 90
24
+ }
25
+ }
26
+ }
27
+ })
@@ -0,0 +1,68 @@
1
+ import { defineConfig } from "vitest/config"
2
+ import fs from "fs"
3
+ import pathModule from "path"
4
+
5
+ /**
6
+ * Load credentials from a gitignored `.env.e2e` at the repo root (KEY=VALUE lines) into process.env
7
+ * WITHOUT overriding anything already set (CI passes real GitHub secrets via env). This keeps the
8
+ * account creds out of the repo and out of any command line during local runs.
9
+ */
10
+ function loadDotEnvE2E(): void {
11
+ const file = pathModule.join(__dirname, ".env.e2e")
12
+
13
+ if (!fs.existsSync(file)) {
14
+ return
15
+ }
16
+
17
+ for (const rawLine of fs.readFileSync(file, "utf-8").split("\n")) {
18
+ const line = rawLine.trim()
19
+
20
+ if (!line || line.startsWith("#")) {
21
+ continue
22
+ }
23
+
24
+ const eq = line.indexOf("=")
25
+
26
+ if (eq === -1) {
27
+ continue
28
+ }
29
+
30
+ const key = line.slice(0, eq).trim()
31
+ const value = line
32
+ .slice(eq + 1)
33
+ .trim()
34
+ .replace(/^["']|["']$/g, "")
35
+
36
+ if (key && process.env[key] === undefined) {
37
+ process.env[key] = value
38
+ }
39
+ }
40
+ }
41
+
42
+ loadDotEnvE2E()
43
+
44
+ /**
45
+ * E2E config — real @filen/sdk against a live test account. Completely separate from the deterministic
46
+ * unit/scenario suite: a REAL clock (no fake timers), generous network timeouts, and serial execution
47
+ * (one world at a time against the shared account). No coverage gate — these validate live behavior, not
48
+ * lines. The suite skips itself when FILEN_TEST_EMAIL / FILEN_TEST_PASSWORD are absent.
49
+ */
50
+ export default defineConfig({
51
+ test: {
52
+ globals: true,
53
+ environment: "node",
54
+ include: ["tests/e2e/**/*.test.ts"],
55
+ // Very generous: CI runners can be slow and real transfers + tree fetches add up. Better to wait
56
+ // than to flake on a slow-but-healthy run.
57
+ testTimeout: 3600_000,
58
+ hookTimeout: 3600_000,
59
+ teardownTimeout: 3600_000,
60
+ // One retry absorbs a transient network blip without masking a persistent failure.
61
+ retry: 1,
62
+ // Never run e2e files concurrently — they share one account.
63
+ fileParallelism: false,
64
+ sequence: {
65
+ concurrent: false
66
+ }
67
+ }
68
+ })
package/.eslintrc DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "root": true,
3
- "parser": "@typescript-eslint/parser",
4
- "plugins": ["@typescript-eslint"],
5
- "extends": ["eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended"],
6
- "rules": {
7
- "eqeqeq": 2,
8
- "quotes": ["error", "double"],
9
- "no-mixed-spaces-and-tabs": 0,
10
- "no-duplicate-imports": "error"
11
- },
12
- "env": {
13
- "browser": true,
14
- "node": true
15
- }
16
- }
package/jest.config.js DELETED
@@ -1,5 +0,0 @@
1
- module.exports = {
2
- preset: "ts-jest",
3
- testEnvironment: "node",
4
- testMatch: ["**/tests/**/*.(spec|test).ts"]
5
- }