@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,354 @@
|
|
|
1
|
+
import { Volume, createFsFromVolume, type IFs } from "memfs"
|
|
2
|
+
import pathModule from "path"
|
|
3
|
+
import { type SyncFS, type SyncGlobFS } from "../../src/lib/environment"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Declarative description of an initial in-memory tree.
|
|
7
|
+
*
|
|
8
|
+
* Key = absolute path (e.g. "/local/a.txt").
|
|
9
|
+
* Value = `string` → a file with that UTF-8 content,
|
|
10
|
+
* `VfsFileSpec` → a file with content and/or an explicit mtime,
|
|
11
|
+
* `null` → an (empty) directory.
|
|
12
|
+
*
|
|
13
|
+
* Intermediate directories are created automatically.
|
|
14
|
+
*/
|
|
15
|
+
export type VfsFileSpec = { content?: string; mtimeMs?: number }
|
|
16
|
+
export type VfsSpec = Record<string, string | VfsFileSpec | null>
|
|
17
|
+
|
|
18
|
+
export type VirtualFS = {
|
|
19
|
+
/** The {@link SyncFS} surface to inject into the engine's environment. */
|
|
20
|
+
fs: SyncFS
|
|
21
|
+
/** The fast-glob filesystem adapter to inject as `globFs`. */
|
|
22
|
+
globFs: SyncGlobFS
|
|
23
|
+
/** The underlying memfs volume (for advanced manipulation in tests). */
|
|
24
|
+
vol: InstanceType<typeof Volume>
|
|
25
|
+
/** The memfs fs implementation backing both {@link fs} and {@link globFs}. */
|
|
26
|
+
ifs: IFs
|
|
27
|
+
controls: {
|
|
28
|
+
/** Current inode of a path, or null if it does not exist. */
|
|
29
|
+
getInode(path: string): number | null
|
|
30
|
+
/**
|
|
31
|
+
* Force `stat`/`lstat` of `path` to report inode `ino`, simulating an OS (ext4) that recycles a
|
|
32
|
+
* freed inode number for the next-created file. memfs has no native reuse, so this is the only way
|
|
33
|
+
* to reproduce the inode-reuse rename misdetection deterministically. Use the real posix path the
|
|
34
|
+
* engine will stat (e.g. "/local/c.txt").
|
|
35
|
+
*/
|
|
36
|
+
setInode(path: string, ino: number): void
|
|
37
|
+
/** Remove a previously forced inode for `path`. */
|
|
38
|
+
clearInode(path: string): void
|
|
39
|
+
/** Whether a path currently exists (no symlink following beyond stat). */
|
|
40
|
+
exists(path: string): boolean
|
|
41
|
+
/** Force the next fs operation that touches `path` to throw `error`. */
|
|
42
|
+
setError(path: string, error: NodeJS.ErrnoException): void
|
|
43
|
+
/** Remove a previously injected error for `path`. */
|
|
44
|
+
clearError(path: string): void
|
|
45
|
+
/** Remove all injected errors. */
|
|
46
|
+
clearAllErrors(): void
|
|
47
|
+
/**
|
|
48
|
+
* Register a callback invoked synchronously AFTER every `lstat`/`stat` returns, with the posix path
|
|
49
|
+
* just stat'd. Lets a test edit a file mid-scan (after the engine has already read it) to reproduce a
|
|
50
|
+
* read-during-scan race deterministically. The callback should gate itself (path match + a one-shot
|
|
51
|
+
* flag) and is cleared with {@link clearStatHook}. Only one hook is active at a time.
|
|
52
|
+
*/
|
|
53
|
+
onStat(hook: (posixPath: string) => void): void
|
|
54
|
+
/** Remove the {@link onStat} hook. */
|
|
55
|
+
clearStatHook(): void
|
|
56
|
+
/** Flat JSON view of the volume (file path → content, dir → null). */
|
|
57
|
+
toJSON(): Record<string, string | null>
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a Node `ErrnoException` with a `code` (e.g. "ENOENT", "EACCES").
|
|
63
|
+
*/
|
|
64
|
+
export function makeErrnoError(code: string, message?: string): NodeJS.ErrnoException {
|
|
65
|
+
const error = new Error(message ?? code) as NodeJS.ErrnoException
|
|
66
|
+
|
|
67
|
+
error.code = code
|
|
68
|
+
|
|
69
|
+
return error
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a path the ENGINE produced into the posix form memfs understands.
|
|
74
|
+
*
|
|
75
|
+
* memfs is strictly posix (it rejects backslashes), and the virtual tree is seeded with `/`-paths. But
|
|
76
|
+
* the engine joins local paths with the host's real `path` module, so on a Windows runner it emits
|
|
77
|
+
* backslash separators — and FastGlob/@nodelib may resolve the posix `cwd` to a drive-rooted absolute
|
|
78
|
+
* like `C:\local\...`. Stripping a leading drive letter and converting `\`→`/` lets the in-memory fs
|
|
79
|
+
* accept whatever the host's path module emits, so the identical suite runs on win32/darwin/linux.
|
|
80
|
+
*
|
|
81
|
+
* On posix hosts this is a no-op (no drive letter, no backslashes). Only paths the engine passes in are
|
|
82
|
+
* normalized here; test-side `ifs`/`vol` access stays posix (tests always use `/`).
|
|
83
|
+
*/
|
|
84
|
+
export function toPosixPath<T>(path: T): T {
|
|
85
|
+
return (typeof path === "string" ? path.replace(/^[a-zA-Z]:(?=[\\/])/, "").replace(/\\/g, "/") : path) as T
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Materialize a {@link VfsSpec} into a memfs filesystem.
|
|
90
|
+
*/
|
|
91
|
+
export function applyVfsSpec(ifs: IFs, spec: VfsSpec): void {
|
|
92
|
+
for (const [path, value] of Object.entries(spec)) {
|
|
93
|
+
if (value === null) {
|
|
94
|
+
ifs.mkdirSync(path, { recursive: true })
|
|
95
|
+
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const content = typeof value === "string" ? value : value.content ?? ""
|
|
100
|
+
|
|
101
|
+
ifs.mkdirSync(pathModule.posix.dirname(path), { recursive: true })
|
|
102
|
+
ifs.writeFileSync(path, content)
|
|
103
|
+
|
|
104
|
+
const mtimeMs = typeof value === "string" ? undefined : value.mtimeMs
|
|
105
|
+
|
|
106
|
+
if (typeof mtimeMs === "number") {
|
|
107
|
+
const seconds = mtimeMs / 1000
|
|
108
|
+
|
|
109
|
+
ifs.utimesSync(path, seconds, seconds)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create an in-memory filesystem that satisfies the engine's {@link SyncFS} and
|
|
116
|
+
* {@link SyncGlobFS} contracts. Backed by memfs (battle-tested), with the
|
|
117
|
+
* fs-extra conveniences the engine relies on (`ensureDir`, `exists`,
|
|
118
|
+
* `pathExists`, `move`) layered on top, plus a per-path error-injection map for
|
|
119
|
+
* resilience tests.
|
|
120
|
+
*/
|
|
121
|
+
export function createVirtualFS(initial: VfsSpec = {}): VirtualFS {
|
|
122
|
+
const vol = new Volume()
|
|
123
|
+
const ifs = createFsFromVolume(vol)
|
|
124
|
+
|
|
125
|
+
applyVfsSpec(ifs, initial)
|
|
126
|
+
|
|
127
|
+
const errors = new Map<string, NodeJS.ErrnoException>()
|
|
128
|
+
// Forced inode numbers (posix path -> ino) so a test can reproduce ext4-style inode reuse, which
|
|
129
|
+
// memfs's allocator does not surface naturally.
|
|
130
|
+
const inodeOverrides = new Map<string, number>()
|
|
131
|
+
// Optional one-shot-friendly hook fired after each lstat/stat returns, so a test can mutate a file
|
|
132
|
+
// mid-scan (after the engine read it) to reproduce a read-during-scan race deterministically.
|
|
133
|
+
let statHook: ((posixPath: string) => void) | null = null
|
|
134
|
+
// `guard` is always called with an already-posix-normalized path (each method normalizes its inputs
|
|
135
|
+
// at entry), so the error map — keyed by the posix paths tests inject — matches on every host.
|
|
136
|
+
const guard = (path: string): void => {
|
|
137
|
+
const error = errors.get(path)
|
|
138
|
+
|
|
139
|
+
if (error) {
|
|
140
|
+
throw error
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const promises = ifs.promises
|
|
145
|
+
|
|
146
|
+
const fs = {
|
|
147
|
+
constants: ifs.constants,
|
|
148
|
+
stat: async (path: string) => {
|
|
149
|
+
path = toPosixPath(path)
|
|
150
|
+
guard(path)
|
|
151
|
+
|
|
152
|
+
const stats = await promises.stat(path)
|
|
153
|
+
const overriddenInode = inodeOverrides.get(path)
|
|
154
|
+
|
|
155
|
+
if (overriddenInode !== undefined) {
|
|
156
|
+
stats.ino = overriddenInode
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (statHook) {
|
|
160
|
+
statHook(path)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return stats
|
|
164
|
+
},
|
|
165
|
+
lstat: async (path: string) => {
|
|
166
|
+
path = toPosixPath(path)
|
|
167
|
+
guard(path)
|
|
168
|
+
|
|
169
|
+
const stats = await promises.lstat(path)
|
|
170
|
+
const overriddenInode = inodeOverrides.get(path)
|
|
171
|
+
|
|
172
|
+
if (overriddenInode !== undefined) {
|
|
173
|
+
stats.ino = overriddenInode
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (statHook) {
|
|
177
|
+
statHook(path)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return stats
|
|
181
|
+
},
|
|
182
|
+
access: async (path: string, mode?: number) => {
|
|
183
|
+
path = toPosixPath(path)
|
|
184
|
+
guard(path)
|
|
185
|
+
|
|
186
|
+
return await promises.access(path, mode)
|
|
187
|
+
},
|
|
188
|
+
exists: async (path: string): Promise<boolean> => {
|
|
189
|
+
try {
|
|
190
|
+
path = toPosixPath(path)
|
|
191
|
+
guard(path)
|
|
192
|
+
|
|
193
|
+
await promises.access(path)
|
|
194
|
+
|
|
195
|
+
return true
|
|
196
|
+
} catch {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
pathExists: async (path: string): Promise<boolean> => {
|
|
201
|
+
try {
|
|
202
|
+
path = toPosixPath(path)
|
|
203
|
+
guard(path)
|
|
204
|
+
|
|
205
|
+
await promises.access(path)
|
|
206
|
+
|
|
207
|
+
return true
|
|
208
|
+
} catch {
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
ensureDir: async (path: string): Promise<void> => {
|
|
213
|
+
path = toPosixPath(path)
|
|
214
|
+
guard(path)
|
|
215
|
+
|
|
216
|
+
await promises.mkdir(path, { recursive: true })
|
|
217
|
+
},
|
|
218
|
+
mkdir: async (path: string, options?: { recursive?: boolean }) => {
|
|
219
|
+
path = toPosixPath(path)
|
|
220
|
+
guard(path)
|
|
221
|
+
|
|
222
|
+
return await promises.mkdir(path, options)
|
|
223
|
+
},
|
|
224
|
+
rm: async (
|
|
225
|
+
path: string,
|
|
226
|
+
options?: { force?: boolean; maxRetries?: number; recursive?: boolean; retryDelay?: number }
|
|
227
|
+
): Promise<void> => {
|
|
228
|
+
path = toPosixPath(path)
|
|
229
|
+
guard(path)
|
|
230
|
+
|
|
231
|
+
await promises.rm(path, options)
|
|
232
|
+
},
|
|
233
|
+
rename: async (src: string, dest: string): Promise<void> => {
|
|
234
|
+
src = toPosixPath(src)
|
|
235
|
+
dest = toPosixPath(dest)
|
|
236
|
+
guard(src)
|
|
237
|
+
|
|
238
|
+
await promises.rename(src, dest)
|
|
239
|
+
},
|
|
240
|
+
move: async (src: string, dest: string, options?: { overwrite?: boolean }): Promise<void> => {
|
|
241
|
+
src = toPosixPath(src)
|
|
242
|
+
dest = toPosixPath(dest)
|
|
243
|
+
guard(src)
|
|
244
|
+
|
|
245
|
+
await promises.mkdir(pathModule.posix.dirname(dest), { recursive: true })
|
|
246
|
+
|
|
247
|
+
if (options?.overwrite) {
|
|
248
|
+
try {
|
|
249
|
+
await promises.rm(dest, { recursive: true, force: true })
|
|
250
|
+
} catch {
|
|
251
|
+
// destination did not exist — nothing to overwrite
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await promises.rename(src, dest)
|
|
256
|
+
},
|
|
257
|
+
utimes: async (path: string, atime: number | Date, mtime: number | Date): Promise<void> => {
|
|
258
|
+
path = toPosixPath(path)
|
|
259
|
+
guard(path)
|
|
260
|
+
|
|
261
|
+
await promises.utimes(path, atime, mtime)
|
|
262
|
+
},
|
|
263
|
+
readFile: async (path: string, options?: { encoding?: BufferEncoding }) => {
|
|
264
|
+
path = toPosixPath(path)
|
|
265
|
+
guard(path)
|
|
266
|
+
|
|
267
|
+
return await promises.readFile(path, options)
|
|
268
|
+
},
|
|
269
|
+
writeFile: async (path: string, data: string | Uint8Array, options?: { encoding?: BufferEncoding }): Promise<void> => {
|
|
270
|
+
path = toPosixPath(path)
|
|
271
|
+
guard(path)
|
|
272
|
+
|
|
273
|
+
await promises.writeFile(path, data, options)
|
|
274
|
+
},
|
|
275
|
+
createReadStream: (path: string, options?: Parameters<IFs["createReadStream"]>[1]) => {
|
|
276
|
+
path = toPosixPath(path)
|
|
277
|
+
guard(path)
|
|
278
|
+
|
|
279
|
+
return ifs.createReadStream(path, options)
|
|
280
|
+
},
|
|
281
|
+
createWriteStream: (path: string, options?: Parameters<IFs["createWriteStream"]>[1]) => {
|
|
282
|
+
path = toPosixPath(path)
|
|
283
|
+
guard(path)
|
|
284
|
+
|
|
285
|
+
return ifs.createWriteStream(path, options)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// FastGlob walks the tree through this adapter (lstat/stat/readdir + sync variants). On a Windows
|
|
290
|
+
// runner @nodelib builds child paths with the host separator and may resolve the posix `cwd` to a
|
|
291
|
+
// drive-rooted absolute, so every path it hands us is posix-normalized before reaching memfs. The
|
|
292
|
+
// returned glob entries are already forward-slash (fast-glob normalizes its output on all platforms).
|
|
293
|
+
const globFs = {
|
|
294
|
+
lstat: (path: string, ...rest: unknown[]) => (ifs.lstat as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
|
|
295
|
+
lstatSync: (path: string, ...rest: unknown[]) => (ifs.lstatSync as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
|
|
296
|
+
stat: (path: string, ...rest: unknown[]) => (ifs.stat as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
|
|
297
|
+
statSync: (path: string, ...rest: unknown[]) => (ifs.statSync as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
|
|
298
|
+
readdir: (path: string, ...rest: unknown[]) => (ifs.readdir as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest),
|
|
299
|
+
readdirSync: (path: string, ...rest: unknown[]) => (ifs.readdirSync as (...args: unknown[]) => unknown)(toPosixPath(path), ...rest)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const controls: VirtualFS["controls"] = {
|
|
303
|
+
// Every path-keyed control normalizes with toPosixPath, exactly as the fs methods do before they
|
|
304
|
+
// consult these maps. Tests pass either posix literals (a no-op here) or engine-computed paths,
|
|
305
|
+
// which on Windows carry host backslash separators — without this an injected key would never
|
|
306
|
+
// match the posix path the engine actually stats/opens.
|
|
307
|
+
getInode: (path: string): number | null => {
|
|
308
|
+
try {
|
|
309
|
+
return Number(vol.statSync(toPosixPath(path)).ino)
|
|
310
|
+
} catch {
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
exists: (path: string): boolean => {
|
|
315
|
+
try {
|
|
316
|
+
vol.statSync(toPosixPath(path))
|
|
317
|
+
|
|
318
|
+
return true
|
|
319
|
+
} catch {
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
setInode: (path: string, ino: number): void => {
|
|
324
|
+
inodeOverrides.set(toPosixPath(path), ino)
|
|
325
|
+
},
|
|
326
|
+
clearInode: (path: string): void => {
|
|
327
|
+
inodeOverrides.delete(toPosixPath(path))
|
|
328
|
+
},
|
|
329
|
+
setError: (path: string, error: NodeJS.ErrnoException): void => {
|
|
330
|
+
errors.set(toPosixPath(path), error)
|
|
331
|
+
},
|
|
332
|
+
clearError: (path: string): void => {
|
|
333
|
+
errors.delete(toPosixPath(path))
|
|
334
|
+
},
|
|
335
|
+
clearAllErrors: (): void => {
|
|
336
|
+
errors.clear()
|
|
337
|
+
},
|
|
338
|
+
onStat: (hook: (posixPath: string) => void): void => {
|
|
339
|
+
statHook = hook
|
|
340
|
+
},
|
|
341
|
+
clearStatHook: (): void => {
|
|
342
|
+
statHook = null
|
|
343
|
+
},
|
|
344
|
+
toJSON: (): Record<string, string | null> => vol.toJSON()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
fs: fs as unknown as SyncFS,
|
|
349
|
+
globFs: globFs as unknown as SyncGlobFS,
|
|
350
|
+
vol,
|
|
351
|
+
ifs,
|
|
352
|
+
controls
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { it } from "vitest"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A scenario that encodes the CORRECT behavior the engine currently violates (catalogued in
|
|
5
|
+
* docs/sync-bug-catalog.md). Implemented with `it.fails` so the suite stays green while the bug
|
|
6
|
+
* exists: the body asserts correct behavior, the engine produces wrong behavior, the assertion
|
|
7
|
+
* throws, and `it.fails` treats that throw as a pass.
|
|
8
|
+
*
|
|
9
|
+
* When the corresponding Phase 2 fix lands, the assertion starts passing, `it.fails` then FAILS
|
|
10
|
+
* (the body no longer throws) — that is the signal to convert `knownBug(...)` to a plain `it(...)`.
|
|
11
|
+
*
|
|
12
|
+
* Keep the body focused so it fails for the RIGHT reason (the catalogued defect), not an unrelated
|
|
13
|
+
* harness error.
|
|
14
|
+
*/
|
|
15
|
+
export function knownBug(bugId: string, name: string, fn: () => Promise<void>): void {
|
|
16
|
+
it.fails(`[KB ${bugId}] ${name}`, fn)
|
|
17
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import pathModule from "path"
|
|
2
|
+
import { type World } from "./world"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Local filesystem mutations applied to a {@link World} between cycles. All paths are relative to the
|
|
6
|
+
* local sync root (e.g. "a.txt", "dir/b.txt"). Parent directories are created as needed. After a
|
|
7
|
+
* local mutation the runner fires the watcher so the next cycle rescans.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function localFull(world: World, relativePath: string): string {
|
|
11
|
+
return pathModule.posix.join(world.localPath, relativePath.replace(/^\/+/, ""))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function writeLocal(world: World, relativePath: string, content: string): void {
|
|
15
|
+
const full = localFull(world, relativePath)
|
|
16
|
+
|
|
17
|
+
world.vfs.ifs.mkdirSync(pathModule.posix.dirname(full), { recursive: true })
|
|
18
|
+
world.vfs.ifs.writeFileSync(full, content)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Write a local file and force a specific mtime (whole-second precision drives conflict resolution). */
|
|
22
|
+
export function writeLocalAt(world: World, relativePath: string, content: string, mtimeMs: number): void {
|
|
23
|
+
writeLocal(world, relativePath, content)
|
|
24
|
+
|
|
25
|
+
const seconds = mtimeMs / 1000
|
|
26
|
+
|
|
27
|
+
world.vfs.ifs.utimesSync(localFull(world, relativePath), seconds, seconds)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Update a local file's mtime WITHOUT changing its content or inode (a pure touch). */
|
|
31
|
+
export function touchLocal(world: World, relativePath: string, mtimeMs: number): void {
|
|
32
|
+
const seconds = mtimeMs / 1000
|
|
33
|
+
|
|
34
|
+
world.vfs.ifs.utimesSync(localFull(world, relativePath), seconds, seconds)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mkdirLocal(world: World, relativePath: string): void {
|
|
38
|
+
world.vfs.ifs.mkdirSync(localFull(world, relativePath), { recursive: true })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function rmLocal(world: World, relativePath: string): void {
|
|
42
|
+
world.vfs.ifs.rmSync(localFull(world, relativePath), { recursive: true, force: true })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renameLocal(world: World, fromRelativePath: string, toRelativePath: string): void {
|
|
46
|
+
const from = localFull(world, fromRelativePath)
|
|
47
|
+
const to = localFull(world, toRelativePath)
|
|
48
|
+
|
|
49
|
+
world.vfs.ifs.mkdirSync(pathModule.posix.dirname(to), { recursive: true })
|
|
50
|
+
world.vfs.ifs.renameSync(from, to)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readLocal(world: World, relativePath: string): string {
|
|
54
|
+
return world.vfs.ifs.readFileSync(localFull(world, relativePath), "utf-8") as string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function existsLocal(world: World, relativePath: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
world.vfs.ifs.statSync(localFull(world, relativePath))
|
|
60
|
+
|
|
61
|
+
return true
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { vi, expect } from "vitest"
|
|
2
|
+
import { SYNC_INTERVAL } from "../../src/constants"
|
|
3
|
+
import { createWorld, restartSync, BASE_TIME, type CreateWorldOptions, type World } from "./world"
|
|
4
|
+
import { snapshotLocal, snapshotRemote, type WorldSnapshot } from "./snapshot"
|
|
5
|
+
import { type SyncMessage } from "../../src/types"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One step in a scenario. `runCycle` drives exactly one synchronization cycle; the mutate/control
|
|
9
|
+
* steps apply changes between cycles. `localMutate` automatically fires the watcher afterwards so the
|
|
10
|
+
* next cycle rescans the local tree.
|
|
11
|
+
*/
|
|
12
|
+
export type Step =
|
|
13
|
+
| { type: "runCycle" }
|
|
14
|
+
| { type: "restart" }
|
|
15
|
+
| { type: "localMutate"; mutate: (world: World) => unknown }
|
|
16
|
+
| { type: "remoteMutate"; mutate: (world: World) => unknown }
|
|
17
|
+
| { type: "control"; control: (world: World) => unknown }
|
|
18
|
+
|
|
19
|
+
export function runCycle(): Step {
|
|
20
|
+
return { type: "runCycle" }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Simulate a process restart: reload persisted state and rebuild the engine over the same world. */
|
|
24
|
+
export function restart(): Step {
|
|
25
|
+
return { type: "restart" }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function localMutate(mutate: (world: World) => unknown): Step {
|
|
29
|
+
return { type: "localMutate", mutate }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function remoteMutate(mutate: (world: World) => unknown): Step {
|
|
33
|
+
return { type: "remoteMutate", mutate }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function control(controlFn: (world: World) => unknown): Step {
|
|
37
|
+
return { type: "control", control: controlFn }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Scenario = CreateWorldOptions & {
|
|
41
|
+
name: string
|
|
42
|
+
steps: Step[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RunResult = {
|
|
46
|
+
world: World
|
|
47
|
+
finalLocal: WorldSnapshot
|
|
48
|
+
finalRemote: WorldSnapshot
|
|
49
|
+
messages: SyncMessage[]
|
|
50
|
+
/** Per-`runCycle` snapshots and the messages emitted during that cycle. */
|
|
51
|
+
cycles: { local: WorldSnapshot; remote: WorldSnapshot; messages: SyncMessage[] }[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function applyStep(world: World, step: Step): Promise<void> {
|
|
55
|
+
switch (step.type) {
|
|
56
|
+
case "runCycle": {
|
|
57
|
+
// Advance past the local-change debounce window so waitForLocalDirectoryChanges returns
|
|
58
|
+
// immediately (its first synchronous check passes); during the cycle the clock is frozen,
|
|
59
|
+
// so the engine's internal progress timeouts and the lock-refresh interval never fire.
|
|
60
|
+
await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
|
|
61
|
+
|
|
62
|
+
await world.sync.runCycle()
|
|
63
|
+
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case "restart": {
|
|
68
|
+
await restartSync(world)
|
|
69
|
+
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case "localMutate": {
|
|
74
|
+
await step.mutate(world)
|
|
75
|
+
|
|
76
|
+
world.triggerWatcher()
|
|
77
|
+
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case "remoteMutate": {
|
|
82
|
+
await step.mutate(world)
|
|
83
|
+
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case "control": {
|
|
88
|
+
await step.control(world)
|
|
89
|
+
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build an in-memory world from the scenario spec, run its steps under fake timers, and return the
|
|
97
|
+
* final local/remote snapshots, the full message stream, and per-cycle snapshots.
|
|
98
|
+
*/
|
|
99
|
+
export async function runScenario(scenario: Scenario): Promise<RunResult> {
|
|
100
|
+
// Fake only the timers the engine schedules on (debounce, lock refresh, cleanup, retries) and the
|
|
101
|
+
// clock. Leave setImmediate/microtasks real so async I/O (fast-glob traversal, streams) resolves
|
|
102
|
+
// while the fake clock is frozen mid-cycle.
|
|
103
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] })
|
|
104
|
+
vi.setSystemTime(BASE_TIME)
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const world = await createWorld(scenario)
|
|
108
|
+
const cycles: { local: WorldSnapshot; remote: WorldSnapshot; messages: SyncMessage[] }[] = []
|
|
109
|
+
|
|
110
|
+
for (const step of scenario.steps) {
|
|
111
|
+
const messagesBefore = world.messages.length
|
|
112
|
+
|
|
113
|
+
await applyStep(world, step)
|
|
114
|
+
|
|
115
|
+
if (step.type === "runCycle") {
|
|
116
|
+
cycles.push({
|
|
117
|
+
local: snapshotLocal(world),
|
|
118
|
+
remote: snapshotRemote(world),
|
|
119
|
+
messages: world.messages.slice(messagesBefore)
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
world,
|
|
126
|
+
finalLocal: snapshotLocal(world),
|
|
127
|
+
finalRemote: snapshotRemote(world),
|
|
128
|
+
messages: world.messages,
|
|
129
|
+
cycles
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
vi.useRealTimers()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Assert the two normalized worlds are identical (used for twoWay equivalence: §2.3).
|
|
138
|
+
*/
|
|
139
|
+
export function expectWorldsEqual(a: WorldSnapshot, b: WorldSnapshot): void {
|
|
140
|
+
expect(a).toEqual(b)
|
|
141
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import pathModule from "path"
|
|
2
|
+
import crypto from "crypto"
|
|
3
|
+
import { LOCAL_TRASH_NAME } from "../../src/constants"
|
|
4
|
+
import { type SyncMessage } from "../../src/types"
|
|
5
|
+
import { type World } from "./world"
|
|
6
|
+
|
|
7
|
+
export type SnapshotEntry = { type: "file" | "directory"; size: number; mtimeSec: number; contentHash: string }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The normalized assertion unit: `path → { type, size, mtime(sec), contentHash }`, excluding the
|
|
11
|
+
* local trash directory. Local and remote snapshots are directly comparable — same content yields
|
|
12
|
+
* the same sha512 hash and the same whole-second mtime on both sides.
|
|
13
|
+
*/
|
|
14
|
+
export type WorldSnapshot = Record<string, SnapshotEntry>
|
|
15
|
+
|
|
16
|
+
function sha512Hex(data: Buffer): string {
|
|
17
|
+
return crypto.createHash("sha512").update(Uint8Array.from(data)).digest("hex")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalized snapshot of the local sync directory (relative POSIX paths), excluding the local trash.
|
|
22
|
+
*/
|
|
23
|
+
export function snapshotLocal(world: World): WorldSnapshot {
|
|
24
|
+
const result: WorldSnapshot = {}
|
|
25
|
+
const ifs = world.vfs.ifs
|
|
26
|
+
const root = world.localPath
|
|
27
|
+
|
|
28
|
+
const walk = (directory: string): void => {
|
|
29
|
+
let entries: string[]
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
entries = ifs.readdirSync(directory) as string[]
|
|
33
|
+
} catch {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry === LOCAL_TRASH_NAME) {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const full = pathModule.posix.join(directory, entry)
|
|
43
|
+
const relativePath = full.slice(root.length)
|
|
44
|
+
const stats = ifs.statSync(full)
|
|
45
|
+
|
|
46
|
+
if (stats.isDirectory()) {
|
|
47
|
+
result[relativePath] = { type: "directory", size: 0, mtimeSec: 0, contentHash: "" }
|
|
48
|
+
|
|
49
|
+
walk(full)
|
|
50
|
+
} else if (stats.isFile()) {
|
|
51
|
+
const content = ifs.readFileSync(full) as Buffer
|
|
52
|
+
|
|
53
|
+
result[relativePath] = {
|
|
54
|
+
type: "file",
|
|
55
|
+
size: stats.size,
|
|
56
|
+
mtimeSec: Math.floor(stats.mtimeMs / 1000),
|
|
57
|
+
contentHash: sha512Hex(content)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
walk(root)
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalized snapshot of the live remote tree (relative POSIX paths).
|
|
70
|
+
*/
|
|
71
|
+
export function snapshotRemote(world: World): WorldSnapshot {
|
|
72
|
+
return world.cloud.controls.snapshot()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** All messages of a given `type`. */
|
|
76
|
+
export function messagesOfType<T extends SyncMessage["type"]>(messages: SyncMessage[], type: T): Extract<SyncMessage, { type: T }>[] {
|
|
77
|
+
return messages.filter((message): message is Extract<SyncMessage, { type: T }> => message.type === type)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Count messages of a given `type`. */
|
|
81
|
+
export function countMessages(messages: SyncMessage[], type: SyncMessage["type"]): number {
|
|
82
|
+
return messages.filter(message => message.type === type).length
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The `of` discriminators of every "transfer" message (e.g. "upload", "downloadFile", ...). */
|
|
86
|
+
export function transferKinds(messages: SyncMessage[]): string[] {
|
|
87
|
+
return messagesOfType(messages, "transfer").map(message => message.data.of)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The transfer `of` discriminators that represent an actual file transfer (not a dir/rename op). */
|
|
91
|
+
export const FILE_TRANSFER_OPS = ["upload", "uploadFile", "download", "downloadFile"] as const
|
|
92
|
+
|
|
93
|
+
/** The actual file-transfer operations seen in a message stream (upload/download, queued..finished). */
|
|
94
|
+
export function transferOps(messages: SyncMessage[]): string[] {
|
|
95
|
+
return transferKinds(messages).filter(kind => (FILE_TRANSFER_OPS as readonly string[]).includes(kind))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every operation kind in the stream — file transfers AND directory creates, deletes, and renames (the
|
|
100
|
+
* full set of `transfer.of` discriminators), deduplicated and sorted for readable failure output.
|
|
101
|
+
*
|
|
102
|
+
* `expect(allOps(messages)).toEqual([])` is the assertion for a COMPLETE no-op. transferOps() sees only
|
|
103
|
+
* file up/downloads, so a cycle that spuriously renamed, deleted, or mkdir'd a path slips past it; allOps
|
|
104
|
+
* catches it. Mirror of the e2e harness helper of the same name.
|
|
105
|
+
*/
|
|
106
|
+
export function allOps(messages: SyncMessage[]): string[] {
|
|
107
|
+
return [...new Set(transferKinds(messages))].sort()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether any actual file transfer (upload/download) occurred in the message stream. */
|
|
111
|
+
export function hadTransfers(messages: SyncMessage[]): boolean {
|
|
112
|
+
return transferOps(messages).length > 0
|
|
113
|
+
}
|