@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,104 @@
1
+ import FilenSDK, { type FilenSDKConfig } from "@filen/sdk"
2
+ import os from "os"
3
+ import pathModule from "path"
4
+ import fs from "fs-extra"
5
+ import { v4 as uuidv4 } from "uuid"
6
+
7
+ /**
8
+ * Real-account login for the e2e suite.
9
+ *
10
+ * Credentials come from the environment so they never touch the repo, in preference order:
11
+ * 1. FILEN_SYNC_LEGACY_E2E_SDK_CONFIG — base64(JSON) of an already-authenticated SDK config. Reusing
12
+ * a saved session means we DON'T hit the rate-limited login endpoint on every run. Preferred.
13
+ * 2. FILEN_SYNC_LEGACY_E2E_EMAIL + FILEN_SYNC_LEGACY_E2E_PASSWORD — a DEDICATED throwaway test account
14
+ * (no 2FA). Fallback for local runs without a saved config.
15
+ *
16
+ * Either way the suite creates and permanently deletes data under the account and empties its trash.
17
+ * When NEITHER is present the whole e2e suite is skipped (see {@link E2E_ENABLED}); it never fails a
18
+ * build that simply has no secrets (local dev, fork PRs).
19
+ */
20
+ export const E2E_ENABLED: boolean = Boolean(
21
+ process.env["FILEN_SYNC_LEGACY_E2E_SDK_CONFIG"] ||
22
+ (process.env["FILEN_SYNC_LEGACY_E2E_EMAIL"] && process.env["FILEN_SYNC_LEGACY_E2E_PASSWORD"])
23
+ )
24
+
25
+ /**
26
+ * A uniquely-named tmp dir the SDK uses for its own scratch (download chunks etc.). Random per process
27
+ * so parallel CI runners never collide; removed by {@link teardownTestSDK}.
28
+ */
29
+ export const SDK_TMP_PATH: string = pathModule.join(os.tmpdir(), `filen-sync-e2e-sdk-${uuidv4()}`)
30
+
31
+ let sdkPromise: Promise<FilenSDK> | null = null
32
+
33
+ /**
34
+ * Log in once per worker and reuse the instance. `connectToSocket: false` — the suite drives discrete
35
+ * cycles and never needs the realtime socket (which would also keep the process alive past teardown).
36
+ */
37
+ export function loginTestSDK(): Promise<FilenSDK> {
38
+ if (!E2E_ENABLED) {
39
+ throw new Error("e2e credentials are not set (FILEN_SYNC_LEGACY_E2E_SDK_CONFIG, or _EMAIL / _PASSWORD).")
40
+ }
41
+
42
+ if (!sdkPromise) {
43
+ sdkPromise = (async (): Promise<FilenSDK> => {
44
+ await fs.ensureDir(SDK_TMP_PATH)
45
+
46
+ // Preferred: reuse a saved, already-authenticated SDK config (base64 of its JSON) so we never
47
+ // touch the rate-limited login endpoint. We override only tmpPath so each worker gets its own
48
+ // scratch dir; everything else (apiKey, master keys, baseFolderUUID, …) comes from the config.
49
+ const configBase64 = process.env["FILEN_SYNC_LEGACY_E2E_SDK_CONFIG"]
50
+
51
+ if (configBase64 && configBase64.length > 0) {
52
+ const config = JSON.parse(Buffer.from(configBase64, "base64").toString("utf-8")) as FilenSDKConfig
53
+
54
+ return new FilenSDK({
55
+ ...config,
56
+ tmpPath: SDK_TMP_PATH
57
+ })
58
+ }
59
+
60
+ // Fallback: a real login with email + password.
61
+ const email = process.env["FILEN_SYNC_LEGACY_E2E_EMAIL"]
62
+ const password = process.env["FILEN_SYNC_LEGACY_E2E_PASSWORD"]
63
+
64
+ if (!email || !password) {
65
+ throw new Error("e2e credentials are not set (FILEN_SYNC_LEGACY_E2E_SDK_CONFIG, or _EMAIL / _PASSWORD).")
66
+ }
67
+
68
+ const sdk = new FilenSDK({
69
+ metadataCache: true,
70
+ connectToSocket: false,
71
+ tmpPath: SDK_TMP_PATH
72
+ })
73
+
74
+ await sdk.login({ email, password })
75
+
76
+ return sdk
77
+ })()
78
+ }
79
+
80
+ return sdkPromise
81
+ }
82
+
83
+ /**
84
+ * Best-effort logout + scratch cleanup for the end of a run. Safe to call when login never happened.
85
+ */
86
+ export async function teardownTestSDK(): Promise<void> {
87
+ if (sdkPromise) {
88
+ try {
89
+ const sdk = await sdkPromise
90
+
91
+ sdk.logout()
92
+ } catch {
93
+ // already gone / never logged in
94
+ }
95
+
96
+ sdkPromise = null
97
+ }
98
+
99
+ try {
100
+ await fs.rm(SDK_TMP_PATH, { force: true, recursive: true, maxRetries: 10, retryDelay: 100 })
101
+ } catch {
102
+ // best effort
103
+ }
104
+ }
@@ -0,0 +1,127 @@
1
+ import type FilenSDK from "@filen/sdk"
2
+ import { type E2EWorld } from "./world"
3
+ import { LOCAL_TRASH_NAME } from "../../../src/constants"
4
+ import pathModule from "path"
5
+ import fs from "fs-extra"
6
+ import crypto from "crypto"
7
+ import { v4 as uuidv4 } from "uuid"
8
+
9
+ // The exact param shape the SDK's downloadFileToLocal expects, derived from its public surface so the
10
+ // file-encryption-version type stays in lock-step.
11
+ type DownloadParams = Parameters<ReturnType<FilenSDK["cloud"]>["downloadFileToLocal"]>[0]
12
+
13
+ export type E2ESnapshotEntry = { type: "file" | "directory"; size: number; contentHash?: string }
14
+
15
+ /**
16
+ * `relativePath -> { type, size, contentHash? }`. Local and remote snapshots are directly comparable:
17
+ * identical content yields the same sha512 on both sides. `contentHash` is only populated when
18
+ * `withContent` is requested (it costs a download per remote file), so size-only convergence stays cheap.
19
+ */
20
+ export type E2ESnapshot = Record<string, E2ESnapshotEntry>
21
+
22
+ // Control files that live locally but never sync to the remote root — excluded so the two sides match.
23
+ const LOCAL_ONLY_ENTRIES = new Set<string>([LOCAL_TRASH_NAME, ".filenignore"])
24
+
25
+ function sha512Hex(data: Buffer): string {
26
+ return crypto.createHash("sha512").update(Uint8Array.from(data)).digest("hex")
27
+ }
28
+
29
+ /**
30
+ * Snapshot the real local sync directory (relative POSIX paths), skipping the local trash and the
31
+ * `.filenignore` control file.
32
+ */
33
+ export async function snapshotLocalReal(world: E2EWorld, options: { withContent?: boolean } = {}): Promise<E2ESnapshot> {
34
+ const result: E2ESnapshot = {}
35
+ const root = world.localRoot
36
+
37
+ const walk = async (directory: string): Promise<void> => {
38
+ const entries = await fs.readdir(directory)
39
+
40
+ for (const entry of entries) {
41
+ const full = pathModule.join(directory, entry)
42
+ const relativePath = full.slice(root.length).split(pathModule.sep).join("/")
43
+
44
+ if (relativePath.split("/").length === 2 && LOCAL_ONLY_ENTRIES.has(entry)) {
45
+ continue
46
+ }
47
+
48
+ const stats = await fs.lstat(full)
49
+
50
+ if (stats.isDirectory()) {
51
+ result[relativePath] = { type: "directory", size: 0 }
52
+
53
+ await walk(full)
54
+ } else if (stats.isFile()) {
55
+ const entryResult: E2ESnapshotEntry = { type: "file", size: stats.size }
56
+
57
+ if (options.withContent) {
58
+ entryResult.contentHash = sha512Hex(await fs.readFile(full))
59
+ }
60
+
61
+ result[relativePath] = entryResult
62
+ }
63
+ }
64
+ }
65
+
66
+ await walk(root)
67
+
68
+ return result
69
+ }
70
+
71
+ /**
72
+ * Snapshot the real remote `/<runId>` tree (relative POSIX paths) by recursively listing it. With
73
+ * `withContent`, each file is downloaded to a temp path, hashed, and removed.
74
+ */
75
+ export async function snapshotRemoteReal(world: E2EWorld, options: { withContent?: boolean } = {}): Promise<E2ESnapshot> {
76
+ const result: E2ESnapshot = {}
77
+
78
+ const walk = async (uuid: string, prefix: string): Promise<void> => {
79
+ const items = await world.sdk.cloud().listDirectory({ uuid })
80
+
81
+ for (const item of items) {
82
+ const relativePath = `${prefix}/${item.name}`
83
+
84
+ if (item.type === "directory") {
85
+ result[relativePath] = { type: "directory", size: 0 }
86
+
87
+ await walk(item.uuid, relativePath)
88
+ } else {
89
+ const entryResult: E2ESnapshotEntry = { type: "file", size: item.size }
90
+
91
+ if (options.withContent) {
92
+ entryResult.contentHash = await downloadAndHash(world, item)
93
+ }
94
+
95
+ result[relativePath] = entryResult
96
+ }
97
+ }
98
+ }
99
+
100
+ await walk(world.remoteParentUUID, "")
101
+
102
+ return result
103
+ }
104
+
105
+ async function downloadAndHash(
106
+ world: E2EWorld,
107
+ item: Pick<DownloadParams, "uuid" | "bucket" | "region" | "chunks" | "version" | "key" | "size">
108
+ ): Promise<string> {
109
+ const to = pathModule.join(world.workRoot, `dl-${uuidv4()}.tmp`)
110
+
111
+ try {
112
+ await world.sdk.cloud().downloadFileToLocal({
113
+ uuid: item.uuid,
114
+ bucket: item.bucket,
115
+ region: item.region,
116
+ chunks: item.chunks,
117
+ version: item.version,
118
+ key: item.key,
119
+ size: item.size,
120
+ to
121
+ })
122
+
123
+ return sha512Hex(await fs.readFile(to))
124
+ } finally {
125
+ await fs.rm(to, { force: true, maxRetries: 5, retryDelay: 50 }).catch(() => {})
126
+ }
127
+ }
@@ -0,0 +1,88 @@
1
+ import { type E2EWorld } from "./world"
2
+ import { snapshotLocalReal, snapshotRemoteReal } from "./assert"
3
+ import { type SyncMessage } from "../../../src/types"
4
+ import { expect } from "vitest"
5
+
6
+ /**
7
+ * Drive exactly one synchronization cycle and return the messages it emitted.
8
+ *
9
+ * `resetCache` (default true) clears the in-memory local+remote tree caches and rolls the
10
+ * local-change timestamp back, so the cycle re-reads both trees from scratch and immediately passes the
11
+ * change-debounce — the deterministic way to make a cycle pick up a mutation we just applied. Pass
12
+ * `false` to exercise the warm-cache / deviceId "unchanged" path (e.g. proving a settled sync no-ops).
13
+ */
14
+ export async function cycle(world: E2EWorld, options: { resetCache?: boolean } = {}): Promise<SyncMessage[]> {
15
+ const mark = world.messages.length
16
+
17
+ if (options.resetCache ?? true) {
18
+ world.worker.resetCache(world.syncPair.uuid)
19
+ }
20
+
21
+ await world.sync.runCycle()
22
+
23
+ return world.messages.slice(mark)
24
+ }
25
+
26
+ /**
27
+ * Run cycles until the world stops changing (a fixpoint of the combined local+remote structure/size
28
+ * snapshot) or `maxCycles` is hit. Real backends settle a small tree in 2-3 cycles (transfer, then a
29
+ * confirming no-op); the cap is a safety net, not the expected path.
30
+ */
31
+ export async function settle(world: E2EWorld, options: { maxCycles?: number; resetCache?: boolean } = {}): Promise<void> {
32
+ const maxCycles = options.maxCycles ?? 8
33
+ let previous = ""
34
+
35
+ for (let i = 0; i < maxCycles; i++) {
36
+ await cycle(world, { resetCache: options.resetCache ?? true })
37
+
38
+ const [local, remote] = await Promise.all([snapshotLocalReal(world), snapshotRemoteReal(world)])
39
+ const signature = JSON.stringify([local, remote])
40
+
41
+ if (signature === previous) {
42
+ return
43
+ }
44
+
45
+ previous = signature
46
+ }
47
+ }
48
+
49
+ const FILE_TRANSFER_OPS = ["upload", "uploadFile", "download", "downloadFile"] as const
50
+
51
+ /** The `of` discriminators of every "transfer" message in the stream. */
52
+ export function transferKinds(messages: SyncMessage[]): string[] {
53
+ return messages.filter((message): message is Extract<SyncMessage, { type: "transfer" }> => message.type === "transfer").map(message => message.data.of)
54
+ }
55
+
56
+ /** Only the discriminators that represent an actual file transfer (not a dir/rename op). */
57
+ export function transferOps(messages: SyncMessage[]): string[] {
58
+ return transferKinds(messages).filter(kind => (FILE_TRANSFER_OPS as readonly string[]).includes(kind))
59
+ }
60
+
61
+ /**
62
+ * Every operation kind in the stream — file transfers AND directory creates, deletes, and renames (the
63
+ * full set of `transfer.of` discriminators), deduplicated and sorted for readable failure output.
64
+ *
65
+ * `expect(allOps(messages)).toEqual([])` is the assertion for a COMPLETE no-op. transferOps() sees only
66
+ * file up/downloads, so a cycle that spuriously renamed, deleted, or mkdir'd a path — exactly what a wrong
67
+ * post-restart base re-derivation would emit — slips past it. A genuine no-op cycle starts no tasks and so
68
+ * emits no transfer messages of any kind, making this empty.
69
+ */
70
+ export function allOps(messages: SyncMessage[]): string[] {
71
+ return [...new Set(transferKinds(messages))].sort()
72
+ }
73
+
74
+ /** All messages of a given `type`. */
75
+ export function messagesOfType<T extends SyncMessage["type"]>(messages: SyncMessage[], type: T): Extract<SyncMessage, { type: T }>[] {
76
+ return messages.filter((message): message is Extract<SyncMessage, { type: T }> => message.type === type)
77
+ }
78
+
79
+ /**
80
+ * Assert the local and remote trees are identical. With `withContent` (default) every file is hashed on
81
+ * both sides (downloading the remote copy), so this proves real content identity, not just structure.
82
+ */
83
+ export async function expectConverged(world: E2EWorld, options: { withContent?: boolean } = {}): Promise<void> {
84
+ const withContent = options.withContent ?? true
85
+ const [local, remote] = await Promise.all([snapshotLocalReal(world, { withContent }), snapshotRemoteReal(world, { withContent })])
86
+
87
+ expect(local).toEqual(remote)
88
+ }
@@ -0,0 +1,249 @@
1
+ import { type E2EWorld } from "./world"
2
+ import pathModule from "path"
3
+ import fs from "fs-extra"
4
+ import { v4 as uuidv4 } from "uuid"
5
+
6
+ /** Resolve a root-relative path (with or without a leading slash) to an absolute local path. */
7
+ function abs(world: E2EWorld, relativePath: string): string {
8
+ return pathModule.join(world.localRoot, relativePath.replace(/^\/+/, ""))
9
+ }
10
+
11
+ // ---- Local mutations (real filesystem under the sync root) -------------------------------------------
12
+
13
+ export async function writeLocal(world: E2EWorld, relativePath: string, content: string | Uint8Array): Promise<void> {
14
+ const full = abs(world, relativePath)
15
+
16
+ await fs.ensureDir(pathModule.dirname(full))
17
+ await fs.writeFile(full, content)
18
+ }
19
+
20
+ /**
21
+ * Modify an existing file's content AND stamp it with a clearly-newer mtime. The engine compares
22
+ * whole-second mtimes (plus size), so a same-size edit that lands in the same second as the previous
23
+ * version is — by design — invisible to it. Tests that specifically assert "a modification propagates"
24
+ * use this to make the change deterministically detectable regardless of how fast the prior cycle ran.
25
+ */
26
+ export async function modifyLocal(world: E2EWorld, relativePath: string, content: string): Promise<void> {
27
+ const full = abs(world, relativePath)
28
+
29
+ await fs.ensureDir(pathModule.dirname(full))
30
+ await fs.writeFile(full, content)
31
+
32
+ const newer = new Date(Date.now() + 5000)
33
+
34
+ await fs.utimes(full, newer, newer)
35
+ }
36
+
37
+ /**
38
+ * Overwrite a file with new content while RESTORING its previous mtime, reproducing an edit that
39
+ * lands in the same whole-second as the last sync (the exact E2E-OBS-002 condition). The whole-second
40
+ * mtime is unchanged, so only base-relative SIZE comparison can detect the edit.
41
+ */
42
+ export async function writeLocalPreservingMtime(world: E2EWorld, relativePath: string, content: string): Promise<void> {
43
+ const full = abs(world, relativePath)
44
+ const { atime, mtime } = await fs.stat(full)
45
+
46
+ await fs.writeFile(full, content)
47
+ await fs.utimes(full, atime, mtime)
48
+ }
49
+
50
+ export async function readLocal(world: E2EWorld, relativePath: string): Promise<string> {
51
+ return await fs.readFile(abs(world, relativePath), { encoding: "utf-8" })
52
+ }
53
+
54
+ export async function mkdirLocal(world: E2EWorld, relativePath: string): Promise<void> {
55
+ await fs.ensureDir(abs(world, relativePath))
56
+ }
57
+
58
+ export async function rmLocal(world: E2EWorld, relativePath: string): Promise<void> {
59
+ await fs.rm(abs(world, relativePath), { force: true, recursive: true, maxRetries: 10, retryDelay: 100 })
60
+ }
61
+
62
+ export async function renameLocal(world: E2EWorld, fromRelative: string, toRelative: string): Promise<void> {
63
+ const to = abs(world, toRelative)
64
+
65
+ await fs.ensureDir(pathModule.dirname(to))
66
+ await fs.move(abs(world, fromRelative), to, { overwrite: true })
67
+ }
68
+
69
+ export async function existsLocal(world: E2EWorld, relativePath: string): Promise<boolean> {
70
+ return await fs.pathExists(abs(world, relativePath))
71
+ }
72
+
73
+ /**
74
+ * Create a symlink at `linkRelativePath` pointing at `target` (raw link contents — typically an
75
+ * absolute path). Throws on platforms/permissions that forbid symlink creation (e.g. Windows without
76
+ * Developer Mode); callers skip the test in that case.
77
+ */
78
+ export async function symlinkLocal(world: E2EWorld, linkRelativePath: string, target: string): Promise<void> {
79
+ const full = abs(world, linkRelativePath)
80
+
81
+ await fs.ensureDir(pathModule.dirname(full))
82
+ await fs.symlink(target, full)
83
+ }
84
+
85
+ // ---- Remote mutations (real SDK calls under the /<runId> root) ---------------------------------------
86
+
87
+ const segments = (relativePath: string): string[] => relativePath.split("/").map(segment => segment.trim()).filter(Boolean)
88
+
89
+ /**
90
+ * Find-or-create the directory chain for a root-relative directory path, returning the leaf directory's
91
+ * uuid. An empty path resolves to the remote root itself.
92
+ */
93
+ export async function ensureRemoteDir(world: E2EWorld, relativeDir: string): Promise<string> {
94
+ let parentUUID = world.remoteParentUUID
95
+
96
+ for (const segment of segments(relativeDir)) {
97
+ const items = await world.sdk.cloud().listDirectory({ uuid: parentUUID })
98
+ const existing = items.find(item => item.type === "directory" && item.name === segment)
99
+
100
+ parentUUID = existing ? existing.uuid : await world.sdk.cloud().createDirectory({ name: segment, parent: parentUUID })
101
+ }
102
+
103
+ return parentUUID
104
+ }
105
+
106
+ export async function mkdirRemote(world: E2EWorld, relativePath: string): Promise<string> {
107
+ return await ensureRemoteDir(world, relativePath)
108
+ }
109
+
110
+ /** Upload `content` to the remote at `relativePath`, creating any missing parent directories. */
111
+ export async function uploadRemote(world: E2EWorld, relativePath: string, content: string): Promise<void> {
112
+ const parts = segments(relativePath)
113
+ const name = parts[parts.length - 1]
114
+
115
+ if (!name) {
116
+ throw new Error(`Invalid remote upload path: ${relativePath}`)
117
+ }
118
+
119
+ const parentUUID = await ensureRemoteDir(world, parts.slice(0, -1).join("/"))
120
+ const source = pathModule.join(world.workRoot, `upload-${uuidv4()}.tmp`)
121
+
122
+ await fs.writeFile(source, content)
123
+
124
+ try {
125
+ await world.sdk.cloud().uploadLocalFile({ source, parent: parentUUID, name })
126
+ } finally {
127
+ await fs.rm(source, { force: true, maxRetries: 5, retryDelay: 50 }).catch(() => {})
128
+ }
129
+ }
130
+
131
+ /** The CloudItem at a root-relative path, or null if absent. */
132
+ export async function resolveRemote(
133
+ world: E2EWorld,
134
+ relativePath: string
135
+ ): Promise<Awaited<ReturnType<ReturnType<E2EWorld["sdk"]["cloud"]>["listDirectory"]>>[number] | null> {
136
+ const parts = segments(relativePath)
137
+
138
+ if (parts.length === 0) {
139
+ return null
140
+ }
141
+
142
+ let parentUUID = world.remoteParentUUID
143
+
144
+ for (let i = 0; i < parts.length; i++) {
145
+ const items = await world.sdk.cloud().listDirectory({ uuid: parentUUID })
146
+ const match = items.find(item => item.name === parts[i])
147
+
148
+ if (!match) {
149
+ return null
150
+ }
151
+
152
+ if (i === parts.length - 1) {
153
+ return match
154
+ }
155
+
156
+ if (match.type !== "directory") {
157
+ return null
158
+ }
159
+
160
+ parentUUID = match.uuid
161
+ }
162
+
163
+ return null
164
+ }
165
+
166
+ /**
167
+ * Rename a remote DIRECTORY within its current parent (simulating a peer client's rename), via the SDK
168
+ * renameDirectory call the engine itself uses.
169
+ */
170
+ export async function renameRemoteDir(world: E2EWorld, fromRelative: string, toRelative: string): Promise<void> {
171
+ const item = await resolveRemote(world, fromRelative)
172
+
173
+ if (!item || item.type !== "directory") {
174
+ throw new Error(`renameRemoteDir: no remote directory at ${fromRelative}`)
175
+ }
176
+
177
+ const newName = segments(toRelative).pop()
178
+
179
+ if (!newName) {
180
+ throw new Error(`renameRemoteDir: invalid target ${toRelative}`)
181
+ }
182
+
183
+ await world.sdk.cloud().renameDirectory({
184
+ uuid: item.uuid,
185
+ name: newName,
186
+ overwriteIfExists: true
187
+ })
188
+ }
189
+
190
+ /**
191
+ * Rename a remote FILE within its current directory (simulating a peer client's rename), via the SDK
192
+ * renameFile call the engine itself uses. Only same-directory file renames are needed by the conflict
193
+ * suite, so the metadata is rebuilt from the resolved item.
194
+ */
195
+ export async function renameRemote(world: E2EWorld, fromRelative: string, toRelative: string): Promise<void> {
196
+ const item = await resolveRemote(world, fromRelative)
197
+
198
+ if (!item || item.type !== "file") {
199
+ throw new Error(`renameRemote: no remote file at ${fromRelative}`)
200
+ }
201
+
202
+ const newName = segments(toRelative).pop()
203
+
204
+ if (!newName) {
205
+ throw new Error(`renameRemote: invalid target ${toRelative}`)
206
+ }
207
+
208
+ await world.sdk.cloud().renameFile({
209
+ uuid: item.uuid,
210
+ name: newName,
211
+ metadata: {
212
+ name: newName,
213
+ size: item.size,
214
+ mime: item.mime,
215
+ key: item.key,
216
+ lastModified: item.lastModified,
217
+ ...(item.creation !== undefined ? { creation: item.creation } : {}),
218
+ ...(item.hash !== undefined ? { hash: item.hash } : {})
219
+ },
220
+ overwriteIfExists: true
221
+ })
222
+ }
223
+
224
+ /** Force a local file's mtime to a specific epoch-ms (e.g. to age it before a newer conflicting write). */
225
+ export async function setLocalMtime(world: E2EWorld, relativePath: string, epochMs: number): Promise<void> {
226
+ const when = new Date(epochMs)
227
+
228
+ await fs.utimes(abs(world, relativePath), when, when)
229
+ }
230
+
231
+ /** Change a local file's permission bits (e.g. 0o000 to make it unreadable for a fault-tolerance test). */
232
+ export async function chmodLocal(world: E2EWorld, relativePath: string, mode: number): Promise<void> {
233
+ await fs.chmod(abs(world, relativePath), mode)
234
+ }
235
+
236
+ /** Permanently delete (no trash) the remote item at `relativePath`. */
237
+ export async function deleteRemote(world: E2EWorld, relativePath: string): Promise<void> {
238
+ const item = await resolveRemote(world, relativePath)
239
+
240
+ if (!item) {
241
+ return
242
+ }
243
+
244
+ if (item.type === "directory") {
245
+ await world.sdk.cloud().deleteDirectory({ uuid: item.uuid })
246
+ } else {
247
+ await world.sdk.cloud().deleteFile({ uuid: item.uuid })
248
+ }
249
+ }