@pyreon/reactivity 0.24.4 → 0.24.6

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 (44) hide show
  1. package/package.json +1 -4
  2. package/src/batch.ts +0 -196
  3. package/src/cell.ts +0 -72
  4. package/src/computed.ts +0 -313
  5. package/src/createSelector.ts +0 -109
  6. package/src/debug.ts +0 -134
  7. package/src/effect.ts +0 -467
  8. package/src/env.d.ts +0 -6
  9. package/src/index.ts +0 -60
  10. package/src/lpih.ts +0 -227
  11. package/src/manifest.ts +0 -660
  12. package/src/reactive-devtools.ts +0 -494
  13. package/src/reactive-trace.ts +0 -142
  14. package/src/reconcile.ts +0 -118
  15. package/src/resource.ts +0 -84
  16. package/src/scope.ts +0 -123
  17. package/src/signal.ts +0 -261
  18. package/src/store.ts +0 -250
  19. package/src/tests/batch.test.ts +0 -751
  20. package/src/tests/bind.test.ts +0 -84
  21. package/src/tests/branches.test.ts +0 -343
  22. package/src/tests/cell.test.ts +0 -159
  23. package/src/tests/computed.test.ts +0 -436
  24. package/src/tests/coverage-hardening.test.ts +0 -471
  25. package/src/tests/createSelector.test.ts +0 -291
  26. package/src/tests/debug.test.ts +0 -196
  27. package/src/tests/effect.test.ts +0 -464
  28. package/src/tests/fanout-repro.test.ts +0 -179
  29. package/src/tests/lpih-source-location.test.ts +0 -277
  30. package/src/tests/lpih.test.ts +0 -351
  31. package/src/tests/manifest-snapshot.test.ts +0 -96
  32. package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
  33. package/src/tests/reactive-devtools.test.ts +0 -296
  34. package/src/tests/reactive-trace.test.ts +0 -102
  35. package/src/tests/reconcile-security.test.ts +0 -45
  36. package/src/tests/resource.test.ts +0 -326
  37. package/src/tests/scope.test.ts +0 -231
  38. package/src/tests/signal.test.ts +0 -368
  39. package/src/tests/store.test.ts +0 -286
  40. package/src/tests/tracking.test.ts +0 -158
  41. package/src/tests/vue-parity.test.ts +0 -191
  42. package/src/tests/watch.test.ts +0 -246
  43. package/src/tracking.ts +0 -139
  44. package/src/watch.ts +0 -68
@@ -1,277 +0,0 @@
1
- /**
2
- * Live Program Inlay Hints — source-location capture for signal/computed/effect.
3
- *
4
- * Validates that:
5
- * 1. When devtools is INACTIVE, no stack capture happens (zero cost).
6
- * 2. When devtools is ACTIVE, every reactive creation captures the
7
- * USER's call site (not the framework's internal frames).
8
- * 3. `getFireSummaries()` aggregates fires by location with the right
9
- * shape (count, lastFire, kind) and dedupes multiple nodes at the
10
- * same location.
11
- * 4. Stack-line parsing handles V8, JSC, and Firefox formats.
12
- * 5. `loc` field on `ReactiveNode` is surfaced via `getReactiveGraph()`.
13
- */
14
- import { afterEach, describe, expect, it } from 'vitest'
15
- import { computed } from '../computed'
16
- import { effect } from '../effect'
17
- import {
18
- _captureCallerLocation,
19
- _parseStackLine,
20
- activateReactiveDevtools,
21
- deactivateReactiveDevtools,
22
- getFireSummaries,
23
- getReactiveGraph,
24
- } from '../reactive-devtools'
25
- import { signal } from '../signal'
26
-
27
- afterEach(() => {
28
- deactivateReactiveDevtools()
29
- })
30
-
31
- describe('LPIH — stack-line parser', () => {
32
- it('parses V8 parenthesized form', () => {
33
- const loc = _parseStackLine(
34
- ' at userCode (/Users/test/app.ts:42:7)',
35
- )
36
- expect(loc).toEqual({ file: '/Users/test/app.ts', line: 42, col: 7 })
37
- })
38
-
39
- it('parses V8 anonymous form', () => {
40
- const loc = _parseStackLine(' at /Users/test/app.ts:42:7')
41
- expect(loc).toEqual({ file: '/Users/test/app.ts', line: 42, col: 7 })
42
- })
43
-
44
- it('parses JSC / SpiderMonkey form', () => {
45
- const loc = _parseStackLine('userCode@/Users/test/app.ts:42:7')
46
- expect(loc).toEqual({ file: '/Users/test/app.ts', line: 42, col: 7 })
47
- })
48
-
49
- it('returns undefined for unparseable lines', () => {
50
- expect(_parseStackLine('garbage')).toBeUndefined()
51
- expect(_parseStackLine('')).toBeUndefined()
52
- })
53
-
54
- it('handles file paths with colons (Windows-like or URL schemes)', () => {
55
- const loc = _parseStackLine(
56
- ' at userCode (file:///Users/test/app.ts:42:7)',
57
- )
58
- // The regex captures the LAST ":line:col" pair — `file` includes any
59
- // earlier colons (file:/// prefix preserved). Editors handle that.
60
- expect(loc?.line).toBe(42)
61
- expect(loc?.col).toBe(7)
62
- expect(loc?.file).toContain('app.ts')
63
- })
64
- })
65
-
66
- describe('LPIH — zero-cost when inactive', () => {
67
- it('_captureCallerLocation returns undefined when inactive', () => {
68
- expect(_captureCallerLocation(0)).toBeUndefined()
69
- expect(_captureCallerLocation(5)).toBeUndefined()
70
- })
71
-
72
- it('node creation does not allocate Error when inactive', () => {
73
- // Indirect proof: capture happens INSIDE the active guard.
74
- // No throw, no stack, no observable cost in the path.
75
- const s = signal(0)
76
- s.set(1)
77
- const c = computed(() => s() + 1)
78
- c()
79
- // No nodes registered because inactive.
80
- expect(getReactiveGraph().nodes).toEqual([])
81
- })
82
- })
83
-
84
- describe('LPIH — __sourceLocation option (R4 build-time injection)', () => {
85
- it('signal() prefers __sourceLocation over stack capture', () => {
86
- activateReactiveDevtools()
87
- // Simulate what @pyreon/vite-plugin's injectSignalNames emits:
88
- const injected = { file: '/some/build/path.tsx', line: 99, col: 42 }
89
- const s = signal(0, { name: 'test', __sourceLocation: injected })
90
- const nodes = getReactiveGraph().nodes
91
- expect(nodes).toHaveLength(1)
92
- // The captured location is the INJECTED one, not the test file's location.
93
- expect(nodes[0]?.loc?.file).toBe('/some/build/path.tsx')
94
- expect(nodes[0]?.loc?.line).toBe(99)
95
- expect(nodes[0]?.loc?.col).toBe(42)
96
- void s
97
- })
98
-
99
- it('signal() falls back to stack capture when __sourceLocation is absent', () => {
100
- activateReactiveDevtools()
101
- const s = signal(0)
102
- const nodes = getReactiveGraph().nodes
103
- expect(nodes).toHaveLength(1)
104
- // Stack capture → location is the test file.
105
- expect(nodes[0]?.loc?.file).toContain('lpih-source-location.test.ts')
106
- void s
107
- })
108
- })
109
-
110
- describe('LPIH — source-location capture for signals', () => {
111
- it('captures the user call site for signal()', () => {
112
- activateReactiveDevtools()
113
- const s = signal(0) // ← this line
114
- const nodes = getReactiveGraph().nodes
115
- expect(nodes).toHaveLength(1)
116
- expect(nodes[0]?.loc).toBeDefined()
117
- expect(nodes[0]?.loc?.file).toContain('lpih-source-location.test.ts')
118
- // The line number should point at THIS source file, not signal.ts internals.
119
- expect(nodes[0]?.loc?.line).toBeGreaterThan(0)
120
- void s
121
- })
122
-
123
- it('different signals get different locations', () => {
124
- activateReactiveDevtools()
125
- const a = signal(0)
126
- const b = signal(0)
127
- const nodes = getReactiveGraph().nodes
128
- expect(nodes).toHaveLength(2)
129
- const lineA = nodes[0]?.loc?.line
130
- const lineB = nodes[1]?.loc?.line
131
- expect(lineA).toBeDefined()
132
- expect(lineB).toBeDefined()
133
- expect(lineA).not.toBe(lineB)
134
- void a
135
- void b
136
- })
137
- })
138
-
139
- describe('LPIH — source-location capture for computed', () => {
140
- it('captures the user call site for computed()', () => {
141
- activateReactiveDevtools()
142
- const s = signal(1)
143
- const c = computed(() => s() * 2) // ← user site
144
- c()
145
- const nodes = getReactiveGraph().nodes
146
- const derived = nodes.find((n) => n.kind === 'derived')
147
- expect(derived?.loc).toBeDefined()
148
- expect(derived?.loc?.file).toContain('lpih-source-location.test.ts')
149
- })
150
- })
151
-
152
- describe('LPIH — source-location capture for effect', () => {
153
- it('captures the user call site for effect()', () => {
154
- activateReactiveDevtools()
155
- const s = signal(0)
156
- const e = effect(() => {
157
- s()
158
- })
159
- const nodes = getReactiveGraph().nodes
160
- const eff = nodes.find((n) => n.kind === 'effect')
161
- expect(eff?.loc).toBeDefined()
162
- expect(eff?.loc?.file).toContain('lpih-source-location.test.ts')
163
- e.dispose()
164
- })
165
- })
166
-
167
- describe('LPIH — getFireSummaries()', () => {
168
- it('returns empty when no nodes have locations', () => {
169
- activateReactiveDevtools()
170
- // No creations yet
171
- expect(getFireSummaries()).toEqual([])
172
- })
173
-
174
- it('aggregates fires by location', () => {
175
- activateReactiveDevtools()
176
- const s = signal(0)
177
- s.set(1)
178
- s.set(2)
179
- s.set(3)
180
- const summaries = getFireSummaries()
181
- expect(summaries).toHaveLength(1)
182
- expect(summaries[0]?.count).toBe(3)
183
- expect(summaries[0]?.kind).toBe('signal')
184
- expect(summaries[0]?.lastFire).not.toBeNull()
185
- expect(summaries[0]?.loc.file).toContain('lpih-source-location.test.ts')
186
- void s
187
- })
188
-
189
- it('produces one summary per unique location', () => {
190
- activateReactiveDevtools()
191
- const a = signal(0)
192
- const b = signal(0)
193
- a.set(1)
194
- a.set(2)
195
- b.set(1)
196
- const summaries = getFireSummaries()
197
- expect(summaries).toHaveLength(2)
198
- const total = summaries.reduce((acc, s) => acc + s.count, 0)
199
- expect(total).toBe(3)
200
- void a
201
- void b
202
- })
203
-
204
- it('captures fires across signal + computed + effect', () => {
205
- activateReactiveDevtools()
206
- const s = signal(0)
207
- const c = computed(() => s() * 2)
208
- let observed = 0
209
- const e = effect(() => {
210
- observed = c()
211
- })
212
- s.set(5)
213
- s.set(10)
214
- const summaries = getFireSummaries()
215
- // Expect at least 3 distinct locations (signal, computed, effect).
216
- expect(summaries.length).toBeGreaterThanOrEqual(3)
217
- const kinds = new Set(summaries.map((sum) => sum.kind))
218
- expect(kinds.has('signal')).toBe(true)
219
- expect(kinds.has('derived')).toBe(true)
220
- expect(kinds.has('effect')).toBe(true)
221
- expect(observed).toBe(20)
222
- e.dispose()
223
- })
224
- })
225
-
226
- describe('LPIH — rate1s EWMA tracking', () => {
227
- it('rate1s is 0 for a node that has not fired', () => {
228
- activateReactiveDevtools()
229
- const s = signal(0)
230
- const summaries = getFireSummaries()
231
- // Signal was created but never written → rate1s = 0
232
- const summary = summaries.find((x) => x.kind === 'signal')
233
- expect(summary?.rate1s).toBe(0)
234
- void s
235
- })
236
-
237
- it('rate1s rises with rapid fires', () => {
238
- activateReactiveDevtools()
239
- const s = signal(0)
240
- for (let i = 0; i < 10; i++) s.set(i + 1)
241
- const summaries = getFireSummaries()
242
- const summary = summaries.find((x) => x.kind === 'signal')
243
- expect(summary?.rate1s).toBeGreaterThan(0)
244
- void s
245
- })
246
-
247
- it('rate1s for many rapid fires reflects fire density (>1)', () => {
248
- activateReactiveDevtools()
249
- const s = signal(0)
250
- // 100 fires in rapid succession — dt → 0, decay ≈ 1.0, so each fire
251
- // adds ~+1 to rate1s. At read time the value is decayed by the small
252
- // elapsed time → still well above the threshold.
253
- for (let i = 0; i < 100; i++) s.set(i + 1)
254
- const summaries = getFireSummaries()
255
- const summary = summaries.find((x) => x.kind === 'signal')
256
- expect(summary?.rate1s).toBeGreaterThan(10)
257
- void s
258
- })
259
-
260
- it('rate1s decays to ≈0 after the time constant elapses', async () => {
261
- activateReactiveDevtools()
262
- const s = signal(0)
263
- s.set(1)
264
- const initial = getFireSummaries().find((x) => x.kind === 'signal')
265
- expect(initial?.rate1s).toBeGreaterThan(0.5)
266
- // 1.5s = 1.5× TAU → rate1s should drop to exp(-1.5) ≈ 0.22× initial.
267
- await new Promise((r) => setTimeout(r, 1500))
268
- const decayed = getFireSummaries().find((x) => x.kind === 'signal')
269
- expect(decayed?.rate1s).toBeLessThan(0.5)
270
- void s
271
- })
272
-
273
- it('exported LPIH_RATE_TAU_MS constant equals 1000 (1 second)', async () => {
274
- const { LPIH_RATE_TAU_MS } = await import('../reactive-devtools')
275
- expect(LPIH_RATE_TAU_MS).toBe(1000)
276
- })
277
- })
@@ -1,351 +0,0 @@
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
- })