@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.
Files changed (152) hide show
  1. package/.node-version +1 -1
  2. package/dist/ignorer.d.ts +6 -0
  3. package/dist/ignorer.js +43 -24
  4. package/dist/ignorer.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deltas.d.ts +58 -2
  9. package/dist/lib/deltas.js +693 -108
  10. package/dist/lib/deltas.js.map +1 -1
  11. package/dist/lib/environment.d.ts +47 -0
  12. package/dist/lib/environment.js +71 -0
  13. package/dist/lib/environment.js.map +1 -0
  14. package/dist/lib/filesystems/dirTree.d.ts +70 -0
  15. package/dist/lib/filesystems/dirTree.js +157 -0
  16. package/dist/lib/filesystems/dirTree.js.map +1 -0
  17. package/dist/lib/filesystems/local.d.ts +18 -8
  18. package/dist/lib/filesystems/local.js +166 -160
  19. package/dist/lib/filesystems/local.js.map +1 -1
  20. package/dist/lib/filesystems/remote.d.ts +12 -5
  21. package/dist/lib/filesystems/remote.js +226 -172
  22. package/dist/lib/filesystems/remote.js.map +1 -1
  23. package/dist/lib/ipc.js +1 -2
  24. package/dist/lib/ipc.js.map +1 -1
  25. package/dist/lib/lock.js +19 -12
  26. package/dist/lib/lock.js.map +1 -1
  27. package/dist/lib/logger.js +9 -7
  28. package/dist/lib/logger.js.map +1 -1
  29. package/dist/lib/state.js +159 -63
  30. package/dist/lib/state.js.map +1 -1
  31. package/dist/lib/sync.d.ts +18 -0
  32. package/dist/lib/sync.js +165 -96
  33. package/dist/lib/sync.js.map +1 -1
  34. package/dist/lib/tasks.d.ts +7 -8
  35. package/dist/lib/tasks.js +38 -45
  36. package/dist/lib/tasks.js.map +1 -1
  37. package/dist/semaphore.d.ts +1 -0
  38. package/dist/semaphore.js +22 -5
  39. package/dist/semaphore.js.map +1 -1
  40. package/dist/utils.js +51 -35
  41. package/dist/utils.js.map +1 -1
  42. package/eslint.config.mjs +36 -0
  43. package/package.json +19 -15
  44. package/tests/bench/collapse.bench.ts +114 -0
  45. package/tests/bench/cycle.bench.ts +111 -0
  46. package/tests/bench/deltas.bench.ts +151 -0
  47. package/tests/bench/harness/fake-sync.ts +32 -0
  48. package/tests/bench/harness/measure.ts +276 -0
  49. package/tests/bench/harness/scale-world.ts +160 -0
  50. package/tests/bench/harness/trees.ts +275 -0
  51. package/tests/bench/local-scan.bench.ts +74 -0
  52. package/tests/bench/longrun.bench.ts +130 -0
  53. package/tests/bench/profile-incremental.ts +90 -0
  54. package/tests/bench/remote-build.bench.ts +104 -0
  55. package/tests/bench/render.ts +14 -0
  56. package/tests/bench/semaphore.bench.ts +79 -0
  57. package/tests/bench/state.bench.ts +85 -0
  58. package/tests/bench/tasks-dispatch.bench.ts +156 -0
  59. package/tests/conformance/virtual-fs.test.ts +213 -0
  60. package/tests/e2e/backup.e2e.test.ts +130 -0
  61. package/tests/e2e/confirm.e2e.test.ts +191 -0
  62. package/tests/e2e/conflict.e2e.test.ts +261 -0
  63. package/tests/e2e/edge.e2e.test.ts +339 -0
  64. package/tests/e2e/harness/account.ts +104 -0
  65. package/tests/e2e/harness/assert.ts +127 -0
  66. package/tests/e2e/harness/drive.ts +88 -0
  67. package/tests/e2e/harness/mutations.ts +249 -0
  68. package/tests/e2e/harness/world.ts +222 -0
  69. package/tests/e2e/ignore.e2e.test.ts +123 -0
  70. package/tests/e2e/lifecycle.e2e.test.ts +290 -0
  71. package/tests/e2e/modes.e2e.test.ts +215 -0
  72. package/tests/e2e/platform.e2e.test.ts +157 -0
  73. package/tests/e2e/property.e2e.test.ts +163 -0
  74. package/tests/e2e/races.e2e.test.ts +90 -0
  75. package/tests/e2e/regressions.e2e.test.ts +212 -0
  76. package/tests/e2e/resilience.e2e.test.ts +231 -0
  77. package/tests/e2e/special.e2e.test.ts +185 -0
  78. package/tests/e2e/state.e2e.test.ts +229 -0
  79. package/tests/e2e/sync.e2e.test.ts +222 -0
  80. package/tests/fakes/fake-cloud.test.ts +267 -0
  81. package/tests/fakes/fake-cloud.ts +1094 -0
  82. package/tests/fakes/virtual-fs.ts +354 -0
  83. package/tests/harness/known-bug.ts +17 -0
  84. package/tests/harness/mutations.ts +65 -0
  85. package/tests/harness/runner.ts +141 -0
  86. package/tests/harness/snapshot.ts +113 -0
  87. package/tests/harness/world.ts +187 -0
  88. package/tests/scenarios/a-baseline.test.ts +107 -0
  89. package/tests/scenarios/aa-races.test.ts +258 -0
  90. package/tests/scenarios/ab-mode-property.test.ts +189 -0
  91. package/tests/scenarios/ac-platform.test.ts +320 -0
  92. package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
  93. package/tests/scenarios/b-additions.test.ts +160 -0
  94. package/tests/scenarios/c-modifications.test.ts +194 -0
  95. package/tests/scenarios/d-deletions.test.ts +259 -0
  96. package/tests/scenarios/e-rename-move.test.ts +288 -0
  97. package/tests/scenarios/f-ignore-filter.test.ts +346 -0
  98. package/tests/scenarios/g-large-deletion.test.ts +277 -0
  99. package/tests/scenarios/h-resilience.test.ts +167 -0
  100. package/tests/scenarios/i-lifecycle.test.ts +353 -0
  101. package/tests/scenarios/j-state-cache.test.ts +264 -0
  102. package/tests/scenarios/k-scale.test.ts +202 -0
  103. package/tests/scenarios/l-property.test.ts +145 -0
  104. package/tests/scenarios/m-golden.test.ts +452 -0
  105. package/tests/scenarios/o-task-errors.test.ts +497 -0
  106. package/tests/scenarios/p-remote-originated.test.ts +306 -0
  107. package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
  108. package/tests/scenarios/r-rename-stress.test.ts +208 -0
  109. package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
  110. package/tests/scenarios/t-type-change.test.ts +144 -0
  111. package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
  112. package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
  113. package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
  114. package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
  115. package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
  116. package/tests/scenarios/z-cross-ops.test.ts +285 -0
  117. package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
  118. package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
  119. package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
  120. package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
  121. package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
  122. package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
  123. package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
  124. package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
  125. package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
  126. package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
  127. package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
  128. package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
  129. package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
  130. package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
  131. package/tests/unit/collapse-deltas.test.ts +276 -0
  132. package/tests/unit/dir-tree.test.ts +159 -0
  133. package/tests/unit/icloud.test.ts +115 -0
  134. package/tests/unit/ignorer-cache-regression.test.ts +70 -0
  135. package/tests/unit/ignorer.test.ts +63 -0
  136. package/tests/unit/ipc-lock.test.ts +438 -0
  137. package/tests/unit/lock.test.ts +135 -0
  138. package/tests/unit/n-unit.test.ts +632 -0
  139. package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
  140. package/tests/unit/semaphore-regression.test.ts +140 -0
  141. package/tests/unit/state-refencode-regression.test.ts +224 -0
  142. package/tests/unit/state.test.ts +809 -0
  143. package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
  144. package/tests/unit/worker-api.test.ts +379 -0
  145. package/tsconfig.json +10 -1
  146. package/tsconfig.test.json +12 -0
  147. package/tsconfig.tsbuildinfo +1 -0
  148. package/vitest.bench.config.ts +32 -0
  149. package/vitest.config.ts +27 -0
  150. package/vitest.e2e.config.ts +68 -0
  151. package/.eslintrc +0 -16
  152. 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
+ })