@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.
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,212 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest"
2
+ import type FilenSDK from "@filen/sdk"
3
+ import fs from "fs-extra"
4
+ import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
5
+ import { withE2EWorld, restartE2EWorld } from "./harness/world"
6
+ import { settle, expectConverged } from "./harness/drive"
7
+ import { snapshotRemoteReal } from "./harness/assert"
8
+ import { writeLocal, renameLocal, readLocal, existsLocal, uploadRemote, setLocalMtime, rmLocal, deleteRemote } from "./harness/mutations"
9
+
10
+ /**
11
+ * Live-backend counterparts for the audit-round bug fixes, so each fix is proven against the real Filen
12
+ * backend and not just the fake cloud (the mocked regressions live in tests/scenarios/z*.test.ts and
13
+ * tests/unit/*lock.test.ts). One login is shared across the whole serial e2e suite.
14
+ *
15
+ * Mocked-only by necessity (no e2e counterpart):
16
+ * - H1: lock teardown after a FORCED releaseResourceLock failure — the real backend can't be made to
17
+ * fail a release on demand. Unit test with injected faults: tests/unit/lock.test.ts + ipc-lock.test.ts.
18
+ * - H3: a file edited DURING the local scan (a read-during-scan race) — reproducing it needs deterministic
19
+ * control of the scan window, which a real filesystem can't provide without flakiness. Deterministic
20
+ * mocked regression with a mid-scan lstat hook: tests/scenarios/zg-edit-during-scan.test.ts.
21
+ * - H6: the deletion-confirmation wait must bail (releasing the lock) when the pair is paused/removed.
22
+ * This is client-side control flow — the backend plays no part — driven deterministically with fake
23
+ * timers in tests/scenarios/g-large-deletion.test.ts (G6/G7); the gate itself is covered live in
24
+ * tests/e2e/confirm.e2e.test.ts.
25
+ * - H7: the local smoke test must run BEFORE the lock so a local outage never holds the account lock.
26
+ * Lock ORDERING during a LOCAL filesystem outage — no backend semantics — driven deterministically
27
+ * with an injected fs error + fake timers in tests/scenarios/zi-smoke-test-outage.test.ts.
28
+ * - M2/M3: the local-trash eviction sweep must age out trashed DIRECTORIES (not just files), and its
29
+ * setInterval must be torn down with the pair. Both are purely LOCAL — a folder on the local disk and
30
+ * a client-side timer — with no backend involvement. Deterministic mocked regressions (memfs trash +
31
+ * atime backdating + fake timers): tests/scenarios/zj-trash-cleanup.test.ts.
32
+ * - M4: a remote deletion must not wipe a LOCALLY-IGNORED file (one present on disk but excluded from the
33
+ * scanned tree for a non-.filenignore reason). This is client-side delta-attribution logic; the
34
+ * backend's only role (a path deleted remotely) is already exercised by the live deletion tests, and
35
+ * the trigger — a base path that became ignored for a nameLength/invalidPath/defaultIgnore/duplicate
36
+ * reason — cannot be forced deterministically on the real backend. Driven directly through
37
+ * deltas.process() with an injected ignored entry in tests/scenarios/zk-ignore-asymmetry.test.ts.
38
+ * - M5: isValidPath must reject Windows names that end in a dot/space (Windows strips them, causing a
39
+ * re-sync loop). This is a per-OS path rule that only runs on the win32 branch; the e2e backend host
40
+ * here is darwin, whose filesystem does NOT strip trailing dots, so the bug cannot manifest in an e2e
41
+ * round-trip. It is covered instead by the cross-platform unit suite (stubbed process.platform) run on
42
+ * a real Windows host by the matrixed CI: tests/unit/n-unit.test.ts (isValidPath win32).
43
+ * - M6: one cycle must compute its whole delta set under a single mode snapshot, even if updateMode()
44
+ * races the cycle. The race window is an await INSIDE deltas.process(); reproducing it needs to flip
45
+ * the mode at that exact await, which is only controllable by stubbing the awaited hash — not via the
46
+ * backend. Driven deterministically in tests/scenarios/zl-mode-atomicity.test.ts.
47
+ * - P4: the local scan must bound how many filesystem stat operations it launches concurrently (the old
48
+ * walk mapped every entry to a promise up front — an O(n) pending-promise/memory spike on a huge tree).
49
+ * This is a client-side memory/concurrency property: bounded vs unbounded fan-out produces the identical
50
+ * tree, so it has no backend-observable effect, and the bound is only measurable by instrumenting
51
+ * fs.lstat. The batched scan's CORRECTNESS is exercised by every live scenario here (it sits in the sync
52
+ * hot path); the bound itself is asserted with an lstat-counting wrapper in
53
+ * tests/scenarios/zm-scan-concurrency.test.ts.
54
+ * - L1: a download that fails AFTER staging its temp file must discard the temp instead of orphaning it in
55
+ * the local trash dir. The leak is a LOCAL artifact, and the trigger is a failure of the local commit
56
+ * move (or a partial transfer) — neither of which the real backend can be made to produce on demand (a
57
+ * backend-side abort takes the size-mismatch path, which already cleaned up). Driven deterministically by
58
+ * failing the commit move in tests/scenarios/zo-download-temp-cleanup.test.ts.
59
+ */
60
+ describe.skipIf(!E2E_ENABLED)("E2E — audit regression fixes against live backend", () => {
61
+ let sdk: FilenSDK
62
+
63
+ beforeAll(async () => {
64
+ sdk = await loginTestSDK()
65
+ }, 300_000)
66
+
67
+ afterAll(async () => {
68
+ await teardownTestSDK()
69
+ })
70
+
71
+ // ---- C1: create nested dirs from the sync root; don't swallow failed top-level remote ops ----------
72
+
73
+ it("C1: moving a top-level file into a NEW 2-level directory converges with no resurrection", async () => {
74
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
75
+ await writeLocal(world, "a.txt", "hello")
76
+ await writeLocal(world, "keep.txt", "k")
77
+ await settle(world)
78
+
79
+ // Move a TOP-LEVEL file into a directory chain that does NOT exist yet (x/y). The cross-parent
80
+ // rename must build x then y from the sync root inline. The old broken mkdir threw here; the throw
81
+ // was swallowed for the top-level source (fileExists returned false for every top-level file), so
82
+ // the task was dropped, a skewed base was persisted, and the file was resurrected + duplicated.
83
+ await renameLocal(world, "a.txt", "x/y/a.txt")
84
+ await settle(world)
85
+
86
+ const remote = await snapshotRemoteReal(world)
87
+
88
+ expect(remote["/a.txt"], "the moved-away original must not survive on the remote").toBeUndefined()
89
+ expect(remote["/x/y/a.txt"]).toMatchObject({ type: "file", size: 5 })
90
+ expect(await existsLocal(world, "a.txt")).toBe(false)
91
+ expect(await readLocal(world, "x/y/a.txt")).toBe("hello")
92
+ await expectConverged(world)
93
+ })
94
+ })
95
+
96
+ it("C1: moving a directory into a NEW 2-level directory converges", async () => {
97
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
98
+ await writeLocal(world, "dir/child.txt", "c")
99
+ await writeLocal(world, "keep.txt", "k")
100
+ await settle(world)
101
+
102
+ await renameLocal(world, "dir", "x/y/dir")
103
+ await settle(world)
104
+
105
+ const remote = await snapshotRemoteReal(world)
106
+
107
+ expect(remote["/dir"]).toBeUndefined()
108
+ expect(remote["/x/y/dir/child.txt"]).toMatchObject({ type: "file" })
109
+ await expectConverged(world)
110
+ })
111
+ })
112
+
113
+ // ---- H2: a remote change wins over an UNCHANGED local copy regardless of the mtime tiebreak --------
114
+
115
+ it("H2: a remote edit with a non-newer mtime is pulled when the local copy is unchanged", async () => {
116
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
117
+ await writeLocal(world, "a.txt", "v1")
118
+ // Age the local copy into the FUTURE so the remote edit that follows lands with an OLDER mtime,
119
+ // while the local copy itself stays byte-for-byte unchanged vs the base. Only the remote moved,
120
+ // so it is not a conflict — the newer-mtime tiebreak must not gate the pull.
121
+ await setLocalMtime(world, "a.txt", Date.now() + 600_000)
122
+ await settle(world)
123
+
124
+ // A peer re-uploads new content (a new version → new uuid); its lastModified is ~now, i.e. BEHIND
125
+ // the local copy's future mtime. The change must still be pulled down.
126
+ await uploadRemote(world, "a.txt", "v2-remote-edit")
127
+ await settle(world)
128
+
129
+ expect(await readLocal(world, "a.txt")).toBe("v2-remote-edit")
130
+ await expectConverged(world)
131
+ })
132
+ })
133
+
134
+ // ---- H5: a directory deletion must not cascade over a live child the other side did not delete -----
135
+
136
+ it("H5: a remote directory delete does not wipe a child added locally in the same window", async () => {
137
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
138
+ await writeLocal(world, "dir/keep.txt", "keep")
139
+ await writeLocal(world, "other.txt", "o")
140
+ await settle(world)
141
+
142
+ // A peer deletes the whole directory while we add a brand-new file into it.
143
+ await deleteRemote(world, "dir")
144
+ await writeLocal(world, "dir/new.txt", "new-child")
145
+ await settle(world)
146
+
147
+ // The new child survives (uploaded), the directory is re-asserted, the unmodified base child the
148
+ // peer deleted is gone, and both sides converge — the delete did not cascade over the new file.
149
+ expect(await existsLocal(world, "dir/new.txt")).toBe(true)
150
+ expect(await readLocal(world, "dir/new.txt")).toBe("new-child")
151
+ const remote = await snapshotRemoteReal(world)
152
+
153
+ expect(remote["/dir/new.txt"]).toMatchObject({ type: "file" })
154
+ expect(remote["/dir/keep.txt"]).toBeUndefined()
155
+ await expectConverged(world)
156
+ })
157
+ })
158
+
159
+ it("H5 (symmetric): a local directory delete does not wipe a child the remote added in the same window", async () => {
160
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
161
+ await writeLocal(world, "dir/keep.txt", "keep")
162
+ await writeLocal(world, "other.txt", "o")
163
+ await settle(world)
164
+
165
+ // We delete the directory locally while a peer adds a new file into it remotely.
166
+ await rmLocal(world, "dir")
167
+ await uploadRemote(world, "dir/new.txt", "remote-new")
168
+ await settle(world)
169
+
170
+ expect(await existsLocal(world, "dir/new.txt")).toBe(true)
171
+ const remote = await snapshotRemoteReal(world)
172
+
173
+ expect(remote["/dir/new.txt"]).toMatchObject({ type: "file" })
174
+ expect(remote["/dir/keep.txt"]).toBeUndefined()
175
+ await expectConverged(world)
176
+ })
177
+ })
178
+
179
+ // ---- M1: a saved state with a MISSING local-inodes file must reload as "no saved state", not crash --
180
+
181
+ it("M1: a missing local-inodes state file recovers (re-derives) on restart instead of crashing", async () => {
182
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
183
+ await writeLocal(world, "a.txt", "alpha")
184
+ await writeLocal(world, "dir/b.txt", "bravo")
185
+ await settle(world)
186
+ await expectConverged(world)
187
+
188
+ // The loader reads the local-INODES file unconditionally, but its existence guard checked the
189
+ // remote-tree file twice and never this one. Drop it to mimic a partial / interrupted state write
190
+ // (a present tree file with a missing inodes sibling).
191
+ const inodesPath = world.sync.state.previousLocalINodesPath
192
+
193
+ await fs.rm(inodesPath, { force: true })
194
+
195
+ expect(await fs.pathExists(inodesPath)).toBe(false)
196
+
197
+ // A restart reloads persisted state from disk. With the bug this throws ENOENT and bricks startup;
198
+ // with the fix it degrades to "no saved state", re-derives the base from disk, and stays converged.
199
+ await restartE2EWorld(world)
200
+ await settle(world)
201
+
202
+ expect(await readLocal(world, "a.txt")).toBe("alpha")
203
+ expect(await readLocal(world, "dir/b.txt")).toBe("bravo")
204
+
205
+ const remote = await snapshotRemoteReal(world)
206
+
207
+ expect(remote["/a.txt"]).toMatchObject({ type: "file" })
208
+ expect(remote["/dir/b.txt"]).toMatchObject({ type: "file" })
209
+ await expectConverged(world)
210
+ })
211
+ })
212
+ })
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest"
2
+ import type FilenSDK from "@filen/sdk"
3
+ import os from "os"
4
+ import pathModule from "path"
5
+ import fs from "fs-extra"
6
+ import { v4 as uuidv4 } from "uuid"
7
+ import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
8
+ import { withE2EWorld, restartE2EWorld } from "./harness/world"
9
+ import { cycle, settle, expectConverged, allOps, messagesOfType } from "./harness/drive"
10
+ import { snapshotRemoteReal } from "./harness/assert"
11
+ import { writeLocal, modifyLocal, rmLocal, renameLocal, uploadRemote, chmodLocal, existsLocal } from "./harness/mutations"
12
+
13
+ /**
14
+ * Phase 3 e2e — resilience + long-lived stability against the live backend. The mocked suite injects
15
+ * faults and drives many cycles with fake timers; here the equivalents run against the real filesystem
16
+ * and real backend, so we know the engine survives a genuinely unreadable file and that a long-lived
17
+ * sync stays correct (and quiet) across many real mutation rounds and a restart.
18
+ */
19
+ describe.skipIf(!E2E_ENABLED)("E2E — resilience & long-lived stability", () => {
20
+ let sdk: FilenSDK
21
+
22
+ beforeAll(async () => {
23
+ sdk = await loginTestSDK()
24
+ }, 300_000)
25
+
26
+ afterAll(async () => {
27
+ await teardownTestSDK()
28
+ })
29
+
30
+ it("a permission-denied local file is skipped and the cycle continues (posix, non-root)", async ctx => {
31
+ // Windows' permission model doesn't deny reads via chmod, and root bypasses 0o000 entirely — in
32
+ // either case the permission guard can't trigger, so probe first and skip rather than mis-assert.
33
+ if (process.platform === "win32") {
34
+ ctx.skip()
35
+
36
+ return
37
+ }
38
+
39
+ const probeDir = await fs.mkdtemp(pathModule.join(os.tmpdir(), `e2e-perm-probe-${uuidv4()}-`))
40
+ const probeFile = pathModule.join(probeDir, "p")
41
+
42
+ await fs.writeFile(probeFile, "x")
43
+ await fs.chmod(probeFile, 0o000)
44
+
45
+ let denies = false
46
+
47
+ try {
48
+ await fs.readFile(probeFile)
49
+ } catch {
50
+ denies = true
51
+ }
52
+
53
+ await fs.chmod(probeFile, 0o644).catch(() => {})
54
+ await fs.rm(probeDir, { recursive: true, force: true }).catch(() => {})
55
+
56
+ if (!denies) {
57
+ ctx.skip()
58
+
59
+ return
60
+ }
61
+
62
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
63
+ await writeLocal(world, "ok.txt", "fine")
64
+ await writeLocal(world, "denied.txt", "secret")
65
+ await chmodLocal(world, "denied.txt", 0o000)
66
+
67
+ await settle(world)
68
+
69
+ const remote = await snapshotRemoteReal(world)
70
+
71
+ // The readable file syncs; the unreadable one is skipped (never uploaded), and the cycle did
72
+ // not crash on it.
73
+ expect(remote["/ok.txt"]).toMatchObject({ type: "file" })
74
+ expect(remote["/denied.txt"]).toBeUndefined()
75
+
76
+ // Restore permissions so teardown can clean up without relying on directory-level removal.
77
+ await chmodLocal(world, "denied.txt", 0o644)
78
+ })
79
+ })
80
+
81
+ it("a failed local writable smoke test retries until the path heals, then syncs (Q1, posix, non-root)", async ctx => {
82
+ // The smoke test gates each cycle on the sync root being writable; a read-only root must not crash
83
+ // the cycle — it emits cycleLocalSmokeTestFailed and retries every SYNC_INTERVAL until it heals.
84
+ // Windows chmod doesn't deny dir writes and root bypasses the W_OK bit, so probe-then-skip.
85
+ if (process.platform === "win32") {
86
+ ctx.skip()
87
+
88
+ return
89
+ }
90
+
91
+ const probeDir = await fs.mkdtemp(pathModule.join(os.tmpdir(), `e2e-rodir-probe-${uuidv4()}-`))
92
+
93
+ await fs.chmod(probeDir, 0o555)
94
+
95
+ let denies = false
96
+
97
+ try {
98
+ await fs.access(probeDir, fs.constants.W_OK)
99
+ } catch {
100
+ denies = true
101
+ }
102
+
103
+ await fs.chmod(probeDir, 0o755).catch(() => {})
104
+ await fs.rm(probeDir, { recursive: true, force: true }).catch(() => {})
105
+
106
+ if (!denies) {
107
+ ctx.skip()
108
+
109
+ return
110
+ }
111
+
112
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
113
+ await writeLocal(world, "a.txt", "data")
114
+
115
+ // Make the sync root unwritable, then drive a cycle WITHOUT awaiting — its smoke test fails and
116
+ // the cycle parks on a retry loop instead of erroring.
117
+ await fs.chmod(world.localRoot, 0o555)
118
+
119
+ try {
120
+ world.worker.resetCache(world.syncPair.uuid)
121
+
122
+ const cyclePromise = world.sync.runCycle()
123
+
124
+ // Wait for the smoke test to report the failure (it then sleeps SYNC_INTERVAL before retrying).
125
+ for (let tick = 0; tick < 40 && messagesOfType(world.messages, "cycleLocalSmokeTestFailed").length === 0; tick++) {
126
+ await new Promise<void>(resolve => setTimeout(resolve, 100))
127
+ }
128
+
129
+ expect(messagesOfType(world.messages, "cycleLocalSmokeTestFailed").length).toBeGreaterThan(0)
130
+ // Nothing synced while the smoke test was failing.
131
+ expect((await snapshotRemoteReal(world))["/a.txt"]).toBeUndefined()
132
+
133
+ // Heal the path; the next retry passes and the cycle completes its work.
134
+ await fs.chmod(world.localRoot, 0o755)
135
+
136
+ await cyclePromise
137
+ } finally {
138
+ // Always restore writability so teardown can remove the tree.
139
+ await fs.chmod(world.localRoot, 0o755).catch(() => {})
140
+ }
141
+
142
+ await settle(world)
143
+ await expectConverged(world)
144
+
145
+ expect((await snapshotRemoteReal(world))["/a.txt"]).toMatchObject({ type: "file" })
146
+ })
147
+ })
148
+
149
+ it("a deleted remote root parks on the remote smoke test and does NOT delete local files (Q3, data-loss guard)", async () => {
150
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
151
+ await writeLocal(world, "a.txt", "a")
152
+ await writeLocal(world, "b.txt", "b")
153
+ await settle(world)
154
+ await expectConverged(world)
155
+
156
+ // Another client deletes the ENTIRE synced remote folder. The engine must NOT read this as
157
+ // "remote emptied" and mirror-delete the local files — the remote smoke test catches the missing
158
+ // root first and parks the cycle (retrying) instead.
159
+ await sdk.cloud().deleteDirectory({ uuid: world.remoteParentUUID })
160
+
161
+ world.worker.resetCache(world.syncPair.uuid)
162
+
163
+ const cyclePromise = world.sync.runCycle()
164
+
165
+ try {
166
+ for (let tick = 0; tick < 60 && messagesOfType(world.messages, "cycleRemoteSmokeTestFailed").length === 0; tick++) {
167
+ await new Promise<void>(resolve => setTimeout(resolve, 100))
168
+ }
169
+
170
+ expect(messagesOfType(world.messages, "cycleRemoteSmokeTestFailed").length).toBeGreaterThan(0)
171
+ // The critical guarantee: the vanished remote did NOT cause the local files to be deleted.
172
+ expect(await existsLocal(world, "a.txt")).toBe(true)
173
+ expect(await existsLocal(world, "b.txt")).toBe(true)
174
+ } finally {
175
+ // The remote root uuid is gone for good, so the parked cycle can never heal — remove the pair
176
+ // to abort its retry loop (smokeTest throws "Aborted" once removed), then drain it.
177
+ await world.worker.updateRemoved(world.syncPair.uuid, true).catch(() => {})
178
+ await cyclePromise.catch(() => {})
179
+ }
180
+ })
181
+ })
182
+
183
+ it("a long-lived sync stays correct across many real mutation rounds and a restart, then no-ops", async () => {
184
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
185
+ // Round 1 — create a small tree.
186
+ await writeLocal(world, "doc.txt", "v1")
187
+ await writeLocal(world, "dir/nested.txt", "n1")
188
+ await settle(world)
189
+ await expectConverged(world)
190
+
191
+ // Round 2 — modify.
192
+ await modifyLocal(world, "doc.txt", "v2-longer-content")
193
+ await settle(world)
194
+ await expectConverged(world)
195
+
196
+ // Round 3 — rename.
197
+ await renameLocal(world, "doc.txt", "renamed.txt")
198
+ await settle(world)
199
+ await expectConverged(world)
200
+
201
+ // Round 4 — a remote-originated addition (a peer client).
202
+ await uploadRemote(world, "from-remote.txt", "r1")
203
+ await settle(world)
204
+ await expectConverged(world)
205
+
206
+ // Round 5 — survive a process restart with no work, then keep going.
207
+ await restartE2EWorld(world)
208
+ await settle(world)
209
+ await expectConverged(world)
210
+
211
+ // Round 6 — delete.
212
+ await rmLocal(world, "dir/nested.txt")
213
+ await settle(world)
214
+ await expectConverged(world)
215
+
216
+ // After all that churn, a settled cycle must be a COMPLETE no-op — the long-lived sync is quiet.
217
+ const messages = await cycle(world)
218
+
219
+ expect(allOps(messages)).toEqual([])
220
+
221
+ // Final-state sanity across the whole history.
222
+ const remote = await snapshotRemoteReal(world)
223
+
224
+ expect(remote["/renamed.txt"]).toMatchObject({ type: "file" })
225
+ expect(remote["/from-remote.txt"]).toMatchObject({ type: "file" })
226
+ expect(remote["/dir/nested.txt"]).toBeUndefined()
227
+ expect(await existsLocal(world, "renamed.txt")).toBe(true)
228
+ expect(await existsLocal(world, "from-remote.txt")).toBe(true)
229
+ })
230
+ })
231
+ })
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest"
2
+ import type FilenSDK from "@filen/sdk"
3
+ import os from "os"
4
+ import pathModule from "path"
5
+ import fs from "fs-extra"
6
+ import { v4 as uuidv4 } from "uuid"
7
+ import { E2E_ENABLED, loginTestSDK, teardownTestSDK } from "./harness/account"
8
+ import { withE2EWorld } from "./harness/world"
9
+ import { settle, expectConverged } from "./harness/drive"
10
+ import { snapshotRemoteReal } from "./harness/assert"
11
+ import { writeLocal, modifyLocal, writeLocalPreservingMtime, symlinkLocal, rmLocal, existsLocal } from "./harness/mutations"
12
+
13
+ /**
14
+ * Phase 3 e2e — special files against the live backend: symlinks (skipped, BUG-006) and size-edge
15
+ * transitions (truncate-to-0, grow-from-0).
16
+ */
17
+ describe.skipIf(!E2E_ENABLED)("E2E — special files", () => {
18
+ let sdk: FilenSDK
19
+
20
+ beforeAll(async () => {
21
+ sdk = await loginTestSDK()
22
+ }, 300_000)
23
+
24
+ afterAll(async () => {
25
+ await teardownTestSDK()
26
+ })
27
+
28
+ it("skips symlinks: they never sync and don't break the cycle (BUG-006)", async ctx => {
29
+ // Probe symlink support first (Windows without Developer Mode can't create them).
30
+ const probeDir = pathModule.join(os.tmpdir(), `e2e-symlink-probe-${uuidv4()}`)
31
+
32
+ await fs.ensureDir(probeDir)
33
+
34
+ let symlinksSupported = true
35
+
36
+ try {
37
+ await fs.symlink(pathModule.join(probeDir, "target"), pathModule.join(probeDir, "link"))
38
+ } catch {
39
+ symlinksSupported = false
40
+ }
41
+
42
+ await fs.rm(probeDir, { recursive: true, force: true }).catch(() => {})
43
+
44
+ if (!symlinksSupported) {
45
+ ctx.skip()
46
+
47
+ return
48
+ }
49
+
50
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
51
+ await writeLocal(world, "real.txt", "real")
52
+
53
+ // A symlink pointing outside the sync root — must be skipped, never followed/uploaded.
54
+ const externalTarget = pathModule.join(os.tmpdir(), `e2e-symlink-target-${uuidv4()}.txt`)
55
+
56
+ await fs.writeFile(externalTarget, "external content")
57
+
58
+ try {
59
+ await symlinkLocal(world, "link.txt", externalTarget)
60
+
61
+ await settle(world)
62
+
63
+ const remote = await snapshotRemoteReal(world)
64
+
65
+ expect(remote["/real.txt"]).toMatchObject({ type: "file" })
66
+ expect(remote["/link.txt"]).toBeUndefined()
67
+ // The real file converges; the symlink is simply absent on both sides of the comparison.
68
+ await expectConverged(world)
69
+ } finally {
70
+ await fs.rm(externalTarget, { force: true }).catch(() => {})
71
+ }
72
+ })
73
+ })
74
+
75
+ it("a synced file replaced by a symlink is skipped, and the cloud copy survives (F15/BUG-006)", async ctx => {
76
+ // Probe symlink support first (Windows without Developer Mode can't create them).
77
+ const probeDir = pathModule.join(os.tmpdir(), `e2e-symlink-probe-${uuidv4()}`)
78
+
79
+ await fs.ensureDir(probeDir)
80
+
81
+ let symlinksSupported = true
82
+
83
+ try {
84
+ await fs.symlink(pathModule.join(probeDir, "target"), pathModule.join(probeDir, "link"))
85
+ } catch {
86
+ symlinksSupported = false
87
+ }
88
+
89
+ await fs.rm(probeDir, { recursive: true, force: true }).catch(() => {})
90
+
91
+ if (!symlinksSupported) {
92
+ ctx.skip()
93
+
94
+ return
95
+ }
96
+
97
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
98
+ // A file synced normally to the cloud…
99
+ await writeLocal(world, "data.txt", "real content")
100
+ await settle(world)
101
+
102
+ expect((await snapshotRemoteReal(world))["/data.txt"]).toMatchObject({ type: "file" })
103
+
104
+ // …is then replaced in place by a (dangling) symlink. The walk lstats it, sees a link, and skips
105
+ // it structurally; a structurally-skipped path is treated like ignore-after-sync, so the cloud
106
+ // copy is NOT deleted (mirrors mocked F15 — prevents data loss after the lstat upgrade). The two
107
+ // sides are intentionally divergent here (local no longer syncs the path), so we assert the cloud
108
+ // survivor directly rather than convergence.
109
+ await rmLocal(world, "data.txt")
110
+ await symlinkLocal(world, "data.txt", pathModule.join(os.tmpdir(), `e2e-dangling-${uuidv4()}.txt`))
111
+ await settle(world)
112
+
113
+ expect((await snapshotRemoteReal(world))["/data.txt"]).toMatchObject({ type: "file" })
114
+ })
115
+ })
116
+
117
+ it("round-trips a multi-chunk (>1 MiB) file with byte integrity across chunk boundaries", async () => {
118
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
119
+ // The SDK uploads in 1 MiB chunks, so 2.5 MiB spans three chunks. The bytes are position-dependent
120
+ // (not a constant fill), so a dropped, duplicated, or reordered chunk changes the content hash —
121
+ // not just the size — and the withContent convergence check below would catch it.
122
+ const size = 2.5 * 1024 * 1024
123
+ const bytes = new Uint8Array(size)
124
+
125
+ for (let i = 0; i < size; i++) {
126
+ bytes[i] = (i * 31 + 7) % 251
127
+ }
128
+
129
+ await writeLocal(world, "big.bin", bytes)
130
+ await settle(world)
131
+
132
+ expect((await snapshotRemoteReal(world))["/big.bin"]).toMatchObject({ type: "file", size })
133
+
134
+ // withContent downloads the remote copy and sha512s both sides → proves byte-for-byte integrity
135
+ // of the large file after a full upload-then-download round trip through the real backend.
136
+ await expectConverged(world)
137
+ })
138
+ })
139
+
140
+ it("truncating a file to 0 bytes propagates", async () => {
141
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
142
+ await writeLocal(world, "shrink.txt", "hello world")
143
+ await settle(world)
144
+
145
+ // modifyLocal stamps a clearly-newer mtime so the change is detected regardless of how fast the
146
+ // prior settle ran (change detection is mtime-gated at whole-second precision; size is not compared).
147
+ await modifyLocal(world, "shrink.txt", "")
148
+ await settle(world)
149
+
150
+ expect((await snapshotRemoteReal(world))["/shrink.txt"]).toMatchObject({ type: "file", size: 0 })
151
+ await expectConverged(world)
152
+ })
153
+ })
154
+
155
+ it("growing a file from 0 bytes propagates", async () => {
156
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
157
+ await writeLocal(world, "grow.txt", "")
158
+ await settle(world)
159
+
160
+ expect((await snapshotRemoteReal(world))["/grow.txt"]).toMatchObject({ type: "file", size: 0 })
161
+
162
+ await modifyLocal(world, "grow.txt", "now it has content")
163
+ await settle(world)
164
+
165
+ expect((await snapshotRemoteReal(world))["/grow.txt"]).toMatchObject({ type: "file", size: 18 })
166
+ expect(await existsLocal(world, "grow.txt")).toBe(true)
167
+ await expectConverged(world)
168
+ })
169
+ })
170
+
171
+ it("propagates a same-mtime size change (base-relative detection, E2E-OBS-002)", async () => {
172
+ await withE2EWorld({ sdk, mode: "twoWay" }, async world => {
173
+ await writeLocal(world, "sized.txt", "12345678")
174
+ await settle(world)
175
+
176
+ // Change the size but restore the original mtime: the whole-second mtime is unchanged, so only
177
+ // the base-relative size comparison can detect this edit (the old side-vs-side gate missed it).
178
+ await writeLocalPreservingMtime(world, "sized.txt", "123")
179
+ await settle(world)
180
+
181
+ expect((await snapshotRemoteReal(world))["/sized.txt"]).toMatchObject({ type: "file", size: 3 })
182
+ await expectConverged(world)
183
+ })
184
+ })
185
+ })