@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,213 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import fsExtra from "fs-extra"
|
|
3
|
+
import os from "os"
|
|
4
|
+
import pathModule from "path"
|
|
5
|
+
import FastGlob from "fast-glob"
|
|
6
|
+
import { createVirtualFS, type VfsSpec } from "../fakes/virtual-fs"
|
|
7
|
+
import { type SyncFS } from "../../src/lib/environment"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* These tests are the backstop for the in-memory virtual filesystem: they assert
|
|
11
|
+
* that it behaves like a real OS filesystem for exactly the semantics the sync
|
|
12
|
+
* engine relies on (inode identity, timestamps, recursive ensureDir, symlink
|
|
13
|
+
* detection, fast-glob enumeration). They run with REAL timers on purpose.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const UTIMES_TARGET = new Date("2020-06-15T12:00:00.000Z")
|
|
17
|
+
const UTIMES_TARGET_SEC = Math.floor(UTIMES_TARGET.getTime() / 1000)
|
|
18
|
+
|
|
19
|
+
type FsProbe = {
|
|
20
|
+
inoStable: boolean
|
|
21
|
+
inoAfterRename: number
|
|
22
|
+
inoBeforeRename: number
|
|
23
|
+
inoAfterMove: number
|
|
24
|
+
inoBeforeMove: number
|
|
25
|
+
inoRecreateBefore: number
|
|
26
|
+
inoRecreateAfter: number
|
|
27
|
+
mtimeMsAfterWrite: number
|
|
28
|
+
birthtimeMsAfterWrite: number
|
|
29
|
+
mtimeSecAfterUtimes: number
|
|
30
|
+
nestedDirsAreDirectories: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Exercise the engine-relevant subset of an fs surface under `root` and report
|
|
35
|
+
* what was observed, so the real and virtual implementations can be compared.
|
|
36
|
+
*/
|
|
37
|
+
async function probe(fs: SyncFS, root: string): Promise<FsProbe> {
|
|
38
|
+
// Stable inode across two stats of the same file.
|
|
39
|
+
const a = pathModule.join(root, "a.txt")
|
|
40
|
+
await fs.writeFile(a, "alpha", { encoding: "utf-8" })
|
|
41
|
+
const aStat1 = await fs.stat(a)
|
|
42
|
+
const aStat2 = await fs.stat(a)
|
|
43
|
+
|
|
44
|
+
// Inode preserved across rename within the same directory.
|
|
45
|
+
const b = pathModule.join(root, "b.txt")
|
|
46
|
+
const b2 = pathModule.join(root, "b-renamed.txt")
|
|
47
|
+
await fs.writeFile(b, "bravo", { encoding: "utf-8" })
|
|
48
|
+
const inoBeforeRename = (await fs.stat(b)).ino
|
|
49
|
+
await fs.rename(b, b2)
|
|
50
|
+
const inoAfterRename = (await fs.stat(b2)).ino
|
|
51
|
+
|
|
52
|
+
// Inode preserved across a move into another directory.
|
|
53
|
+
const sub = pathModule.join(root, "sub")
|
|
54
|
+
await fs.ensureDir(sub)
|
|
55
|
+
const c = pathModule.join(root, "c.txt")
|
|
56
|
+
const c2 = pathModule.join(sub, "c.txt")
|
|
57
|
+
await fs.writeFile(c, "charlie", { encoding: "utf-8" })
|
|
58
|
+
const inoBeforeMove = (await fs.stat(c)).ino
|
|
59
|
+
await fs.move(c, c2, { overwrite: true })
|
|
60
|
+
const inoAfterMove = (await fs.stat(c2)).ino
|
|
61
|
+
|
|
62
|
+
// Fresh inode after delete + recreate at the same path.
|
|
63
|
+
const d = pathModule.join(root, "d.txt")
|
|
64
|
+
await fs.writeFile(d, "delta", { encoding: "utf-8" })
|
|
65
|
+
const inoRecreateBefore = (await fs.stat(d)).ino
|
|
66
|
+
await fs.rm(d, { force: true, recursive: true })
|
|
67
|
+
await fs.writeFile(d, "delta-2", { encoding: "utf-8" })
|
|
68
|
+
const inoRecreateAfter = (await fs.stat(d)).ino
|
|
69
|
+
|
|
70
|
+
// Timestamps populated on write; utimes updates mtime.
|
|
71
|
+
const e = pathModule.join(root, "e.txt")
|
|
72
|
+
await fs.writeFile(e, "echo", { encoding: "utf-8" })
|
|
73
|
+
const eStat = await fs.stat(e)
|
|
74
|
+
await fs.utimes(e, UTIMES_TARGET, UTIMES_TARGET)
|
|
75
|
+
const eStatAfter = await fs.stat(e)
|
|
76
|
+
|
|
77
|
+
// Recursive ensureDir.
|
|
78
|
+
const deep = pathModule.join(root, "x", "y", "z")
|
|
79
|
+
await fs.ensureDir(deep)
|
|
80
|
+
const deepStat = await fs.stat(deep)
|
|
81
|
+
const midStat = await fs.stat(pathModule.join(root, "x", "y"))
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
inoStable: aStat1.ino === aStat2.ino,
|
|
85
|
+
inoBeforeRename,
|
|
86
|
+
inoAfterRename,
|
|
87
|
+
inoBeforeMove,
|
|
88
|
+
inoAfterMove,
|
|
89
|
+
inoRecreateBefore,
|
|
90
|
+
inoRecreateAfter,
|
|
91
|
+
mtimeMsAfterWrite: eStat.mtimeMs,
|
|
92
|
+
birthtimeMsAfterWrite: eStat.birthtimeMs,
|
|
93
|
+
mtimeSecAfterUtimes: Math.floor(eStatAfter.mtimeMs / 1000),
|
|
94
|
+
nestedDirsAreDirectories: deepStat.isDirectory() && midStat.isDirectory()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("virtual filesystem conformance", () => {
|
|
99
|
+
let realRoot: string
|
|
100
|
+
let realProbe: FsProbe
|
|
101
|
+
let virtualProbe: FsProbe
|
|
102
|
+
|
|
103
|
+
beforeAll(async () => {
|
|
104
|
+
realRoot = await fsExtra.mkdtemp(pathModule.join(os.tmpdir(), "filen-sync-conformance-"))
|
|
105
|
+
|
|
106
|
+
realProbe = await probe(fsExtra as unknown as SyncFS, realRoot)
|
|
107
|
+
|
|
108
|
+
const virtual = createVirtualFS()
|
|
109
|
+
await virtual.fs.ensureDir("/root")
|
|
110
|
+
virtualProbe = await probe(virtual.fs, "/root")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
if (!realRoot) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Best-effort removal of the OS temp directory; never let a cleanup
|
|
119
|
+
// failure mask a real test result.
|
|
120
|
+
try {
|
|
121
|
+
await fsExtra.rm(realRoot, { force: true, recursive: true, maxRetries: 5, retryDelay: 50 })
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("reports a stable inode across repeated stats (real + virtual)", () => {
|
|
128
|
+
expect(realProbe.inoStable).toBe(true)
|
|
129
|
+
expect(virtualProbe.inoStable).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("preserves the inode across a rename (real + virtual)", () => {
|
|
133
|
+
expect(realProbe.inoAfterRename).toBe(realProbe.inoBeforeRename)
|
|
134
|
+
expect(virtualProbe.inoAfterRename).toBe(virtualProbe.inoBeforeRename)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("preserves the inode across a cross-directory move (real + virtual)", () => {
|
|
138
|
+
expect(realProbe.inoAfterMove).toBe(realProbe.inoBeforeMove)
|
|
139
|
+
expect(virtualProbe.inoAfterMove).toBe(virtualProbe.inoBeforeMove)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("assigns a fresh inode after delete + recreate (virtual is deterministic)", () => {
|
|
143
|
+
// The engine relies on a recreated path looking like a new item (delete + add,
|
|
144
|
+
// not a no-op). memfs never reuses inodes, so this is deterministic.
|
|
145
|
+
expect(virtualProbe.inoRecreateAfter).not.toBe(virtualProbe.inoRecreateBefore)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("populates mtime and birthtime on write (real + virtual)", () => {
|
|
149
|
+
expect(realProbe.mtimeMsAfterWrite).toBeGreaterThan(0)
|
|
150
|
+
expect(virtualProbe.mtimeMsAfterWrite).toBeGreaterThan(0)
|
|
151
|
+
expect(virtualProbe.birthtimeMsAfterWrite).toBeGreaterThan(0)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("updates mtime via utimes to whole-second precision (real + virtual)", () => {
|
|
155
|
+
expect(realProbe.mtimeSecAfterUtimes).toBe(UTIMES_TARGET_SEC)
|
|
156
|
+
expect(virtualProbe.mtimeSecAfterUtimes).toBe(UTIMES_TARGET_SEC)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("creates intermediate directories with ensureDir (real + virtual)", () => {
|
|
160
|
+
expect(realProbe.nestedDirsAreDirectories).toBe(true)
|
|
161
|
+
expect(virtualProbe.nestedDirsAreDirectories).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("detects symlinks via lstat while stat follows them (virtual)", () => {
|
|
165
|
+
const virtual = createVirtualFS({ "/root/target.txt": "payload" })
|
|
166
|
+
|
|
167
|
+
virtual.ifs.symlinkSync("/root/target.txt", "/root/link.txt")
|
|
168
|
+
|
|
169
|
+
expect(virtual.ifs.lstatSync("/root/link.txt").isSymbolicLink()).toBe(true)
|
|
170
|
+
expect(virtual.ifs.statSync("/root/link.txt").isSymbolicLink()).toBe(false)
|
|
171
|
+
expect(virtual.ifs.statSync("/root/link.txt").isFile()).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("enumerates a tree with fast-glob identically to a real directory", async () => {
|
|
175
|
+
const spec: VfsSpec = {
|
|
176
|
+
"/tree/a.txt": "a",
|
|
177
|
+
"/tree/sub/b.txt": "b",
|
|
178
|
+
"/tree/sub/c/d.txt": "d"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const virtual = createVirtualFS(spec)
|
|
182
|
+
|
|
183
|
+
// Mirror the same tree on the real filesystem.
|
|
184
|
+
const realTree = pathModule.join(realRoot, "tree")
|
|
185
|
+
await fsExtra.ensureDir(pathModule.join(realTree, "sub", "c"))
|
|
186
|
+
await fsExtra.writeFile(pathModule.join(realTree, "a.txt"), "a")
|
|
187
|
+
await fsExtra.writeFile(pathModule.join(realTree, "sub", "b.txt"), "b")
|
|
188
|
+
await fsExtra.writeFile(pathModule.join(realTree, "sub", "c", "d.txt"), "d")
|
|
189
|
+
|
|
190
|
+
const globOptions = {
|
|
191
|
+
dot: true,
|
|
192
|
+
onlyDirectories: false,
|
|
193
|
+
onlyFiles: false,
|
|
194
|
+
followSymbolicLinks: false,
|
|
195
|
+
deep: Infinity,
|
|
196
|
+
unique: false,
|
|
197
|
+
objectMode: false,
|
|
198
|
+
stats: false
|
|
199
|
+
} as const
|
|
200
|
+
|
|
201
|
+
const realEntries = (await FastGlob.async("**/*", { ...globOptions, cwd: realTree })).sort()
|
|
202
|
+
const virtualEntries = (
|
|
203
|
+
await FastGlob.async("**/*", {
|
|
204
|
+
...globOptions,
|
|
205
|
+
cwd: "/tree",
|
|
206
|
+
fs: virtual.globFs as FastGlob.FileSystemAdapter
|
|
207
|
+
})
|
|
208
|
+
).sort()
|
|
209
|
+
|
|
210
|
+
expect(virtualEntries).toEqual(["a.txt", "sub", "sub/b.txt", "sub/c", "sub/c/d.txt"])
|
|
211
|
+
expect(virtualEntries).toEqual(realEntries)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import type FilenSDK from "@filen/sdk"
|
|
3
|
+
import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
|
|
4
|
+
import { withE2EWorld } from "./harness/world"
|
|
5
|
+
import { settle } from "./harness/drive"
|
|
6
|
+
import { snapshotRemoteReal } from "./harness/assert"
|
|
7
|
+
import { writeLocal, modifyLocal, rmLocal, renameLocal, readLocal, existsLocal, uploadRemote, deleteRemote } from "./harness/mutations"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Phase 3 e2e — the ADDITIVE backup modes against the live backend (previously untested live).
|
|
11
|
+
* localBackup pushes local up but NEVER deletes the remote and tolerates foreign remote edits;
|
|
12
|
+
* cloudBackup pulls remote down but NEVER deletes the local copy and tolerates foreign local edits.
|
|
13
|
+
*/
|
|
14
|
+
describe.skipIf(!E2E_ENABLED)("E2E — backup modes (additive)", () => {
|
|
15
|
+
let sdk: FilenSDK
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
sdk = await loginTestSDK()
|
|
19
|
+
}, 300_000)
|
|
20
|
+
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
await teardownTestSDK()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// ---- localBackup ------------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
it("localBackup: a new local file is uploaded", async () => {
|
|
28
|
+
await withE2EWorld({ sdk, mode: "localBackup" }, async world => {
|
|
29
|
+
await writeLocal(world, "a.txt", "data")
|
|
30
|
+
await settle(world)
|
|
31
|
+
|
|
32
|
+
expect((await snapshotRemoteReal(world))["/a.txt"]).toMatchObject({ type: "file", size: 4 })
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("localBackup: a local deletion does NOT propagate — the remote keeps the backup", async () => {
|
|
37
|
+
await withE2EWorld({ sdk, mode: "localBackup" }, async world => {
|
|
38
|
+
await writeLocal(world, "keep.txt", "keep")
|
|
39
|
+
await settle(world)
|
|
40
|
+
|
|
41
|
+
await rmLocal(world, "keep.txt")
|
|
42
|
+
await settle(world)
|
|
43
|
+
|
|
44
|
+
// The remote copy survives the local deletion.
|
|
45
|
+
expect((await snapshotRemoteReal(world))["/keep.txt"]).toMatchObject({ type: "file" })
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("localBackup: a local rename propagates (the move follows; no data lost)", async () => {
|
|
50
|
+
await withE2EWorld({ sdk, mode: "localBackup" }, async world => {
|
|
51
|
+
await writeLocal(world, "before.txt", "data")
|
|
52
|
+
await settle(world)
|
|
53
|
+
|
|
54
|
+
await renameLocal(world, "before.txt", "after.txt")
|
|
55
|
+
await settle(world)
|
|
56
|
+
|
|
57
|
+
const remote = await snapshotRemoteReal(world)
|
|
58
|
+
|
|
59
|
+
expect(remote["/after.txt"]).toMatchObject({ type: "file" })
|
|
60
|
+
expect(remote["/before.txt"]).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("localBackup: a foreign remote edit is tolerated (not reverted)", async () => {
|
|
65
|
+
await withE2EWorld({ sdk, mode: "localBackup" }, async world => {
|
|
66
|
+
await writeLocal(world, "a.txt", "local-content")
|
|
67
|
+
await settle(world)
|
|
68
|
+
|
|
69
|
+
// Another device overwrites the remote copy.
|
|
70
|
+
await uploadRemote(world, "a.txt", "FOREIGN-EDIT")
|
|
71
|
+
await settle(world)
|
|
72
|
+
|
|
73
|
+
// The remote keeps the foreign edit; the local copy is untouched (additive — never reverts).
|
|
74
|
+
expect((await snapshotRemoteReal(world, { withContent: true }))["/a.txt"]!.size).toBe("FOREIGN-EDIT".length)
|
|
75
|
+
expect(await readLocal(world, "a.txt")).toBe("local-content")
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ---- cloudBackup ------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
it("cloudBackup: a new remote file is downloaded", async () => {
|
|
82
|
+
await withE2EWorld({ sdk, mode: "cloudBackup" }, async world => {
|
|
83
|
+
await uploadRemote(world, "a.txt", "data")
|
|
84
|
+
await settle(world)
|
|
85
|
+
|
|
86
|
+
expect(await existsLocal(world, "a.txt")).toBe(true)
|
|
87
|
+
expect(await readLocal(world, "a.txt")).toBe("data")
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("cloudBackup: a remote deletion does NOT propagate — the local copy is kept", async () => {
|
|
92
|
+
await withE2EWorld({ sdk, mode: "cloudBackup" }, async world => {
|
|
93
|
+
await uploadRemote(world, "keep.txt", "keep")
|
|
94
|
+
await settle(world)
|
|
95
|
+
|
|
96
|
+
expect(await existsLocal(world, "keep.txt")).toBe(true)
|
|
97
|
+
|
|
98
|
+
await deleteRemote(world, "keep.txt")
|
|
99
|
+
await settle(world)
|
|
100
|
+
|
|
101
|
+
// The local copy survives the remote deletion.
|
|
102
|
+
expect(await existsLocal(world, "keep.txt")).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("cloudBackup: a remote modification is pulled down (remote authoritative)", async () => {
|
|
107
|
+
await withE2EWorld({ sdk, mode: "cloudBackup" }, async world => {
|
|
108
|
+
await uploadRemote(world, "a.txt", "v1")
|
|
109
|
+
await settle(world)
|
|
110
|
+
|
|
111
|
+
await uploadRemote(world, "a.txt", "v2-modified-longer")
|
|
112
|
+
await settle(world)
|
|
113
|
+
|
|
114
|
+
expect(await readLocal(world, "a.txt")).toBe("v2-modified-longer")
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("cloudBackup: a foreign local edit is tolerated (not reverted)", async () => {
|
|
119
|
+
await withE2EWorld({ sdk, mode: "cloudBackup" }, async world => {
|
|
120
|
+
await uploadRemote(world, "a.txt", "remote-content")
|
|
121
|
+
await settle(world)
|
|
122
|
+
|
|
123
|
+
// Edit the pulled-down copy locally; cloudBackup must NOT re-download over it.
|
|
124
|
+
await modifyLocal(world, "a.txt", "LOCAL-EDIT")
|
|
125
|
+
await settle(world)
|
|
126
|
+
|
|
127
|
+
expect(await readLocal(world, "a.txt")).toBe("LOCAL-EDIT")
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import type FilenSDK from "@filen/sdk"
|
|
3
|
+
import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
|
|
4
|
+
import { withE2EWorld, type E2EWorld } from "./harness/world"
|
|
5
|
+
import { settle, messagesOfType } from "./harness/drive"
|
|
6
|
+
import { snapshotRemoteReal, snapshotLocalReal } from "./harness/assert"
|
|
7
|
+
import { writeLocal, rmLocal, uploadRemote, deleteRemote } from "./harness/mutations"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Phase 3 e2e — large-deletion confirmation against the live backend. When
|
|
11
|
+
* `requireConfirmationOnLargeDeletion` is set and an entire side is emptied, the engine raises a
|
|
12
|
+
* `confirmDeletion` prompt every second and BLOCKS the cycle until `confirmDeletion(uuid, decision)`
|
|
13
|
+
* arrives: "delete" proceeds, "restart" skips the cycle's deletions. This is the live counterpart of
|
|
14
|
+
* mocked Category G — the gate's threshold is computed from the real tree sizes the real walk reports,
|
|
15
|
+
* and the confirmed deletion is carried out against the real backend.
|
|
16
|
+
*
|
|
17
|
+
* Because the cycle blocks mid-run, these are driven by hand (real timers): start the cycle, deliver the
|
|
18
|
+
* decision repeatedly (the prompt resets it to "waiting" each tick) until the cycle resolves.
|
|
19
|
+
*/
|
|
20
|
+
async function runCycleWithDecision(world: E2EWorld, decision: "delete" | "restart"): Promise<void> {
|
|
21
|
+
world.worker.resetCache(world.syncPair.uuid)
|
|
22
|
+
|
|
23
|
+
let settled = false
|
|
24
|
+
const cyclePromise = world.sync.runCycle().finally(() => {
|
|
25
|
+
settled = true
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Deliver the decision until the 1s prompt interval observes it and the cycle moves on. 250ms poll vs
|
|
29
|
+
// the 1s prompt interval is a comfortable margin; the 80-iteration cap (20s) is only a safety net.
|
|
30
|
+
for (let tick = 0; tick < 80 && !settled; tick++) {
|
|
31
|
+
world.worker.confirmDeletion(world.syncPair.uuid, decision)
|
|
32
|
+
|
|
33
|
+
await new Promise<void>(resolve => setTimeout(resolve, 250))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await cyclePromise
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe.skipIf(!E2E_ENABLED)("E2E — large-deletion confirmation", () => {
|
|
40
|
+
let sdk: FilenSDK
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
sdk = await loginTestSDK()
|
|
44
|
+
}, 300_000)
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await teardownTestSDK()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("a confirmed full-emptying deletion (decision: delete) propagates to the real backend", async () => {
|
|
51
|
+
await withE2EWorld({ sdk, mode: "twoWay", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
52
|
+
await writeLocal(world, "a.txt", "a")
|
|
53
|
+
await writeLocal(world, "b.txt", "b")
|
|
54
|
+
await settle(world)
|
|
55
|
+
|
|
56
|
+
expect((await snapshotRemoteReal(world))["/a.txt"]).toMatchObject({ type: "file" })
|
|
57
|
+
|
|
58
|
+
// Empty the entire local side — this trips the large-deletion gate on the next cycle.
|
|
59
|
+
await rmLocal(world, "a.txt")
|
|
60
|
+
await rmLocal(world, "b.txt")
|
|
61
|
+
|
|
62
|
+
await runCycleWithDecision(world, "delete")
|
|
63
|
+
|
|
64
|
+
// A prompt WAS raised, and the confirmed deletion really emptied the remote.
|
|
65
|
+
expect(messagesOfType(world.messages, "confirmDeletion").length).toBeGreaterThan(0)
|
|
66
|
+
|
|
67
|
+
const remote = await snapshotRemoteReal(world)
|
|
68
|
+
|
|
69
|
+
expect(remote["/a.txt"]).toBeUndefined()
|
|
70
|
+
expect(remote["/b.txt"]).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("declining the prompt (decision: restart) skips the deletion — the remote copies survive", async () => {
|
|
75
|
+
await withE2EWorld({ sdk, mode: "twoWay", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
76
|
+
await writeLocal(world, "x.txt", "x")
|
|
77
|
+
await writeLocal(world, "y.txt", "y")
|
|
78
|
+
await settle(world)
|
|
79
|
+
|
|
80
|
+
await rmLocal(world, "x.txt")
|
|
81
|
+
await rmLocal(world, "y.txt")
|
|
82
|
+
|
|
83
|
+
await runCycleWithDecision(world, "restart")
|
|
84
|
+
|
|
85
|
+
// The deletion was declined: the remote still holds both files (the gate protected them).
|
|
86
|
+
const remote = await snapshotRemoteReal(world)
|
|
87
|
+
|
|
88
|
+
expect(remote["/x.txt"]).toMatchObject({ type: "file" })
|
|
89
|
+
expect(remote["/y.txt"]).toMatchObject({ type: "file" })
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("localToCloud — emptying the local side prompts (where: local) and 'delete' mirrors the emptying (AA3)", async () => {
|
|
94
|
+
await withE2EWorld({ sdk, mode: "localToCloud", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
95
|
+
await writeLocal(world, "a.txt", "a")
|
|
96
|
+
await writeLocal(world, "b.txt", "b")
|
|
97
|
+
await settle(world)
|
|
98
|
+
|
|
99
|
+
await rmLocal(world, "a.txt")
|
|
100
|
+
await rmLocal(world, "b.txt")
|
|
101
|
+
|
|
102
|
+
await runCycleWithDecision(world, "delete")
|
|
103
|
+
|
|
104
|
+
const prompts = messagesOfType(world.messages, "confirmDeletion")
|
|
105
|
+
|
|
106
|
+
expect(prompts.length).toBeGreaterThan(0)
|
|
107
|
+
expect(prompts[0]!.data.where).toBe("local")
|
|
108
|
+
// "delete" confirmed → the remote mirror is emptied.
|
|
109
|
+
expect(await snapshotRemoteReal(world)).toEqual({})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("localToCloud — answering 'restart' to the prompt skips the deletions (AA5)", async () => {
|
|
114
|
+
await withE2EWorld({ sdk, mode: "localToCloud", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
115
|
+
await writeLocal(world, "a.txt", "a")
|
|
116
|
+
await writeLocal(world, "b.txt", "b")
|
|
117
|
+
await settle(world)
|
|
118
|
+
|
|
119
|
+
await rmLocal(world, "a.txt")
|
|
120
|
+
await rmLocal(world, "b.txt")
|
|
121
|
+
|
|
122
|
+
await runCycleWithDecision(world, "restart")
|
|
123
|
+
|
|
124
|
+
expect(messagesOfType(world.messages, "confirmDeletion").length).toBeGreaterThan(0)
|
|
125
|
+
// "restart" → the remote still holds both files (deletions not applied).
|
|
126
|
+
const remote = await snapshotRemoteReal(world)
|
|
127
|
+
|
|
128
|
+
expect(remote["/a.txt"]).toMatchObject({ type: "file" })
|
|
129
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("cloudToLocal — emptying the remote side prompts (where: remote) and 'delete' empties local (AA4)", async () => {
|
|
134
|
+
await withE2EWorld({ sdk, mode: "cloudToLocal", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
135
|
+
await uploadRemote(world, "a.txt", "a")
|
|
136
|
+
await uploadRemote(world, "b.txt", "b")
|
|
137
|
+
await settle(world)
|
|
138
|
+
|
|
139
|
+
await deleteRemote(world, "a.txt")
|
|
140
|
+
await deleteRemote(world, "b.txt")
|
|
141
|
+
|
|
142
|
+
await runCycleWithDecision(world, "delete")
|
|
143
|
+
|
|
144
|
+
const prompts = messagesOfType(world.messages, "confirmDeletion")
|
|
145
|
+
|
|
146
|
+
expect(prompts.length).toBeGreaterThan(0)
|
|
147
|
+
expect(prompts[0]!.data.where).toBe("remote")
|
|
148
|
+
// "delete" confirmed → the local mirror is emptied.
|
|
149
|
+
expect(await snapshotLocalReal(world)).toEqual({})
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("localBackup — emptying the local side NEVER prompts; the remote backup is untouched (AA6)", async () => {
|
|
154
|
+
await withE2EWorld({ sdk, mode: "localBackup", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
155
|
+
await writeLocal(world, "a.txt", "a")
|
|
156
|
+
await writeLocal(world, "b.txt", "b")
|
|
157
|
+
await settle(world)
|
|
158
|
+
|
|
159
|
+
await rmLocal(world, "a.txt")
|
|
160
|
+
await rmLocal(world, "b.txt")
|
|
161
|
+
await settle(world)
|
|
162
|
+
|
|
163
|
+
// Additive backup: the source emptying is never mirrored, so no prompt and the backup survives.
|
|
164
|
+
expect(messagesOfType(world.messages, "confirmDeletion").length).toBe(0)
|
|
165
|
+
|
|
166
|
+
const remote = await snapshotRemoteReal(world)
|
|
167
|
+
|
|
168
|
+
expect(remote["/a.txt"]).toMatchObject({ type: "file" })
|
|
169
|
+
expect(remote["/b.txt"]).toMatchObject({ type: "file" })
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("cloudBackup — emptying the remote side NEVER prompts; the local backup is untouched (AA7)", async () => {
|
|
174
|
+
await withE2EWorld({ sdk, mode: "cloudBackup", requireConfirmationOnLargeDeletion: true }, async world => {
|
|
175
|
+
await uploadRemote(world, "a.txt", "a")
|
|
176
|
+
await uploadRemote(world, "b.txt", "b")
|
|
177
|
+
await settle(world)
|
|
178
|
+
|
|
179
|
+
await deleteRemote(world, "a.txt")
|
|
180
|
+
await deleteRemote(world, "b.txt")
|
|
181
|
+
await settle(world)
|
|
182
|
+
|
|
183
|
+
expect(messagesOfType(world.messages, "confirmDeletion").length).toBe(0)
|
|
184
|
+
|
|
185
|
+
const local = await snapshotLocalReal(world)
|
|
186
|
+
|
|
187
|
+
expect(local["/a.txt"]).toMatchObject({ type: "file" })
|
|
188
|
+
expect(local["/b.txt"]).toMatchObject({ type: "file" })
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
})
|