@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,130 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, control } from "../harness/runner"
|
|
3
|
+
import { renameLocal } from "../harness/mutations"
|
|
4
|
+
import { messagesOfType } from "../harness/snapshot"
|
|
5
|
+
import { type SyncMessage } from "../../src/types"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category ZE — moving an item INTO a newly-created nested directory.
|
|
9
|
+
*
|
|
10
|
+
* remote.mkdir() must be able to create a directory whose missing parent is the sync ROOT. The
|
|
11
|
+
* intermediate-directory loop used to skip any level whose parent was the root (the `if (!parentItem)
|
|
12
|
+
* continue` fired before the `parentIsBase` branch that supplies remoteParentUUID), so mkdir('/x/y')
|
|
13
|
+
* threw whenever '/x' did not already exist. A cross-parent rename builds its destination parent inline
|
|
14
|
+
* (BEFORE the createRemoteDirectory tasks run), so moving a file into a NEW >=2-level folder made the
|
|
15
|
+
* rename throw; for a TOP-LEVEL source that throw was then swallowed by fileExists() (which returned
|
|
16
|
+
* false for every top-level file because tree['/'] is never a cache key) -> zero task errors -> a skewed
|
|
17
|
+
* base was persisted -> the file was silently DUPLICATED (original resurrected + a copy at the new path).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function taskErrorCount(messages: SyncMessage[]): number {
|
|
21
|
+
return messagesOfType(messages, "taskErrors").reduce((sum, m) => sum + m.data.errors.length, 0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("Category ZE — move into a newly-created nested directory", () => {
|
|
25
|
+
it("ZE1: twoWay — move a top-level file into a NEW 2-level dir converges (no duplication)", async () => {
|
|
26
|
+
const result = await runScenario({
|
|
27
|
+
name: "ZE1",
|
|
28
|
+
mode: "twoWay",
|
|
29
|
+
initialLocal: { "/local/a.txt": "hello", "/local/keep.txt": "k" },
|
|
30
|
+
steps: [
|
|
31
|
+
runCycle(),
|
|
32
|
+
localMutate(world => renameLocal(world, "a.txt", "x/y/a.txt")),
|
|
33
|
+
runCycle(),
|
|
34
|
+
runCycle(),
|
|
35
|
+
runCycle()
|
|
36
|
+
]
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(result.finalRemote["/a.txt"], "the moved-away original must NOT survive").toBeUndefined()
|
|
40
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
41
|
+
expect(result.finalRemote["/x/y/a.txt"]).toMatchObject({ type: "file", size: "hello".length })
|
|
42
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("ZE2: twoWay — move a top-level file into a NEW 3-level dir converges", async () => {
|
|
46
|
+
const result = await runScenario({
|
|
47
|
+
name: "ZE2",
|
|
48
|
+
mode: "twoWay",
|
|
49
|
+
initialLocal: { "/local/a.txt": "deep", "/local/keep.txt": "k" },
|
|
50
|
+
steps: [
|
|
51
|
+
runCycle(),
|
|
52
|
+
localMutate(world => renameLocal(world, "a.txt", "p/q/r/a.txt")),
|
|
53
|
+
runCycle(),
|
|
54
|
+
runCycle(),
|
|
55
|
+
runCycle()
|
|
56
|
+
]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
60
|
+
expect(result.finalRemote["/p/q/r/a.txt"]).toMatchObject({ type: "file" })
|
|
61
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("ZE3: twoWay — move a top-level DIRECTORY into a NEW 2-level dir converges", async () => {
|
|
65
|
+
const result = await runScenario({
|
|
66
|
+
name: "ZE3",
|
|
67
|
+
mode: "twoWay",
|
|
68
|
+
initialLocal: { "/local/dir/child.txt": "c", "/local/keep.txt": "k" },
|
|
69
|
+
steps: [
|
|
70
|
+
runCycle(),
|
|
71
|
+
localMutate(world => renameLocal(world, "dir", "x/y/dir")),
|
|
72
|
+
runCycle(),
|
|
73
|
+
runCycle(),
|
|
74
|
+
runCycle()
|
|
75
|
+
]
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
79
|
+
expect(result.finalRemote["/x/y/dir/child.txt"]).toMatchObject({ type: "file" })
|
|
80
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("ZE4: localToCloud — move a top-level file into a NEW 2-level dir converges", async () => {
|
|
84
|
+
const result = await runScenario({
|
|
85
|
+
name: "ZE4",
|
|
86
|
+
mode: "localToCloud",
|
|
87
|
+
initialLocal: { "/local/a.txt": "mirror", "/local/keep.txt": "k" },
|
|
88
|
+
steps: [
|
|
89
|
+
runCycle(),
|
|
90
|
+
localMutate(world => renameLocal(world, "a.txt", "x/y/a.txt")),
|
|
91
|
+
runCycle(),
|
|
92
|
+
runCycle(),
|
|
93
|
+
runCycle()
|
|
94
|
+
]
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
98
|
+
expect(result.finalRemote["/x/y/a.txt"]).toMatchObject({ type: "file" })
|
|
99
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("ZE5: a TRANSIENT error on a top-level remote file op SURFACES (not swallowed) and retries to convergence", async () => {
|
|
103
|
+
const result = await runScenario({
|
|
104
|
+
name: "ZE5",
|
|
105
|
+
mode: "twoWay",
|
|
106
|
+
initialLocal: { "/local/a.txt": "x", "/local/keep.txt": "k" },
|
|
107
|
+
steps: [
|
|
108
|
+
runCycle(),
|
|
109
|
+
localMutate(world => renameLocal(world, "a.txt", "b.txt")),
|
|
110
|
+
control(world => world.cloud.controls.setError("renameFile", new Error("transient rename failure"))),
|
|
111
|
+
runCycle(),
|
|
112
|
+
control(world => {
|
|
113
|
+
world.cloud.controls.clearError("renameFile")
|
|
114
|
+
world.worker.resetTaskErrors(world.syncPair.uuid)
|
|
115
|
+
world.triggerWatcher()
|
|
116
|
+
}),
|
|
117
|
+
runCycle(),
|
|
118
|
+
runCycle()
|
|
119
|
+
]
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// The failing cycle must REPORT a task error (the top-level rename failure must not be swallowed as
|
|
123
|
+
// "target vanished" — fileExists has to see the still-present top-level file).
|
|
124
|
+
expect(taskErrorCount(result.cycles[1]!.messages)).toBeGreaterThan(0)
|
|
125
|
+
// After recovery: the rename completed, no resurrection of the old name, both sides converge.
|
|
126
|
+
expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file" })
|
|
127
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
128
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, remoteMutate, localMutate } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { transferKinds } from "../harness/snapshot"
|
|
5
|
+
import { readLocal, writeLocalAt } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category ZF — a genuine REMOTE content change must be pulled in twoWay even when the remote's mtime is
|
|
9
|
+
* not strictly newer than the local copy, AS LONG AS the local copy is itself unchanged since the base.
|
|
10
|
+
*
|
|
11
|
+
* The download gate's `remoteWins` term only resolved a CONFLICT (newer mtime wins), but it applied the
|
|
12
|
+
* newer-mtime tiebreak unconditionally — so when only the remote changed (no conflict, local untouched) a
|
|
13
|
+
* remote edit whose mtime equalled or trailed the local mtime was silently dropped and never re-pulled. The
|
|
14
|
+
* remote re-upload mints a new uuid, so the change is unambiguously detectable; the tiebreak belongs to the
|
|
15
|
+
* both-changed case only. The fix makes `remoteWins` also true when the local copy is unchanged vs the base.
|
|
16
|
+
*/
|
|
17
|
+
const SECOND = 1000
|
|
18
|
+
|
|
19
|
+
describe("Category ZF — remote change vs unchanged local (twoWay, mtime tiebreak)", () => {
|
|
20
|
+
it("ZF1: a remote edit with an EQUAL mtime is pulled when local is unchanged", async () => {
|
|
21
|
+
const result = await runScenario({
|
|
22
|
+
name: "ZF1",
|
|
23
|
+
mode: "twoWay",
|
|
24
|
+
initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME }, "/local/keep.txt": "k" },
|
|
25
|
+
steps: [
|
|
26
|
+
runCycle(),
|
|
27
|
+
// Remote re-uploads new content (new uuid) but stamps the SAME whole-second mtime as local.
|
|
28
|
+
remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "v2-remote-edit", { mtimeMs: BASE_TIME })),
|
|
29
|
+
runCycle(),
|
|
30
|
+
runCycle(),
|
|
31
|
+
runCycle()
|
|
32
|
+
]
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// The change must actually be downloaded (not just "eventually equal" by some other path).
|
|
36
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
|
|
37
|
+
expect(readLocal(result.world, "a.txt")).toBe("v2-remote-edit")
|
|
38
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("ZF2: a remote edit with an OLDER mtime is pulled when local is unchanged", async () => {
|
|
42
|
+
const result = await runScenario({
|
|
43
|
+
name: "ZF2",
|
|
44
|
+
mode: "twoWay",
|
|
45
|
+
initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME + 10 * SECOND }, "/local/keep.txt": "k" },
|
|
46
|
+
steps: [
|
|
47
|
+
runCycle(),
|
|
48
|
+
// Remote edit lands with an mtime BEHIND the local copy (e.g. an out-of-sync clock on the
|
|
49
|
+
// editing device). It still changed (new uuid) and local is untouched, so it must win.
|
|
50
|
+
remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "older-but-real", { mtimeMs: BASE_TIME })),
|
|
51
|
+
runCycle(),
|
|
52
|
+
runCycle(),
|
|
53
|
+
runCycle()
|
|
54
|
+
]
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(readLocal(result.world, "a.txt")).toBe("older-but-real")
|
|
58
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("ZF3: a real CONFLICT (both edited) still resolves by newer mtime — local newer wins, not the older remote", async () => {
|
|
62
|
+
const result = await runScenario({
|
|
63
|
+
name: "ZF3",
|
|
64
|
+
mode: "twoWay",
|
|
65
|
+
initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME }, "/local/keep.txt": "k" },
|
|
66
|
+
steps: [
|
|
67
|
+
runCycle(),
|
|
68
|
+
// BOTH sides edit: local becomes strictly newer than the remote edit → local must win the
|
|
69
|
+
// conflict (the fix must NOT turn every remote change into an unconditional pull).
|
|
70
|
+
remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "remote-older", { mtimeMs: BASE_TIME + 1 * SECOND })),
|
|
71
|
+
localMutate(world => writeLocalAt(world, "a.txt", "local-newer", BASE_TIME + 5 * SECOND)),
|
|
72
|
+
runCycle(),
|
|
73
|
+
runCycle(),
|
|
74
|
+
runCycle()
|
|
75
|
+
]
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(readLocal(result.world, "a.txt")).toBe("local-newer")
|
|
79
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, control } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { writeLocalAt, readLocal } from "../harness/mutations"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Category ZG — a file edited WHILE the local scan is running must not be lost.
|
|
8
|
+
*
|
|
9
|
+
* getDirectoryTree() skips a rescan while `lastDirectoryChangeTimestamp < cache.timestamp`. The cache used
|
|
10
|
+
* to be stamped when the walk ENDED, so a file changed after the engine lstat'd it (but before the walk
|
|
11
|
+
* returned) recorded a change time EARLIER than the stamp — the next cycle then judged the cache fresh and
|
|
12
|
+
* never re-read the edit. Stamping at the walk's START closes that window: the change time is >= the stamp,
|
|
13
|
+
* the strict `<` fails, and the next cycle rescans. This reproduces the race deterministically by editing
|
|
14
|
+
* the file from inside the lstat hook (after the engine read it) and advancing the fake clock so that an
|
|
15
|
+
* end-of-walk stamp WOULD have beaten the edit.
|
|
16
|
+
*/
|
|
17
|
+
const SECOND = 1000
|
|
18
|
+
|
|
19
|
+
describe("Category ZG — an edit during the local scan is not lost", () => {
|
|
20
|
+
it("ZG1: a file edited mid-scan is re-detected and uploaded on the next cycle (twoWay)", async () => {
|
|
21
|
+
let fired = false
|
|
22
|
+
|
|
23
|
+
const result = await runScenario({
|
|
24
|
+
name: "ZG1",
|
|
25
|
+
mode: "twoWay",
|
|
26
|
+
initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME }, "/local/keep.txt": "k" },
|
|
27
|
+
steps: [
|
|
28
|
+
// Cycle 0: settle the initial state — a.txt="v1" is uploaded and becomes the base.
|
|
29
|
+
runCycle(),
|
|
30
|
+
// Force the next cycle to actually scan (bump the change clock) and arm a one-shot hook that,
|
|
31
|
+
// the moment the engine lstats a.txt, simulates the user overwriting it AFTER it was read, then
|
|
32
|
+
// advances the clock so an end-of-walk stamp would land after the edit's change time.
|
|
33
|
+
control(world => {
|
|
34
|
+
world.triggerWatcher()
|
|
35
|
+
|
|
36
|
+
world.vfs.controls.onStat(posixPath => {
|
|
37
|
+
if (fired || !posixPath.endsWith("/a.txt")) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fired = true
|
|
42
|
+
|
|
43
|
+
const editAt = Date.now()
|
|
44
|
+
|
|
45
|
+
writeLocalAt(world, "a.txt", "v2-edited-mid-scan", editAt)
|
|
46
|
+
world.triggerWatcher()
|
|
47
|
+
vi.setSystemTime(editAt + 30 * SECOND)
|
|
48
|
+
world.vfs.controls.clearStatHook()
|
|
49
|
+
})
|
|
50
|
+
}),
|
|
51
|
+
// Cycle 1: the racy scan reads "v1"; the hook overwrites to v2, bumps the change clock, and
|
|
52
|
+
// jumps the clock forward. With the bug the cache is stamped AFTER the edit's change time.
|
|
53
|
+
runCycle(),
|
|
54
|
+
// Cycle 2: must rescan (change time >= scan-start stamp) and upload v2. The bug skips it as a
|
|
55
|
+
// "fresh" cache, so the edit never reaches the remote.
|
|
56
|
+
runCycle(),
|
|
57
|
+
runCycle()
|
|
58
|
+
]
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// The hook edited the local file in BOTH the fixed and buggy worlds, so the discriminating signal is
|
|
62
|
+
// whether the edit reached the REMOTE: convergence holds only if the mid-scan edit was re-detected.
|
|
63
|
+
expect(fired, "the mid-scan lstat hook must have fired").toBe(true)
|
|
64
|
+
expect(readLocal(result.world, "a.txt")).toBe("v2-edited-mid-scan")
|
|
65
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "v2-edited-mid-scan".length })
|
|
66
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate } from "../harness/runner"
|
|
3
|
+
import { writeLocal, rmLocal } from "../harness/mutations"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Category ZH — a directory deletion must not cascade over live content the OTHER side did not delete.
|
|
7
|
+
*
|
|
8
|
+
* When one side removes a directory while the other adds (or keeps a modified) child inside it in the same
|
|
9
|
+
* cycle, the raw delta set holds both `deleteXDirectory <dir>` and the child's own add. The dir-delete used
|
|
10
|
+
* to win — collapse subsumed the child's sibling deletes under it and the dir-delete then wiped the
|
|
11
|
+
* brand-new file at execution time, before it was ever propagated (silent data loss). Newer content beats a
|
|
12
|
+
* delete (the same rule the per-file passes apply), so the directory survives: its delete is dropped and
|
|
13
|
+
* the surviving child's own add re-creates it. A child that is merely RENAMED out of the directory does NOT
|
|
14
|
+
* keep it alive (that is a normal dir rename, which deletes the now-empty old directory).
|
|
15
|
+
*/
|
|
16
|
+
describe("Category ZH — directory delete vs a live child", () => {
|
|
17
|
+
it("ZH1: remote deletes a dir while local adds a new child — the child survives, the dir is kept (twoWay)", async () => {
|
|
18
|
+
const result = await runScenario({
|
|
19
|
+
name: "ZH1",
|
|
20
|
+
mode: "twoWay",
|
|
21
|
+
initialLocal: { "/local/dir/keep.txt": "keep", "/local/other.txt": "o" },
|
|
22
|
+
steps: [
|
|
23
|
+
runCycle(),
|
|
24
|
+
remoteMutate(world => world.cloud.controls.trashPath("/dir")),
|
|
25
|
+
localMutate(world => writeLocal(world, "dir/new.txt", "new-child")),
|
|
26
|
+
runCycle(),
|
|
27
|
+
runCycle(),
|
|
28
|
+
runCycle()
|
|
29
|
+
]
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// The new child survives and is uploaded; the dir is re-asserted; the unmodified base child the
|
|
33
|
+
// remote deleted is gone; both sides converge.
|
|
34
|
+
expect(result.finalLocal["/dir/new.txt"]).toMatchObject({ type: "file", size: "new-child".length })
|
|
35
|
+
expect(result.finalRemote["/dir/new.txt"]).toMatchObject({ type: "file" })
|
|
36
|
+
expect(result.finalLocal["/dir/keep.txt"]).toBeUndefined()
|
|
37
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("ZH2: local deletes a dir while remote adds a new child — the child survives (symmetric)", async () => {
|
|
41
|
+
const result = await runScenario({
|
|
42
|
+
name: "ZH2",
|
|
43
|
+
mode: "twoWay",
|
|
44
|
+
initialLocal: { "/local/dir/keep.txt": "keep", "/local/other.txt": "o" },
|
|
45
|
+
steps: [
|
|
46
|
+
runCycle(),
|
|
47
|
+
localMutate(world => rmLocal(world, "dir")),
|
|
48
|
+
remoteMutate(world => world.cloud.controls.addFile("/dir/new.txt", "remote-new")),
|
|
49
|
+
runCycle(),
|
|
50
|
+
runCycle(),
|
|
51
|
+
runCycle()
|
|
52
|
+
]
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(result.finalRemote["/dir/new.txt"]).toMatchObject({ type: "file", size: "remote-new".length })
|
|
56
|
+
expect(result.finalLocal["/dir/new.txt"]).toMatchObject({ type: "file" })
|
|
57
|
+
expect(result.finalRemote["/dir/keep.txt"]).toBeUndefined()
|
|
58
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("ZH3: remote deletes a dir while local MODIFIES a base child — the modified child wins, dir kept", async () => {
|
|
62
|
+
const result = await runScenario({
|
|
63
|
+
name: "ZH3",
|
|
64
|
+
mode: "twoWay",
|
|
65
|
+
initialLocal: { "/local/dir/keep.txt": "v1", "/local/other.txt": "o" },
|
|
66
|
+
steps: [
|
|
67
|
+
runCycle(),
|
|
68
|
+
remoteMutate(world => world.cloud.controls.trashPath("/dir")),
|
|
69
|
+
localMutate(world => writeLocal(world, "dir/keep.txt", "v2-modified-bigger")),
|
|
70
|
+
runCycle(),
|
|
71
|
+
runCycle(),
|
|
72
|
+
runCycle()
|
|
73
|
+
]
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Newer-modify-wins: the locally-modified child is resurrected (re-uploaded), keeping its directory.
|
|
77
|
+
expect(result.finalRemote["/dir/keep.txt"]).toMatchObject({ type: "file", size: "v2-modified-bigger".length })
|
|
78
|
+
expect(result.finalLocal["/dir/keep.txt"]).toMatchObject({ type: "file" })
|
|
79
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("ZH4: deleting a dir whose ONLY child is moved out still deletes the dir (no false-keep on rename)", async () => {
|
|
83
|
+
const result = await runScenario({
|
|
84
|
+
name: "ZH4",
|
|
85
|
+
mode: "twoWay",
|
|
86
|
+
initialLocal: { "/local/dir/only.txt": "x", "/local/other.txt": "o" },
|
|
87
|
+
steps: [
|
|
88
|
+
runCycle(),
|
|
89
|
+
localMutate(world => {
|
|
90
|
+
// Move the only child OUT to the root, then remove the now-empty directory.
|
|
91
|
+
writeLocal(world, "moved.txt", "x")
|
|
92
|
+
rmLocal(world, "dir")
|
|
93
|
+
}),
|
|
94
|
+
runCycle(),
|
|
95
|
+
runCycle(),
|
|
96
|
+
runCycle()
|
|
97
|
+
]
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
101
|
+
expect(result.finalRemote["/moved.txt"]).toMatchObject({ type: "file" })
|
|
102
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { SYNC_INTERVAL } from "../../src/constants"
|
|
3
|
+
import { createWorld, BASE_TIME, type CreateWorldOptions, type World } from "../harness/world"
|
|
4
|
+
import { messagesOfType } from "../harness/snapshot"
|
|
5
|
+
import { makeErrnoError } from "../fakes/virtual-fs"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category ZI — the local smoke test must not hold the account lock during a local outage (H7).
|
|
9
|
+
*
|
|
10
|
+
* The smoke test retries every SYNC_INTERVAL for as long as the local sync root is unavailable (an
|
|
11
|
+
* unmounted drive, a disconnected network filesystem). It used to run AFTER the lock was acquired, so the
|
|
12
|
+
* whole outage held the account lock and starved every other device. Running it BEFORE the lock means an
|
|
13
|
+
* outage stalls only this pair's cycle. (The retry loop was also de-recursed so a long outage no longer
|
|
14
|
+
* grows the stack by one suspended frame per retry.)
|
|
15
|
+
*/
|
|
16
|
+
const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
|
|
17
|
+
|
|
18
|
+
async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
|
|
19
|
+
vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
|
|
20
|
+
vi.setSystemTime(BASE_TIME)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const world = await createWorld(options)
|
|
24
|
+
|
|
25
|
+
await body(world)
|
|
26
|
+
} finally {
|
|
27
|
+
vi.useRealTimers()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const countOf = (world: World, type: Parameters<typeof messagesOfType>[1]): number => messagesOfType(world.messages, type).length
|
|
32
|
+
|
|
33
|
+
describe("Category ZI — smoke test does not hold the lock during a local outage", () => {
|
|
34
|
+
it("ZI1: a local outage retries the smoke test WITHOUT acquiring the lock, then proceeds on recovery", async () => {
|
|
35
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
|
|
36
|
+
// Settle once for a clean base.
|
|
37
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
38
|
+
await world.sync.runCycle()
|
|
39
|
+
|
|
40
|
+
const lockDoneBaseline = countOf(world, "cycleAcquiringLockDone")
|
|
41
|
+
|
|
42
|
+
// Simulate a local outage: the sync root is no longer accessible.
|
|
43
|
+
world.vfs.controls.setError("/local", makeErrnoError("EACCES", "local sync root unavailable"))
|
|
44
|
+
|
|
45
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
46
|
+
|
|
47
|
+
let settled = false
|
|
48
|
+
const cyclePromise = world.sync.runCycle().finally(() => {
|
|
49
|
+
settled = true
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Pump several retry intervals while the outage persists.
|
|
53
|
+
for (let i = 0; i < 3 && !settled; i++) {
|
|
54
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The smoke test failed and kept failing — but the lock was NEVER acquired (before the fix it
|
|
58
|
+
// would have been, because the smoke test ran under the lock), and the cycle has not settled.
|
|
59
|
+
expect(countOf(world, "cycleLocalSmokeTestFailed")).toBeGreaterThan(0)
|
|
60
|
+
expect(countOf(world, "cycleAcquiringLockDone"), "the lock must not be acquired during the outage").toBe(lockDoneBaseline)
|
|
61
|
+
expect(settled).toBe(false)
|
|
62
|
+
|
|
63
|
+
// Recover: the path is accessible again. The next retry succeeds and the cycle runs to completion.
|
|
64
|
+
world.vfs.controls.clearError("/local")
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < 10 && !settled; i++) {
|
|
67
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await cyclePromise
|
|
71
|
+
|
|
72
|
+
expect(settled).toBe(true)
|
|
73
|
+
expect(countOf(world, "cycleAcquiringLockDone"), "after recovery the lock is acquired and the cycle proceeds").toBeGreaterThan(
|
|
74
|
+
lockDoneBaseline
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import pathModule from "path"
|
|
3
|
+
import { createWorld, BASE_TIME, LOCAL_ROOT, type CreateWorldOptions, type World } from "../harness/world"
|
|
4
|
+
import { toPosixPath } from "../fakes/virtual-fs"
|
|
5
|
+
import { LOCAL_TRASH_NAME } from "../../src/constants"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category ZJ — the local-trash eviction sweep must age out trashed DIRECTORIES, and the sweep's timer
|
|
9
|
+
* must be torn down with the pair (M2 + M3).
|
|
10
|
+
*
|
|
11
|
+
* The local trash holds whatever a delete moved aside — and a deleted directory is moved in wholesale by
|
|
12
|
+
* its basename, so it lands as a top-level DIRECTORY inside the trash. The 30-day eviction sweep globbed
|
|
13
|
+
* with `onlyFiles:true`, which skipped every trashed directory (and, with `deep:0`, their contents too),
|
|
14
|
+
* so trashed directories accumulated on disk forever (M2). Separately, the sweep's `setInterval` handle
|
|
15
|
+
* was discarded, so a removed pair's timer kept firing every 5 minutes forever, pinning the whole Sync
|
|
16
|
+
* (and its in-memory trees) and burning a periodic glob on a dead pair (M3).
|
|
17
|
+
*
|
|
18
|
+
* These are LOCAL-only concerns (a folder on the local disk + a client-side timer); the backend plays no
|
|
19
|
+
* part, so there is no e2e counterpart — see the boundary note in tests/e2e/regressions.e2e.test.ts.
|
|
20
|
+
*/
|
|
21
|
+
const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
|
|
22
|
+
const CLEANUP_INTERVAL_MS = 300000
|
|
23
|
+
const DAY_MS = 86400000
|
|
24
|
+
const TRASH_ROOT = pathModule.posix.join(LOCAL_ROOT, LOCAL_TRASH_NAME)
|
|
25
|
+
|
|
26
|
+
async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
|
|
27
|
+
vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
|
|
28
|
+
vi.setSystemTime(BASE_TIME)
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const world = await createWorld(options)
|
|
32
|
+
|
|
33
|
+
await body(world)
|
|
34
|
+
} finally {
|
|
35
|
+
vi.useRealTimers()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Drop a file or a directory (with one child) straight into the on-disk trash via memfs, then backdate its
|
|
40
|
+
// access time so the eviction gate (`atime + 30d < now`) decides it on the next sweep.
|
|
41
|
+
function seedTrashEntry(world: World, name: string, kind: "file" | "dir", ageDays: number): string {
|
|
42
|
+
const ifs = world.vfs.ifs
|
|
43
|
+
const entryPath = pathModule.posix.join(TRASH_ROOT, name)
|
|
44
|
+
|
|
45
|
+
if (kind === "dir") {
|
|
46
|
+
ifs.mkdirSync(toPosixPath(entryPath), { recursive: true })
|
|
47
|
+
ifs.writeFileSync(toPosixPath(pathModule.posix.join(entryPath, "inner.txt")), "inner")
|
|
48
|
+
} else {
|
|
49
|
+
ifs.mkdirSync(toPosixPath(TRASH_ROOT), { recursive: true })
|
|
50
|
+
ifs.writeFileSync(toPosixPath(entryPath), "leaf")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// utimesSync takes seconds; set atime to `ageDays` before the (fake) current time.
|
|
54
|
+
const atimeSeconds = (Date.now() - ageDays * DAY_MS) / 1000
|
|
55
|
+
|
|
56
|
+
ifs.utimesSync(toPosixPath(entryPath), atimeSeconds, atimeSeconds)
|
|
57
|
+
|
|
58
|
+
return entryPath
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const existsInTrash = (world: World, name: string): boolean => world.vfs.ifs.existsSync(toPosixPath(pathModule.posix.join(TRASH_ROOT, name)))
|
|
62
|
+
|
|
63
|
+
describe("Category ZJ — local trash cleanup evicts directories and tears down its timer", () => {
|
|
64
|
+
it("ZJ1: an old trashed DIRECTORY is evicted by the sweep (not just old files)", async () => {
|
|
65
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
|
|
66
|
+
const oldDir = seedTrashEntry(world, "old-dir", "dir", 40)
|
|
67
|
+
const oldFile = seedTrashEntry(world, "old-file.txt", "file", 40)
|
|
68
|
+
|
|
69
|
+
expect(existsInTrash(world, "old-dir")).toBe(true)
|
|
70
|
+
expect(existsInTrash(world, "old-file.txt")).toBe(true)
|
|
71
|
+
|
|
72
|
+
await world.sync.cleanupLocalTrashOnce()
|
|
73
|
+
|
|
74
|
+
// Both the aged directory (with onlyFiles:true it was skipped and leaked forever) and the aged file
|
|
75
|
+
// are gone.
|
|
76
|
+
expect(world.vfs.ifs.existsSync(toPosixPath(oldDir)), "an aged trashed directory must be removed").toBe(false)
|
|
77
|
+
expect(world.vfs.ifs.existsSync(toPosixPath(oldFile))).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("ZJ2: a RECENT trashed directory is kept — the 30-day gate still applies to directories", async () => {
|
|
82
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
|
|
83
|
+
seedTrashEntry(world, "recent-dir", "dir", 5)
|
|
84
|
+
seedTrashEntry(world, "old-dir", "dir", 40)
|
|
85
|
+
|
|
86
|
+
await world.sync.cleanupLocalTrashOnce()
|
|
87
|
+
|
|
88
|
+
expect(existsInTrash(world, "recent-dir"), "a directory trashed 5 days ago is still within the retention window").toBe(true)
|
|
89
|
+
expect(existsInTrash(world, "old-dir")).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("ZJ3: cleanup() tears down the sweep timer so a removed pair stops sweeping", async () => {
|
|
94
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
|
|
95
|
+
// The constructor armed the periodic sweep and kept its handle.
|
|
96
|
+
expect(world.sync.cleanupLocalTrashInterval, "the sweep timer must be retained so it can be cleared").toBeDefined()
|
|
97
|
+
|
|
98
|
+
await world.sync.cleanup({})
|
|
99
|
+
|
|
100
|
+
expect(world.sync.cleanupLocalTrashInterval, "cleanup() must clear the sweep timer handle").toBeUndefined()
|
|
101
|
+
|
|
102
|
+
// An aged directory appears in the trash AFTER the pair was removed. If the timer were still alive
|
|
103
|
+
// it would fire within one interval and delete it; because it was cleared, the entry survives.
|
|
104
|
+
seedTrashEntry(world, "post-removal-old-dir", "dir", 40)
|
|
105
|
+
|
|
106
|
+
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS * 3 + 1)
|
|
107
|
+
|
|
108
|
+
expect(existsInTrash(world, "post-removal-old-dir"), "a removed pair must not keep sweeping the trash").toBe(true)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("ZJ4: while the pair is live the timer invokes the sweep once per period", async () => {
|
|
113
|
+
await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
|
|
114
|
+
// Assert the timer WIRING (it calls the sweep on schedule) rather than the sweep's async filesystem
|
|
115
|
+
// effect — FastGlob's stream completion leans on the real event loop, which a floating timer
|
|
116
|
+
// callback can't deterministically drain under fake timers (ZJ1/ZJ2 cover the eviction effect when
|
|
117
|
+
// the sweep is awaited directly).
|
|
118
|
+
const sweep = vi.spyOn(world.sync, "cleanupLocalTrashOnce").mockResolvedValue()
|
|
119
|
+
|
|
120
|
+
expect(sweep).not.toHaveBeenCalled()
|
|
121
|
+
|
|
122
|
+
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS + 1)
|
|
123
|
+
|
|
124
|
+
expect(sweep, "the armed interval must invoke the sweep once per period").toHaveBeenCalledTimes(1)
|
|
125
|
+
|
|
126
|
+
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS)
|
|
127
|
+
|
|
128
|
+
expect(sweep, "and again on the next period").toHaveBeenCalledTimes(2)
|
|
129
|
+
|
|
130
|
+
sweep.mockRestore()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
})
|