@filen/sync 0.2.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
|
@@ -0,0 +1,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
|
+
})
|