@pyreon/vite-plugin 0.24.5 → 0.24.6
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/package.json +2 -4
- package/src/hmr-runtime.ts +0 -92
- package/src/index.ts +0 -2116
- package/src/rocketstyle-collapse.ts +0 -199
- package/src/tests/cache-eviction-on-delete.test.ts +0 -187
- package/src/tests/compat-resolve.test.ts +0 -260
- package/src/tests/cross-module-signals.test.ts +0 -425
- package/src/tests/dev-server.test.ts +0 -171
- package/src/tests/islands-registry.test.ts +0 -236
- package/src/tests/lpih-auto-bridge.test.ts +0 -408
- package/src/tests/lpih-injection.test.ts +0 -559
- package/src/tests/rocketstyle-collapse-dev.test.ts +0 -119
- package/src/tests/rocketstyle-collapse.test.ts +0 -352
- package/src/tests/ssr-no-external.test.ts +0 -82
- package/src/tests/vite-plugin.test.ts +0 -503
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-discovered island-registry tests for @pyreon/vite-plugin.
|
|
3
|
-
*
|
|
4
|
-
* Exercises the `pyreon({ islands: true })` path:
|
|
5
|
-
* 1. Materialize synthetic source files containing `island()` calls
|
|
6
|
-
* 2. Drive plugin.config() + plugin.buildStart() to populate the registry
|
|
7
|
-
* 3. Drive plugin.load('\0pyreon/islands-registry') + assert the emitted
|
|
8
|
-
* source contains the expected loader entries (and excludes
|
|
9
|
-
* hydrate: 'never' islands)
|
|
10
|
-
*
|
|
11
|
-
* Companion to `cross-module-signals.test.ts` — same harness shape.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
-
import { tmpdir } from 'node:os'
|
|
16
|
-
import { join } from 'node:path'
|
|
17
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
18
|
-
import pyreonPlugin, { type PyreonPluginOptions } from '../index'
|
|
19
|
-
|
|
20
|
-
type ConfigHook = (
|
|
21
|
-
userConfig: Record<string, unknown>,
|
|
22
|
-
env: { command: string; isSsrBuild?: boolean },
|
|
23
|
-
) => Record<string, unknown>
|
|
24
|
-
|
|
25
|
-
type BuildStartHook = (this: unknown) => Promise<void>
|
|
26
|
-
type LoadHook = (id: string) => string | undefined
|
|
27
|
-
type ResolveIdHook = (
|
|
28
|
-
this: unknown,
|
|
29
|
-
id: string,
|
|
30
|
-
importer?: string,
|
|
31
|
-
) => Promise<string | null | undefined>
|
|
32
|
-
|
|
33
|
-
let root: string
|
|
34
|
-
|
|
35
|
-
beforeAll(() => {
|
|
36
|
-
root = mkdtempSync(join(tmpdir(), 'pyreon-islands-registry-'))
|
|
37
|
-
})
|
|
38
|
-
afterAll(() => {
|
|
39
|
-
rmSync(root, { recursive: true, force: true })
|
|
40
|
-
})
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
rmSync(root, { recursive: true, force: true })
|
|
43
|
-
mkdirSync(root, { recursive: true })
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
function writeFile(rel: string, contents: string): string {
|
|
47
|
-
const full = join(root, rel)
|
|
48
|
-
const dir = full.slice(0, full.lastIndexOf('/'))
|
|
49
|
-
mkdirSync(dir, { recursive: true })
|
|
50
|
-
writeFileSync(full, contents)
|
|
51
|
-
return full
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function bootstrap(opts?: PyreonPluginOptions) {
|
|
55
|
-
const plugin = pyreonPlugin(opts)
|
|
56
|
-
;(plugin.config as unknown as ConfigHook)({ root }, { command: 'build' })
|
|
57
|
-
return plugin
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function runBuildStart(plugin: ReturnType<typeof pyreonPlugin>) {
|
|
61
|
-
const buildStart = plugin.buildStart as BuildStartHook
|
|
62
|
-
await buildStart.call({})
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function runLoad(plugin: ReturnType<typeof pyreonPlugin>, id: string): string {
|
|
66
|
-
const result = (plugin.load as LoadHook)(id)
|
|
67
|
-
if (typeof result !== 'string') {
|
|
68
|
-
throw new Error(`load('${id}') returned ${typeof result}, expected string`)
|
|
69
|
-
}
|
|
70
|
-
return result
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function runResolveId(
|
|
74
|
-
plugin: ReturnType<typeof pyreonPlugin>,
|
|
75
|
-
id: string,
|
|
76
|
-
): Promise<string | null | undefined> {
|
|
77
|
-
return (plugin.resolveId as ResolveIdHook).call({}, id)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const ISLANDS_REGISTRY_IMPORT = 'virtual:pyreon/islands-registry'
|
|
81
|
-
const ISLANDS_REGISTRY_ID = '\0pyreon/islands-registry'
|
|
82
|
-
|
|
83
|
-
describe('vite-plugin — islands virtual module', () => {
|
|
84
|
-
it('resolveId redirects virtual:pyreon/islands-registry to the \\0-prefixed id', async () => {
|
|
85
|
-
const plugin = bootstrap()
|
|
86
|
-
expect(await runResolveId(plugin, ISLANDS_REGISTRY_IMPORT)).toBe(ISLANDS_REGISTRY_ID)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('emits an empty registry when no island() calls exist', async () => {
|
|
90
|
-
writeFile('src/App.tsx', `export const App = () => null`)
|
|
91
|
-
const plugin = bootstrap()
|
|
92
|
-
await runBuildStart(plugin)
|
|
93
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
94
|
-
expect(source).toContain('__pyreonIslandsEnabled = true')
|
|
95
|
-
expect(source).toContain('__pyreonIslandRegistry = {')
|
|
96
|
-
// No entries beyond the opening and closing braces
|
|
97
|
-
expect(source).not.toMatch(/import\(.+\)/)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('discovers `island(() => import("./X"), { name, hydrate: "load" })` calls', async () => {
|
|
101
|
-
writeFile(
|
|
102
|
-
'src/islands.ts',
|
|
103
|
-
`import { island } from '@pyreon/server'
|
|
104
|
-
export const Counter = island(() => import('./components/Counter'), {
|
|
105
|
-
name: 'Counter',
|
|
106
|
-
hydrate: 'load',
|
|
107
|
-
})`,
|
|
108
|
-
)
|
|
109
|
-
const plugin = bootstrap()
|
|
110
|
-
await runBuildStart(plugin)
|
|
111
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
112
|
-
expect(source).toContain('"Counter":')
|
|
113
|
-
// Loader path was resolved relative to the file where the call lives
|
|
114
|
-
expect(source).toContain(`/components/Counter`)
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
it('omits hydrate: "never" islands from the registry', async () => {
|
|
118
|
-
writeFile(
|
|
119
|
-
'src/islands.ts',
|
|
120
|
-
`import { island } from '@pyreon/server'
|
|
121
|
-
export const Counter = island(() => import('./Counter'), { name: 'Counter', hydrate: 'load' })
|
|
122
|
-
export const StaticBadge = island(() => import('./StaticBadge'), { name: 'StaticBadge', hydrate: 'never' })`,
|
|
123
|
-
)
|
|
124
|
-
const plugin = bootstrap()
|
|
125
|
-
await runBuildStart(plugin)
|
|
126
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
127
|
-
expect(source).toContain('"Counter":')
|
|
128
|
-
expect(source).not.toContain('"StaticBadge":')
|
|
129
|
-
expect(source).not.toContain('StaticBadge')
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('handles `media(...)` and `interaction` strategy strings without omitting them', async () => {
|
|
133
|
-
writeFile(
|
|
134
|
-
'src/islands.ts',
|
|
135
|
-
`import { island } from '@pyreon/server'
|
|
136
|
-
export const Mobile = island(() => import('./Mobile'), { name: 'Mobile', hydrate: 'media((max-width: 768px))' })
|
|
137
|
-
export const Idle = island(() => import('./Idle'), { name: 'Idle', hydrate: 'idle' })
|
|
138
|
-
export const Visible = island(() => import('./Visible'), { name: 'Visible', hydrate: 'visible' })`,
|
|
139
|
-
)
|
|
140
|
-
const plugin = bootstrap()
|
|
141
|
-
await runBuildStart(plugin)
|
|
142
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
143
|
-
expect(source).toContain('"Mobile":')
|
|
144
|
-
expect(source).toContain('"Idle":')
|
|
145
|
-
expect(source).toContain('"Visible":')
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it('discovers island() calls across multiple source files', async () => {
|
|
149
|
-
writeFile(
|
|
150
|
-
'src/foo/A.ts',
|
|
151
|
-
`import { island } from '@pyreon/server'
|
|
152
|
-
export const A = island(() => import('./component'), { name: 'A', hydrate: 'load' })`,
|
|
153
|
-
)
|
|
154
|
-
writeFile(
|
|
155
|
-
'src/bar/B.ts',
|
|
156
|
-
`import { island } from '@pyreon/server'
|
|
157
|
-
export const B = island(() => import('./component'), { name: 'B', hydrate: 'idle' })`,
|
|
158
|
-
)
|
|
159
|
-
const plugin = bootstrap()
|
|
160
|
-
await runBuildStart(plugin)
|
|
161
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
162
|
-
expect(source).toContain('"A":')
|
|
163
|
-
expect(source).toContain('"B":')
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('skips node_modules / dist / lib / build during the prescan walk', async () => {
|
|
167
|
-
writeFile(
|
|
168
|
-
'node_modules/some-pkg/island.ts',
|
|
169
|
-
`island(() => import('./X'), { name: 'IgnoreMe', hydrate: 'load' })`,
|
|
170
|
-
)
|
|
171
|
-
writeFile(
|
|
172
|
-
'dist/build-output.ts',
|
|
173
|
-
`island(() => import('./X'), { name: 'AlsoIgnoreMe', hydrate: 'load' })`,
|
|
174
|
-
)
|
|
175
|
-
writeFile(
|
|
176
|
-
'src/Real.ts',
|
|
177
|
-
`import { island } from '@pyreon/server'
|
|
178
|
-
export const Real = island(() => import('./X'), { name: 'Real', hydrate: 'load' })`,
|
|
179
|
-
)
|
|
180
|
-
const plugin = bootstrap()
|
|
181
|
-
await runBuildStart(plugin)
|
|
182
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
183
|
-
expect(source).toContain('"Real":')
|
|
184
|
-
expect(source).not.toContain('"IgnoreMe":')
|
|
185
|
-
expect(source).not.toContain('"AlsoIgnoreMe":')
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('emits a stub registry when islands: false is set', async () => {
|
|
189
|
-
writeFile(
|
|
190
|
-
'src/islands.ts',
|
|
191
|
-
`import { island } from '@pyreon/server'
|
|
192
|
-
export const Counter = island(() => import('./Counter'), { name: 'Counter', hydrate: 'load' })`,
|
|
193
|
-
)
|
|
194
|
-
const plugin = bootstrap({ islands: false })
|
|
195
|
-
await runBuildStart(plugin)
|
|
196
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
197
|
-
// Stub flips the enabled flag so hydrateIslandsAuto() throws at runtime
|
|
198
|
-
// with a clear message — better than a silent empty registry.
|
|
199
|
-
expect(source).toContain('__pyreonIslandsEnabled = false')
|
|
200
|
-
expect(source).not.toContain('"Counter":')
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
it('deduplicates duplicate names (last-wins order)', async () => {
|
|
204
|
-
writeFile(
|
|
205
|
-
'src/a.ts',
|
|
206
|
-
`import { island } from '@pyreon/server'
|
|
207
|
-
export const A = island(() => import('./a-comp'), { name: 'Same', hydrate: 'load' })`,
|
|
208
|
-
)
|
|
209
|
-
writeFile(
|
|
210
|
-
'src/b.ts',
|
|
211
|
-
`import { island } from '@pyreon/server'
|
|
212
|
-
export const B = island(() => import('./b-comp'), { name: 'Same', hydrate: 'load' })`,
|
|
213
|
-
)
|
|
214
|
-
const plugin = bootstrap()
|
|
215
|
-
await runBuildStart(plugin)
|
|
216
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
217
|
-
// Only one entry for "Same" emitted — registry can't have duplicate keys.
|
|
218
|
-
const matches = source.match(/"Same":/g) ?? []
|
|
219
|
-
expect(matches).toHaveLength(1)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('skips island() calls without a name field (auto-registry has nothing to key on)', async () => {
|
|
223
|
-
writeFile(
|
|
224
|
-
'src/islands.ts',
|
|
225
|
-
`import { island } from '@pyreon/server'
|
|
226
|
-
// Anomaly: island() without a name option. Auto-registry can't include this.
|
|
227
|
-
export const Bad = island(() => import('./X'), { hydrate: 'load' } as any)
|
|
228
|
-
export const Good = island(() => import('./Y'), { name: 'Good', hydrate: 'load' })`,
|
|
229
|
-
)
|
|
230
|
-
const plugin = bootstrap()
|
|
231
|
-
await runBuildStart(plugin)
|
|
232
|
-
const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
|
|
233
|
-
expect(source).toContain('"Good":')
|
|
234
|
-
expect(source).not.toContain('"Bad":')
|
|
235
|
-
})
|
|
236
|
-
})
|
|
@@ -1,408 +0,0 @@
|
|
|
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
|
-
})
|