@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.
- package/README.md +141 -36
- package/lib/_chunks/reactive-devtools-BCpGoGZ5.js +280 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +16 -173
- package/lib/lpih.js +177 -0
- package/lib/types/index.d.ts +116 -2
- package/lib/types/lpih.d.ts +111 -0
- package/package.json +6 -1
- package/src/computed.ts +47 -6
- package/src/effect.ts +33 -4
- package/src/index.ts +8 -0
- package/src/lpih.ts +227 -0
- package/src/reactive-devtools.ts +213 -0
- package/src/signal.ts +23 -3
- package/src/tests/lpih-source-location.test.ts +277 -0
- package/src/tests/lpih.test.ts +351 -0
|
@@ -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
|
+
})
|