@pyreon/reactivity 0.22.0 → 0.24.0

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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * LPIH runtime bridge — `writeLpihCache` + `startLpihPolling` tests.
3
+ *
4
+ * Proves the filesystem cache contract:
5
+ * 1. Atomic write (tmp + rename, never half-written)
6
+ * 2. Empty payload when devtools inactive
7
+ * 3. Real fire data when devtools active + signals firing
8
+ * 4. Polling helper writes repeatedly + disposer stops it
9
+ */
10
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
14
+ import { computed } from '../computed'
15
+ import { effect } from '../effect'
16
+ import {
17
+ LPIH_DEFAULT_FILENAME,
18
+ getDefaultLpihCachePath,
19
+ startLpihPolling,
20
+ writeLpihCache,
21
+ } from '../lpih'
22
+ import {
23
+ activateReactiveDevtools,
24
+ deactivateReactiveDevtools,
25
+ } from '../reactive-devtools'
26
+ import { signal } from '../signal'
27
+
28
+ let TMP_DIR: string
29
+
30
+ beforeAll(() => {
31
+ TMP_DIR = mkdtempSync(join(tmpdir(), 'lpih-test-'))
32
+ })
33
+
34
+ afterAll(() => {
35
+ rmSync(TMP_DIR, { recursive: true, force: true })
36
+ })
37
+
38
+ beforeEach(() => {
39
+ deactivateReactiveDevtools()
40
+ })
41
+
42
+ afterEach(() => {
43
+ deactivateReactiveDevtools()
44
+ })
45
+
46
+ const readCache = (path: string): { fires: unknown[] } =>
47
+ JSON.parse(readFileSync(path, 'utf8')) as { fires: unknown[] }
48
+
49
+ describe('writeLpihCache', () => {
50
+ it('writes an empty payload when devtools is inactive', async () => {
51
+ const path = join(TMP_DIR, 'inactive.json')
52
+ const count = await writeLpihCache(path)
53
+ expect(count).toBe(0)
54
+ const parsed = readCache(path)
55
+ expect(parsed.fires).toEqual([])
56
+ })
57
+
58
+ it('writes real fire data when devtools is active', async () => {
59
+ activateReactiveDevtools()
60
+ const s = signal(0)
61
+ s.set(1)
62
+ s.set(2)
63
+ s.set(3)
64
+ const path = join(TMP_DIR, 'active.json')
65
+ const count = await writeLpihCache(path)
66
+ expect(count).toBe(1) // one source location
67
+ const parsed = readCache(path) as {
68
+ fires: Array<{ file: string; line: number; count: number; kind: string }>
69
+ }
70
+ expect(parsed.fires).toHaveLength(1)
71
+ expect(parsed.fires[0]?.count).toBe(3)
72
+ expect(parsed.fires[0]?.kind).toBe('signal')
73
+ expect(parsed.fires[0]?.file).toContain('lpih.test.ts')
74
+ void s
75
+ })
76
+
77
+ it('captures signal + computed + effect locations', async () => {
78
+ activateReactiveDevtools()
79
+ const s = signal(0)
80
+ const c = computed(() => s() * 2)
81
+ const e = effect(() => {
82
+ c()
83
+ })
84
+ s.set(5)
85
+ s.set(10)
86
+ const path = join(TMP_DIR, 'multi.json')
87
+ await writeLpihCache(path)
88
+ const parsed = readCache(path) as {
89
+ fires: Array<{ kind: string; count: number }>
90
+ }
91
+ const kinds = new Set(parsed.fires.map((f) => f.kind))
92
+ expect(kinds.has('signal')).toBe(true)
93
+ expect(kinds.has('effect')).toBe(true)
94
+ // derived may show up as 'native:1' under bun's compiled inline callbacks
95
+ e.dispose()
96
+ })
97
+
98
+ it('overwrites existing file (atomic rename semantics)', async () => {
99
+ activateReactiveDevtools()
100
+ const s = signal(0)
101
+ const path = join(TMP_DIR, 'overwrite.json')
102
+
103
+ s.set(1)
104
+ await writeLpihCache(path)
105
+ const before = readCache(path) as { fires: Array<{ count: number }> }
106
+ expect(before.fires[0]?.count).toBe(1)
107
+
108
+ s.set(2)
109
+ s.set(3)
110
+ await writeLpihCache(path)
111
+ const after = readCache(path) as { fires: Array<{ count: number }> }
112
+ expect(after.fires[0]?.count).toBe(3) // updated count
113
+ void s
114
+ })
115
+
116
+ it('leaves no tmp files after successful write', async () => {
117
+ activateReactiveDevtools()
118
+ const s = signal(0)
119
+ s.set(1)
120
+ const path = join(TMP_DIR, 'no-tmp.json')
121
+ await writeLpihCache(path)
122
+ const fs = await import('node:fs/promises')
123
+ const files = await fs.readdir(TMP_DIR)
124
+ const tmpFiles = files.filter((f) => f.includes('.tmp.'))
125
+ expect(tmpFiles).toEqual([]) // tmp file should be renamed away
126
+ void s
127
+ })
128
+ })
129
+
130
+ describe('writeLpihCache — defensive cleanup', () => {
131
+ it('cleans up tmp file when rename fails (target is a directory)', async () => {
132
+ activateReactiveDevtools()
133
+ const s = signal(0)
134
+ s.set(1)
135
+ // Build a path whose target IS a directory — rename onto a directory
136
+ // throws EISDIR (POSIX) / EPERM (Windows). The tmp file should be
137
+ // unlinked even though writeLpihCache rethrows.
138
+ const targetDir = join(TMP_DIR, 'target-as-dir')
139
+ const fs = await import('node:fs/promises')
140
+ await fs.mkdir(targetDir, { recursive: true })
141
+
142
+ await expect(writeLpihCache(targetDir)).rejects.toBeDefined()
143
+
144
+ // No tmp file should be left behind in TMP_DIR.
145
+ const files = await fs.readdir(TMP_DIR)
146
+ const tmpFiles = files.filter(
147
+ (f) => f.startsWith('target-as-dir.tmp.') || f.includes('.tmp.'),
148
+ )
149
+ expect(tmpFiles).toEqual([])
150
+ void s
151
+ })
152
+
153
+ it('cleans up tmp file even when unlink fallback fails silently', async () => {
154
+ // This is the harder case: we want to assert the cleanup runs without
155
+ // letting an unlink-failure leak as the user-visible error. We can't
156
+ // easily make unlink fail in a portable way, but the code path is
157
+ // covered by the try/catch — verify the behavior: rejecting with the
158
+ // ORIGINAL rename error, never the unlink one.
159
+ activateReactiveDevtools()
160
+ const s = signal(0)
161
+ s.set(1)
162
+ const targetDir = join(TMP_DIR, 'silent-unlink')
163
+ const fs = await import('node:fs/promises')
164
+ await fs.mkdir(targetDir, { recursive: true })
165
+ try {
166
+ await writeLpihCache(targetDir)
167
+ expect.fail('should have rejected')
168
+ } catch (err) {
169
+ // The error message should reference the rename failure, NOT the
170
+ // unlink fallback — proof we prioritize the original error.
171
+ const code = (err as { code?: string }).code
172
+ expect(['EISDIR', 'EPERM', 'EACCES']).toContain(code)
173
+ }
174
+ void s
175
+ })
176
+ })
177
+
178
+ describe('startLpihPolling', () => {
179
+ it('timer is unref()d so polling does not block process exit', async () => {
180
+ activateReactiveDevtools()
181
+ const path = join(TMP_DIR, 'unref-check.json')
182
+ const dispose = startLpihPolling(path, 50)
183
+ // Wait one tick so the timer is actually scheduled.
184
+ await new Promise((r) => setTimeout(r, 100))
185
+ // Indirect check: the polling timer is internal, but we can verify
186
+ // the disposer stops cleanly without hanging. If unref() weren't
187
+ // applied, this test could still pass (since vitest holds the loop
188
+ // open) — the bisect test is "test runs at all without timing out",
189
+ // which is the load-bearing behavior.
190
+ dispose()
191
+ // After dispose, an in-flight async write may still be completing.
192
+ // Wait long enough for that to settle BEFORE the stat1 baseline,
193
+ // otherwise stat1 captures a stale mtime and stat2 sees the late
194
+ // write, racing the assertion. Slower CI runners trip this without
195
+ // the buffer.
196
+ const fs = await import('node:fs/promises')
197
+ await new Promise((r) => setTimeout(r, 100))
198
+ const stat1 = await fs.stat(path)
199
+ // Verify no NEW writes happen post-dispose + post-flush.
200
+ await new Promise((r) => setTimeout(r, 200))
201
+ const stat2 = await fs.stat(path)
202
+ expect(stat2.mtimeMs).toBe(stat1.mtimeMs)
203
+ })
204
+
205
+ it('writes repeatedly + disposer stops it', async () => {
206
+ activateReactiveDevtools()
207
+ const s = signal(0)
208
+ const path = join(TMP_DIR, 'polling.json')
209
+ const dispose = startLpihPolling(path, 50)
210
+
211
+ // Wait for at least 3 polls (150ms expected).
212
+ await new Promise((r) => setTimeout(r, 200))
213
+ s.set(1)
214
+ s.set(2)
215
+ await new Promise((r) => setTimeout(r, 100))
216
+
217
+ dispose()
218
+ const fs = await import('node:fs/promises')
219
+ const before = await fs.stat(path)
220
+
221
+ // After dispose, no new writes for 200ms.
222
+ await new Promise((r) => setTimeout(r, 200))
223
+ const after = await fs.stat(path)
224
+ expect(after.mtimeMs).toBe(before.mtimeMs) // mtime unchanged after dispose
225
+ void s
226
+ })
227
+ })
228
+
229
+ describe('getDefaultLpihCachePath', () => {
230
+ it('returns <cwd>/.pyreon-lpih.json when process.cwd is available', () => {
231
+ const out = getDefaultLpihCachePath()
232
+ expect(out).toBeTruthy()
233
+ expect(out).toContain(LPIH_DEFAULT_FILENAME)
234
+ // Should be a real cwd-rooted path
235
+ expect(out?.startsWith('/') || /^[A-Za-z]:/.test(out ?? '')).toBe(true)
236
+ })
237
+
238
+ it('exposes the canonical filename constant', () => {
239
+ expect(LPIH_DEFAULT_FILENAME).toBe('.pyreon-lpih.json')
240
+ })
241
+
242
+ it('returns null when process.cwd is unavailable (web worker fallback)', () => {
243
+ // Temporarily shadow process.cwd to simulate browser/worker.
244
+ // Cast `process` since the package's narrow type omits .cwd.
245
+ const proc = process as unknown as { cwd?: (() => string) | undefined }
246
+ const realCwd = proc.cwd
247
+ try {
248
+ delete proc.cwd
249
+ const out = getDefaultLpihCachePath()
250
+ expect(out).toBeNull()
251
+ } finally {
252
+ proc.cwd = realCwd
253
+ }
254
+ })
255
+ })
256
+
257
+ describe('writeLpihCache / startLpihPolling — default path resolution', () => {
258
+ it('writeLpihCache() with no arg writes to <cwd>/.pyreon-lpih.json', async () => {
259
+ // Move cwd to TMP_DIR so the default-path write lands somewhere
260
+ // we control + can clean up.
261
+ const fs = await import('node:fs/promises')
262
+ const proc = process as unknown as {
263
+ cwd(): string
264
+ chdir(p: string): void
265
+ }
266
+ const originalCwd = proc.cwd()
267
+ proc.chdir(TMP_DIR)
268
+ activateReactiveDevtools()
269
+ const s = signal(0)
270
+ s.set(1)
271
+ try {
272
+ const count = await writeLpihCache()
273
+ expect(count).toBe(1)
274
+ // Verify the file landed at the cwd-relative default location.
275
+ const stat = await fs.stat(join(TMP_DIR, LPIH_DEFAULT_FILENAME))
276
+ expect(stat.isFile()).toBe(true)
277
+ } finally {
278
+ proc.chdir(originalCwd)
279
+ // Best-effort cleanup
280
+ try {
281
+ await fs.unlink(join(TMP_DIR, LPIH_DEFAULT_FILENAME))
282
+ } catch {
283
+ /* */
284
+ }
285
+ }
286
+ void s
287
+ })
288
+
289
+ it('writeLpihCache(explicitPath) still honors the explicit path', async () => {
290
+ const fs = await import('node:fs/promises')
291
+ activateReactiveDevtools()
292
+ const s = signal(0)
293
+ s.set(1)
294
+ const explicit = join(TMP_DIR, 'explicit-override.json')
295
+ const count = await writeLpihCache(explicit)
296
+ expect(count).toBe(1)
297
+ const stat = await fs.stat(explicit)
298
+ expect(stat.isFile()).toBe(true)
299
+ void s
300
+ })
301
+
302
+ it('startLpihPolling() with no arg uses the default path', async () => {
303
+ const fs = await import('node:fs/promises')
304
+ const proc = process as unknown as {
305
+ cwd(): string
306
+ chdir(p: string): void
307
+ }
308
+ const originalCwd = proc.cwd()
309
+ proc.chdir(TMP_DIR)
310
+ activateReactiveDevtools()
311
+ const s = signal(0)
312
+ s.set(1)
313
+ try {
314
+ const dispose = startLpihPolling(undefined, 50)
315
+ await new Promise((r) => setTimeout(r, 150))
316
+ dispose()
317
+ const stat = await fs.stat(join(TMP_DIR, LPIH_DEFAULT_FILENAME))
318
+ expect(stat.isFile()).toBe(true)
319
+ } finally {
320
+ proc.chdir(originalCwd)
321
+ try {
322
+ await fs.unlink(join(TMP_DIR, LPIH_DEFAULT_FILENAME))
323
+ } catch {
324
+ /* */
325
+ }
326
+ }
327
+ void s
328
+ })
329
+
330
+ it('startLpihPolling() throws synchronously when no default + no path', () => {
331
+ const proc = process as unknown as { cwd?: (() => string) | undefined }
332
+ const realCwd = proc.cwd
333
+ try {
334
+ delete proc.cwd
335
+ expect(() => startLpihPolling()).toThrow(/no path provided/)
336
+ } finally {
337
+ proc.cwd = realCwd
338
+ }
339
+ })
340
+
341
+ it('writeLpihCache() rejects when no default + no path', async () => {
342
+ const proc = process as unknown as { cwd?: (() => string) | undefined }
343
+ const realCwd = proc.cwd
344
+ try {
345
+ delete proc.cwd
346
+ await expect(writeLpihCache()).rejects.toThrow(/no path provided/)
347
+ } finally {
348
+ proc.cwd = realCwd
349
+ }
350
+ })
351
+ })