@pyreon/vite-plugin 0.23.0 → 0.24.1

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,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
+ })