@pyreon/vite-plugin 0.13.0 → 0.14.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,425 @@
1
+ /**
2
+ * Cross-module signal resolution tests for @pyreon/vite-plugin.
3
+ *
4
+ * These tests exercise the buildStart pre-scan + import-resolution path
5
+ * that the existing test file doesn't reach. Approach:
6
+ * 1. Materialize synthetic Pyreon source files in a tmp dir
7
+ * 2. Drive plugin.config() with that root
8
+ * 3. Drive plugin.buildStart() to populate the signal registry
9
+ * 4. Drive plugin.transform() on a consumer file with imports
10
+ * whose `resolve()` mock returns the synthetic file path
11
+ * 5. Assert the compiled output recognises the cross-module signal
12
+ * via the auto-call rewrite (`{count}` → `{() => count()}`)
13
+ */
14
+
15
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { tmpdir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
19
+ import pyreonPlugin, { type PyreonPluginOptions } from '../index'
20
+
21
+ type ConfigHook = (
22
+ userConfig: Record<string, unknown>,
23
+ env: { command: string; isSsrBuild?: boolean },
24
+ ) => Record<string, unknown>
25
+
26
+ type BuildStartHook = (this: unknown) => Promise<void>
27
+
28
+ type TransformCtx = {
29
+ warn: (msg: string) => void
30
+ resolve: (
31
+ id: string,
32
+ importer?: string,
33
+ options?: { skipSelf: boolean },
34
+ ) => Promise<{ id: string } | null>
35
+ }
36
+ type TransformHook = (
37
+ this: TransformCtx,
38
+ code: string,
39
+ id: string,
40
+ ) => Promise<{ code: string; map: null } | undefined>
41
+
42
+ // One tmp dir per file, populated by individual tests
43
+ let root: string
44
+
45
+ beforeAll(() => {
46
+ root = mkdtempSync(join(tmpdir(), 'pyreon-cross-module-'))
47
+ })
48
+ afterAll(() => {
49
+ rmSync(root, { recursive: true, force: true })
50
+ })
51
+ beforeEach(() => {
52
+ // Re-create the root each test so the registry sees fresh fixtures
53
+ rmSync(root, { recursive: true, force: true })
54
+ mkdirSync(root, { recursive: true })
55
+ })
56
+
57
+ function writeFile(rel: string, contents: string): string {
58
+ const full = join(root, rel)
59
+ const dir = full.slice(0, full.lastIndexOf('/'))
60
+ mkdirSync(dir, { recursive: true })
61
+ writeFileSync(full, contents)
62
+ return full
63
+ }
64
+
65
+ function bootstrap(opts?: PyreonPluginOptions) {
66
+ const plugin = pyreonPlugin(opts)
67
+ ;(plugin.config as unknown as ConfigHook)({ root }, { command: 'build' })
68
+ return plugin
69
+ }
70
+
71
+ async function runBuildStart(plugin: ReturnType<typeof pyreonPlugin>) {
72
+ const buildStart = plugin.buildStart as BuildStartHook
73
+ await buildStart.call({})
74
+ }
75
+
76
+ async function runTransform(
77
+ plugin: ReturnType<typeof pyreonPlugin>,
78
+ code: string,
79
+ id: string,
80
+ resolveMap: Record<string, string> = {},
81
+ ) {
82
+ const hook = plugin.transform as TransformHook
83
+ const warnings: string[] = []
84
+ return hook.call(
85
+ {
86
+ warn: (msg: string) => warnings.push(msg),
87
+ resolve: async (specifier: string) => {
88
+ const resolved = resolveMap[specifier]
89
+ return resolved ? { id: resolved } : null
90
+ },
91
+ },
92
+ code,
93
+ id,
94
+ )
95
+ }
96
+
97
+ describe('vite-plugin — buildStart signal pre-scan', () => {
98
+ it('walks the project root and registers `export const x = signal()` patterns', async () => {
99
+ writeFile(
100
+ 'src/store.ts',
101
+ `import { signal } from "@pyreon/core"
102
+ export const count = signal(0)
103
+ export const theme = signal("light")`,
104
+ )
105
+ writeFile(
106
+ 'src/App.tsx',
107
+ `import { h } from "@pyreon/core"
108
+ import { count } from "./store"
109
+ export function App() { return <div>{count}</div> }`,
110
+ )
111
+
112
+ const plugin = bootstrap()
113
+ await runBuildStart(plugin)
114
+
115
+ // Drive transform on App.tsx; mock the resolver to return the
116
+ // store.ts absolute path. The plugin should recognise `count` as
117
+ // a known signal and emit auto-call wiring.
118
+ const result = await runTransform(
119
+ plugin,
120
+ `import { h } from "@pyreon/core"
121
+ import { count } from "./store"
122
+ export function App() { return <div>{count}</div> }`,
123
+ join(root, 'src/App.tsx'),
124
+ { './store': join(root, 'src/store.ts') },
125
+ )
126
+ expect(result).toBeDefined()
127
+ // Auto-call rewrite — `count` reference becomes `count()` because
128
+ // it's tracked as a signal export from ./store
129
+ expect(result!.code).toMatch(/count\(\)/)
130
+ })
131
+
132
+ it('registers `const x = signal(); export { x }` separate-export pattern', async () => {
133
+ writeFile(
134
+ 'src/state.ts',
135
+ `import { signal } from "@pyreon/core"
136
+ const internal = signal(42)
137
+ const renamed = signal(0)
138
+ export { internal, renamed as exported }`,
139
+ )
140
+
141
+ const plugin = bootstrap()
142
+ await runBuildStart(plugin)
143
+
144
+ // Consumer: imports the renamed (via 'as') name
145
+ const result = await runTransform(
146
+ plugin,
147
+ `import { h } from "@pyreon/core"
148
+ import { exported } from "./state"
149
+ export const Comp = () => <div>{exported}</div>`,
150
+ join(root, 'src/use.tsx'),
151
+ { './state': join(root, 'src/state.ts') },
152
+ )
153
+ expect(result).toBeDefined()
154
+ // The 'exported' name should be recognised — it's the as-aliased
155
+ // export of a signal-bound local. Auto-call rewrite turns the
156
+ // bare reference into a tracked call.
157
+ expect(result!.code).toMatch(/exported\(\)/)
158
+ })
159
+
160
+ it('registers `export default signal()` as the magic default key', async () => {
161
+ writeFile(
162
+ 'src/single.ts',
163
+ `import { signal } from "@pyreon/core"
164
+ export default signal(0)`,
165
+ )
166
+
167
+ const plugin = bootstrap()
168
+ await runBuildStart(plugin)
169
+
170
+ // Default-import the signal
171
+ const result = await runTransform(
172
+ plugin,
173
+ `import { h } from "@pyreon/core"
174
+ import counter from "./single"
175
+ export const Comp = () => <div>{counter}</div>`,
176
+ join(root, 'src/use.tsx'),
177
+ { './single': join(root, 'src/single.ts') },
178
+ )
179
+ expect(result).toBeDefined()
180
+ // Default-import path resolves through the magic 'default' registry key.
181
+ expect(result!.code).toMatch(/counter\(\)/)
182
+ })
183
+
184
+ it('skips node_modules / dist / lib / build / dot directories during walk', async () => {
185
+ // Files under excluded dirs should NOT register
186
+ writeFile(
187
+ 'node_modules/foo/index.ts',
188
+ `import { signal } from "@pyreon/core"
189
+ export const ignored = signal(0)`,
190
+ )
191
+ writeFile(
192
+ 'dist/bundle.js',
193
+ `import { signal } from "@pyreon/core"
194
+ export const alsoIgnored = signal(0)`,
195
+ )
196
+ writeFile(
197
+ '.git/config.ts',
198
+ `import { signal } from "@pyreon/core"
199
+ export const dotIgnored = signal(0)`,
200
+ )
201
+ // A real source file — should register
202
+ writeFile(
203
+ 'src/visible.ts',
204
+ `import { signal } from "@pyreon/core"
205
+ export const visible = signal(0)`,
206
+ )
207
+
208
+ const plugin = bootstrap()
209
+ await runBuildStart(plugin)
210
+
211
+ // Compile a consumer that imports `ignored` from a real file.
212
+ // If the walker had visited node_modules, `ignored` would be
213
+ // recognised. We assert it's NOT — so the import is treated as a
214
+ // plain identifier, no auto-call.
215
+ const result = await runTransform(
216
+ plugin,
217
+ `import { ignored } from "./from-mods"
218
+ export const X = () => ignored`,
219
+ join(root, 'src/use.tsx'),
220
+ { './from-mods': join(root, 'node_modules/foo/index.ts') },
221
+ )
222
+ expect(result).toBeDefined()
223
+ // ignored should NOT have been registered, so no auto-call
224
+ expect(result!.code).not.toMatch(/ignored\(\)/)
225
+ })
226
+
227
+ it('handles multiple signal exports per file', async () => {
228
+ writeFile(
229
+ 'src/many.ts',
230
+ `import { signal, computed } from "@pyreon/core"
231
+ export const a = signal(1)
232
+ export const b = signal(2)
233
+ export const c = computed(() => a() + b())`,
234
+ )
235
+
236
+ const plugin = bootstrap()
237
+ await runBuildStart(plugin)
238
+
239
+ // All three should be tracked
240
+ for (const name of ['a', 'b', 'c']) {
241
+ const result = await runTransform(
242
+ plugin,
243
+ `import { ${name} } from "./many"
244
+ export const Use = () => <div>{${name}}</div>`,
245
+ join(root, 'src/use.tsx'),
246
+ { './many': join(root, 'src/many.ts') },
247
+ )
248
+ expect(result).toBeDefined()
249
+ // Each signal name should be auto-called when used as a bare
250
+ // identifier in an h() child position.
251
+ expect(result!.code).toMatch(new RegExp(`${name}\\(\\)`))
252
+ }
253
+ })
254
+
255
+ it('does not register non-signal const exports', async () => {
256
+ writeFile(
257
+ 'src/plain.ts',
258
+ `export const PI = 3.14
259
+ export const formatDate = (d) => d.toISOString()`,
260
+ )
261
+
262
+ const plugin = bootstrap()
263
+ await runBuildStart(plugin)
264
+
265
+ const result = await runTransform(
266
+ plugin,
267
+ `import { PI } from "./plain"
268
+ export const Use = () => PI`,
269
+ join(root, 'src/use.tsx'),
270
+ { './plain': join(root, 'src/plain.ts') },
271
+ )
272
+ expect(result).toBeDefined()
273
+ // Plain const — not a signal, no auto-call
274
+ expect(result!.code).not.toMatch(/PI\(\)/)
275
+ })
276
+
277
+ it('skips re-exports (export { x } from "./other")', async () => {
278
+ // The signal scanner explicitly skips re-export forms.
279
+ writeFile(
280
+ 'src/source.ts',
281
+ `import { signal } from "@pyreon/core"
282
+ export const real = signal(0)`,
283
+ )
284
+ writeFile(
285
+ 'src/barrel.ts',
286
+ `export { real } from "./source"`,
287
+ )
288
+
289
+ const plugin = bootstrap()
290
+ await runBuildStart(plugin)
291
+
292
+ // Importing through the barrel — barrel.ts has no LOCAL `signal()`
293
+ // declaration, so the re-export is NOT registered as a signal in
294
+ // barrel.ts's own entry. (Direct ./source import would still work.)
295
+ const result = await runTransform(
296
+ plugin,
297
+ `import { real } from "./barrel"
298
+ export const Use = () => real`,
299
+ join(root, 'src/use.tsx'),
300
+ { './barrel': join(root, 'src/barrel.ts') },
301
+ )
302
+ expect(result).toBeDefined()
303
+ // Documented limitation in scanSignalExports header — not auto-called
304
+ expect(result!.code).not.toMatch(/real\(\)/)
305
+ })
306
+ })
307
+
308
+ describe('vite-plugin — resolveImportedSignals', () => {
309
+ it('resolves named imports through the cache (second call is a hit)', async () => {
310
+ writeFile(
311
+ 'src/store.ts',
312
+ `import { signal } from "@pyreon/core"
313
+ export const cached = signal(0)`,
314
+ )
315
+
316
+ const plugin = bootstrap()
317
+ await runBuildStart(plugin)
318
+
319
+ let resolveCalls = 0
320
+ const hook = plugin.transform as TransformHook
321
+ const ctx: TransformCtx = {
322
+ warn: () => {},
323
+ resolve: async (specifier: string) => {
324
+ resolveCalls++
325
+ if (specifier === './store') return { id: join(root, 'src/store.ts') }
326
+ return null
327
+ },
328
+ }
329
+
330
+ // Two transforms with the same import — the per-plugin
331
+ // resolveCache should make the second resolve a no-op.
332
+ const code = `import { cached } from "./store"
333
+ export const Use = () => cached`
334
+ await hook.call(ctx, code, join(root, 'src/use1.tsx'))
335
+ const callsAfterFirst = resolveCalls
336
+ await hook.call(ctx, code, join(root, 'src/use1.tsx'))
337
+ // Second invocation reuses the cache for the same (moduleId, source) pair
338
+ expect(resolveCalls).toBe(callsAfterFirst)
339
+ })
340
+
341
+ it('skips type-only imports', async () => {
342
+ writeFile(
343
+ 'src/types.ts',
344
+ `import { signal } from "@pyreon/core"
345
+ export const myType = signal(0)`,
346
+ )
347
+
348
+ const plugin = bootstrap()
349
+ await runBuildStart(plugin)
350
+
351
+ // `import type { X }` should NOT be processed by resolveImportedSignals
352
+ const result = await runTransform(
353
+ plugin,
354
+ `import type { myType } from "./types"
355
+ export const Use = () => null`,
356
+ join(root, 'src/use.tsx'),
357
+ { './types': join(root, 'src/types.ts') },
358
+ )
359
+ expect(result).toBeDefined()
360
+ // The type import is irrelevant at runtime; nothing should auto-call
361
+ expect(result!.code).not.toMatch(/myType\(\)/)
362
+ })
363
+
364
+ it('handles imports from a module that has no signal exports', async () => {
365
+ writeFile(
366
+ 'src/utils.ts',
367
+ `export const formatDate = (d) => String(d)
368
+ export const ID = "x"`,
369
+ )
370
+
371
+ const plugin = bootstrap()
372
+ await runBuildStart(plugin)
373
+
374
+ const result = await runTransform(
375
+ plugin,
376
+ `import { formatDate } from "./utils"
377
+ export const x = formatDate`,
378
+ join(root, 'src/use.tsx'),
379
+ { './utils': join(root, 'src/utils.ts') },
380
+ )
381
+ expect(result).toBeDefined()
382
+ // No registry hit — formatDate is plain
383
+ expect(result!.code).not.toMatch(/formatDate\(\)/)
384
+ })
385
+
386
+ it('skips when resolve() returns null (unresolvable import)', async () => {
387
+ const plugin = bootstrap()
388
+ await runBuildStart(plugin)
389
+
390
+ // resolve mock returns null for everything — no registry lookup possible
391
+ const result = await runTransform(
392
+ plugin,
393
+ `import { mystery } from "./nowhere"
394
+ export const Use = () => mystery`,
395
+ join(root, 'src/use.tsx'),
396
+ {},
397
+ )
398
+ expect(result).toBeDefined()
399
+ expect(result!.code).not.toMatch(/mystery\(\)/)
400
+ })
401
+
402
+ it('handles default imports against the magic "default" registry key', async () => {
403
+ writeFile(
404
+ 'src/default.ts',
405
+ `import { signal } from "@pyreon/core"
406
+ export default signal(0)`,
407
+ )
408
+
409
+ const plugin = bootstrap()
410
+ await runBuildStart(plugin)
411
+
412
+ const result = await runTransform(
413
+ plugin,
414
+ `import { h } from "@pyreon/core"
415
+ import myCount from "./default"
416
+ export const Use = () => <div>{myCount}</div>`,
417
+ join(root, 'src/use.tsx'),
418
+ { './default': join(root, 'src/default.ts') },
419
+ )
420
+ expect(result).toBeDefined()
421
+ // The local name `myCount` should be auto-called even though the
422
+ // export is `default` — that's the cross-module default-import path
423
+ expect(result!.code).toMatch(/myCount\(\)/)
424
+ })
425
+ })
@@ -0,0 +1,167 @@
1
+ /**
2
+ * configureServer hook coverage for @pyreon/vite-plugin (PR #323).
3
+ *
4
+ * The hook accepts a ViteDevServer mock and:
5
+ * 1. Eagerly generates .pyreon/context.json (wrapped in try/catch)
6
+ * 2. Subscribes to file-change events with a 500ms debounce
7
+ * 3. Returns a server-side middleware factory ONLY when SSR is enabled
8
+ *
9
+ * We mock ViteDevServer minimally — just `watcher.on` and `middlewares.use`.
10
+ */
11
+
12
+ import { mkdtempSync, rmSync } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16
+ import pyreonPlugin, { type PyreonPluginOptions } from '../index'
17
+
18
+ type ConfigHook = (
19
+ userConfig: Record<string, unknown>,
20
+ env: { command: string; isSsrBuild?: boolean },
21
+ ) => Record<string, unknown>
22
+
23
+ interface MockServer {
24
+ watcher: { on: ReturnType<typeof vi.fn>; emit?: (event: string, file: string) => void }
25
+ middlewares: { use: ReturnType<typeof vi.fn> }
26
+ ssrFixStacktrace: (e: Error) => void
27
+ ssrLoadModule: ReturnType<typeof vi.fn>
28
+ transformIndexHtml: ReturnType<typeof vi.fn>
29
+ }
30
+
31
+ function createMockServer(): MockServer {
32
+ const handlers: Record<string, (file: string) => void> = {}
33
+ return {
34
+ watcher: {
35
+ on: vi.fn((event: string, cb: (file: string) => void) => {
36
+ handlers[event] = cb
37
+ }),
38
+ emit: (event: string, file: string) => handlers[event]?.(file),
39
+ },
40
+ middlewares: { use: vi.fn() },
41
+ ssrFixStacktrace: () => {},
42
+ ssrLoadModule: vi.fn(),
43
+ transformIndexHtml: vi.fn(async (_url: string, html: string) => html),
44
+ }
45
+ }
46
+
47
+ let root: string
48
+
49
+ beforeEach(() => {
50
+ root = mkdtempSync(join(tmpdir(), 'pyreon-dev-server-'))
51
+ })
52
+ afterEach(() => {
53
+ rmSync(root, { recursive: true, force: true })
54
+ })
55
+
56
+ function bootstrap(opts?: PyreonPluginOptions) {
57
+ const plugin = pyreonPlugin(opts)
58
+ ;(plugin.config as unknown as ConfigHook)({ root }, { command: 'serve' })
59
+ return plugin
60
+ }
61
+
62
+ describe('vite-plugin — configureServer (no SSR)', () => {
63
+ it('subscribes to watcher.change events when no SSR config provided', () => {
64
+ const plugin = bootstrap()
65
+ const server = createMockServer()
66
+ const result = (plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
67
+
68
+ expect(server.watcher.on).toHaveBeenCalledWith('change', expect.any(Function))
69
+ // No SSR → no middleware factory returned
70
+ expect(result).toBeUndefined()
71
+ // No middlewares registered
72
+ expect(server.middlewares.use).not.toHaveBeenCalled()
73
+ })
74
+
75
+ it('regenerates .pyreon/context.json on a debounced .tsx change', () => {
76
+ vi.useFakeTimers()
77
+ const plugin = bootstrap()
78
+ const server = createMockServer()
79
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
80
+
81
+ server.watcher.emit?.('change', join(root, 'src/App.tsx'))
82
+ server.watcher.emit?.('change', join(root, 'src/App.tsx'))
83
+ server.watcher.emit?.('change', join(root, 'src/App.tsx'))
84
+
85
+ // Three events queued; debounce should collapse them. Run timers.
86
+ vi.advanceTimersByTime(600)
87
+ vi.useRealTimers()
88
+ // (No assertion on the content — the inner generateProjectContext
89
+ // just touches the filesystem; we're exercising the subscription
90
+ // and debounce path. Coverage is the goal.)
91
+ })
92
+
93
+ it('ignores changes inside node_modules', () => {
94
+ vi.useFakeTimers()
95
+ const plugin = bootstrap()
96
+ const server = createMockServer()
97
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
98
+
99
+ server.watcher.emit?.('change', join(root, 'node_modules/foo/index.ts'))
100
+ vi.advanceTimersByTime(600)
101
+ vi.useRealTimers()
102
+ })
103
+
104
+ it('ignores changes to non-source files (e.g. .md, .json, .css)', () => {
105
+ vi.useFakeTimers()
106
+ const plugin = bootstrap()
107
+ const server = createMockServer()
108
+ ;(plugin.configureServer as unknown as (s: MockServer) => unknown)(server)
109
+
110
+ server.watcher.emit?.('change', join(root, 'README.md'))
111
+ server.watcher.emit?.('change', join(root, 'package.json'))
112
+ server.watcher.emit?.('change', join(root, 'styles.css'))
113
+ vi.advanceTimersByTime(600)
114
+ vi.useRealTimers()
115
+ })
116
+ })
117
+
118
+ describe('vite-plugin — configureServer (SSR enabled)', () => {
119
+ it('returns a middleware factory when ssr.entry is configured', () => {
120
+ const plugin = bootstrap({
121
+ ssr: { entry: '/src/entry-server.ts' },
122
+ })
123
+ const server = createMockServer()
124
+ const result = (plugin.configureServer as unknown as (s: MockServer) => () => void)(server)
125
+ expect(typeof result).toBe('function')
126
+
127
+ // Calling the returned factory registers the middleware
128
+ result()
129
+ expect(server.middlewares.use).toHaveBeenCalledTimes(1)
130
+ })
131
+
132
+ it('the registered middleware skips non-GET requests', async () => {
133
+ const plugin = bootstrap({ ssr: { entry: '/src/entry-server.ts' } })
134
+ const server = createMockServer()
135
+ const factory = (plugin.configureServer as unknown as (s: MockServer) => () => void)(server)
136
+ factory()
137
+
138
+ const middleware = server.middlewares.use.mock.calls[0]?.[0] as
139
+ | ((req: { method: string; url: string }, res: unknown, next: () => void) => Promise<void>)
140
+ | undefined
141
+ expect(middleware).toBeDefined()
142
+
143
+ const next = vi.fn()
144
+ await middleware!({ method: 'POST', url: '/api/x' }, {}, next)
145
+ expect(next).toHaveBeenCalledTimes(1)
146
+ })
147
+
148
+ it('the registered middleware skips asset requests', async () => {
149
+ const plugin = bootstrap({ ssr: { entry: '/src/entry-server.ts' } })
150
+ const server = createMockServer()
151
+ const factory = (plugin.configureServer as unknown as (s: MockServer) => () => void)(server)
152
+ factory()
153
+
154
+ const middleware = server.middlewares.use.mock.calls[0]?.[0] as
155
+ | ((req: { method: string; url: string }, res: unknown, next: () => void) => Promise<void>)
156
+ | undefined
157
+ expect(middleware).toBeDefined()
158
+
159
+ const next = vi.fn()
160
+ await middleware!({ method: 'GET', url: '/style.css' }, {}, next)
161
+ expect(next).toHaveBeenCalledTimes(1)
162
+
163
+ const next2 = vi.fn()
164
+ await middleware!({ method: 'GET', url: '/image.svg' }, {}, next2)
165
+ expect(next2).toHaveBeenCalledTimes(1)
166
+ })
167
+ })