@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,632 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ isValidPath,
4
+ isPathOverMaxLength,
5
+ isNameOverMaxLength,
6
+ convertTimestampToMs,
7
+ normalizeUTime,
8
+ normalizeLastModifiedMsForComparison,
9
+ pathIncludesDotFile,
10
+ isNameIgnoredByDefault,
11
+ isRelativePathIgnoredByDefault,
12
+ isAbsolutePathIgnoredByDefault,
13
+ serializeError,
14
+ deserializeError,
15
+ fastHash,
16
+ tryingToSyncDesktop,
17
+ replacePathStartWithFromAndTo
18
+ } from "../../src/utils"
19
+ import { Semaphore } from "../../src/semaphore"
20
+ import { Logger } from "../../src/lib/logger"
21
+
22
+ /**
23
+ * Category N — unit tests (behavioral spec §N).
24
+ *
25
+ * Pure-function and small-class coverage that complements the scenario suite: platform-specific
26
+ * path/length validation (via stubbed `process.platform`), time normalization, default-ignore
27
+ * helpers, error (de)serialization, hashing, the desktop guard, the Semaphore primitive and the
28
+ * disabled-Logger path. Every expectation here was verified against the real implementation.
29
+ */
30
+
31
+ /**
32
+ * Run `fn` with `process.platform` temporarily stubbed. `process.platform` is a non-writable but
33
+ * configurable property, so it must be replaced via `defineProperty` and restored from its original
34
+ * descriptor.
35
+ */
36
+ function withPlatform(platform: NodeJS.Platform, fn: () => void): void {
37
+ const original = Object.getOwnPropertyDescriptor(process, "platform")
38
+
39
+ Object.defineProperty(process, "platform", { value: platform, configurable: true })
40
+
41
+ try {
42
+ fn()
43
+ } finally {
44
+ if (original) {
45
+ Object.defineProperty(process, "platform", original)
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Run `fn` with a `process.env` key temporarily overridden, restoring (or deleting) it afterwards.
52
+ */
53
+ function withEnv(key: string, value: string, fn: () => void): void {
54
+ const original = process.env[key]
55
+
56
+ process.env[key] = value
57
+
58
+ try {
59
+ fn()
60
+ } finally {
61
+ if (original === undefined) {
62
+ delete process.env[key]
63
+ } else {
64
+ process.env[key] = original
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve on the next macrotask tick so all pending promise microtasks have drained — used to assert
71
+ * that a Semaphore waiter has NOT resolved.
72
+ */
73
+ function tick(): Promise<void> {
74
+ return new Promise<void>(resolve => setTimeout(resolve, 0))
75
+ }
76
+
77
+ describe("Category N — utils.isValidPath (win32)", () => {
78
+ it("accepts an ordinary drive-rooted path", () => {
79
+ withPlatform("win32", () => {
80
+ expect(isValidPath("C:/Users/me/Documents/report.txt")).toBe(true)
81
+ })
82
+ })
83
+
84
+ it("normalizes backslashes before validating", () => {
85
+ withPlatform("win32", () => {
86
+ expect(isValidPath("C:\\Users\\me\\file.txt")).toBe(true)
87
+ })
88
+ })
89
+
90
+ it("rejects each Windows illegal char in a component", () => {
91
+ withPlatform("win32", () => {
92
+ for (const bad of ["<", ">", "\"", "|", "?", "*"]) {
93
+ expect(isValidPath(`C:/folder/in${bad}valid.txt`)).toBe(false)
94
+ }
95
+ })
96
+ })
97
+
98
+ it("rejects a colon outside the drive-letter position", () => {
99
+ withPlatform("win32", () => {
100
+ expect(isValidPath("C:/a:b/c")).toBe(false)
101
+ })
102
+ })
103
+
104
+ it("rejects reserved device names case-insensitively", () => {
105
+ withPlatform("win32", () => {
106
+ for (const name of ["con", "CON", "Prn", "aux", "NUL", "com1", "LPT9"]) {
107
+ expect(isValidPath(`C:/folder/${name}`)).toBe(false)
108
+ }
109
+ })
110
+ })
111
+
112
+ it("rejects a reserved name even when it carries an extension", () => {
113
+ withPlatform("win32", () => {
114
+ expect(isValidPath("C:/folder/con.txt")).toBe(false)
115
+ expect(isValidPath("C:/folder/com1.log")).toBe(false)
116
+ })
117
+ })
118
+
119
+ it("rejects a component that ends with a dot or a space (Windows strips them)", () => {
120
+ withPlatform("win32", () => {
121
+ // A name Windows silently strips would be created under a DIFFERENT name than recorded — an
122
+ // endless re-sync/duplication loop — so it must be reported invalid and ignored. (M5)
123
+ expect(isValidPath("C:/folder/report.")).toBe(false)
124
+ expect(isValidPath("C:/folder/report ")).toBe(false)
125
+ expect(isValidPath("C:/folder/report...")).toBe(false)
126
+ expect(isValidPath("C:/folder/report. ")).toBe(false)
127
+ // An intermediate DIRECTORY component is just as fatal as the leaf.
128
+ expect(isValidPath("C:/folder./report.txt")).toBe(false)
129
+ expect(isValidPath("C:/folder /report.txt")).toBe(false)
130
+ })
131
+ })
132
+
133
+ it("rejects an all-dots component", () => {
134
+ withPlatform("win32", () => {
135
+ expect(isValidPath("C:/folder/...")).toBe(false)
136
+ expect(isValidPath("C:/folder/..")).toBe(false)
137
+ expect(isValidPath("C:/folder/.")).toBe(false)
138
+ })
139
+ })
140
+
141
+ it("still accepts a leading-dot (hidden) name and interior dots", () => {
142
+ withPlatform("win32", () => {
143
+ // The fix strips only TRAILING dots/spaces; a leading dot (hidden files) and interior dots are fine.
144
+ expect(isValidPath("C:/folder/.gitignore")).toBe(true)
145
+ expect(isValidPath("C:/folder/my.file.txt")).toBe(true)
146
+ expect(isValidPath("C:/folder/.config/app.json")).toBe(true)
147
+ })
148
+ })
149
+ })
150
+
151
+ describe("Category N — utils.isValidPath (darwin)", () => {
152
+ it("accepts an ordinary absolute path", () => {
153
+ withPlatform("darwin", () => {
154
+ expect(isValidPath("/Users/me/Documents/file.txt")).toBe(true)
155
+ })
156
+ })
157
+
158
+ it("rejects a colon and a NUL byte", () => {
159
+ withPlatform("darwin", () => {
160
+ expect(isValidPath("/Users/me/foo:bar.txt")).toBe(false)
161
+ expect(isValidPath("/Users/me/foo\x00bar")).toBe(false)
162
+ })
163
+ })
164
+
165
+ it("permits chars that are illegal on Windows but legal on macOS", () => {
166
+ withPlatform("darwin", () => {
167
+ expect(isValidPath("/Users/me/a<b>c|d?e*.txt")).toBe(true)
168
+ })
169
+ })
170
+
171
+ it("does not treat Windows reserved names as invalid", () => {
172
+ withPlatform("darwin", () => {
173
+ expect(isValidPath("/Users/me/con")).toBe(true)
174
+ })
175
+ })
176
+
177
+ it("permits trailing dots and spaces (legal on macOS, only stripped on Windows)", () => {
178
+ withPlatform("darwin", () => {
179
+ // The trailing-dot/space rejection is a Windows-only quirk; macOS represents these names fine, so
180
+ // the M5 guard must not bleed into the darwin branch.
181
+ expect(isValidPath("/Users/me/report.")).toBe(true)
182
+ expect(isValidPath("/Users/me/report ")).toBe(true)
183
+ })
184
+ })
185
+ })
186
+
187
+ describe("Category N — utils.isValidPath (linux)", () => {
188
+ it("accepts an ordinary absolute path", () => {
189
+ withPlatform("linux", () => {
190
+ expect(isValidPath("/home/me/Documents/file.txt")).toBe(true)
191
+ })
192
+ })
193
+
194
+ it("permits a colon (only NUL is illegal on linux)", () => {
195
+ withPlatform("linux", () => {
196
+ expect(isValidPath("/home/me/foo:bar")).toBe(true)
197
+ expect(isValidPath("/home/me/a<b>:c")).toBe(true)
198
+ })
199
+ })
200
+
201
+ it("rejects a NUL byte", () => {
202
+ withPlatform("linux", () => {
203
+ expect(isValidPath("/home/me/foo\x00bar")).toBe(false)
204
+ })
205
+ })
206
+ })
207
+
208
+ describe("Category N — utils.isValidPath (unknown platform)", () => {
209
+ it("rejects everything on an unrecognized platform (default branch)", () => {
210
+ withPlatform("freebsd", () => {
211
+ expect(isValidPath("/perfectly/valid/path.txt")).toBe(false)
212
+ })
213
+ })
214
+ })
215
+
216
+ describe("Category N — utils.isPathOverMaxLength / isNameOverMaxLength", () => {
217
+ it("uses a 4096 path limit on linux (length + 1 > limit)", () => {
218
+ withPlatform("linux", () => {
219
+ expect(isPathOverMaxLength("a".repeat(4095))).toBe(false)
220
+ expect(isPathOverMaxLength("a".repeat(4096))).toBe(true)
221
+ })
222
+ })
223
+
224
+ it("uses a 1024 path limit on darwin", () => {
225
+ withPlatform("darwin", () => {
226
+ expect(isPathOverMaxLength("a".repeat(1023))).toBe(false)
227
+ expect(isPathOverMaxLength("a".repeat(1024))).toBe(true)
228
+ })
229
+ })
230
+
231
+ it("uses a 512 path limit on win32", () => {
232
+ withPlatform("win32", () => {
233
+ expect(isPathOverMaxLength("a".repeat(511))).toBe(false)
234
+ expect(isPathOverMaxLength("a".repeat(512))).toBe(true)
235
+ })
236
+ })
237
+
238
+ it("falls back to a 512 path limit on an unknown platform", () => {
239
+ withPlatform("freebsd", () => {
240
+ expect(isPathOverMaxLength("a".repeat(511))).toBe(false)
241
+ expect(isPathOverMaxLength("a".repeat(512))).toBe(true)
242
+ })
243
+ })
244
+
245
+ it("uses a 255 name limit on every platform", () => {
246
+ for (const platform of ["linux", "darwin", "win32", "freebsd"] as const) {
247
+ withPlatform(platform, () => {
248
+ expect(isNameOverMaxLength("a".repeat(254))).toBe(false)
249
+ expect(isNameOverMaxLength("a".repeat(255))).toBe(true)
250
+ })
251
+ }
252
+ })
253
+
254
+ // Multibyte semantics. "あ" is 1 UTF-16 code unit but 3 UTF-8 bytes. Linux NAME_MAX/PATH_MAX are BYTE
255
+ // limits, so the guard must count UTF-8 bytes there; macOS/Windows count UTF-16 code units for NAMES
256
+ // (verified empirically on darwin: 255 units / 765 bytes is accepted, 256 units rejected) but macOS
257
+ // PATH_MAX is a BYTE limit (a 473-unit / 1273-byte path is ENAMETOOLONG). String.length undercounting a
258
+ // multibyte name on linux is the bug: it would pass the guard and then fail at the filesystem forever.
259
+ const NAME_100_CJK = "あ".repeat(100) // 100 UTF-16 units, 300 UTF-8 bytes
260
+
261
+ it("counts NAME length in UTF-8 BYTES on linux (a 300-byte / 100-unit name is over the 255 limit)", () => {
262
+ withPlatform("linux", () => {
263
+ expect(isNameOverMaxLength(NAME_100_CJK)).toBe(true)
264
+ // A name comfortably under 255 BYTES is fine (80 CJK = 240 bytes).
265
+ expect(isNameOverMaxLength("あ".repeat(80))).toBe(false)
266
+ })
267
+ })
268
+
269
+ it("counts NAME length in UTF-16 CODE UNITS on darwin and win32 (a 300-byte / 100-unit name is fine)", () => {
270
+ for (const platform of ["darwin", "win32"] as const) {
271
+ withPlatform(platform, () => {
272
+ expect(isNameOverMaxLength(NAME_100_CJK)).toBe(false)
273
+ // 256 UTF-16 units is over regardless of byte length.
274
+ expect(isNameOverMaxLength("あ".repeat(256))).toBe(true)
275
+ })
276
+ }
277
+ })
278
+
279
+ it("counts PATH length in UTF-8 BYTES on linux and darwin, UTF-16 units on win32", () => {
280
+ // 400 CJK = 400 UTF-16 units, 1200 UTF-8 bytes.
281
+ const PATH_400_CJK = "あ".repeat(400)
282
+
283
+ // darwin: 1200 bytes > 1024 byte limit → over; the 400-unit count would be UNDER and wrongly allow it.
284
+ withPlatform("darwin", () => expect(isPathOverMaxLength(PATH_400_CJK)).toBe(true))
285
+ // linux: 1200 bytes < 4096 → fine; but 1400 CJK = 4200 bytes > 4096 → over (4096 is a BYTE limit).
286
+ withPlatform("linux", () => {
287
+ expect(isPathOverMaxLength(PATH_400_CJK)).toBe(false)
288
+ expect(isPathOverMaxLength("あ".repeat(1400))).toBe(true)
289
+ })
290
+ // win32: paths are UTF-16 code units — 400 units < 512 → fine even though it is 1200 bytes.
291
+ withPlatform("win32", () => expect(isPathOverMaxLength(PATH_400_CJK)).toBe(false))
292
+ })
293
+ })
294
+
295
+ describe("Category N — utils.convertTimestampToMs", () => {
296
+ it("passes a value already in milliseconds through unchanged", () => {
297
+ const nowMs = Date.now()
298
+
299
+ expect(convertTimestampToMs(nowMs)).toBe(nowMs)
300
+ // Fixed golden well above the seconds/ms crossover.
301
+ expect(convertTimestampToMs(1_700_000_000_000)).toBe(1_700_000_000_000)
302
+ })
303
+
304
+ it("multiplies a value expressed in seconds by 1000", () => {
305
+ const nowSec = Math.floor(Date.now() / 1000)
306
+
307
+ expect(convertTimestampToMs(nowSec)).toBe(nowSec * 1000)
308
+ // Fixed golden well below the crossover (year 2001 in seconds).
309
+ expect(convertTimestampToMs(1_000_000_000)).toBe(1_000_000_000_000)
310
+ })
311
+ })
312
+
313
+ describe("Category N — utils.normalizeUTime", () => {
314
+ it("returns integers unchanged", () => {
315
+ expect(normalizeUTime(1234)).toBe(1234)
316
+ expect(normalizeUTime(0)).toBe(0)
317
+ })
318
+
319
+ it("floors non-integers (towards negative infinity, not truncation)", () => {
320
+ expect(normalizeUTime(1234.99)).toBe(1234)
321
+ expect(normalizeUTime(-3.2)).toBe(-4)
322
+ })
323
+ })
324
+
325
+ describe("Category N — utils.normalizeLastModifiedMsForComparison", () => {
326
+ it("floors milliseconds down to whole seconds", () => {
327
+ expect(normalizeLastModifiedMsForComparison(1500)).toBe(1)
328
+ expect(normalizeLastModifiedMsForComparison(1999)).toBe(1)
329
+ expect(normalizeLastModifiedMsForComparison(2000)).toBe(2)
330
+ expect(normalizeLastModifiedMsForComparison(0)).toBe(0)
331
+ })
332
+
333
+ it("collapses two times within the same second to the same value", () => {
334
+ expect(normalizeLastModifiedMsForComparison(1000)).toBe(normalizeLastModifiedMsForComparison(1999))
335
+ })
336
+ })
337
+
338
+ describe("Category N — utils.pathIncludesDotFile", () => {
339
+ it("detects a dot-prefixed component at any depth", () => {
340
+ expect(pathIncludesDotFile("/a/.b/c")).toBe(true)
341
+ expect(pathIncludesDotFile("/a/b/.c/d")).toBe(true)
342
+ expect(pathIncludesDotFile("/a/b/c/.last")).toBe(true)
343
+ expect(pathIncludesDotFile(".hidden")).toBe(true)
344
+ })
345
+
346
+ it("returns false when no component is dot-prefixed", () => {
347
+ expect(pathIncludesDotFile("/a/b/c")).toBe(false)
348
+ // A dot inside a component (an extension) is not a dot file.
349
+ expect(pathIncludesDotFile("/normal/path.txt")).toBe(false)
350
+ expect(pathIncludesDotFile("")).toBe(false)
351
+ })
352
+ })
353
+
354
+ describe("Category N — utils default-ignore helpers", () => {
355
+ it("ignores OS-junk names case-insensitively", () => {
356
+ expect(isNameIgnoredByDefault(".DS_Store")).toBe(true)
357
+ expect(isNameIgnoredByDefault(".ds_store")).toBe(true)
358
+ expect(isNameIgnoredByDefault("thumbs.db")).toBe(true)
359
+ expect(isNameIgnoredByDefault("desktop.ini")).toBe(true)
360
+ })
361
+
362
+ it("ignores names by lock/owner prefix and ignored extensions", () => {
363
+ expect(isNameIgnoredByDefault(".~lock.document.odt#")).toBe(true)
364
+ expect(isNameIgnoredByDefault("~$report.docx")).toBe(true)
365
+ expect(isNameIgnoredByDefault("scratch.tmp")).toBe(true)
366
+ // Extension match is case-insensitive.
367
+ expect(isNameIgnoredByDefault("Scratch.TMP")).toBe(true)
368
+ })
369
+
370
+ it("ignores empty / whitespace-only names", () => {
371
+ expect(isNameIgnoredByDefault("")).toBe(true)
372
+ expect(isNameIgnoredByDefault(" ")).toBe(true)
373
+ })
374
+
375
+ it("does NOT ignore an ordinary name", () => {
376
+ expect(isNameIgnoredByDefault("report.txt")).toBe(false)
377
+ expect(isNameIgnoredByDefault("photo.jpg")).toBe(false)
378
+ })
379
+
380
+ it("ignores a relative path containing an ignored component or matching a glob", () => {
381
+ expect(isRelativePathIgnoredByDefault("foo/.DS_Store")).toBe(true)
382
+ expect(isRelativePathIgnoredByDefault("a/b/thumbs.db")).toBe(true)
383
+ expect(isRelativePathIgnoredByDefault(".filen.trash.local/sub/file.txt")).toBe(true)
384
+ })
385
+
386
+ it("does NOT ignore an ordinary relative path", () => {
387
+ expect(isRelativePathIgnoredByDefault("documents/work/report.txt")).toBe(false)
388
+ })
389
+
390
+ it("ignores absolute Windows system paths via globs", () => {
391
+ expect(isAbsolutePathIgnoredByDefault("C:/Program Files/app/x.dll")).toBe(true)
392
+ expect(isAbsolutePathIgnoredByDefault("D:/$RECYCLE.BIN/S-1-5/desktop.ini")).toBe(true)
393
+ })
394
+
395
+ it("does NOT ignore an ordinary absolute path", () => {
396
+ expect(isAbsolutePathIgnoredByDefault("/home/user/documents/file.txt")).toBe(false)
397
+ expect(isAbsolutePathIgnoredByDefault("C:/Users/me/file.txt")).toBe(false)
398
+ })
399
+ })
400
+
401
+ describe("Category N — utils.serializeError / deserializeError", () => {
402
+ it("captures exactly name, message, stack and stringified", () => {
403
+ const error = new Error("something failed")
404
+ const serialized = serializeError(error)
405
+
406
+ expect(serialized.name).toBe("Error")
407
+ expect(serialized.message).toBe("something failed")
408
+ expect(serialized.stack).toBe(error.stack)
409
+ expect(serialized.stringified).toBe("Error: something failed")
410
+ expect(Object.keys(serialized).sort()).toEqual(["message", "name", "stack", "stringified"])
411
+ })
412
+
413
+ it("round-trips name, message and stack through deserializeError", () => {
414
+ const error = new TypeError("bad type")
415
+ const restored = deserializeError(serializeError(error))
416
+
417
+ expect(restored).toBeInstanceOf(Error)
418
+ expect(restored.name).toBe("TypeError")
419
+ expect(restored.message).toBe("bad type")
420
+ expect(restored.stack).toBe(error.stack)
421
+ })
422
+
423
+ it("does not carry a `code` property across the round-trip (display-oriented contract)", () => {
424
+ const error = Object.assign(new Error("missing file"), { code: "ENOENT" })
425
+ const serialized = serializeError(error)
426
+
427
+ // `SerializedError` deliberately has no `code` field, so name/message survive but `code` does not.
428
+ expect(serialized.name).toBe("Error")
429
+ expect(serialized.message).toBe("missing file")
430
+ expect("code" in serialized).toBe(false)
431
+
432
+ const restored = deserializeError(serialized) as Error & { code?: string }
433
+
434
+ expect(restored.message).toBe("missing file")
435
+ expect(restored.code).toBeUndefined()
436
+ })
437
+ })
438
+
439
+ describe("Category N — utils.fastHash", () => {
440
+ it("is deterministic for identical input", () => {
441
+ expect(fastHash("the quick brown fox")).toBe(fastHash("the quick brown fox"))
442
+ })
443
+
444
+ it("produces different digests for different input", () => {
445
+ expect(fastHash("a")).not.toBe(fastHash("b"))
446
+ })
447
+
448
+ it("matches the well-known md5 digests", () => {
449
+ expect(fastHash("hello")).toBe("5d41402abc4b2a76b9719d911017c592")
450
+ expect(fastHash("")).toBe("d41d8cd98f00b204e9800998ecf8427e")
451
+ expect(fastHash("input")).toMatch(/^[0-9a-f]{32}$/)
452
+ })
453
+ })
454
+
455
+ describe("Category N — utils.tryingToSyncDesktop", () => {
456
+ it("matches the user's Desktop path on darwin (case-insensitive, trailing slash tolerant)", () => {
457
+ withPlatform("darwin", () => {
458
+ withEnv("USER", "alice", () => {
459
+ expect(tryingToSyncDesktop("/Users/alice/Desktop")).toBe(true)
460
+ expect(tryingToSyncDesktop("/Users/alice/Desktop/")).toBe(true)
461
+ expect(tryingToSyncDesktop(" /users/alice/desktop ")).toBe(true)
462
+ })
463
+ })
464
+ })
465
+
466
+ it("does not match a non-Desktop path on darwin", () => {
467
+ withPlatform("darwin", () => {
468
+ withEnv("USER", "alice", () => {
469
+ expect(tryingToSyncDesktop("/Users/alice/Documents")).toBe(false)
470
+ expect(tryingToSyncDesktop("/Users/bob/Desktop")).toBe(false)
471
+ })
472
+ })
473
+ })
474
+
475
+ it("always returns false on non-darwin platforms", () => {
476
+ withEnv("USER", "alice", () => {
477
+ withPlatform("linux", () => {
478
+ expect(tryingToSyncDesktop("/Users/alice/Desktop")).toBe(false)
479
+ })
480
+ withPlatform("win32", () => {
481
+ expect(tryingToSyncDesktop("/Users/alice/Desktop")).toBe(false)
482
+ })
483
+ })
484
+ })
485
+ })
486
+
487
+ describe("Category N — utils.replacePathStartWithFromAndTo (edge cases)", () => {
488
+ it("maps the root onto the new root when from === path", () => {
489
+ expect(replacePathStartWithFromAndTo("/foo/bar", "/foo/bar", "/baz/qux")).toBe("/baz/qux")
490
+ })
491
+
492
+ it("strips a trailing slash on the path before reparenting", () => {
493
+ expect(replacePathStartWithFromAndTo("/foo/bar/", "/foo", "/baz")).toBe("/baz/bar")
494
+ })
495
+
496
+ it("strips trailing slashes on from and to", () => {
497
+ expect(replacePathStartWithFromAndTo("/foo/bar", "/foo/", "/baz/")).toBe("/baz/bar")
498
+ })
499
+
500
+ it("normalizes missing leading slashes on all three inputs", () => {
501
+ expect(replacePathStartWithFromAndTo("foo/bar", "foo", "baz")).toBe("/baz/bar")
502
+ })
503
+
504
+ it("supports reparenting onto a deeper destination", () => {
505
+ expect(replacePathStartWithFromAndTo("/a/b/c", "/a", "/x/y/z")).toBe("/x/y/z/b/c")
506
+ })
507
+ })
508
+
509
+ describe("Category N — Semaphore", () => {
510
+ it("starts empty and reports its counter via count()", async () => {
511
+ const sem = new Semaphore(1)
512
+
513
+ expect(sem.count()).toBe(0)
514
+
515
+ await sem.acquire()
516
+
517
+ expect(sem.count()).toBe(1)
518
+ })
519
+
520
+ it("serializes acquisition with max = 1 until release", async () => {
521
+ const sem = new Semaphore(1)
522
+
523
+ await sem.acquire()
524
+
525
+ let secondAcquired = false
526
+ const second = sem.acquire().then(() => {
527
+ secondAcquired = true
528
+ })
529
+
530
+ await tick()
531
+
532
+ // The second acquire is blocked behind the first holder.
533
+ expect(secondAcquired).toBe(false)
534
+ expect(sem.count()).toBe(1)
535
+
536
+ sem.release()
537
+ await second
538
+
539
+ expect(secondAcquired).toBe(true)
540
+ expect(sem.count()).toBe(1)
541
+
542
+ sem.release()
543
+
544
+ expect(sem.count()).toBe(0)
545
+ })
546
+
547
+ it("ignores release() when nothing is held", () => {
548
+ const sem = new Semaphore(1)
549
+
550
+ expect(() => sem.release()).not.toThrow()
551
+ expect(sem.count()).toBe(0)
552
+ })
553
+
554
+ it("setMax raises concurrency and unblocks a waiter", async () => {
555
+ const sem = new Semaphore(1)
556
+
557
+ await sem.acquire()
558
+
559
+ let secondAcquired = false
560
+ const second = sem.acquire().then(() => {
561
+ secondAcquired = true
562
+ })
563
+
564
+ await tick()
565
+
566
+ expect(secondAcquired).toBe(false)
567
+
568
+ sem.setMax(2)
569
+ await second
570
+
571
+ expect(secondAcquired).toBe(true)
572
+ expect(sem.count()).toBe(2)
573
+ })
574
+
575
+ it("purge() rejects all waiters, returns their count and resets the counter", async () => {
576
+ const sem = new Semaphore(1)
577
+
578
+ await sem.acquire()
579
+
580
+ const first = sem.acquire()
581
+ const second = sem.acquire()
582
+ const firstRejection = expect(first).rejects.toBe("Task has been purged")
583
+ const secondRejection = expect(second).rejects.toBe("Task has been purged")
584
+
585
+ const purged = sem.purge()
586
+
587
+ expect(purged).toBe(2)
588
+ expect(sem.count()).toBe(0)
589
+
590
+ await firstRejection
591
+ await secondRejection
592
+ })
593
+
594
+ it("purge() with no waiters returns 0 but still resets the counter", async () => {
595
+ const sem = new Semaphore(1)
596
+
597
+ await sem.acquire()
598
+
599
+ expect(sem.count()).toBe(1)
600
+ expect(sem.purge()).toBe(0)
601
+ expect(sem.count()).toBe(0)
602
+ })
603
+ })
604
+
605
+ describe("Category N — Logger (disabled)", () => {
606
+ it("constructs and init()s without creating a stream or throwing", async () => {
607
+ const logger = new Logger(true)
608
+ const internal = logger as unknown as { logger: unknown; dest: unknown }
609
+
610
+ // The disabled branch short-circuits before touching the filesystem.
611
+ expect(internal.logger).toBeNull()
612
+ expect(internal.dest).toBeNull()
613
+
614
+ await expect(logger.init()).resolves.toBeUndefined()
615
+
616
+ expect(internal.logger).toBeNull()
617
+ expect(internal.dest).toBeNull()
618
+ })
619
+
620
+ it("treats every log() call as a no-op that never throws", () => {
621
+ const logger = new Logger(true)
622
+
623
+ expect(() => {
624
+ logger.log("info", "a string")
625
+ logger.log("debug", { nested: { value: 1 } })
626
+ logger.log("warn", 42, "where")
627
+ logger.log("error", new Error("ignored"))
628
+ logger.log("trace")
629
+ logger.log("fatal", "boom")
630
+ }).not.toThrow()
631
+ })
632
+ })