@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,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
|
+
})
|
package/vitest.config.ts
ADDED
|
@@ -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
|
-
}
|