@pyreon/vite-plugin 0.13.1 → 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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +144 -3
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +236 -3
- package/src/tests/compat-resolve.test.ts +178 -0
- package/src/tests/cross-module-signals.test.ts +425 -0
- package/src/tests/dev-server.test.ts +167 -0
- package/src/tests/vite-plugin.test.ts +60 -53
|
@@ -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
|
+
})
|