@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,160 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate } from "../harness/runner"
|
|
3
|
+
import { transferKinds, allOps, hadTransfers } from "../harness/snapshot"
|
|
4
|
+
import { writeLocal } from "../harness/mutations"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Category B — additions (behavioral spec §B). Additions are applied dynamically (after an initial
|
|
8
|
+
* converged cycle) so the delta-vs-previous-state path and the watcher trigger are exercised.
|
|
9
|
+
*/
|
|
10
|
+
describe("Category B — additions", () => {
|
|
11
|
+
it("B1: a file added locally uploads on the next cycle (twoWay)", async () => {
|
|
12
|
+
const result = await runScenario({
|
|
13
|
+
name: "B1",
|
|
14
|
+
mode: "twoWay",
|
|
15
|
+
steps: [runCycle(), localMutate(world => writeLocal(world, "a.txt", "added")), runCycle(), runCycle()]
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
|
|
19
|
+
expect(result.cycles[1]!.remote["/a.txt"]).toMatchObject({ type: "file", size: 5 })
|
|
20
|
+
expect(allOps(result.cycles[2]!.messages)).toEqual([])
|
|
21
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("B2: a directory tree added locally creates the remote dirs and uploads its files", async () => {
|
|
25
|
+
const result = await runScenario({
|
|
26
|
+
name: "B2",
|
|
27
|
+
mode: "twoWay",
|
|
28
|
+
steps: [
|
|
29
|
+
runCycle(),
|
|
30
|
+
localMutate(world => {
|
|
31
|
+
writeLocal(world, "docs/x.txt", "x")
|
|
32
|
+
writeLocal(world, "docs/y.txt", "y")
|
|
33
|
+
}),
|
|
34
|
+
runCycle(),
|
|
35
|
+
runCycle()
|
|
36
|
+
]
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(result.finalRemote["/docs"]).toMatchObject({ type: "directory" })
|
|
40
|
+
expect(result.finalRemote["/docs/x.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
41
|
+
expect(result.finalRemote["/docs/y.txt"]).toMatchObject({ type: "file", size: 1 })
|
|
42
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("B3: a file added remotely downloads on the next cycle (twoWay)", async () => {
|
|
46
|
+
const result = await runScenario({
|
|
47
|
+
name: "B3",
|
|
48
|
+
mode: "twoWay",
|
|
49
|
+
steps: [runCycle(), remoteMutate(world => world.cloud.controls.addFile("/r.txt", "remote")), runCycle(), runCycle()]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
|
|
53
|
+
expect(result.cycles[1]!.local["/r.txt"]).toMatchObject({ type: "file", size: 6 })
|
|
54
|
+
expect(allOps(result.cycles[2]!.messages)).toEqual([])
|
|
55
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("B4: a directory tree added remotely creates the local dirs and downloads its files", async () => {
|
|
59
|
+
const result = await runScenario({
|
|
60
|
+
name: "B4",
|
|
61
|
+
mode: "twoWay",
|
|
62
|
+
steps: [
|
|
63
|
+
runCycle(),
|
|
64
|
+
remoteMutate(world => {
|
|
65
|
+
world.cloud.controls.addDir("/album")
|
|
66
|
+
world.cloud.controls.addFile("/album/p.txt", "pixels")
|
|
67
|
+
}),
|
|
68
|
+
runCycle(),
|
|
69
|
+
runCycle()
|
|
70
|
+
]
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(result.finalLocal["/album"]).toMatchObject({ type: "directory" })
|
|
74
|
+
expect(result.finalLocal["/album/p.txt"]).toMatchObject({ type: "file", size: 6 })
|
|
75
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("B5: a deeply nested new path creates all intermediate directories", async () => {
|
|
79
|
+
const result = await runScenario({
|
|
80
|
+
name: "B5",
|
|
81
|
+
mode: "twoWay",
|
|
82
|
+
steps: [runCycle(), localMutate(world => writeLocal(world, "p/q/r/s/t.txt", "deep")), runCycle(), runCycle()]
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(result.finalRemote["/p/q/r/s"]).toMatchObject({ type: "directory" })
|
|
86
|
+
expect(result.finalRemote["/p/q/r/s/t.txt"]).toMatchObject({ type: "file", size: 4 })
|
|
87
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("B6: a bulk of new files all upload", async () => {
|
|
91
|
+
const fileCount = 25
|
|
92
|
+
|
|
93
|
+
const result = await runScenario({
|
|
94
|
+
name: "B6",
|
|
95
|
+
mode: "twoWay",
|
|
96
|
+
steps: [
|
|
97
|
+
runCycle(),
|
|
98
|
+
localMutate(world => {
|
|
99
|
+
for (let index = 0; index < fileCount; index++) {
|
|
100
|
+
writeLocal(world, `bulk/file-${index}.txt`, `content-${index}`)
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
runCycle(),
|
|
104
|
+
runCycle()
|
|
105
|
+
]
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
for (let index = 0; index < fileCount; index++) {
|
|
109
|
+
expect(result.finalRemote[`/bulk/file-${index}.txt`]).toMatchObject({ type: "file" })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("B7: distinct additions on both sides in the same cycle produce the union (twoWay)", async () => {
|
|
116
|
+
const result = await runScenario({
|
|
117
|
+
name: "B7",
|
|
118
|
+
mode: "twoWay",
|
|
119
|
+
steps: [
|
|
120
|
+
runCycle(),
|
|
121
|
+
localMutate(world => writeLocal(world, "local-only.txt", "L")),
|
|
122
|
+
remoteMutate(world => world.cloud.controls.addFile("/remote-only.txt", "R")),
|
|
123
|
+
runCycle(),
|
|
124
|
+
runCycle(),
|
|
125
|
+
runCycle()
|
|
126
|
+
]
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(result.finalLocal["/local-only.txt"]).toMatchObject({ type: "file" })
|
|
130
|
+
expect(result.finalLocal["/remote-only.txt"]).toMatchObject({ type: "file" })
|
|
131
|
+
expect(result.finalRemote["/local-only.txt"]).toMatchObject({ type: "file" })
|
|
132
|
+
expect(result.finalRemote["/remote-only.txt"]).toMatchObject({ type: "file" })
|
|
133
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// B8: 0-byte files sync normally. (BUG-002 fix: the size<=0 skip in the local and remote tree
|
|
137
|
+
// builds is removed, so empty files are tracked and transferred like any other file.)
|
|
138
|
+
it("B8: a new 0-byte file syncs to the remote", async () => {
|
|
139
|
+
const result = await runScenario({
|
|
140
|
+
name: "B8",
|
|
141
|
+
mode: "twoWay",
|
|
142
|
+
steps: [runCycle(), localMutate(world => writeLocal(world, "empty.txt", "")), runCycle(), runCycle()]
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(result.finalRemote["/empty.txt"]).toMatchObject({ type: "file", size: 0 })
|
|
146
|
+
expect(hadTransfers(result.messages)).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("B9: a remote 0-byte file downloads to local", async () => {
|
|
150
|
+
const result = await runScenario({
|
|
151
|
+
name: "B9",
|
|
152
|
+
mode: "twoWay",
|
|
153
|
+
initialRemote: { "/remote-empty.txt": "" },
|
|
154
|
+
steps: [runCycle(), runCycle()]
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(result.finalLocal["/remote-empty.txt"]).toMatchObject({ type: "file", size: 0 })
|
|
158
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { transferKinds, allOps } from "../harness/snapshot"
|
|
5
|
+
import { writeLocal, writeLocalAt } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category C — modifications (behavioral spec §C, §4). Change is attributed against the last-synced
|
|
9
|
+
* base (previousLocalTree): a local file changed iff its size OR whole-second mtime differs from the
|
|
10
|
+
* base; a remote file changed iff its uuid changed. Conflicts resolve latest-mtime-wins at whole-
|
|
11
|
+
* second precision, with an unorderable equal-second tie going to local (C6). Uploads are additionally
|
|
12
|
+
* deduped by the md5 differing from the stored hash (optional — older files have none), and downloads
|
|
13
|
+
* gated on the remote uuid having actually changed. The one irreducible gap (C11): a same-second AND
|
|
14
|
+
* same-size content swap on a file with no stored hash.
|
|
15
|
+
*/
|
|
16
|
+
const SECOND = 1000
|
|
17
|
+
|
|
18
|
+
describe("Category C — modifications", () => {
|
|
19
|
+
it("C1: a locally modified file (newer mtime + changed content) uploads", async () => {
|
|
20
|
+
const result = await runScenario({
|
|
21
|
+
name: "C1",
|
|
22
|
+
mode: "twoWay",
|
|
23
|
+
initialLocal: { "/local/a.txt": "original" },
|
|
24
|
+
steps: [runCycle(), localMutate(world => writeLocal(world, "a.txt", "modified-longer")), runCycle(), runCycle()]
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
|
|
28
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "modified-longer".length })
|
|
29
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("C2: a remotely modified file (newer mtime, new uuid) downloads", async () => {
|
|
33
|
+
const result = await runScenario({
|
|
34
|
+
name: "C2",
|
|
35
|
+
mode: "twoWay",
|
|
36
|
+
initialLocal: { "/local/a.txt": "original" },
|
|
37
|
+
steps: [runCycle(), remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "remote-modified")), runCycle(), runCycle()]
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
|
|
41
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: "remote-modified".length })
|
|
42
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("C3: a touch (newer mtime, identical content) does NOT upload (md5 guard)", async () => {
|
|
46
|
+
const result = await runScenario({
|
|
47
|
+
name: "C3",
|
|
48
|
+
mode: "twoWay",
|
|
49
|
+
initialLocal: { "/local/a.txt": "original" },
|
|
50
|
+
// Rewrite identical content at a newer mtime.
|
|
51
|
+
steps: [runCycle(), localMutate(world => writeLocal(world, "a.txt", "original")), runCycle(), runCycle()]
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// A touch does not upload; content stays identical on both sides, but the local mtime is now
|
|
55
|
+
// newer than the (un-reuploaded) remote — so only content, not the whole snapshot, converges.
|
|
56
|
+
expect(allOps(result.cycles[1]!.messages)).toEqual([])
|
|
57
|
+
expect(allOps(result.cycles[2]!.messages)).toEqual([])
|
|
58
|
+
expect(result.finalLocal["/a.txt"]!.contentHash).toBe(result.finalRemote["/a.txt"]!.contentHash)
|
|
59
|
+
expect(result.finalLocal["/a.txt"]!.size).toBe(result.finalRemote["/a.txt"]!.size)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("C4: both sides modified, local newer → local wins (upload)", async () => {
|
|
63
|
+
const result = await runScenario({
|
|
64
|
+
name: "C4",
|
|
65
|
+
mode: "twoWay",
|
|
66
|
+
initialLocal: { "/local/a.txt": "original" },
|
|
67
|
+
steps: [
|
|
68
|
+
runCycle(),
|
|
69
|
+
localMutate(world => writeLocalAt(world, "a.txt", "LLLLLLLLLL", BASE_TIME + 2 * SECOND)),
|
|
70
|
+
remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "RRR", { mtimeMs: BASE_TIME + 1 * SECOND })),
|
|
71
|
+
runCycle(),
|
|
72
|
+
runCycle()
|
|
73
|
+
]
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: 10 })
|
|
77
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("C5: both sides modified, remote newer → remote wins (download)", async () => {
|
|
81
|
+
const result = await runScenario({
|
|
82
|
+
name: "C5",
|
|
83
|
+
mode: "twoWay",
|
|
84
|
+
initialLocal: { "/local/a.txt": "original" },
|
|
85
|
+
steps: [
|
|
86
|
+
runCycle(),
|
|
87
|
+
localMutate(world => writeLocalAt(world, "a.txt", "LLL", BASE_TIME + 1 * SECOND)),
|
|
88
|
+
remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "RRRRRRRRRR", { mtimeMs: BASE_TIME + 2 * SECOND })),
|
|
89
|
+
runCycle(),
|
|
90
|
+
runCycle()
|
|
91
|
+
]
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 10 })
|
|
95
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("C6: both sides modified with equal whole-second mtime → local wins the tie (upload)", async () => {
|
|
99
|
+
const result = await runScenario({
|
|
100
|
+
name: "C6",
|
|
101
|
+
mode: "twoWay",
|
|
102
|
+
initialLocal: { "/local/a.txt": "original" },
|
|
103
|
+
steps: [
|
|
104
|
+
runCycle(),
|
|
105
|
+
localMutate(world => writeLocalAt(world, "a.txt", "LOCAL6", BASE_TIME + 1 * SECOND + 200)),
|
|
106
|
+
remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-6", { mtimeMs: BASE_TIME + 1 * SECOND + 800 })),
|
|
107
|
+
runCycle(),
|
|
108
|
+
runCycle()
|
|
109
|
+
]
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Both sides changed since the base and their mtimes floor to the same second (unorderable). Per
|
|
113
|
+
// the confirmed tie policy local wins: the upload pass runs before the download pass and marks the
|
|
114
|
+
// path added, so local uploads and the worlds converge to the local content.
|
|
115
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
|
|
116
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "LOCAL6".length })
|
|
117
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("C7: a remote mtime bump with an unchanged uuid does NOT download (uuid guard)", async () => {
|
|
121
|
+
const result = await runScenario({
|
|
122
|
+
name: "C7",
|
|
123
|
+
mode: "twoWay",
|
|
124
|
+
initialLocal: { "/local/a.txt": "original" },
|
|
125
|
+
steps: [runCycle(), remoteMutate(world => world.cloud.controls.touchRemote("/a.txt", BASE_TIME + 5 * SECOND)), runCycle(), runCycle()]
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(allOps(result.cycles[1]!.messages)).toEqual([])
|
|
129
|
+
expect(allOps(result.cycles[2]!.messages)).toEqual([])
|
|
130
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: "original".length })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("C9: truncating a synced file to 0 bytes propagates the empty version (BUG-002)", async () => {
|
|
134
|
+
// The md5 of an empty file differs from the non-empty original, so the upload gate fires and the
|
|
135
|
+
// remote becomes a real 0-byte file (rather than the change being skipped as an ignored "empty").
|
|
136
|
+
const result = await runScenario({
|
|
137
|
+
name: "C9",
|
|
138
|
+
mode: "twoWay",
|
|
139
|
+
initialLocal: { "/local/data.txt": "has content" },
|
|
140
|
+
steps: [
|
|
141
|
+
runCycle(),
|
|
142
|
+
localMutate(world => writeLocalAt(world, "data.txt", "", BASE_TIME + 5 * SECOND)),
|
|
143
|
+
runCycle(),
|
|
144
|
+
runCycle()
|
|
145
|
+
]
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(result.finalRemote["/data.txt"]).toMatchObject({ type: "file", size: 0 })
|
|
149
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("C10: a same-second size change uploads (base-relative detection, E2E-OBS-002)", async () => {
|
|
153
|
+
// The edit keeps the SAME whole-second mtime as the last sync but changes the SIZE. The old
|
|
154
|
+
// side-vs-side gate (local.mtime > remote.mtime) missed it; base-relative detection (size OR
|
|
155
|
+
// mtime vs previousLocalTree) catches it.
|
|
156
|
+
const result = await runScenario({
|
|
157
|
+
name: "C10",
|
|
158
|
+
mode: "twoWay",
|
|
159
|
+
initialLocal: { "/local/a.txt": "12345678" },
|
|
160
|
+
steps: [
|
|
161
|
+
runCycle(),
|
|
162
|
+
localMutate(world => writeLocalAt(world, "a.txt", "123", BASE_TIME)),
|
|
163
|
+
runCycle(),
|
|
164
|
+
runCycle()
|
|
165
|
+
]
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
|
|
169
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: 3 })
|
|
170
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("C11: a same-second SAME-size content swap is NOT detected (documented irreducible limit)", async () => {
|
|
174
|
+
// Same whole-second mtime AND same size, only the bytes differ. With no mtime/size signal and no
|
|
175
|
+
// reliable stored hash, this is undetectable without re-hashing every file every cycle (which the
|
|
176
|
+
// perf budget forbids). Codified so a future "fix" that re-hashes unconditionally is a conscious
|
|
177
|
+
// choice, not an accident. Astronomically rare in practice (it needs an edit within the same
|
|
178
|
+
// wall-clock second as the last sync) and self-heals on the next edit in a later second.
|
|
179
|
+
const result = await runScenario({
|
|
180
|
+
name: "C11",
|
|
181
|
+
mode: "twoWay",
|
|
182
|
+
initialLocal: { "/local/a.txt": "AAAA" },
|
|
183
|
+
steps: [
|
|
184
|
+
runCycle(),
|
|
185
|
+
localMutate(world => writeLocalAt(world, "a.txt", "BBBB", BASE_TIME)),
|
|
186
|
+
runCycle(),
|
|
187
|
+
runCycle()
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
expect(allOps(result.cycles[1]!.messages)).toEqual([])
|
|
192
|
+
expect(result.finalLocal["/a.txt"]!.contentHash).not.toBe(result.finalRemote["/a.txt"]!.contentHash)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate, control } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { transferKinds } from "../harness/snapshot"
|
|
5
|
+
import { writeLocal, writeLocalAt, touchLocal, readLocal, rmLocal, existsLocal } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
const SECOND = 1000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Category D — deletions and trash (behavioral spec §D, §3, §4). Deletions propagate per the mode
|
|
11
|
+
* matrix; the losing copy is sent to trash (cloud trash for remote, .filen.trash.local for local)
|
|
12
|
+
* rather than hard-deleted, unless localTrashDisabled. A directory deletion collapses to a single
|
|
13
|
+
* parent op (children implied).
|
|
14
|
+
*/
|
|
15
|
+
describe("Category D — deletions", () => {
|
|
16
|
+
// D1 — a local deletion removes the remote copy (to cloud trash) in both modes that propagate
|
|
17
|
+
// local→remote deletions: twoWay (diff vs previous) and localToCloud (mirror to current local).
|
|
18
|
+
for (const mode of ["twoWay", "localToCloud"] as const) {
|
|
19
|
+
it(`D1/${mode}: deleting a local file deletes the remote copy (to cloud trash)`, async () => {
|
|
20
|
+
let remoteUUID: string | undefined
|
|
21
|
+
|
|
22
|
+
const result = await runScenario({
|
|
23
|
+
name: `D1-${mode}`,
|
|
24
|
+
mode,
|
|
25
|
+
initialLocal: { "/local/a.txt": "content" },
|
|
26
|
+
steps: [
|
|
27
|
+
runCycle(),
|
|
28
|
+
control(world => {
|
|
29
|
+
remoteUUID = world.cloud.controls.getByPath("/a.txt")?.uuid
|
|
30
|
+
}),
|
|
31
|
+
localMutate(world => rmLocal(world, "a.txt")),
|
|
32
|
+
runCycle(),
|
|
33
|
+
runCycle()
|
|
34
|
+
]
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("deleteRemoteFile")
|
|
38
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
39
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
40
|
+
|
|
41
|
+
// No data loss: the remote copy went to cloud trash, not a permanent delete.
|
|
42
|
+
expect(remoteUUID).toBeDefined()
|
|
43
|
+
expect(await result.world.cloud.sdk.api(3).dir().present({ uuid: remoteUUID! })).toEqual({ present: true, trash: true })
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// D2 — a remote deletion removes the local copy (to .filen.trash.local) in both modes that
|
|
48
|
+
// propagate remote→local deletions: twoWay and cloudToLocal.
|
|
49
|
+
for (const mode of ["twoWay", "cloudToLocal"] as const) {
|
|
50
|
+
it(`D2/${mode}: deleting a remote file deletes the local copy (to .filen.trash.local)`, async () => {
|
|
51
|
+
const result = await runScenario({
|
|
52
|
+
name: `D2-${mode}`,
|
|
53
|
+
mode,
|
|
54
|
+
initialRemote: { "/a.txt": "content" },
|
|
55
|
+
steps: [
|
|
56
|
+
runCycle(),
|
|
57
|
+
remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
|
|
58
|
+
runCycle(),
|
|
59
|
+
runCycle()
|
|
60
|
+
]
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("deleteLocalFile")
|
|
64
|
+
// The normalized snapshot excludes the trash dir, so the file is gone from the synced tree…
|
|
65
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
66
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
67
|
+
// …but it was moved to the local trash, not hard-deleted (no data loss).
|
|
68
|
+
expect(existsLocal(result.world, ".filen.trash.local/a.txt")).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it("D3: with localTrashDisabled, a remote deletion HARD-deletes the local copy (no trash)", async () => {
|
|
73
|
+
const result = await runScenario({
|
|
74
|
+
name: "D3",
|
|
75
|
+
mode: "twoWay",
|
|
76
|
+
localTrashDisabled: true,
|
|
77
|
+
initialRemote: { "/a.txt": "content" },
|
|
78
|
+
steps: [runCycle(), remoteMutate(world => world.cloud.controls.trashPath("/a.txt")), runCycle(), runCycle()]
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("deleteLocalFile")
|
|
82
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
83
|
+
// Hard delete: the file is not in the local trash either.
|
|
84
|
+
expect(existsLocal(result.world, ".filen.trash.local/a.txt")).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("D4: deleting a directory with children emits ONE parent delete (children collapsed)", async () => {
|
|
88
|
+
const result = await runScenario({
|
|
89
|
+
name: "D4",
|
|
90
|
+
mode: "twoWay",
|
|
91
|
+
initialLocal: {
|
|
92
|
+
"/local/dir/a.txt": "a",
|
|
93
|
+
"/local/dir/b.txt": "b",
|
|
94
|
+
"/local/dir/sub/c.txt": "c"
|
|
95
|
+
},
|
|
96
|
+
steps: [runCycle(), localMutate(world => rmLocal(world, "dir")), runCycle(), runCycle()]
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const kinds = transferKinds(result.cycles[1]!.messages)
|
|
100
|
+
|
|
101
|
+
// Only the parent directory delete is emitted; the per-child file/dir deletes are collapsed.
|
|
102
|
+
expect(kinds).toContain("deleteRemoteDirectory")
|
|
103
|
+
expect(kinds.filter(kind => kind === "deleteRemoteDirectory")).toHaveLength(1)
|
|
104
|
+
expect(kinds).not.toContain("deleteRemoteFile")
|
|
105
|
+
|
|
106
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
107
|
+
expect(result.finalRemote["/dir/a.txt"]).toBeUndefined()
|
|
108
|
+
expect(result.finalRemote["/dir/sub/c.txt"]).toBeUndefined()
|
|
109
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("D5/localBackup: a local deletion does NOT propagate to the remote backup", async () => {
|
|
113
|
+
const result = await runScenario({
|
|
114
|
+
name: "D5-localBackup",
|
|
115
|
+
mode: "localBackup",
|
|
116
|
+
initialLocal: { "/local/a.txt": "content" },
|
|
117
|
+
steps: [runCycle(), localMutate(world => rmLocal(world, "a.txt")), runCycle(), runCycle()]
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Backup modes never delete the target. The remote keeps the file; local is not re-downloaded.
|
|
121
|
+
expect(transferKinds(result.messages)).not.toContain("deleteRemoteFile")
|
|
122
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file" })
|
|
123
|
+
expect(result.finalLocal["/a.txt"]).toBeUndefined()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("D5/cloudBackup: a remote deletion does NOT propagate to the local backup", async () => {
|
|
127
|
+
const result = await runScenario({
|
|
128
|
+
name: "D5-cloudBackup",
|
|
129
|
+
mode: "cloudBackup",
|
|
130
|
+
initialRemote: { "/a.txt": "content" },
|
|
131
|
+
steps: [runCycle(), remoteMutate(world => world.cloud.controls.trashPath("/a.txt")), runCycle(), runCycle()]
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Backup modes never delete the target. Local keeps the file; it is not re-uploaded.
|
|
135
|
+
expect(transferKinds(result.messages)).not.toContain("deleteLocalFile")
|
|
136
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file" })
|
|
137
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("D6: deleting one file leaves the sibling files on the target untouched", async () => {
|
|
141
|
+
const result = await runScenario({
|
|
142
|
+
name: "D6",
|
|
143
|
+
mode: "twoWay",
|
|
144
|
+
initialLocal: {
|
|
145
|
+
"/local/a.txt": "aaa",
|
|
146
|
+
"/local/b.txt": "bbb",
|
|
147
|
+
"/local/c.txt": "ccc"
|
|
148
|
+
},
|
|
149
|
+
steps: [runCycle(), localMutate(world => rmLocal(world, "b.txt")), runCycle(), runCycle()]
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const kinds = transferKinds(result.cycles[1]!.messages)
|
|
153
|
+
|
|
154
|
+
// Exactly one delete; the untouched siblings are not re-transferred.
|
|
155
|
+
expect(kinds.filter(kind => kind === "deleteRemoteFile")).toHaveLength(1)
|
|
156
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file" })
|
|
157
|
+
expect(result.finalRemote["/c.txt"]).toMatchObject({ type: "file" })
|
|
158
|
+
expect(result.finalRemote["/b.txt"]).toBeUndefined()
|
|
159
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("D7: deleting then re-creating the same name is a delete+add (fresh uuid), not a rename", async () => {
|
|
163
|
+
let originalUUID: string | undefined
|
|
164
|
+
|
|
165
|
+
const result = await runScenario({
|
|
166
|
+
name: "D7",
|
|
167
|
+
mode: "twoWay",
|
|
168
|
+
initialLocal: { "/local/a.txt": "v1" },
|
|
169
|
+
steps: [
|
|
170
|
+
runCycle(),
|
|
171
|
+
control(world => {
|
|
172
|
+
originalUUID = world.cloud.controls.getByPath("/a.txt")?.uuid
|
|
173
|
+
}),
|
|
174
|
+
localMutate(world => rmLocal(world, "a.txt")),
|
|
175
|
+
runCycle(),
|
|
176
|
+
localMutate(world => writeLocal(world, "a.txt", "version-2")),
|
|
177
|
+
runCycle(),
|
|
178
|
+
runCycle()
|
|
179
|
+
]
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// The deletion is observed in its own cycle, the re-creation as an add in a later cycle.
|
|
183
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("deleteRemoteFile")
|
|
184
|
+
expect(transferKinds(result.cycles[2]!.messages)).toContain("upload")
|
|
185
|
+
|
|
186
|
+
// Nothing is renamed at any point.
|
|
187
|
+
expect(transferKinds(result.messages).filter(kind => kind.startsWith("renameRemote"))).toEqual([])
|
|
188
|
+
|
|
189
|
+
// The recreated file is a brand-new remote node, not the resurrected original.
|
|
190
|
+
const finalUUID = result.world.cloud.controls.getByPath("/a.txt")?.uuid
|
|
191
|
+
|
|
192
|
+
expect(originalUUID).toBeDefined()
|
|
193
|
+
expect(finalUUID).toBeDefined()
|
|
194
|
+
expect(finalUUID).not.toBe(originalUUID)
|
|
195
|
+
expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "version-2".length })
|
|
196
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// D8–D10 — newer-modify-wins over a remote delete (E2E-OBS-001). When a file is deleted remotely
|
|
200
|
+
// but modified locally since the last sync, the local modification survives: the file is re-uploaded
|
|
201
|
+
// (resurrected) rather than deleted. "Modified" means a real CONTENT change (size differs, or the
|
|
202
|
+
// cached upload hash differs) — a bare mtime touch is not enough and lets the delete proceed.
|
|
203
|
+
it("D8: a local content modify (size changed) survives a remote delete — newer-modify-wins (E2E-OBS-001)", async () => {
|
|
204
|
+
const result = await runScenario({
|
|
205
|
+
name: "D8",
|
|
206
|
+
mode: "twoWay",
|
|
207
|
+
initialLocal: { "/local/f.txt": "v1" },
|
|
208
|
+
steps: [
|
|
209
|
+
runCycle(),
|
|
210
|
+
localMutate(world => writeLocalAt(world, "f.txt", "v2-modified-longer", BASE_TIME + 5 * SECOND)),
|
|
211
|
+
remoteMutate(world => world.cloud.controls.deletePath("/f.txt")),
|
|
212
|
+
runCycle(),
|
|
213
|
+
runCycle()
|
|
214
|
+
]
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
|
|
218
|
+
expect(result.finalRemote["/f.txt"]).toMatchObject({ type: "file", size: "v2-modified-longer".length })
|
|
219
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
220
|
+
expect(readLocal(result.world, "f.txt")).toBe("v2-modified-longer")
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("D9: a bare local touch (no content change) does NOT survive a remote delete — the delete wins", async () => {
|
|
224
|
+
const result = await runScenario({
|
|
225
|
+
name: "D9",
|
|
226
|
+
mode: "twoWay",
|
|
227
|
+
initialLocal: { "/local/f.txt": "v1" },
|
|
228
|
+
steps: [
|
|
229
|
+
runCycle(),
|
|
230
|
+
localMutate(world => touchLocal(world, "f.txt", BASE_TIME + 5 * SECOND)),
|
|
231
|
+
remoteMutate(world => world.cloud.controls.deletePath("/f.txt")),
|
|
232
|
+
runCycle(),
|
|
233
|
+
runCycle()
|
|
234
|
+
]
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
expect(result.finalRemote["/f.txt"]).toBeUndefined()
|
|
238
|
+
expect(existsLocal(result.world, "f.txt")).toBe(false)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("D10: a same-size content change survives a remote delete via the cached upload hash (E2E-OBS-001)", async () => {
|
|
242
|
+
const result = await runScenario({
|
|
243
|
+
name: "D10",
|
|
244
|
+
mode: "twoWay",
|
|
245
|
+
initialLocal: { "/local/f.txt": "AAAA" },
|
|
246
|
+
steps: [
|
|
247
|
+
runCycle(),
|
|
248
|
+
localMutate(world => writeLocalAt(world, "f.txt", "BBBB", BASE_TIME + 5 * SECOND)),
|
|
249
|
+
remoteMutate(world => world.cloud.controls.deletePath("/f.txt")),
|
|
250
|
+
runCycle(),
|
|
251
|
+
runCycle()
|
|
252
|
+
]
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
expect(result.finalRemote["/f.txt"]).toMatchObject({ type: "file", size: 4 })
|
|
256
|
+
expect(readLocal(result.world, "f.txt")).toBe("BBBB")
|
|
257
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
258
|
+
})
|
|
259
|
+
})
|