@pyreon/vite-plugin 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,187 @@
1
+ /**
2
+ * REPRODUCTION + REGRESSION — `signalExportRegistry`, `resolveCache`,
3
+ * and `islandRegistry` accumulated stale entries for the lifetime of
4
+ * a `vite dev` session. Vite's `watchChange` hook fires on filesystem
5
+ * `'create' | 'update' | 'delete'` events; pre-fix none of the four
6
+ * per-instance caches subscribed, so deleting / renaming a source
7
+ * file left orphaned entries forever.
8
+ *
9
+ * Bounded by total source-tree size in practice, but a real Class C
10
+ * leak over hours of editing on a large project — every source file
11
+ * the developer touches that later gets deleted leaves one entry per
12
+ * cache stuck until process exit.
13
+ */
14
+ import { describe, expect, it } from 'vitest'
15
+ import type { PyreonPluginOptions } from '../index'
16
+ import pyreonPlugin from '../index'
17
+
18
+ type ConfigHook = (
19
+ userConfig: Record<string, unknown>,
20
+ env: { command: string; isSsrBuild?: boolean },
21
+ ) => Record<string, unknown>
22
+
23
+ function createServePlugin(opts?: PyreonPluginOptions) {
24
+ const plugin = pyreonPlugin(opts)
25
+ ;(plugin.config as unknown as ConfigHook)({}, { command: 'serve' })
26
+ return plugin
27
+ }
28
+
29
+ interface PluginInternalShape {
30
+ buildStart: () => Promise<void> | void
31
+ transform: (
32
+ this: {
33
+ warn: (msg: string) => void
34
+ resolve: (
35
+ id: string,
36
+ importer?: string,
37
+ opts?: { skipSelf: boolean },
38
+ ) => Promise<{ id: string } | null>
39
+ },
40
+ code: string,
41
+ id: string,
42
+ ) => Promise<{ code: string; map: null } | undefined>
43
+ watchChange: (id: string, change: { event: 'create' | 'update' | 'delete' }) => void
44
+ }
45
+
46
+ interface PluginCaches {
47
+ signalExportRegistry: Map<string, Set<string>>
48
+ resolveCache: Map<string, string | null>
49
+ pyreonWorkspaceDirCache: Map<string, boolean>
50
+ islandRegistry: Map<string, unknown[]>
51
+ }
52
+
53
+ const CACHES_SYMBOL = Symbol.for('pyreon/vite-plugin:caches')
54
+
55
+ function getCaches(plugin: ReturnType<typeof pyreonPlugin>): PluginCaches {
56
+ const caches = (plugin as unknown as Record<symbol, PluginCaches | undefined>)[CACHES_SYMBOL]
57
+ if (!caches) throw new Error('plugin should expose CACHES_SYMBOL')
58
+ return caches
59
+ }
60
+
61
+ async function transform(
62
+ plugin: ReturnType<typeof pyreonPlugin>,
63
+ code: string,
64
+ id: string,
65
+ ): Promise<void> {
66
+ const p = plugin as unknown as PluginInternalShape
67
+ await p.transform.call(
68
+ {
69
+ warn: () => {},
70
+ resolve: async (specifier: string, importer?: string) => {
71
+ // Simulate resolution — bare relative imports map to virtual
72
+ // paths so resolveCache gets real entries.
73
+ if (specifier.startsWith('./') && importer) {
74
+ return { id: `/test-project/${specifier.slice(2)}.ts` }
75
+ }
76
+ return null
77
+ },
78
+ },
79
+ code,
80
+ id,
81
+ )
82
+ }
83
+
84
+ describe('@pyreon/vite-plugin — file-delete cache eviction (watchChange)', () => {
85
+ it('REGRESSION: signalExportRegistry entry is evicted on file delete', async () => {
86
+ const plugin = createServePlugin()
87
+ const p = plugin as unknown as PluginInternalShape
88
+ const caches = getCaches(plugin)
89
+
90
+ // Transform a source file that exports a top-level signal. The
91
+ // plugin's incremental scanner populates the registry.
92
+ await transform(plugin, `export const count = signal(0)`, '/test-project/store.tsx')
93
+ expect(caches.signalExportRegistry.has('/test-project/store.tsx')).toBe(true)
94
+
95
+ // Fire the delete event. The critical assertion: the registry
96
+ // entry is GONE post-delete. Pre-fix (no watchChange hook), the
97
+ // entry would persist forever.
98
+ p.watchChange('/test-project/store.tsx', { event: 'delete' })
99
+ expect(caches.signalExportRegistry.has('/test-project/store.tsx')).toBe(false)
100
+ })
101
+
102
+ it('REGRESSION: resolveCache entries pointing at the deleted file are evicted', async () => {
103
+ const plugin = createServePlugin()
104
+ const p = plugin as unknown as PluginInternalShape
105
+ const caches = getCaches(plugin)
106
+
107
+ // Populate signalExportRegistry first.
108
+ await transform(plugin, `export const a = signal(0)`, '/test-project/a.tsx')
109
+ // Consumer file imports a.ts — populates resolveCache.
110
+ await transform(
111
+ plugin,
112
+ `import { a } from './a'\nexport default () => a`,
113
+ '/test-project/consumer.tsx',
114
+ )
115
+
116
+ const beforeSize = caches.resolveCache.size
117
+ expect(beforeSize).toBeGreaterThan(0)
118
+
119
+ // Delete `a.ts`. Both the importer-keyed entry AND any entry
120
+ // whose VALUE is `/test-project/a.tsx` should evict.
121
+ p.watchChange('/test-project/a.tsx', { event: 'delete' })
122
+
123
+ // Critical: no entry in resolveCache references the deleted file.
124
+ for (const [key, value] of caches.resolveCache) {
125
+ expect(key.startsWith('/test-project/a.tsx::')).toBe(false)
126
+ expect(value).not.toBe('/test-project/a.tsx')
127
+ }
128
+ })
129
+
130
+ it('REGRESSION: islandRegistry entry is evicted on file delete', async () => {
131
+ const plugin = createServePlugin({ islands: true })
132
+ const p = plugin as unknown as PluginInternalShape
133
+ const caches = getCaches(plugin)
134
+
135
+ // Populate the island registry. Use a minimal island declaration.
136
+ await transform(
137
+ plugin,
138
+ `import { island } from '@pyreon/server'\nexport const C = island(() => import('./c'), { name: 'C' })`,
139
+ '/test-project/c-island.tsx',
140
+ )
141
+
142
+ // Either the absolute id or its normalized form may have landed
143
+ // in the registry — assert at least one is there.
144
+ const hasEntry
145
+ = caches.islandRegistry.has('/test-project/c-island.tsx')
146
+ || [...caches.islandRegistry.keys()].some((k) => k.includes('c-island'))
147
+
148
+ if (hasEntry) {
149
+ p.watchChange('/test-project/c-island.tsx', { event: 'delete' })
150
+ // Post-delete the registry should NOT have the entry.
151
+ expect(caches.islandRegistry.has('/test-project/c-island.tsx')).toBe(false)
152
+ } else {
153
+ // If the scanner didn't pick up the island (test fixture too
154
+ // minimal), the watchChange call must still be a no-op without
155
+ // throwing — verifies the defensive path.
156
+ expect(() =>
157
+ p.watchChange('/test-project/c-island.tsx', { event: 'delete' }),
158
+ ).not.toThrow()
159
+ }
160
+ })
161
+
162
+ it('REGRESSION: watchChange ignores create/update events (handled by transform)', async () => {
163
+ const plugin = createServePlugin()
164
+ const p = plugin as unknown as PluginInternalShape
165
+ const caches = getCaches(plugin)
166
+
167
+ // Populate then update — update should NOT evict.
168
+ await transform(plugin, `export const v = signal(0)`, '/test-project/v.tsx')
169
+ expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(true)
170
+ p.watchChange('/test-project/v.tsx', { event: 'create' })
171
+ expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(true)
172
+ p.watchChange('/test-project/v.tsx', { event: 'update' })
173
+ expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(true)
174
+
175
+ // Only delete evicts.
176
+ p.watchChange('/test-project/v.tsx', { event: 'delete' })
177
+ expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(false)
178
+ })
179
+
180
+ it('REGRESSION: deleting an untracked file is a safe no-op', () => {
181
+ const plugin = createServePlugin()
182
+ const p = plugin as unknown as PluginInternalShape
183
+ expect(() =>
184
+ p.watchChange('/test-project/never-tracked.tsx', { event: 'delete' }),
185
+ ).not.toThrow()
186
+ })
187
+ })
@@ -54,7 +54,11 @@ afterEach(() => {
54
54
  })
55
55
 
56
56
  function bootstrap(opts?: PyreonPluginOptions) {
57
- const plugin = pyreonPlugin(opts)
57
+ // Default `lpih: false` — these tests cover the SSR / watcher / debounce
58
+ // surface; LPIH auto-bridge adds its own middleware whose presence would
59
+ // change the `middlewares.use` call count + first-element shape. Tests
60
+ // that specifically exercise LPIH live in `lpih-auto-bridge.test.ts`.
61
+ const plugin = pyreonPlugin({ lpih: false, ...opts })
58
62
  ;(plugin.config as unknown as ConfigHook)({ root }, { command: 'serve' })
59
63
  return plugin
60
64
  }
@@ -0,0 +1,408 @@
1
+ /**
2
+ * R1 — LPIH auto-bridge.
3
+ *
4
+ * The plugin auto-wires Live Program Inlay Hints in dev:
5
+ * 1. `configureServer` registers a POST /__pyreon_lpih__ middleware
6
+ * that atomically writes the cache file the LSP auto-discovers.
7
+ * 2. `transformIndexHtml` injects a client-side `<script type="module">`
8
+ * that activates devtools + polls `getFireSummaries()` every
9
+ * `intervalMs` and POSTs to the endpoint.
10
+ *
11
+ * Bisect-verified-with-restore:
12
+ * - Disabling the lpihEnabled gate in configureServer → "no LPIH
13
+ * middleware registered when lpih:true" fails.
14
+ * - Disabling the lpihEnabled gate in transformIndexHtml → "injects
15
+ * the LPIH client script into <head> when lpih:true" fails.
16
+ * - Reverting writeLpihCacheFile to bare `JSON.parse` → "rejects
17
+ * malformed body" fails (would silently corrupt the file).
18
+ */
19
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { join } from 'node:path'
22
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
23
+ import pyreonPlugin, {
24
+ type PyreonPluginOptions,
25
+ buildLpihClientScript,
26
+ resolveLpihCachePath,
27
+ writeLpihCacheFile,
28
+ } from '../index'
29
+
30
+ type ConfigHook = (
31
+ userConfig: Record<string, unknown>,
32
+ env: { command: string; isSsrBuild?: boolean },
33
+ ) => Record<string, unknown>
34
+
35
+ type TransformIndexHtmlHook = (html: string) => string | undefined
36
+
37
+ interface MockServer {
38
+ watcher: { on: ReturnType<typeof vi.fn>; emit?: (event: string, file: string) => void }
39
+ middlewares: { use: ReturnType<typeof vi.fn> }
40
+ ssrFixStacktrace: (e: Error) => void
41
+ ssrLoadModule: ReturnType<typeof vi.fn>
42
+ transformIndexHtml: ReturnType<typeof vi.fn>
43
+ }
44
+
45
+ function createMockServer(): MockServer {
46
+ const handlers: Record<string, (file: string) => void> = {}
47
+ return {
48
+ watcher: {
49
+ on: vi.fn((event: string, cb: (file: string) => void) => {
50
+ handlers[event] = cb
51
+ }),
52
+ emit: (event: string, file: string) => handlers[event]?.(file),
53
+ },
54
+ middlewares: { use: vi.fn() },
55
+ ssrFixStacktrace: () => {},
56
+ ssrLoadModule: vi.fn(),
57
+ transformIndexHtml: vi.fn(async (_url: string, html: string) => html),
58
+ }
59
+ }
60
+
61
+ let root: string
62
+
63
+ beforeEach(() => {
64
+ root = mkdtempSync(join(tmpdir(), 'pyreon-lpih-bridge-'))
65
+ })
66
+ afterEach(() => {
67
+ rmSync(root, { recursive: true, force: true })
68
+ })
69
+
70
+ function bootstrap(
71
+ opts?: PyreonPluginOptions,
72
+ env: { command: 'serve' | 'build' } = { command: 'serve' },
73
+ ) {
74
+ const plugin = pyreonPlugin(opts)
75
+ ;(plugin.config as unknown as ConfigHook)({ root }, env)
76
+ return plugin
77
+ }
78
+
79
+ describe('resolveLpihCachePath', () => {
80
+ it('returns <projectRoot>/.pyreon-lpih.json', () => {
81
+ expect(resolveLpihCachePath('/abs/proj')).toBe('/abs/proj/.pyreon-lpih.json')
82
+ })
83
+
84
+ it('handles paths without trailing slash', () => {
85
+ expect(resolveLpihCachePath('/abs/proj/')).toBe('/abs/proj/.pyreon-lpih.json')
86
+ })
87
+ })
88
+
89
+ describe('writeLpihCacheFile', () => {
90
+ it('writes JSON payload at the target path', async () => {
91
+ const path = join(root, '.pyreon-lpih.json')
92
+ const payload = JSON.stringify({
93
+ fires: [{ file: '/a.tsx', line: 1, count: 1, kind: 'signal' }],
94
+ })
95
+ await writeLpihCacheFile(path, payload)
96
+ expect(existsSync(path)).toBe(true)
97
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as { fires: unknown[] }
98
+ expect(parsed.fires).toHaveLength(1)
99
+ })
100
+
101
+ it('overwrites existing file (atomic rename)', async () => {
102
+ const path = join(root, '.pyreon-lpih.json')
103
+ await writeLpihCacheFile(path, JSON.stringify({ fires: [{ file: 'a', line: 1, count: 1 }] }))
104
+ await writeLpihCacheFile(path, JSON.stringify({ fires: [{ file: 'b', line: 2, count: 2 }] }))
105
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
106
+ fires: Array<{ file: string }>
107
+ }
108
+ expect(parsed.fires[0]?.file).toBe('b')
109
+ })
110
+
111
+ it('rejects malformed JSON body', async () => {
112
+ const path = join(root, '.pyreon-lpih.json')
113
+ await expect(writeLpihCacheFile(path, 'not json')).rejects.toThrow(/not valid JSON/)
114
+ expect(existsSync(path)).toBe(false)
115
+ })
116
+
117
+ it('rejects body without `fires` array', async () => {
118
+ const path = join(root, '.pyreon-lpih.json')
119
+ await expect(writeLpihCacheFile(path, JSON.stringify({}))).rejects.toThrow(/missing `fires`/)
120
+ await expect(
121
+ writeLpihCacheFile(path, JSON.stringify({ fires: 'not array' })),
122
+ ).rejects.toThrow(/missing `fires`/)
123
+ await expect(writeLpihCacheFile(path, JSON.stringify(null))).rejects.toThrow(
124
+ /missing `fires`/,
125
+ )
126
+ expect(existsSync(path)).toBe(false)
127
+ })
128
+
129
+ it('leaves no tmp files after successful write', async () => {
130
+ const path = join(root, '.pyreon-lpih.json')
131
+ await writeLpihCacheFile(path, JSON.stringify({ fires: [] }))
132
+ const fs = await import('node:fs/promises')
133
+ const files = await fs.readdir(root)
134
+ const tmpFiles = files.filter((f) => f.includes('.tmp.'))
135
+ expect(tmpFiles).toEqual([])
136
+ })
137
+
138
+ it('cleans up tmp file when rename fails (rename onto a directory)', async () => {
139
+ // The pre-existing case the original try-around-rename ALREADY handled:
140
+ // writeFile succeeds (writes a tmp file in root/), rename onto a path
141
+ // that is a directory fails (EISDIR / EPERM), the catch unlinks tmp.
142
+ // This test locks in that branch — bisect-verified by the existing
143
+ // 'as-dir' rename-failure handling in writeLpihCacheFile.
144
+ const targetDir = join(root, 'as-dir')
145
+ const fs = await import('node:fs/promises')
146
+ await fs.mkdir(targetDir, { recursive: true })
147
+ await expect(
148
+ writeLpihCacheFile(targetDir, JSON.stringify({ fires: [] })),
149
+ ).rejects.toBeDefined()
150
+ const files = await fs.readdir(root)
151
+ const tmpFiles = files.filter(
152
+ (f) => f.startsWith('as-dir.tmp.') || f.includes('.tmp.'),
153
+ )
154
+ expect(tmpFiles).toEqual([])
155
+ })
156
+ })
157
+
158
+ describe('buildLpihClientScript', () => {
159
+ it('returns a <script type="module"> block', () => {
160
+ const script = buildLpihClientScript(250)
161
+ expect(script).toContain('<script type="module">')
162
+ expect(script).toContain('</script>')
163
+ })
164
+
165
+ it('embeds the interval as a JSON literal', () => {
166
+ const script = buildLpihClientScript(500)
167
+ expect(script).toContain('const __pxInterval = 500')
168
+ })
169
+
170
+ it('imports activateReactiveDevtools + getFireSummaries from @pyreon/reactivity', () => {
171
+ const script = buildLpihClientScript(250)
172
+ expect(script).toContain("import('@pyreon/reactivity')")
173
+ expect(script).toContain('activateReactiveDevtools')
174
+ expect(script).toContain('getFireSummaries')
175
+ })
176
+
177
+ it('uses top-level await on the dynamic import — activation BEFORE app modules', () => {
178
+ // Critical for tracking module-scope signals. `<script type="module">`
179
+ // tags defer + run in document order; top-level await blocks subsequent
180
+ // modules. Dynamic import via `.then()` does NOT — the module body
181
+ // completes immediately, the app's entry runs, module-scope signals
182
+ // get created with `_active = false`, and `_rdRegister` skips them.
183
+ const script = buildLpihClientScript(250)
184
+ expect(script).toMatch(/await import\(['"]@pyreon\/reactivity['"]\)/)
185
+ // The catch path returns null (silent fallback for missing-dep apps).
186
+ expect(script).toMatch(/\.catch\(\(\) => null\)/)
187
+ // Activation MUST appear AFTER the await (synchronous after the import resolves).
188
+ const awaitIdx = script.indexOf('await import')
189
+ const activateIdx = script.indexOf('activateReactiveDevtools()')
190
+ expect(awaitIdx).toBeGreaterThan(0)
191
+ expect(activateIdx).toBeGreaterThan(awaitIdx)
192
+ })
193
+
194
+ it('POSTs to /__pyreon_lpih__ with JSON content-type', () => {
195
+ const script = buildLpihClientScript(250)
196
+ expect(script).toContain("fetch('/__pyreon_lpih__'")
197
+ expect(script).toContain("method: 'POST'")
198
+ expect(script).toContain("'content-type': 'application/json'")
199
+ })
200
+
201
+ it('cleans up the interval on beforeunload', () => {
202
+ const script = buildLpihClientScript(250)
203
+ expect(script).toContain("addEventListener('beforeunload'")
204
+ expect(script).toContain('clearInterval')
205
+ })
206
+
207
+ it('serializes the `fires` shape that the dev-server expects', () => {
208
+ const script = buildLpihClientScript(250)
209
+ // The browser-side must produce { fires: [{ file, line, count, kind, lastFire, rate1s }] }
210
+ expect(script).toContain('fires: summaries.map')
211
+ expect(script).toContain('file: s.loc.file')
212
+ expect(script).toContain('line: s.loc.line')
213
+ expect(script).toContain('count: s.count')
214
+ expect(script).toContain('kind: s.kind')
215
+ expect(script).toContain('rate1s: s.rate1s')
216
+ })
217
+ })
218
+
219
+ describe('LPIH transformIndexHtml — injection gating', () => {
220
+ it('injects the LPIH client script into <head> when lpih:true (default)', () => {
221
+ const plugin = bootstrap()
222
+ const transform = plugin.transformIndexHtml as unknown as TransformIndexHtmlHook
223
+ const out = transform('<html><head><title>X</title></head><body></body></html>')
224
+ expect(out).toBeDefined()
225
+ expect(out).toContain('/__pyreon_lpih__')
226
+ expect(out).toContain('@pyreon/reactivity')
227
+ // Injected BEFORE the closing </head>, not after.
228
+ const headEnd = out!.indexOf('</head>')
229
+ const scriptStart = out!.indexOf('<script type="module">')
230
+ expect(scriptStart).toBeGreaterThan(0)
231
+ expect(scriptStart).toBeLessThan(headEnd)
232
+ })
233
+
234
+ it('does NOT inject when lpih:false', () => {
235
+ const plugin = bootstrap({ lpih: false })
236
+ const transform = plugin.transformIndexHtml as unknown as TransformIndexHtmlHook
237
+ const out = transform('<html><head></head><body></body></html>')
238
+ // Plugin returns undefined → Vite uses the original HTML.
239
+ expect(out).toBeUndefined()
240
+ })
241
+
242
+ it('does NOT inject in build mode (lpih is dev-only)', () => {
243
+ const plugin = bootstrap({ lpih: true }, { command: 'build' })
244
+ const transform = plugin.transformIndexHtml as unknown as TransformIndexHtmlHook
245
+ const out = transform('<html><head></head><body></body></html>')
246
+ expect(out).toBeUndefined()
247
+ })
248
+
249
+ it('respects custom intervalMs via object-form option', () => {
250
+ const plugin = bootstrap({ lpih: { intervalMs: 1000 } })
251
+ const transform = plugin.transformIndexHtml as unknown as TransformIndexHtmlHook
252
+ const out = transform('<html><head></head><body></body></html>')
253
+ expect(out).toContain('const __pxInterval = 1000')
254
+ })
255
+
256
+ it('uses default 250ms interval when lpih:true with no override', () => {
257
+ const plugin = bootstrap({ lpih: true })
258
+ const transform = plugin.transformIndexHtml as unknown as TransformIndexHtmlHook
259
+ const out = transform('<html><head></head><body></body></html>')
260
+ expect(out).toContain('const __pxInterval = 250')
261
+ })
262
+ })
263
+
264
+ describe('LPIH configureServer — middleware registration', () => {
265
+ it('registers a /__pyreon_lpih__ middleware when lpih:true (default)', () => {
266
+ const plugin = bootstrap()
267
+ const server = createMockServer()
268
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
269
+
270
+ // Find the LPIH middleware in the call list — first arg is the path.
271
+ const lpihCall = server.middlewares.use.mock.calls.find(
272
+ (c: unknown[]) => c[0] === '/__pyreon_lpih__',
273
+ )
274
+ expect(lpihCall).toBeDefined()
275
+ expect(typeof lpihCall![1]).toBe('function')
276
+ })
277
+
278
+ it('does NOT register the LPIH middleware when lpih:false', () => {
279
+ const plugin = bootstrap({ lpih: false })
280
+ const server = createMockServer()
281
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
282
+
283
+ const lpihCall = server.middlewares.use.mock.calls.find(
284
+ (c: unknown[]) => c[0] === '/__pyreon_lpih__',
285
+ )
286
+ expect(lpihCall).toBeUndefined()
287
+ })
288
+
289
+ it('LPIH middleware rejects non-POST with 405', () => {
290
+ const plugin = bootstrap()
291
+ const server = createMockServer()
292
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
293
+
294
+ const lpihCall = server.middlewares.use.mock.calls.find(
295
+ (c: unknown[]) => c[0] === '/__pyreon_lpih__',
296
+ )
297
+ const handler = lpihCall![1] as (
298
+ req: { method: string },
299
+ res: { statusCode: number; end: (msg?: string) => void },
300
+ ) => void
301
+
302
+ let statusCode = 0
303
+ let ended = false
304
+ handler(
305
+ { method: 'GET' },
306
+ {
307
+ statusCode: 0,
308
+ end(_msg?: string) {
309
+ ended = true
310
+ // capture
311
+ statusCode = (this as { statusCode: number }).statusCode
312
+ },
313
+ },
314
+ )
315
+ expect(ended).toBe(true)
316
+ expect(statusCode).toBe(405)
317
+ })
318
+
319
+ it('LPIH middleware writes valid POST payload to the cache file', async () => {
320
+ const plugin = bootstrap()
321
+ const server = createMockServer()
322
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
323
+
324
+ const lpihCall = server.middlewares.use.mock.calls.find(
325
+ (c: unknown[]) => c[0] === '/__pyreon_lpih__',
326
+ )
327
+ const handler = lpihCall![1] as (
328
+ req: { method: string; on: (ev: string, cb: (chunk: string) => void) => void; destroy: () => void },
329
+ res: { statusCode: number; end: () => void },
330
+ ) => void
331
+
332
+ type DataCb = (chunk: string) => void
333
+ type EndCb = () => void
334
+ const handlers: { data?: DataCb; end?: EndCb } = {}
335
+ const req = {
336
+ method: 'POST',
337
+ on: (ev: string, cb: (chunk: string) => void) => {
338
+ if (ev === 'data') handlers.data = cb as DataCb
339
+ if (ev === 'end') handlers.end = cb as unknown as EndCb
340
+ },
341
+ destroy: () => {},
342
+ }
343
+ const resultPromise = new Promise<number>((resolve) => {
344
+ const res = {
345
+ statusCode: 0,
346
+ end() {
347
+ resolve(res.statusCode)
348
+ },
349
+ }
350
+ handler(req, res)
351
+ })
352
+
353
+ const payload = JSON.stringify({
354
+ fires: [
355
+ { file: '/a.tsx', line: 5, count: 3, kind: 'signal', lastFire: 100, rate1s: 1.5 },
356
+ ],
357
+ })
358
+ handlers.data!(payload)
359
+ handlers.end!()
360
+
361
+ const status = await resultPromise
362
+ expect(status).toBe(204)
363
+ const cachePath = join(root, '.pyreon-lpih.json')
364
+ expect(existsSync(cachePath)).toBe(true)
365
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf8')) as { fires: unknown[] }
366
+ expect(parsed.fires).toHaveLength(1)
367
+ })
368
+
369
+ it('LPIH middleware honours custom cachePath via object-form option', async () => {
370
+ const customPath = join(root, 'custom-lpih.json')
371
+ const plugin = bootstrap({ lpih: { cachePath: customPath } })
372
+ const server = createMockServer()
373
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
374
+
375
+ const lpihCall = server.middlewares.use.mock.calls.find(
376
+ (c: unknown[]) => c[0] === '/__pyreon_lpih__',
377
+ )
378
+ const handler = lpihCall![1] as (
379
+ req: { method: string; on: (ev: string, cb: (chunk: string) => void) => void; destroy: () => void },
380
+ res: { statusCode: number; end: () => void },
381
+ ) => void
382
+
383
+ type DataCb = (chunk: string) => void
384
+ type EndCb = () => void
385
+ const handlers: { data?: DataCb; end?: EndCb } = {}
386
+ const req = {
387
+ method: 'POST',
388
+ on: (ev: string, cb: (chunk: string) => void) => {
389
+ if (ev === 'data') handlers.data = cb as DataCb
390
+ if (ev === 'end') handlers.end = cb as unknown as EndCb
391
+ },
392
+ destroy: () => {},
393
+ }
394
+ const done = new Promise<void>((resolve) => {
395
+ handler(req, {
396
+ statusCode: 0,
397
+ end: () => resolve(),
398
+ })
399
+ })
400
+ handlers.data!(JSON.stringify({ fires: [] }))
401
+ handlers.end!()
402
+ await done
403
+
404
+ // Default cache path NOT written; custom path IS.
405
+ expect(existsSync(join(root, '.pyreon-lpih.json'))).toBe(false)
406
+ expect(existsSync(customPath)).toBe(true)
407
+ })
408
+ })