@pyreon/vite-plugin 0.24.5 → 0.25.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.
@@ -1,503 +0,0 @@
1
- /**
2
- * Tests for @pyreon/vite-plugin — HMR injection, signal rewriting,
3
- * compat alias resolution, and helper functions.
4
- *
5
- * These test the plugin's transform logic directly (no Vite required).
6
- */
7
-
8
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
9
- import { join as pathJoin } from 'node:path'
10
- import { describe, expect, it } from 'vitest'
11
-
12
- // ── Import internals ─────────────────────────────────────────────────────────
13
- // We import the default export and call it to get the plugin object,
14
- // then invoke its hooks directly.
15
-
16
- import type { PyreonPluginOptions } from '../index'
17
- import pyreonPlugin from '../index'
18
-
19
- type ConfigHook = (
20
- userConfig: Record<string, unknown>,
21
- env: { command: string; isSsrBuild?: boolean },
22
- ) => Record<string, unknown>
23
-
24
- function getConfigHook(plugin: ReturnType<typeof pyreonPlugin>): ConfigHook {
25
- return plugin.config as unknown as ConfigHook
26
- }
27
-
28
- function createPlugin(opts?: PyreonPluginOptions) {
29
- const plugin = pyreonPlugin(opts)
30
- // Simulate Vite calling config() so isBuild / projectRoot are set
31
- getConfigHook(plugin)({}, { command: 'serve' })
32
- return plugin
33
- }
34
-
35
- function createBuildPlugin(opts?: PyreonPluginOptions) {
36
- const plugin = pyreonPlugin(opts)
37
- getConfigHook(plugin)({}, { command: 'build' })
38
- return plugin
39
- }
40
-
41
- async function transform(plugin: ReturnType<typeof pyreonPlugin>, code: string, id: string) {
42
- const transformHook = plugin.transform as (
43
- this: { warn: (msg: string) => void; resolve: (id: string, importer?: string, options?: { skipSelf: boolean }) => Promise<{ id: string } | null> },
44
- code: string,
45
- id: string,
46
- ) => Promise<{ code: string; map: null } | undefined>
47
- const warnings: string[] = []
48
- return transformHook.call(
49
- {
50
- warn: (msg: string) => warnings.push(msg),
51
- resolve: async () => null, // no cross-module resolution in unit tests
52
- },
53
- code,
54
- id,
55
- )
56
- }
57
-
58
- // ─── HMR injection ──────────────────────────────────────────────────────────
59
-
60
- describe('HMR injection', () => {
61
- it('injects a coordinator-driven HMR accept for modules with component exports', async () => {
62
- const plugin = createPlugin()
63
- const code = `
64
- import { h } from "@pyreon/core"
65
- export function App() { return h("div", null, "hello") }
66
- `
67
- const result = await transform(plugin, code, '/src/App.tsx')
68
- expect(result).toBeDefined()
69
- // Self-accept WITH a callback receiving the fresh module (the bare
70
- // `accept()` was the bug — it suppressed Vite's reload fallback while
71
- // re-rendering nothing).
72
- expect(result!.code).toContain('import.meta.hot.accept((__m) => {')
73
- expect(result!.code).not.toContain('import.meta.hot.accept();')
74
- // Hands the fresh module to the router-registered HMR coordinator,
75
- // keyed by THIS module's id (zero import coupling).
76
- expect(result!.code).toContain('globalThis.__pyreon_hmr_swap__')
77
- expect(result!.code).toContain('__s("/src/App.tsx", __m)')
78
- // Falls back to an automatic full reload when the edit was outside the
79
- // active route tree or no coordinator is registered.
80
- expect(result!.code).toContain('import.meta.hot.invalidate()')
81
- })
82
-
83
- it('injects HMR for exported const components', async () => {
84
- const plugin = createPlugin()
85
- const code = `
86
- import { h } from "@pyreon/core"
87
- export const Header = () => h("header", null, "nav")
88
- `
89
- const result = await transform(plugin, code, '/src/Header.tsx')
90
- expect(result).toBeDefined()
91
- expect(result!.code).toContain('import.meta.hot')
92
- })
93
-
94
- it('does not inject HMR for modules without component exports or signals', async () => {
95
- const plugin = createPlugin()
96
- // Only lowercase exports — no component-like names (uppercase first letter)
97
- const code = `
98
- export const formatDate = (d) => d.toISOString()
99
- export const maxItems = 100
100
- `
101
- const result = await transform(plugin, code, '/src/utils.tsx')
102
- expect(result).toBeDefined()
103
- expect(result!.code).not.toContain('import.meta.hot')
104
- })
105
-
106
- it('does not inject HMR in build mode', async () => {
107
- const plugin = createBuildPlugin()
108
- const code = `
109
- import { h } from "@pyreon/core"
110
- export function App() { return h("div", null, "hello") }
111
- `
112
- const result = await transform(plugin, code, '/src/App.tsx')
113
- expect(result).toBeDefined()
114
- expect(result!.code).not.toContain('import.meta.hot')
115
- })
116
- })
117
-
118
- // ─── Signal rewriting ────────────────────────────────────────────────────────
119
-
120
- describe('signal rewriting', () => {
121
- it('rewrites module-scope signal() to __hmr_signal()', async () => {
122
- const plugin = createPlugin()
123
- const code = `
124
- import { signal } from "@pyreon/reactivity"
125
- import { h } from "@pyreon/core"
126
- const count = signal(0)
127
- export function Counter() { return h("div", null, count()) }
128
- `
129
- const result = await transform(plugin, code, '/src/Counter.tsx')
130
- expect(result).toBeDefined()
131
- expect(result!.code).toContain('__hmr_signal(')
132
- expect(result!.code).toContain('"count"')
133
- expect(result!.code).toContain('"/src/Counter.tsx"')
134
- expect(result!.code).toContain('__hmr_dispose')
135
- })
136
-
137
- it('rewrites exported signals', async () => {
138
- const plugin = createPlugin()
139
- const code = `
140
- import { signal } from "@pyreon/reactivity"
141
- export const theme = signal("light")
142
- export function App() { return null }
143
- `
144
- const result = await transform(plugin, code, '/src/theme.tsx')
145
- expect(result).toBeDefined()
146
- expect(result!.code).toContain('__hmr_signal("/src/theme.tsx", "theme", signal, "light")')
147
- })
148
-
149
- it('rewrites generic-typed signals (signal<T>(value))', async () => {
150
- // Regression for the silent-skip bug: SIGNAL_PREFIX_RE used to match
151
- // `signal(` but not `signal<T>(`. Pre-rewrite TypeScript still has type
152
- // parameters; declarations like `signal<string>('')` would skip HMR
153
- // preservation silently and produce an empty-string-valued signal that
154
- // — under a separate `__hmr_signal` interaction — could read as
155
- // undefined. Discovered via PR #329 (perf-dashboard form section).
156
- const plugin = createPlugin()
157
- const code = `
158
- import { signal } from "@pyreon/reactivity"
159
- export const password = signal<string>("")
160
- export const items = signal<Array<{ id: number }>>([])
161
- export const count = signal<number>(0)
162
- export function App() { return null }
163
- `
164
- const result = await transform(plugin, code, '/src/state.tsx')
165
- expect(result).toBeDefined()
166
- expect(result!.code).toContain(
167
- '__hmr_signal("/src/state.tsx", "password", signal, "")',
168
- )
169
- expect(result!.code).toContain(
170
- '__hmr_signal("/src/state.tsx", "items", signal, [])',
171
- )
172
- expect(result!.code).toContain('__hmr_signal("/src/state.tsx", "count", signal, 0)')
173
- })
174
-
175
- it('does not rewrite signal() inside functions to __hmr_signal (but injects name)', async () => {
176
- const plugin = createPlugin()
177
- const code = `
178
- import { signal } from "@pyreon/reactivity"
179
- import { h } from "@pyreon/core"
180
- export function Counter() {
181
- const local = signal(0)
182
- return h("div", null, local())
183
- }
184
- `
185
- const result = await transform(plugin, code, '/src/Counter.tsx')
186
- expect(result).toBeDefined()
187
- // The signal inside the function body should NOT be rewritten to __hmr_signal
188
- expect(result!.code).not.toContain('__hmr_signal')
189
- // But should get a debug name + source location injected (the LPIH
190
- // build-time injection, R4 follow-up — see lpih.md docs).
191
- expect(result!.code).toMatch(
192
- /signal\(0, \{ name: "local", __sourceLocation: \{ file: "\/src\/Counter\.tsx", line: \d+, col: \d+ \} \}\)/,
193
- )
194
- })
195
-
196
- it('rewrites multiple module-scope signals', async () => {
197
- const plugin = createPlugin()
198
- const code = `
199
- import { signal } from "@pyreon/reactivity"
200
- const count = signal(0)
201
- const name = signal("world")
202
- export function App() { return null }
203
- `
204
- const result = await transform(plugin, code, '/src/App.tsx')
205
- expect(result).toBeDefined()
206
- expect(result!.code).toContain('"count"')
207
- expect(result!.code).toContain('"name"')
208
- })
209
-
210
- it('handles signal with complex initial values', async () => {
211
- const plugin = createPlugin()
212
- const code = `
213
- import { signal } from "@pyreon/reactivity"
214
- const items = signal([1, 2, 3])
215
- const config = signal({ theme: "dark", size: 14 })
216
- export function App() { return null }
217
- `
218
- const result = await transform(plugin, code, '/src/App.tsx')
219
- expect(result).toBeDefined()
220
- expect(result!.code).toContain('__hmr_signal')
221
- expect(result!.code).toContain('[1, 2, 3]')
222
- expect(result!.code).toContain('{ theme: "dark", size: 14 }')
223
- })
224
-
225
- it('does not rewrite signal in build mode', async () => {
226
- const plugin = createBuildPlugin()
227
- const code = `
228
- import { signal } from "@pyreon/reactivity"
229
- const count = signal(0)
230
- export function App() { return null }
231
- `
232
- const result = await transform(plugin, code, '/src/App.tsx')
233
- expect(result).toBeDefined()
234
- expect(result!.code).not.toContain('__hmr_signal')
235
- // No signal names in production builds
236
- expect(result!.code).toContain('signal(0)')
237
- expect(result!.code).not.toContain('{ name:')
238
- })
239
-
240
- it('skips signal naming when options already provided', async () => {
241
- const plugin = createPlugin()
242
- const code = `
243
- import { signal } from "@pyreon/reactivity"
244
- export function App() {
245
- const count = signal(0, { name: "custom" })
246
- return null
247
- }
248
- `
249
- const result = await transform(plugin, code, '/src/App.tsx')
250
- expect(result).toBeDefined()
251
- // Should not double-inject name
252
- expect(result!.code).toContain('signal(0, { name: "custom" })')
253
- })
254
- })
255
-
256
- // ─── File extension filtering ────────────────────────────────────────────────
257
-
258
- describe('file extension filtering', () => {
259
- it('transforms .tsx files', async () => {
260
- const plugin = createPlugin()
261
- const code = `export function App() { return null }`
262
- const result = await transform(plugin, code, '/src/App.tsx')
263
- expect(result).toBeDefined()
264
- })
265
-
266
- it('transforms .jsx files', async () => {
267
- const plugin = createPlugin()
268
- const code = `export function App() { return null }`
269
- const result = await transform(plugin, code, '/src/App.jsx')
270
- expect(result).toBeDefined()
271
- })
272
-
273
- it('ignores .ts files', async () => {
274
- const plugin = createPlugin()
275
- const code = `export const x = 1`
276
- const result = await transform(plugin, code, '/src/utils.ts')
277
- expect(result).toBeUndefined()
278
- })
279
-
280
- it('ignores .js files', async () => {
281
- const plugin = createPlugin()
282
- const code = `export const x = 1`
283
- const result = await transform(plugin, code, '/src/utils.js')
284
- expect(result).toBeUndefined()
285
- })
286
-
287
- it('handles query strings in file paths', async () => {
288
- const plugin = createPlugin()
289
- const code = `export function App() { return null }`
290
- const result = await transform(plugin, code, '/src/App.tsx?v=123')
291
- expect(result).toBeDefined()
292
- })
293
- })
294
-
295
- // ─── Compat mode ─────────────────────────────────────────────────────────────
296
-
297
- describe('compat mode', () => {
298
- it('skips Pyreon JSX transform in react compat mode', async () => {
299
- const plugin = createPlugin({ compat: 'react' })
300
- const code = `
301
- import { useState } from "react"
302
- export function App() { const [x] = useState(0); return null }
303
- `
304
- const result = await transform(plugin, code, '/src/App.tsx')
305
- expect(result).toBeUndefined()
306
- })
307
-
308
- it('skips transform in preact compat mode', async () => {
309
- const plugin = createPlugin({ compat: 'preact' })
310
- const result = await transform(plugin, 'export function App() { return null }', '/src/App.tsx')
311
- expect(result).toBeUndefined()
312
- })
313
-
314
- it('skips transform in vue compat mode', async () => {
315
- const plugin = createPlugin({ compat: 'vue' })
316
- const result = await transform(plugin, 'export function App() { return null }', '/src/App.tsx')
317
- expect(result).toBeUndefined()
318
- })
319
-
320
- it('skips transform in solid compat mode', async () => {
321
- const plugin = createPlugin({ compat: 'solid' })
322
- const result = await transform(plugin, 'export function App() { return null }', '/src/App.tsx')
323
- expect(result).toBeUndefined()
324
- })
325
- })
326
-
327
- // ─── Plugin config ───────────────────────────────────────────────────────────
328
-
329
- describe('plugin config', () => {
330
- it('sets resolve.conditions: ["bun"] for workspace source resolution', async () => {
331
- const plugin = pyreonPlugin()
332
- const config = getConfigHook(plugin)({}, { command: 'serve' }) as Record<string, any>
333
- expect(config.resolve.conditions).toEqual(['bun'])
334
- })
335
-
336
- it('sets JSX import source to @pyreon/core by default', async () => {
337
- const plugin = pyreonPlugin()
338
- const config = getConfigHook(plugin)({}, { command: 'serve' }) as {
339
- oxc: { jsx: { importSource: string } }
340
- }
341
- expect(config.oxc.jsx.importSource).toBe('@pyreon/core')
342
- })
343
-
344
- it('keeps JSX import source as @pyreon/core in compat mode', async () => {
345
- // OXC's `importSource` is project-wide (one setting for the whole build),
346
- // so pointing it at the compat package would force the compat runtime
347
- // on `@pyreon/*` framework files too — which they cannot handle. Instead
348
- // the plugin keeps OXC at `@pyreon/core` and redirects the resulting
349
- // `@pyreon/core/jsx-runtime` import to the compat package via `resolveId`,
350
- // gated on the importer (user code only). See `compat-resolve.test.ts`
351
- // "framework-importer carve-out". Caught by `cpa-smoke-app-*-compat`.
352
- const plugin = pyreonPlugin({ compat: 'react' })
353
- const config = getConfigHook(plugin)({}, { command: 'serve' }) as {
354
- oxc: { jsx: { importSource: string } }
355
- }
356
- expect(config.oxc.jsx.importSource).toBe('@pyreon/core')
357
- })
358
-
359
- it('excludes compat packages from optimizeDeps', async () => {
360
- const plugin = pyreonPlugin({ compat: 'react' })
361
- const config = getConfigHook(plugin)({}, { command: 'serve' }) as {
362
- optimizeDeps: { exclude: string[] }
363
- }
364
- expect(config.optimizeDeps.exclude).toContain('react')
365
- expect(config.optimizeDeps.exclude).toContain('react-dom')
366
- })
367
-
368
- // Regression: pre-fix, the plugin's `bun` resolve condition redirected
369
- // every `@pyreon/*` import to source `.ts(x)` files. In a non-monorepo
370
- // consumer app, Vite's deps optimizer (esbuild) tried to pre-bundle
371
- // those packages from `node_modules` and silently produced broken
372
- // bundles in `.vite/deps/`, surfacing as
373
- // `File does not exist at .../node_modules/.vite/deps/@pyreon_styler.js`
374
- // at runtime. Fix scans the consumer's package.json for `@pyreon/*`
375
- // deps and adds them to optimizeDeps.exclude so the optimizer skips
376
- // them (resolution then goes through the plugin's own resolveId hook
377
- // and Vite's normal source pipeline).
378
- it("auto-excludes consumer's @pyreon/* deps from optimizeDeps (Vite optimizer fix)", async () => {
379
- // Build a fake consumer package.json with a few @pyreon/* deps.
380
- const tmpRoot = pathJoin(import.meta.dirname, 'fixtures', 'pyreon-deps-consumer')
381
- rmSync(tmpRoot, { recursive: true, force: true })
382
- mkdirSync(tmpRoot, { recursive: true })
383
- writeFileSync(
384
- pathJoin(tmpRoot, 'package.json'),
385
- JSON.stringify({
386
- name: 'fake-consumer',
387
- dependencies: {
388
- '@pyreon/core': '^0.15.0',
389
- '@pyreon/styler': '^0.15.0',
390
- '@pyreon/runtime-dom': '^0.15.0',
391
- // Non-@pyreon dep MUST NOT leak into the exclude list.
392
- react: '^19.0.0',
393
- },
394
- devDependencies: {
395
- '@pyreon/vite-plugin': '^0.15.0',
396
- },
397
- }),
398
- )
399
-
400
- const plugin = pyreonPlugin()
401
- const config = getConfigHook(plugin)({ root: tmpRoot }, { command: 'serve' }) as {
402
- optimizeDeps: { exclude: string[] }
403
- }
404
- expect(config.optimizeDeps.exclude).toContain('@pyreon/core')
405
- expect(config.optimizeDeps.exclude).toContain('@pyreon/styler')
406
- expect(config.optimizeDeps.exclude).toContain('@pyreon/runtime-dom')
407
- expect(config.optimizeDeps.exclude).toContain('@pyreon/vite-plugin')
408
- expect(config.optimizeDeps.exclude).not.toContain('react')
409
-
410
- rmSync(tmpRoot, { recursive: true, force: true })
411
- })
412
-
413
- it("merges @pyreon/* deps with compat aliases without dup'ing", async () => {
414
- const tmpRoot = pathJoin(import.meta.dirname, 'fixtures', 'pyreon-deps-compat')
415
- rmSync(tmpRoot, { recursive: true, force: true })
416
- mkdirSync(tmpRoot, { recursive: true })
417
- writeFileSync(
418
- pathJoin(tmpRoot, 'package.json'),
419
- JSON.stringify({ dependencies: { '@pyreon/core': '^0.15.0' } }),
420
- )
421
-
422
- const plugin = pyreonPlugin({ compat: 'react' })
423
- const config = getConfigHook(plugin)({ root: tmpRoot }, { command: 'serve' }) as {
424
- optimizeDeps: { exclude: string[] }
425
- }
426
- // Compat list still present
427
- expect(config.optimizeDeps.exclude).toContain('react')
428
- // Pyreon list also present
429
- expect(config.optimizeDeps.exclude).toContain('@pyreon/core')
430
- // Deduplicated (Set)
431
- const occurrences = config.optimizeDeps.exclude.filter((d) => d === 'react').length
432
- expect(occurrences).toBe(1)
433
-
434
- rmSync(tmpRoot, { recursive: true, force: true })
435
- })
436
-
437
- it('handles missing/malformed consumer package.json gracefully', async () => {
438
- const plugin = pyreonPlugin()
439
- // Point at a directory that doesn't exist — should not throw.
440
- const config = getConfigHook(plugin)(
441
- { root: '/nonexistent/path/that/does/not/exist' },
442
- { command: 'serve' },
443
- ) as { optimizeDeps: { exclude: string[] } }
444
- expect(config.optimizeDeps.exclude).toEqual([])
445
- })
446
-
447
- it('adds SSR build config when isSsrBuild', async () => {
448
- const plugin = pyreonPlugin({ ssr: { entry: './src/entry-server.ts' } })
449
- const config = getConfigHook(plugin)({}, { command: 'build', isSsrBuild: true }) as {
450
- build: { ssr: boolean; rollupOptions: { input: string } }
451
- }
452
- expect(config.build.ssr).toBe(true)
453
- expect(config.build.rollupOptions.input).toBe('./src/entry-server.ts')
454
- })
455
- })
456
-
457
- // ─── Virtual module (HMR runtime) ────────────────────────────────────────────
458
-
459
- describe('virtual module resolution', () => {
460
- it('resolves virtual:pyreon/hmr-runtime to internal ID', async () => {
461
- const plugin = createPlugin()
462
- const resolveId = plugin.resolveId as (
463
- id: string,
464
- ) => string | undefined | Promise<string | undefined>
465
- const resolved = await resolveId('virtual:pyreon/hmr-runtime')
466
- expect(resolved).toBe('\0pyreon/hmr-runtime')
467
- })
468
-
469
- it('loads HMR runtime source for internal ID', async () => {
470
- const plugin = createPlugin()
471
- const load = plugin.load as (id: string) => string | undefined
472
- const source = load('\0pyreon/hmr-runtime')
473
- expect(source).toBeDefined()
474
- expect(source).toContain('__hmr_signal')
475
- expect(source).toContain('__hmr_dispose')
476
- expect(source).toContain('__pyreon_hmr_registry__')
477
- })
478
-
479
- it('returns undefined for non-virtual IDs', async () => {
480
- const plugin = createPlugin()
481
- const load = plugin.load as (id: string) => string | undefined
482
- expect(load('/src/App.tsx')).toBeUndefined()
483
- })
484
- })
485
-
486
- // ─── Asset request detection ────────────────────────────────────────────────
487
-
488
- describe('asset request filtering', () => {
489
- // The SSR middleware uses isAssetRequest internally.
490
- // We test it via the configureServer middleware behavior.
491
- // For direct testing, we'd need to export it — instead we verify
492
- // the plugin's SSR middleware config exists when ssr option is set.
493
-
494
- it('configureServer returns middleware function when SSR enabled', async () => {
495
- const plugin = pyreonPlugin({ ssr: { entry: './src/entry-server.ts' } })
496
- expect(plugin.configureServer).toBeDefined()
497
- })
498
-
499
- it('configureServer is defined even without SSR (for context generation)', async () => {
500
- const plugin = pyreonPlugin()
501
- expect(plugin.configureServer).toBeDefined()
502
- })
503
- })