@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,288 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, control } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { transferKinds } from "../harness/snapshot"
|
|
5
|
+
import { renameLocal, touchLocal } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category E — rename / move (behavioral spec §E, §4). Identity is the inode (local) / uuid (remote);
|
|
9
|
+
* a path change with stable identity is a rename/move (NOT delete+add), so the remote node keeps its
|
|
10
|
+
* uuid. A directory rename/move collapses its child renames into the single parent op.
|
|
11
|
+
*/
|
|
12
|
+
const SECOND = 1000
|
|
13
|
+
|
|
14
|
+
describe("Category E — rename / move", () => {
|
|
15
|
+
it("E1: renaming a file in place renames the remote node (same uuid, no re-upload)", async () => {
|
|
16
|
+
let originalUUID: string | undefined
|
|
17
|
+
|
|
18
|
+
const result = await runScenario({
|
|
19
|
+
name: "E1",
|
|
20
|
+
mode: "twoWay",
|
|
21
|
+
initialLocal: { "/local/a.txt": "content" },
|
|
22
|
+
steps: [
|
|
23
|
+
runCycle(),
|
|
24
|
+
control(world => {
|
|
25
|
+
originalUUID = world.cloud.controls.getByPath("/a.txt")?.uuid
|
|
26
|
+
}),
|
|
27
|
+
localMutate(world => renameLocal(world, "a.txt", "b.txt")),
|
|
28
|
+
runCycle(),
|
|
29
|
+
runCycle()
|
|
30
|
+
]
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
|
|
34
|
+
// A rename is not a transfer: no upload/download of the content.
|
|
35
|
+
expect(transferKinds(result.cycles[1]!.messages)).not.toContain("upload")
|
|
36
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
37
|
+
expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "content".length })
|
|
38
|
+
|
|
39
|
+
// Same remote identity at the new path → it was renamed, not re-created.
|
|
40
|
+
expect(originalUUID).toBeDefined()
|
|
41
|
+
expect(result.world.cloud.controls.getByPath("/b.txt")?.uuid).toBe(originalUUID)
|
|
42
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("E2: renaming a directory emits ONE parent rename (child renames collapsed)", async () => {
|
|
46
|
+
const result = await runScenario({
|
|
47
|
+
name: "E2",
|
|
48
|
+
mode: "twoWay",
|
|
49
|
+
initialLocal: {
|
|
50
|
+
"/local/dir/a.txt": "a",
|
|
51
|
+
"/local/dir/b.txt": "b",
|
|
52
|
+
"/local/dir/sub/c.txt": "c"
|
|
53
|
+
},
|
|
54
|
+
steps: [runCycle(), localMutate(world => renameLocal(world, "dir", "dir2")), runCycle(), runCycle()]
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const kinds = transferKinds(result.cycles[1]!.messages)
|
|
58
|
+
|
|
59
|
+
expect(kinds.filter(kind => kind === "renameRemoteDirectory")).toHaveLength(1)
|
|
60
|
+
// Children are carried by the parent rename — no per-child rename ops, no re-uploads.
|
|
61
|
+
expect(kinds).not.toContain("renameRemoteFile")
|
|
62
|
+
expect(kinds).not.toContain("upload")
|
|
63
|
+
|
|
64
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
65
|
+
expect(result.finalRemote["/dir2/a.txt"]).toMatchObject({ type: "file" })
|
|
66
|
+
expect(result.finalRemote["/dir2/sub/c.txt"]).toMatchObject({ type: "file" })
|
|
67
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("E3: moving a file across directories moves the remote node (same uuid)", async () => {
|
|
71
|
+
let originalUUID: string | undefined
|
|
72
|
+
|
|
73
|
+
const result = await runScenario({
|
|
74
|
+
name: "E3",
|
|
75
|
+
mode: "twoWay",
|
|
76
|
+
initialLocal: {
|
|
77
|
+
"/local/x/a.txt": "content",
|
|
78
|
+
"/local/y/keep.txt": "keep"
|
|
79
|
+
},
|
|
80
|
+
steps: [
|
|
81
|
+
runCycle(),
|
|
82
|
+
control(world => {
|
|
83
|
+
originalUUID = world.cloud.controls.getByPath("/x/a.txt")?.uuid
|
|
84
|
+
}),
|
|
85
|
+
localMutate(world => renameLocal(world, "x/a.txt", "y/a.txt")),
|
|
86
|
+
runCycle(),
|
|
87
|
+
runCycle()
|
|
88
|
+
]
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
|
|
92
|
+
expect(transferKinds(result.cycles[1]!.messages)).not.toContain("upload")
|
|
93
|
+
expect(result.finalRemote["/x/a.txt"]).toBeUndefined()
|
|
94
|
+
expect(result.finalRemote["/y/a.txt"]).toMatchObject({ type: "file", size: "content".length })
|
|
95
|
+
expect(result.finalRemote["/y/keep.txt"]).toMatchObject({ type: "file" })
|
|
96
|
+
|
|
97
|
+
expect(originalUUID).toBeDefined()
|
|
98
|
+
expect(result.world.cloud.controls.getByPath("/y/a.txt")?.uuid).toBe(originalUUID)
|
|
99
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("E4: moving a directory subtree into another directory is a single move (children collapsed)", async () => {
|
|
103
|
+
const result = await runScenario({
|
|
104
|
+
name: "E4",
|
|
105
|
+
mode: "twoWay",
|
|
106
|
+
initialLocal: {
|
|
107
|
+
"/local/src/a.txt": "a",
|
|
108
|
+
"/local/src/sub/b.txt": "b",
|
|
109
|
+
"/local/dest/keep.txt": "keep"
|
|
110
|
+
},
|
|
111
|
+
steps: [runCycle(), localMutate(world => renameLocal(world, "src", "dest/src")), runCycle(), runCycle()]
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const kinds = transferKinds(result.cycles[1]!.messages)
|
|
115
|
+
|
|
116
|
+
expect(kinds.filter(kind => kind === "renameRemoteDirectory")).toHaveLength(1)
|
|
117
|
+
expect(kinds).not.toContain("renameRemoteFile")
|
|
118
|
+
expect(kinds).not.toContain("upload")
|
|
119
|
+
|
|
120
|
+
expect(result.finalRemote["/src"]).toBeUndefined()
|
|
121
|
+
expect(result.finalRemote["/dest/src/a.txt"]).toMatchObject({ type: "file" })
|
|
122
|
+
expect(result.finalRemote["/dest/src/sub/b.txt"]).toMatchObject({ type: "file" })
|
|
123
|
+
expect(result.finalRemote["/dest/keep.txt"]).toMatchObject({ type: "file" })
|
|
124
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("E5: moving a parent dir AND moving a child within it both resolve (parent + adjusted child op)", async () => {
|
|
128
|
+
const result = await runScenario({
|
|
129
|
+
name: "E5",
|
|
130
|
+
mode: "twoWay",
|
|
131
|
+
initialLocal: {
|
|
132
|
+
"/local/dir/child.txt": "child",
|
|
133
|
+
"/local/dir/keep.txt": "keep"
|
|
134
|
+
},
|
|
135
|
+
steps: [
|
|
136
|
+
runCycle(),
|
|
137
|
+
localMutate(world => {
|
|
138
|
+
// Rename the parent, then independently move the child deeper within the renamed parent.
|
|
139
|
+
renameLocal(world, "dir", "dir2")
|
|
140
|
+
renameLocal(world, "dir2/child.txt", "dir2/sub/child.txt")
|
|
141
|
+
}),
|
|
142
|
+
runCycle(),
|
|
143
|
+
runCycle(),
|
|
144
|
+
runCycle()
|
|
145
|
+
]
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// The parent rename carries keep.txt; the child's move is re-based onto the renamed parent.
|
|
149
|
+
expect(result.finalRemote["/dir"]).toBeUndefined()
|
|
150
|
+
expect(result.finalRemote["/dir2/keep.txt"]).toMatchObject({ type: "file" })
|
|
151
|
+
expect(result.finalRemote["/dir2/sub/child.txt"]).toMatchObject({ type: "file", size: "child".length })
|
|
152
|
+
expect(result.finalRemote["/dir2/child.txt"]).toBeUndefined()
|
|
153
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("E6: swapping two file names converges with both contents preserved", async () => {
|
|
157
|
+
const result = await runScenario({
|
|
158
|
+
name: "E6",
|
|
159
|
+
mode: "twoWay",
|
|
160
|
+
initialLocal: {
|
|
161
|
+
"/local/a.txt": "AAA",
|
|
162
|
+
"/local/b.txt": "BBB"
|
|
163
|
+
},
|
|
164
|
+
steps: [
|
|
165
|
+
runCycle(),
|
|
166
|
+
localMutate(world => {
|
|
167
|
+
renameLocal(world, "a.txt", "tmp.txt")
|
|
168
|
+
renameLocal(world, "b.txt", "a.txt")
|
|
169
|
+
renameLocal(world, "tmp.txt", "b.txt")
|
|
170
|
+
// A swap leaves both targets occupied, so the engine cannot rename onto them; it falls
|
|
171
|
+
// back to content modify, which (like §C) needs a newer whole-second mtime to win. A real
|
|
172
|
+
// swap happens after the initial sync, so stamp the swapped-in files as newer.
|
|
173
|
+
touchLocal(world, "a.txt", BASE_TIME + 10 * SECOND)
|
|
174
|
+
touchLocal(world, "b.txt", BASE_TIME + 10 * SECOND)
|
|
175
|
+
}),
|
|
176
|
+
runCycle(),
|
|
177
|
+
runCycle(),
|
|
178
|
+
runCycle()
|
|
179
|
+
]
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// After the swap, a.txt holds the old b content and vice-versa; both sides agree.
|
|
183
|
+
expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 3 })
|
|
184
|
+
expect(result.finalLocal["/b.txt"]).toMatchObject({ type: "file", size: 3 })
|
|
185
|
+
expect(result.finalRemote["/a.txt"]!.contentHash).toBe(result.finalLocal["/a.txt"]!.contentHash)
|
|
186
|
+
expect(result.finalRemote["/b.txt"]!.contentHash).toBe(result.finalLocal["/b.txt"]!.contentHash)
|
|
187
|
+
// The swap actually happened: a.txt's content differs from b.txt's.
|
|
188
|
+
expect(result.finalLocal["/a.txt"]!.contentHash).not.toBe(result.finalLocal["/b.txt"]!.contentHash)
|
|
189
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("E7: a case-only rename propagates to the remote (same uuid)", async () => {
|
|
193
|
+
let originalUUID: string | undefined
|
|
194
|
+
|
|
195
|
+
const result = await runScenario({
|
|
196
|
+
name: "E7",
|
|
197
|
+
mode: "twoWay",
|
|
198
|
+
initialLocal: { "/local/readme.txt": "content" },
|
|
199
|
+
steps: [
|
|
200
|
+
runCycle(),
|
|
201
|
+
control(world => {
|
|
202
|
+
originalUUID = world.cloud.controls.getByPath("/readme.txt")?.uuid
|
|
203
|
+
}),
|
|
204
|
+
localMutate(world => renameLocal(world, "readme.txt", "README.txt")),
|
|
205
|
+
runCycle(),
|
|
206
|
+
runCycle()
|
|
207
|
+
]
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
|
|
211
|
+
expect(result.finalRemote["/README.txt"]).toMatchObject({ type: "file" })
|
|
212
|
+
expect(result.finalRemote["/readme.txt"]).toBeUndefined()
|
|
213
|
+
|
|
214
|
+
expect(originalUUID).toBeDefined()
|
|
215
|
+
expect(result.world.cloud.controls.getByPath("/README.txt")?.uuid).toBe(originalUUID)
|
|
216
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("E8: renaming a file onto an existing name replaces it (renamed-in content wins)", async () => {
|
|
220
|
+
const result = await runScenario({
|
|
221
|
+
name: "E8",
|
|
222
|
+
mode: "twoWay",
|
|
223
|
+
initialLocal: {
|
|
224
|
+
"/local/a.txt": "from-a",
|
|
225
|
+
"/local/b.txt": "old-b"
|
|
226
|
+
},
|
|
227
|
+
steps: [
|
|
228
|
+
runCycle(),
|
|
229
|
+
localMutate(world => {
|
|
230
|
+
// Overwrite b.txt with a.txt; the renamed-in content must end up at b.txt on both sides.
|
|
231
|
+
renameLocal(world, "a.txt", "b.txt")
|
|
232
|
+
touchLocal(world, "b.txt", BASE_TIME + 10 * SECOND)
|
|
233
|
+
}),
|
|
234
|
+
runCycle(),
|
|
235
|
+
runCycle()
|
|
236
|
+
]
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(result.finalRemote["/a.txt"]).toBeUndefined()
|
|
240
|
+
expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "from-a".length })
|
|
241
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("E9: the remote filesystem rejects moving a directory into its own subdirectory", async () => {
|
|
245
|
+
const result = await runScenario({
|
|
246
|
+
name: "E9",
|
|
247
|
+
mode: "twoWay",
|
|
248
|
+
initialLocal: { "/local/x/a.txt": "content" },
|
|
249
|
+
steps: [runCycle()]
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// The cycle has populated the remote tree cache with /x; an in-place move into its own subtree
|
|
253
|
+
// is structurally invalid and must be refused rather than corrupt the tree.
|
|
254
|
+
await expect(
|
|
255
|
+
result.world.sync.remoteFileSystem.rename({ fromRelativePath: "/x", toRelativePath: "/x/sub" })
|
|
256
|
+
).rejects.toThrow("Invalid paths")
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("E10: a rename chain across consecutive cycles ends at the final name (stable identity)", async () => {
|
|
260
|
+
let originalUUID: string | undefined
|
|
261
|
+
|
|
262
|
+
const result = await runScenario({
|
|
263
|
+
name: "E10",
|
|
264
|
+
mode: "twoWay",
|
|
265
|
+
initialLocal: { "/local/a.txt": "content" },
|
|
266
|
+
steps: [
|
|
267
|
+
runCycle(),
|
|
268
|
+
control(world => {
|
|
269
|
+
originalUUID = world.cloud.controls.getByPath("/a.txt")?.uuid
|
|
270
|
+
}),
|
|
271
|
+
localMutate(world => renameLocal(world, "a.txt", "b.txt")),
|
|
272
|
+
runCycle(),
|
|
273
|
+
localMutate(world => renameLocal(world, "b.txt", "c.txt")),
|
|
274
|
+
runCycle(),
|
|
275
|
+
localMutate(world => renameLocal(world, "c.txt", "d.txt")),
|
|
276
|
+
runCycle(),
|
|
277
|
+
runCycle()
|
|
278
|
+
]
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// No duplicates and nothing lost: exactly one file, at the final name, with the original uuid.
|
|
282
|
+
expect(Object.keys(result.finalRemote)).toEqual(["/d.txt"])
|
|
283
|
+
expect(result.finalRemote["/d.txt"]).toMatchObject({ type: "file", size: "content".length })
|
|
284
|
+
expect(originalUUID).toBeDefined()
|
|
285
|
+
expect(result.world.cloud.controls.getByPath("/d.txt")?.uuid).toBe(originalUUID)
|
|
286
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
287
|
+
})
|
|
288
|
+
})
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import pathModule from "path"
|
|
3
|
+
import { runScenario, runCycle, localMutate, control } from "../harness/runner"
|
|
4
|
+
import { BASE_TIME, DB_ROOT, type World } from "../harness/world"
|
|
5
|
+
import { messagesOfType } from "../harness/snapshot"
|
|
6
|
+
import { writeLocal } from "../harness/mutations"
|
|
7
|
+
import { IGNORER_VERSION } from "../../src/ignorer"
|
|
8
|
+
import { type SyncMessage } from "../../src/types"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Category F — ignore / filter (behavioral spec §F, §5). Default OS junk, excludeDotFiles, and
|
|
12
|
+
* `.filenignore` (gitignore semantics) gate which paths enter the trees. An ignored path is excluded
|
|
13
|
+
* from sync but NOT removed from the side it physically lives on — so assertions are path-specific
|
|
14
|
+
* (the normalized local snapshot still walks the real disk, which keeps ignored files).
|
|
15
|
+
*/
|
|
16
|
+
const SECOND = 1000
|
|
17
|
+
|
|
18
|
+
/** All structural-ignore reasons reported across the message stream (local + remote). */
|
|
19
|
+
function ignoredReasons(messages: SyncMessage[]): string[] {
|
|
20
|
+
const local = messagesOfType(messages, "localTreeIgnored").flatMap(message => message.data.ignored.map(entry => entry.reason))
|
|
21
|
+
const remote = messagesOfType(messages, "remoteTreeIgnored").flatMap(message => message.data.ignored.map(entry => entry.reason))
|
|
22
|
+
|
|
23
|
+
return [...local, ...remote]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Seed the dbPath-side `.filenignore` copy (merged with the physical one each cycle). */
|
|
27
|
+
function writeDbIgnore(world: World, content: string): void {
|
|
28
|
+
const dir = pathModule.posix.join(DB_ROOT, "ignorer", `v${IGNORER_VERSION}`, world.syncPair.uuid)
|
|
29
|
+
|
|
30
|
+
world.vfs.ifs.mkdirSync(dir, { recursive: true })
|
|
31
|
+
world.vfs.ifs.writeFileSync(pathModule.posix.join(dir, "filenIgnore"), content)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("Category F — ignore / filter", () => {
|
|
35
|
+
it("F1: default OS-junk names are ignored (not uploaded), real files sync", async () => {
|
|
36
|
+
const result = await runScenario({
|
|
37
|
+
name: "F1",
|
|
38
|
+
mode: "twoWay",
|
|
39
|
+
initialLocal: {
|
|
40
|
+
"/local/.DS_Store": "junk",
|
|
41
|
+
"/local/Thumbs.db": "junk",
|
|
42
|
+
"/local/real.txt": "real"
|
|
43
|
+
},
|
|
44
|
+
steps: [runCycle(), runCycle()]
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
expect(result.finalRemote["/.DS_Store"]).toBeUndefined()
|
|
48
|
+
expect(result.finalRemote["/Thumbs.db"]).toBeUndefined()
|
|
49
|
+
expect(result.finalRemote["/real.txt"]).toMatchObject({ type: "file" })
|
|
50
|
+
expect(ignoredReasons(result.messages)).toContain("defaultIgnore")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("F2: excludeDotFiles hides dot paths; toggling it off re-includes them next cycle", async () => {
|
|
54
|
+
const result = await runScenario({
|
|
55
|
+
name: "F2",
|
|
56
|
+
mode: "twoWay",
|
|
57
|
+
excludeDotFiles: true,
|
|
58
|
+
initialLocal: {
|
|
59
|
+
"/local/.secret": "hidden",
|
|
60
|
+
"/local/real.txt": "real"
|
|
61
|
+
},
|
|
62
|
+
steps: [
|
|
63
|
+
runCycle(),
|
|
64
|
+
// Toggle inside a local mutation so the watcher fires and the tree is rebuilt next cycle.
|
|
65
|
+
localMutate(world => world.worker.updateExcludeDotFiles(world.syncPair.uuid, false)),
|
|
66
|
+
runCycle(),
|
|
67
|
+
runCycle()
|
|
68
|
+
]
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// First cycle: the dot file is excluded.
|
|
72
|
+
expect(result.cycles[0]!.remote["/.secret"]).toBeUndefined()
|
|
73
|
+
expect(result.cycles[0]!.remote["/real.txt"]).toMatchObject({ type: "file" })
|
|
74
|
+
// After toggling off, it is re-included.
|
|
75
|
+
expect(result.finalRemote["/.secret"]).toMatchObject({ type: "file" })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("F3: a simple `.filenignore` name pattern is ignored", async () => {
|
|
79
|
+
const result = await runScenario({
|
|
80
|
+
name: "F3",
|
|
81
|
+
mode: "twoWay",
|
|
82
|
+
filenIgnore: "ignored.txt",
|
|
83
|
+
initialLocal: {
|
|
84
|
+
"/local/ignored.txt": "nope",
|
|
85
|
+
"/local/kept.txt": "yes"
|
|
86
|
+
},
|
|
87
|
+
steps: [runCycle(), runCycle()]
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(result.finalRemote["/ignored.txt"]).toBeUndefined()
|
|
91
|
+
expect(result.finalRemote["/kept.txt"]).toMatchObject({ type: "file" })
|
|
92
|
+
// The ignored file is untouched on local disk.
|
|
93
|
+
expect(result.finalLocal["/ignored.txt"]).toMatchObject({ type: "file" })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("F4: a directory pattern with a trailing slash ignores the directory and its contents", async () => {
|
|
97
|
+
const result = await runScenario({
|
|
98
|
+
name: "F4",
|
|
99
|
+
mode: "twoWay",
|
|
100
|
+
filenIgnore: "build/",
|
|
101
|
+
initialLocal: {
|
|
102
|
+
"/local/build/out.js": "compiled",
|
|
103
|
+
"/local/build/nested/more.js": "compiled",
|
|
104
|
+
"/local/src.txt": "source"
|
|
105
|
+
},
|
|
106
|
+
steps: [runCycle(), runCycle()]
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
expect(result.finalRemote["/build"]).toBeUndefined()
|
|
110
|
+
expect(result.finalRemote["/build/out.js"]).toBeUndefined()
|
|
111
|
+
expect(result.finalRemote["/build/nested/more.js"]).toBeUndefined()
|
|
112
|
+
expect(result.finalRemote["/src.txt"]).toMatchObject({ type: "file" })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("F5: a `**` nested glob ignores matches at any depth", async () => {
|
|
116
|
+
const result = await runScenario({
|
|
117
|
+
name: "F5",
|
|
118
|
+
mode: "twoWay",
|
|
119
|
+
filenIgnore: "**/*.log",
|
|
120
|
+
initialLocal: {
|
|
121
|
+
"/local/a.log": "log",
|
|
122
|
+
"/local/deep/b.log": "log",
|
|
123
|
+
"/local/deep/deeper/c.log": "log",
|
|
124
|
+
"/local/keep.txt": "keep"
|
|
125
|
+
},
|
|
126
|
+
steps: [runCycle(), runCycle()]
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(result.finalRemote["/a.log"]).toBeUndefined()
|
|
130
|
+
expect(result.finalRemote["/deep/b.log"]).toBeUndefined()
|
|
131
|
+
expect(result.finalRemote["/deep/deeper/c.log"]).toBeUndefined()
|
|
132
|
+
expect(result.finalRemote["/keep.txt"]).toMatchObject({ type: "file" })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("F6: a negation pattern re-includes a specifically excepted file", async () => {
|
|
136
|
+
const result = await runScenario({
|
|
137
|
+
name: "F6",
|
|
138
|
+
mode: "twoWay",
|
|
139
|
+
filenIgnore: "*.txt\n!keep.txt",
|
|
140
|
+
initialLocal: {
|
|
141
|
+
"/local/drop.txt": "drop",
|
|
142
|
+
"/local/keep.txt": "keep"
|
|
143
|
+
},
|
|
144
|
+
steps: [runCycle(), runCycle()]
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
expect(result.finalRemote["/drop.txt"]).toBeUndefined()
|
|
148
|
+
expect(result.finalRemote["/keep.txt"]).toMatchObject({ type: "file" })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("F7: editing `.filenignore` mid-run takes effect on the next cycle", async () => {
|
|
152
|
+
const result = await runScenario({
|
|
153
|
+
name: "F7",
|
|
154
|
+
mode: "twoWay",
|
|
155
|
+
initialLocal: { "/local/seed.txt": "seed" },
|
|
156
|
+
steps: [
|
|
157
|
+
runCycle(),
|
|
158
|
+
// Add an ignore rule and a matching file in the same beat; the new rule must apply next cycle.
|
|
159
|
+
localMutate(world => {
|
|
160
|
+
world.worker.updateIgnorerContent(world.syncPair.uuid, "later.txt")
|
|
161
|
+
writeLocal(world, "later.txt", "added-after-rule")
|
|
162
|
+
}),
|
|
163
|
+
runCycle(),
|
|
164
|
+
runCycle()
|
|
165
|
+
]
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(result.finalRemote["/later.txt"]).toBeUndefined()
|
|
169
|
+
expect(result.finalRemote["/seed.txt"]).toMatchObject({ type: "file" })
|
|
170
|
+
expect(result.finalLocal["/later.txt"]).toMatchObject({ type: "file" })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("F8: the physical and dbPath `.filenignore` copies are merged", async () => {
|
|
174
|
+
const result = await runScenario({
|
|
175
|
+
name: "F8",
|
|
176
|
+
mode: "twoWay",
|
|
177
|
+
filenIgnore: "physical-ignored.txt",
|
|
178
|
+
initialLocal: {
|
|
179
|
+
"/local/physical-ignored.txt": "a",
|
|
180
|
+
"/local/db-ignored.txt": "b",
|
|
181
|
+
"/local/kept.txt": "c"
|
|
182
|
+
},
|
|
183
|
+
steps: [
|
|
184
|
+
control(world => writeDbIgnore(world, "db-ignored.txt")),
|
|
185
|
+
runCycle(),
|
|
186
|
+
runCycle()
|
|
187
|
+
]
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// Both sources contribute patterns.
|
|
191
|
+
expect(result.finalRemote["/physical-ignored.txt"]).toBeUndefined()
|
|
192
|
+
expect(result.finalRemote["/db-ignored.txt"]).toBeUndefined()
|
|
193
|
+
expect(result.finalRemote["/kept.txt"]).toMatchObject({ type: "file" })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// F9: a symlink is recognized and skipped structurally (reason "symlink"). The walk lstats each
|
|
197
|
+
// entry, so isSymbolicLink() is true for a link and it is flagged rather than silently followed and
|
|
198
|
+
// collided on the target's inode. (BUG-006 fix: stat → lstat; the previously-dead skip is now live.)
|
|
199
|
+
it("F9: a symlink is skipped structurally with the symlink reason", async () => {
|
|
200
|
+
const result = await runScenario({
|
|
201
|
+
name: "F9",
|
|
202
|
+
mode: "twoWay",
|
|
203
|
+
initialLocal: { "/local/target.txt": "real" },
|
|
204
|
+
steps: [
|
|
205
|
+
control(world => world.vfs.ifs.symlinkSync("/local/target.txt", "/local/link.txt")),
|
|
206
|
+
localMutate(() => {}),
|
|
207
|
+
runCycle(),
|
|
208
|
+
runCycle()
|
|
209
|
+
]
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(ignoredReasons(result.messages)).toContain("symlink")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("F10: a file whose name exceeds the max length is ignored (nameLength)", async () => {
|
|
216
|
+
const longName = `${"x".repeat(300)}.txt`
|
|
217
|
+
|
|
218
|
+
const result = await runScenario({
|
|
219
|
+
name: "F10",
|
|
220
|
+
mode: "twoWay",
|
|
221
|
+
initialLocal: {
|
|
222
|
+
[`/local/${longName}`]: "too-long",
|
|
223
|
+
"/local/ok.txt": "fine"
|
|
224
|
+
},
|
|
225
|
+
steps: [runCycle(), runCycle()]
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(result.finalRemote[`/${longName}`]).toBeUndefined()
|
|
229
|
+
expect(result.finalRemote["/ok.txt"]).toMatchObject({ type: "file" })
|
|
230
|
+
expect(ignoredReasons(result.messages)).toContain("nameLength")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("F11: a case-insensitive duplicate keeps one copy and flags the other", async () => {
|
|
234
|
+
const result = await runScenario({
|
|
235
|
+
name: "F11",
|
|
236
|
+
mode: "twoWay",
|
|
237
|
+
initialLocal: {
|
|
238
|
+
"/local/Report.txt": "one",
|
|
239
|
+
"/local/report.txt": "two"
|
|
240
|
+
},
|
|
241
|
+
steps: [runCycle(), runCycle()]
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Exactly one of the two case variants survives on the remote (the backend is case-insensitive).
|
|
245
|
+
const surviving = ["/Report.txt", "/report.txt"].filter(path => result.finalRemote[path] !== undefined)
|
|
246
|
+
|
|
247
|
+
expect(surviving).toHaveLength(1)
|
|
248
|
+
expect(ignoredReasons(result.messages)).toContain("duplicate")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("F12: a permission-denied path is skipped (permissions) and the sync continues", async () => {
|
|
252
|
+
const denied: NodeJS.ErrnoException = Object.assign(new Error("EACCES"), { code: "EACCES" })
|
|
253
|
+
|
|
254
|
+
const result = await runScenario({
|
|
255
|
+
name: "F12",
|
|
256
|
+
mode: "twoWay",
|
|
257
|
+
initialLocal: {
|
|
258
|
+
"/local/denied.txt": "secret",
|
|
259
|
+
"/local/ok.txt": "fine"
|
|
260
|
+
},
|
|
261
|
+
steps: [
|
|
262
|
+
control(world => world.vfs.controls.setError("/local/denied.txt", denied)),
|
|
263
|
+
runCycle(),
|
|
264
|
+
runCycle()
|
|
265
|
+
]
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
expect(result.finalRemote["/denied.txt"]).toBeUndefined()
|
|
269
|
+
expect(result.finalRemote["/ok.txt"]).toMatchObject({ type: "file" })
|
|
270
|
+
expect(ignoredReasons(result.messages)).toContain("permissions")
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it("F13: a file already synced and then newly ignored is NOT deleted on the remote", async () => {
|
|
274
|
+
const result = await runScenario({
|
|
275
|
+
name: "F13",
|
|
276
|
+
mode: "twoWay",
|
|
277
|
+
initialLocal: { "/local/keep-me.txt": "content" },
|
|
278
|
+
steps: [
|
|
279
|
+
runCycle(),
|
|
280
|
+
// Now ignore the already-synced file; ignore must not imply deletion.
|
|
281
|
+
localMutate(world => {
|
|
282
|
+
world.worker.updateIgnorerContent(world.syncPair.uuid, "keep-me.txt")
|
|
283
|
+
world.vfs.ifs.utimesSync("/local/keep-me.txt", (BASE_TIME + 5 * SECOND) / 1000, (BASE_TIME + 5 * SECOND) / 1000)
|
|
284
|
+
}),
|
|
285
|
+
runCycle(),
|
|
286
|
+
runCycle()
|
|
287
|
+
]
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// The remote copy survives — ignoring is not deletion.
|
|
291
|
+
expect(result.finalRemote["/keep-me.txt"]).toMatchObject({ type: "file" })
|
|
292
|
+
expect(result.finalLocal["/keep-me.txt"]).toMatchObject({ type: "file" })
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it("F14: a directory-only ignore pattern does not ignore a remote FILE of the same name (BUG-005)", async () => {
|
|
296
|
+
// BUG-005 regression: the remote walk must ignore-check a file AS a file. It previously passed
|
|
297
|
+
// type:"directory", appending a trailing slash, so a file named "build" was matched by the
|
|
298
|
+
// directory-only pattern "build/" and wrongly skipped. A file named "build" must still sync, while
|
|
299
|
+
// a real directory "cache" is still correctly ignored by "cache/".
|
|
300
|
+
const result = await runScenario({
|
|
301
|
+
name: "F14",
|
|
302
|
+
mode: "cloudToLocal",
|
|
303
|
+
filenIgnore: "build/\ncache/",
|
|
304
|
+
initialRemote: {
|
|
305
|
+
"/build": "i am a file, not a directory",
|
|
306
|
+
"/cache/tmp.txt": "real dir content"
|
|
307
|
+
},
|
|
308
|
+
steps: [runCycle(), runCycle()]
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// "build" is a FILE, so the directory-only pattern "build/" must NOT match it → it downloads.
|
|
312
|
+
expect(result.finalLocal["/build"]).toMatchObject({ type: "file" })
|
|
313
|
+
// "cache" IS a directory, so "cache/" correctly ignores it and its contents.
|
|
314
|
+
expect(result.finalLocal["/cache"]).toBeUndefined()
|
|
315
|
+
expect(result.finalLocal["/cache/tmp.txt"]).toBeUndefined()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it("F15: a synced file that becomes a symlink is skipped, not deleted from the remote (BUG-006 compat)", async () => {
|
|
319
|
+
// Backwards-compat: a file synced before the lstat switch (so the cloud has it and it is in the
|
|
320
|
+
// previous local tree) that is then replaced by a symlink must NOT be deleted from the cloud. A
|
|
321
|
+
// structurally-ignored path (a symlink) is treated like ignore-after-sync: skipped, never deleted
|
|
322
|
+
// — the delta layer suppresses remote-deletes for currently-ignored local paths.
|
|
323
|
+
const result = await runScenario({
|
|
324
|
+
name: "F15",
|
|
325
|
+
mode: "twoWay",
|
|
326
|
+
initialLocal: { "/local/data.txt": "real content" },
|
|
327
|
+
steps: [
|
|
328
|
+
runCycle(),
|
|
329
|
+
runCycle(),
|
|
330
|
+
// Replace the synced file with a (dangling) symlink at the same path.
|
|
331
|
+
control(world => {
|
|
332
|
+
world.vfs.ifs.unlinkSync("/local/data.txt")
|
|
333
|
+
world.vfs.ifs.symlinkSync("/local/elsewhere.txt", "/local/data.txt")
|
|
334
|
+
}),
|
|
335
|
+
localMutate(() => {}),
|
|
336
|
+
runCycle(),
|
|
337
|
+
runCycle()
|
|
338
|
+
]
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// The symlink is skipped structurally…
|
|
342
|
+
expect(ignoredReasons(result.messages)).toContain("symlink")
|
|
343
|
+
// …and the previously-synced cloud copy survives (ignore is not deletion).
|
|
344
|
+
expect(result.finalRemote["/data.txt"]).toMatchObject({ type: "file" })
|
|
345
|
+
})
|
|
346
|
+
})
|