@pyreon/reactivity 0.23.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/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,277 @@
|
|
|
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
|
+
})
|
|
@@ -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
|
+
})
|