@invisibleloop/pulse 0.20.38 → 0.20.40

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,491 +0,0 @@
1
- /**
2
- * Tests for the server-only property stripper in build.js
3
- * Run: node scripts/strip-server.test.js
4
- */
5
-
6
- // ---------------------------------------------------------------------------
7
- // Inline the scanner functions (duplicated here to keep test self-contained)
8
- // ---------------------------------------------------------------------------
9
-
10
- const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
11
- const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
12
-
13
- function stripServerOnlyKeys(source) {
14
- for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
15
- return source
16
- }
17
-
18
- function stripServerOnlyImports(source) {
19
- const extraPattern = SERVER_ONLY_IMPORTS
20
- .map(m => m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
21
- .join('|')
22
- const combinedRe = new RegExp(
23
- `^[ \\t]*import\\s+(?:\\{[^}]*\\}|[\\w*]+(?:\\s+as\\s+\\w+)?)\\s+from\\s+['"](?:node:[^'"]+|${extraPattern})['"]\\s*;?[ \\t]*\\n?`,
24
- 'gm'
25
- )
26
- const importedNames = new Set()
27
- for (const m of source.matchAll(combinedRe)) {
28
- const named = m[0].match(/\{\s*([^}]*)\s*\}/)
29
- if (named) {
30
- for (const part of named[1].split(',')) {
31
- const name = part.trim().split(/\s+as\s+/).pop().trim()
32
- if (name) importedNames.add(name)
33
- }
34
- }
35
- const def = m[0].match(/import\s+([\w]+)\s+from/)
36
- if (def) importedNames.add(def[1])
37
- }
38
- source = source.replace(combinedRe, '')
39
- for (const name of importedNames) {
40
- const declRe = new RegExp(
41
- `^[ \\t]*(?:const|let|var)\\s+\\w+\\s*=\\s*${name}\\b[^\\n]*;?[ \\t]*\\n?`,
42
- 'gm'
43
- )
44
- source = source.replace(declRe, '')
45
- }
46
- return source
47
- }
48
-
49
- function strip(source) {
50
- return stripServerOnlyImports(stripServerOnlyKeys(source))
51
- }
52
-
53
- // Mirrors needsClientBundle() in build.js exactly — see its doc comment there.
54
- function needsClientBundle(strippedSource) {
55
- return /\b(mutations|actions|persist|store)\s*:/.test(strippedSource)
56
- }
57
-
58
- function isInsideString(source, pos) {
59
- let i = 0
60
- const stack = []
61
- while (i < pos) {
62
- const c = source[i]
63
- const top = stack[stack.length - 1]
64
- if (top === '"' || top === "'") {
65
- if (c === '\\') { i += 2; continue }
66
- if (c === top) { stack.pop() }
67
- i++; continue
68
- }
69
- if (top === '`') {
70
- if (c === '\\') { i += 2; continue }
71
- if (c === '`') { stack.pop(); i++; continue }
72
- if (c === '$' && source[i + 1] === '{') { stack.push('{'); i += 2; continue }
73
- i++; continue
74
- }
75
- if (top === '{') {
76
- if (c === '{') { stack.push('{'); i++; continue }
77
- if (c === '}') { stack.pop(); i++; continue }
78
- if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
79
- i++; continue
80
- }
81
- if (c === '/') {
82
- if (source[i + 1] === '/') { while (i < pos && source[i] !== '\n') i++; continue }
83
- if (source[i + 1] === '*') { const end = source.indexOf('*/', i + 2); i = end < 0 ? pos : end + 2; continue }
84
- let prev = ''
85
- for (let k = i - 1; k >= 0; k--) { if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break } }
86
- if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
87
- i++
88
- while (i < pos) { if (source[i] === '\\') { i += 2; continue } if (source[i] === '/') { i++; break } if (source[i] === '\n') break; i++ }
89
- while (i < pos && /[gimsuy]/.test(source[i])) i++
90
- continue
91
- }
92
- }
93
- if (c === '"' || c === "'" || c === '`') { stack.push(c) }
94
- i++
95
- }
96
- return stack.length > 0
97
- }
98
-
99
- function removeObjectKey(source, key) {
100
- const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
101
- let match
102
- while ((match = keyRe.exec(source)) !== null) {
103
- if (isInsideString(source, match.index)) { continue }
104
- const removeStart = match.index
105
- const afterColon = match.index + match[0].length
106
- let pos = afterColon
107
- while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
108
- pos = scanJsValue(source, pos)
109
- while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
110
- if (pos < source.length && source[pos] === ',') pos++
111
- if (pos < source.length && source[pos] === '\n') pos++
112
- source = source.slice(0, removeStart) + source.slice(pos)
113
- keyRe.lastIndex = removeStart
114
- }
115
- return source
116
- }
117
-
118
- function scanJsValue(src, pos) {
119
- let i = pos, depth = 0
120
- while (i < src.length) {
121
- const c = src[i]
122
- if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; continue }
123
- if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; continue }
124
- if (c === '"' || c === "'") {
125
- i++
126
- while (i < src.length) { if (src[i] === '\\') { i += 2; continue } if (src[i] === c) { i++; break } i++ }
127
- if (depth === 0) return i
128
- continue
129
- }
130
- if (c === '`') { i = scanTemplateLiteral(src, i + 1); if (depth === 0) return i; continue }
131
- if (c === '{' || c === '[' || c === '(') { depth++; i++; continue }
132
- if (c === '}' || c === ']' || c === ')') {
133
- if (depth === 0) return i
134
- depth--; i++
135
- if (depth === 0) {
136
- let j = i
137
- while (j < src.length && (src[j] === ' ' || src[j] === '\t')) j++
138
- const next = src[j]
139
- if (!next || next === ',' || next === '}' || next === ']' || next === ')' || next === '\n') return i
140
- if (next === '=' && src[j + 1] === '>') continue
141
- continue
142
- }
143
- continue
144
- }
145
- if (depth === 0 && (c === ',' || c === '\n')) return i
146
- i++
147
- }
148
- return i
149
- }
150
-
151
- function scanTemplateLiteral(src, pos) {
152
- let i = pos
153
- while (i < src.length) {
154
- if (src[i] === '\\') { i += 2; continue }
155
- if (src[i] === '`') { return i + 1 }
156
- if (src[i] === '$' && src[i + 1] === '{') {
157
- i += 2; let depth = 1
158
- while (i < src.length && depth > 0) {
159
- const c = src[i]
160
- if (c === '{') { depth++; i++; continue }
161
- if (c === '}') { depth--; i++; continue }
162
- if (c === '"' || c === "'") { const q = c; i++; while (i < src.length) { if (src[i] === '\\') { i += 2; continue } if (src[i] === q) { i++; break } i++ } continue }
163
- if (c === '`') { i = scanTemplateLiteral(src, i + 1); continue }
164
- i++
165
- }
166
- continue
167
- }
168
- i++
169
- }
170
- return i
171
- }
172
-
173
- // ---------------------------------------------------------------------------
174
- // Test runner
175
- // ---------------------------------------------------------------------------
176
-
177
- let pass = 0, fail = 0
178
-
179
- function test(label, input, expected) {
180
- const got = strip(input).trim()
181
- const exp = expected.trim()
182
- if (got === exp) {
183
- console.log(' \u2713 ' + label)
184
- pass++
185
- } else {
186
- console.log(' \u2717 ' + label)
187
- console.log(' expected: ' + JSON.stringify(exp))
188
- console.log(' got: ' + JSON.stringify(got))
189
- fail++
190
- }
191
- }
192
-
193
- // ---------------------------------------------------------------------------
194
-
195
- console.log('\nServer-only key stripping\n')
196
-
197
- test('object value with trailing comma',
198
- `export default {\n route: '/',\n server: { data: async (ctx) => 'hello' },\n view: () => ''\n}`,
199
- `export default {\n route: '/',\n view: () => ''\n}`
200
- )
201
-
202
- test('async arrow with block body',
203
- `export default {\n server: {\n data: async (ctx) => {\n return 'x'\n }\n },\n view: () => ''\n}`,
204
- `export default {\n view: () => ''\n}`
205
- )
206
-
207
- test('serverTimeout simple number',
208
- `export default {\n serverTimeout: 5000,\n view: () => ''\n}`,
209
- `export default {\n view: () => ''\n}`
210
- )
211
-
212
- test('guard async arrow with block body containing nested object',
213
- `export default {\n guard: async (ctx) => {\n if (!ctx.session) return { redirect: '/login' }\n },\n view: () => ''\n}`,
214
- `export default {\n view: () => ''\n}`
215
- )
216
-
217
- test('guard inline arrow expression',
218
- `export default {\n guard: async (ctx) => { if (!ctx.session) return { redirect: '/login' } },\n view: () => ''\n}`,
219
- `export default {\n view: () => ''\n}`
220
- )
221
-
222
- test('contentType and render are both stripped (raw content specs are never hydrated)',
223
- `export default {\n route: '/feed.xml',\n contentType: 'application/rss+xml',\n render: (ctx) => '<rss/>'\n}`,
224
- `export default {\n route: '/feed.xml',\n}`
225
- )
226
-
227
- test('render with template literal containing ${...}',
228
- 'export default {\n render: (ctx, srv) => `<rss>${srv.items.map(i => i.title).join(\',\')}</rss>`,\n view: () => \'\'\n}',
229
- "export default {\n view: () => ''\n}"
230
- )
231
-
232
- test('last property — no trailing comma',
233
- `export default {\n view: () => '',\n server: { data: async () => [] }\n}`,
234
- `export default {\n view: () => '',\n}`
235
- )
236
-
237
- test('multiple fetchers in server object',
238
- `export default {\n server: {\n user: async (ctx) => ctx.user,\n posts: async (ctx) => []\n },\n view: () => ''\n}`,
239
- `export default {\n view: () => ''\n}`
240
- )
241
-
242
- test('does not strip non-server keys',
243
- `export default {\n route: '/',\n state: {},\n view: () => ''\n}`,
244
- `export default {\n route: '/',\n state: {},\n view: () => ''\n}`
245
- )
246
-
247
- test('strips all server-only keys in one pass',
248
- `export default {\n route: '/admin',\n guard: async (ctx) => { if (!ctx.user) return { redirect: '/login' } },\n serverTimeout: 3000,\n server: { profile: async (ctx) => ctx.user },\n view: () => ''\n}`,
249
- `export default {\n route: '/admin',\n view: () => ''\n}`
250
- )
251
-
252
- test('does not touch "server" inside a view string',
253
- `export default {\n view: (state, server) => \`<p>\${server.name}</p>\`\n}`,
254
- `export default {\n view: (state, server) => \`<p>\${server.name}</p>\`\n}`
255
- )
256
-
257
- test('async function expression (not arrow)',
258
- `export default {\n server: {\n data: async function(ctx) { return ctx.db.query() }\n },\n view: () => ''\n}`,
259
- `export default {\n view: () => ''\n}`
260
- )
261
-
262
- test('server with deeply nested template literal in view is not affected',
263
- 'export default {\n server: { items: async () => [] },\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}',
264
- 'export default {\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}'
265
- )
266
-
267
- test('render: inside a template literal string (docs code example) is not stripped',
268
- 'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}',
269
- 'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}'
270
- )
271
-
272
- test('server: inside a double-quoted string is not stripped',
273
- 'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}',
274
- 'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
275
- )
276
-
277
- test('server/meta not fooled by regex literal containing double quote (/"/g)',
278
- 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n meta: { title: \'Page\', description: \'Desc\' },\n server: { data: async () => [] },\n view: () => \'\'\n}',
279
- 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n view: () => \'\'\n}'
280
- )
281
-
282
- test('server not fooled by line comment containing double quote',
283
- 'export default {\n // server: "comment"\n server: { data: async () => [] },\n view: () => \'\'\n}',
284
- 'export default {\n // server: "comment"\n view: () => \'\'\n}'
285
- )
286
-
287
- test('server not fooled by block comment containing double quote',
288
- 'export default {\n /* server: "comment" */\n server: { data: async () => [] },\n view: () => \'\'\n}',
289
- 'export default {\n /* server: "comment" */\n view: () => \'\'\n}'
290
- )
291
-
292
- test('meta object is stripped (client runtime never reads spec.meta)',
293
- `export default {\n route: '/blog',\n meta: {\n title: async (ctx) => fetchPost(ctx).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n view: () => ''\n}`,
294
- `export default {\n route: '/blog',\n view: () => ''\n}`
295
- )
296
-
297
- test('meta: inside a template literal string is not stripped',
298
- 'export default {\n view: () => `<pre>meta: { title: \'Home\' }</pre>`\n}',
299
- 'export default {\n view: () => `<pre>meta: { title: \'Home\' }</pre>`\n}'
300
- )
301
-
302
- // ---------------------------------------------------------------------------
303
- // Server-only import stripping
304
- // ---------------------------------------------------------------------------
305
-
306
- console.log('\nServer-only import stripping\n')
307
-
308
- test('strips @invisibleloop/pulse/md import and const declaration',
309
- `import { md } from '@invisibleloop/pulse/md'\nconst fetchPost = md('src/content/blog.md')\nexport default {\n route: '/blog',\n view: () => ''\n}`,
310
- `export default {\n route: '/blog',\n view: () => ''\n}`
311
- )
312
-
313
- test('strips named import with multiple bindings',
314
- `import { md, parseMd } from '@invisibleloop/pulse/md'\nconst post = md('src/content/post.md')\nconst result = parseMd('# Hello')\nexport default {\n view: () => ''\n}`,
315
- `export default {\n view: () => ''\n}`
316
- )
317
-
318
- test('strips import + const then strips meta that used the const',
319
- `import { md } from '@invisibleloop/pulse/md'\nconst fetchPost = md('src/content/trees.md')\nexport default {\n route: '/blog',\n meta: {\n title: async (ctx) => (await fetchPost(ctx)).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n server: { post: fetchPost },\n view: () => ''\n}`,
320
- `export default {\n route: '/blog',\n view: () => ''\n}`
321
- )
322
-
323
- test('does not strip non-server imports',
324
- `import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`,
325
- `import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`
326
- )
327
-
328
- test('strips node:fs default import',
329
- `import fs from 'node:fs'\nexport default {\n server: { data: async () => fs.readFileSync('x') },\n view: () => ''\n}`,
330
- `export default {\n view: () => ''\n}`
331
- )
332
-
333
- test('strips node:fs/promises named import',
334
- `import { readFile } from 'node:fs/promises'\nexport default {\n server: { data: async () => readFile('x') },\n view: () => ''\n}`,
335
- `export default {\n view: () => ''\n}`
336
- )
337
-
338
- test('strips node:path and node:url together',
339
- `import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nexport default {\n server: { data: async (ctx) => path.join('a', 'b') },\n view: () => ''\n}`,
340
- `export default {\n view: () => ''\n}`
341
- )
342
-
343
- test('strips node:fs default import and top-level const using it',
344
- `import fs from 'node:fs'\nconst DIR = fs.readdirSync('.')\nexport default {\n view: () => ''\n}`,
345
- `export default {\n view: () => ''\n}`
346
- )
347
-
348
- // ---------------------------------------------------------------------------
349
- // Dev-mode node:* import stripping (same regex used in src/cli/dev.js serveFile)
350
- // ---------------------------------------------------------------------------
351
-
352
- console.log('\nDev-mode node:* import stripping\n')
353
-
354
- const DEV_NODE_RE = /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"]node:[^'"]+['"]\s*;?[ \t]*\n?/gm
355
-
356
- function devStrip(source) {
357
- return source.replace(DEV_NODE_RE, '')
358
- }
359
-
360
- function devTest(label, input, expected) {
361
- const got = devStrip(input).trim()
362
- const exp = expected.trim()
363
- if (got === exp) {
364
- console.log(' \u2713 ' + label)
365
- pass++
366
- } else {
367
- console.log(' \u2717 ' + label)
368
- console.log(' expected: ' + JSON.stringify(exp))
369
- console.log(' got: ' + JSON.stringify(got))
370
- fail++
371
- }
372
- }
373
-
374
- devTest('strips default import (node:fs)',
375
- `import fs from 'node:fs'\nconst DIR = '/journal'\nexport default { view: () => '' }`,
376
- `const DIR = '/journal'\nexport default { view: () => '' }`
377
- )
378
-
379
- devTest('strips named import (node:path)',
380
- `import { join, resolve } from 'node:path'\nexport default { view: () => '' }`,
381
- `export default { view: () => '' }`
382
- )
383
-
384
- devTest('strips multiple node: imports',
385
- `import fs from 'node:fs'\nimport path from 'node:path'\nexport default { view: () => '' }`,
386
- `export default { view: () => '' }`
387
- )
388
-
389
- devTest('strips node:fs/promises named import',
390
- `import { readFile } from 'node:fs/promises'\nexport default { view: () => '' }`,
391
- `export default { view: () => '' }`
392
- )
393
-
394
- devTest('does not strip non-node imports',
395
- `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`,
396
- `import { nav } from '@invisibleloop/pulse/ui'\nexport default { view: () => '' }`
397
- )
398
-
399
- devTest('leaves module-level constants intact',
400
- `import fs from 'node:fs'\nconst JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`,
401
- `const JOURNAL_DIR = '/Users/andy/journal'\nexport default { view: () => '' }`
402
- )
403
-
404
- // ---------------------------------------------------------------------------
405
-
406
- console.log('\nStore + new spec fields\n')
407
-
408
- test('sitemap enumerator is stripped (server-only)',
409
- `export default {\n route: '/blog/:slug',\n sitemap: async () => ['/blog/a'],\n view: () => '',\n}`,
410
- `export default {\n route: '/blog/:slug',\n view: () => '',\n}`
411
- )
412
-
413
- test('submit is NOT stripped — it collides with the common submit action name',
414
- `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`,
415
- `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`
416
- )
417
-
418
- test('store definition: server fetchers stripped, state and mutations survive',
419
- `export default {\n state: { basket: [] },\n server: {\n nav: async () => fetchNav(),\n },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`,
420
- `export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
421
- )
422
-
423
- test('package-root import (pushStore) is stripped — the root export is the server itself',
424
- `import { pushStore } from '@invisibleloop/pulse'\nexport default {\n route: '/staff',\n view: () => '',\n}`,
425
- `export default {\n route: '/staff',\n view: () => '',\n}`
426
- )
427
-
428
- test('subpath imports (/ui) are NOT stripped by the root entry',
429
- `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`,
430
- `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
431
- )
432
-
433
- // ---------------------------------------------------------------------------
434
- // needsClientBundle — must match discover.js's needsHydration() exactly.
435
- // Regression: found via dogfooding. This check used to be "does the spec have
436
- // a `view:` key" alone, so a purely server-rendered page (a redirect handler,
437
- // a custom 404, a spec.submit-only form) still got a client bundle generated —
438
- // and if that page imported a local server-only helper module (e.g. a
439
- // node:fs-based persistence layer), the production build failed outright when
440
- // esbuild tried to bundle Node built-ins for the browser.
441
- // ---------------------------------------------------------------------------
442
-
443
- console.log('\nneedsClientBundle — matches discover.js hydration logic\n')
444
-
445
- function boolTest(label, input, expected) {
446
- const got = needsClientBundle(strip(input))
447
- if (got === expected) {
448
- console.log(' ✓ ' + label)
449
- pass++
450
- } else {
451
- console.log(' ✗ ' + label)
452
- console.log(' expected: ' + expected)
453
- console.log(' got: ' + got)
454
- fail++
455
- }
456
- }
457
-
458
- boolTest('a view-only spec (no mutations/actions/persist) does not need a bundle — the exact bug this fixes',
459
- `export default {\n route: '/:code',\n guard: async (ctx) => {},\n view: () => '',\n}`,
460
- false
461
- )
462
-
463
- boolTest('a spec.submit-only page does not need a bundle',
464
- `export default {\n route: '/',\n submit: async (ctx) => ({ redirect: '/' }),\n view: () => '',\n}`,
465
- false
466
- )
467
-
468
- boolTest('mutations require a bundle',
469
- `export default {\n route: '/x',\n state: {},\n mutations: {\n inc: (s) => ({ n: s.n + 1 }),\n },\n view: () => '',\n}`,
470
- true
471
- )
472
-
473
- boolTest('actions require a bundle',
474
- `export default {\n route: '/x',\n state: {},\n actions: {\n save: { run: async () => {} },\n },\n view: () => '',\n}`,
475
- true
476
- )
477
-
478
- boolTest('persist requires a bundle',
479
- `export default {\n route: '/x',\n state: { count: 0 },\n persist: ['count'],\n view: () => '',\n}`,
480
- true
481
- )
482
-
483
- boolTest('store alone (no local mutations/actions) requires a bundle — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; a store-only page with no client bundle would silently never receive pushStore() broadcasts and have a dead data-store-event button',
484
- `export default {\n route: '/x',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count}\`,\n}`,
485
- true
486
- )
487
-
488
- // ---------------------------------------------------------------------------
489
-
490
- console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
491
- if (fail > 0) process.exit(1)
@@ -1,137 +0,0 @@
1
- /**
2
- * Pulse — check-bundles.js tests
3
- * run: node src/cli/check-bundles.test.js
4
- *
5
- * Shared logic behind pulse_check_bundles (MCP tool) and `pulse check-bundles`
6
- * (CLI command). These tests build against a real manifest.json + real boot
7
- * file content (not a mocked build) since the whole point of this check is
8
- * reading what a real build actually produced.
9
- */
10
-
11
- import { test } from 'node:test'
12
- import assert from 'node:assert/strict'
13
- import fs from 'node:fs'
14
- import os from 'node:os'
15
- import path from 'node:path'
16
- import { checkBundles, formatBundleCheck } from './check-bundles.js'
17
-
18
- function tmpProject() {
19
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-check-bundles-test-'))
20
- fs.mkdirSync(path.join(root, 'src', 'pages'), { recursive: true })
21
- fs.mkdirSync(path.join(root, 'public', 'dist'), { recursive: true })
22
- return root
23
- }
24
-
25
- function writeManifest(root, entries) {
26
- fs.writeFileSync(path.join(root, 'public', 'dist', 'manifest.json'), JSON.stringify(entries, null, 2))
27
- }
28
-
29
- function writeBootFile(root, name, content = 'var x = 1;') {
30
- fs.writeFileSync(path.join(root, 'public', 'dist', name), content)
31
- }
32
-
33
- test('reports an error when no manifest.json exists', () => {
34
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-check-bundles-test-'))
35
- const result = checkBundles(root)
36
- assert.match(result.error, /No public\/dist\/manifest\.json found/)
37
- fs.rmSync(root, { recursive: true, force: true })
38
- })
39
-
40
- test('clean pass: a page with mutations correctly has a bundle', () => {
41
- const root = tmpProject()
42
- fs.writeFileSync(path.join(root, 'src', 'pages', 'counter.js'),
43
- `export default {\n route: '/counter',\n state: { count: 0 },\n mutations: { inc: (s) => ({ count: s.count + 1 }) },\n view: (state) => \`<button>\${state.count}</button>\`,\n}`)
44
- writeManifest(root, { '/src/pages/counter.js': '/dist/counter.boot-ABC123.js' })
45
- writeBootFile(root, 'counter.boot-ABC123.js')
46
-
47
- const result = checkBundles(root)
48
- assert.equal(result.error, null)
49
- assert.equal(result.issues.length, 0, `Expected no issues, got: ${JSON.stringify(result.issues)}`)
50
- assert.equal(result.confirms.length, 1)
51
- fs.rmSync(root, { recursive: true, force: true })
52
- })
53
-
54
- test('flags a boot bundle for a page with no mutations/actions/persist', () => {
55
- const root = tmpProject()
56
- fs.writeFileSync(path.join(root, 'src', 'pages', 'redirect.js'),
57
- `export default {\n route: '/:code',\n guard: async (ctx) => { if (!ctx.params.code) return { status: 404 } },\n view: () => '<main id="main-content">redirecting</main>',\n}`)
58
- // Simulate the exact historical bug: a bundle exists for a page that needs none.
59
- writeManifest(root, { '/src/pages/redirect.js': '/dist/redirect.boot-XYZ789.js' })
60
- writeBootFile(root, 'redirect.boot-XYZ789.js')
61
-
62
- const result = checkBundles(root)
63
- assert.equal(result.issues.length, 1)
64
- assert.match(result.issues[0], /\/:code/)
65
- assert.match(result.issues[0], /no mutations\/actions\/persist/)
66
- fs.rmSync(root, { recursive: true, force: true })
67
- })
68
-
69
- test('issue message uses the spec\'s real declared route, not the filename-derived fallback', () => {
70
- // Regression: discoverPages' derivedRoute is filename-based only (it doesn't
71
- // parse spec.route) — a file named redirect.js with route: '/:code' was
72
- // reported as route "/redirect" instead of the route actually registered.
73
- const root = tmpProject()
74
- fs.writeFileSync(path.join(root, 'src', 'pages', 'redirect.js'),
75
- `export default {\n route: '/:code',\n guard: async (ctx) => { if (!ctx.params.code) return { status: 404 } },\n view: () => '<main id="main-content">redirecting</main>',\n}`)
76
- writeManifest(root, { '/src/pages/redirect.js': '/dist/redirect.boot-XYZ789.js' })
77
- writeBootFile(root, 'redirect.boot-XYZ789.js')
78
-
79
- const result = checkBundles(root)
80
- assert.equal(result.issues.length, 1)
81
- assert.match(result.issues[0], /Route "\/:code"/,
82
- `Expected the real declared route /:code, not the filename-derived /redirect — got: ${result.issues[0]}`)
83
- fs.rmSync(root, { recursive: true, force: true })
84
- })
85
-
86
- test('does not flag a page with no mutations/actions/persist that correctly has no bundle', () => {
87
- const root = tmpProject()
88
- fs.writeFileSync(path.join(root, 'src', 'pages', 'redirect.js'),
89
- `export default {\n route: '/:code',\n guard: async (ctx) => { if (!ctx.params.code) return { status: 404 } },\n view: () => '<main id="main-content">redirecting</main>',\n}`)
90
- // No manifest entry for this page — correct behaviour, nothing to flag.
91
- writeManifest(root, {})
92
-
93
- const result = checkBundles(root)
94
- assert.equal(result.issues.length, 0)
95
- fs.rmSync(root, { recursive: true, force: true })
96
- })
97
-
98
- test('flags a boot bundle containing a literal Node built-in reference', () => {
99
- const root = tmpProject()
100
- writeManifest(root, {})
101
- writeBootFile(root, 'leaky.boot-DEF456.js', `var x=require("node:fs");x.readFileSync("/tmp/y")`)
102
-
103
- const result = checkBundles(root)
104
- assert.equal(result.issues.length, 1)
105
- assert.match(result.issues[0], /leaky\.boot-DEF456\.js/)
106
- assert.match(result.issues[0], /node:fs/)
107
- fs.rmSync(root, { recursive: true, force: true })
108
- })
109
-
110
- test('a clean boot file with no Node built-ins is confirmed, not flagged', () => {
111
- const root = tmpProject()
112
- writeManifest(root, {})
113
- writeBootFile(root, 'clean.boot-GHI999.js', `var mount=function(){};mount()`)
114
-
115
- const result = checkBundles(root)
116
- assert.equal(result.issues.length, 0)
117
- assert.equal(result.confirms.some(c => c.includes('clean.boot-GHI999.js')), true)
118
- fs.rmSync(root, { recursive: true, force: true })
119
- })
120
-
121
- test('formatBundleCheck reports all-clear when there are no issues', () => {
122
- const root = tmpProject()
123
- writeManifest(root, {})
124
- const output = formatBundleCheck(checkBundles(root))
125
- assert.match(output, /All clear/)
126
- fs.rmSync(root, { recursive: true, force: true })
127
- })
128
-
129
- test('formatBundleCheck lists issues under their own heading', () => {
130
- const root = tmpProject()
131
- writeManifest(root, {})
132
- writeBootFile(root, 'leaky.boot-JKL111.js', `require("node:crypto")`)
133
- const output = formatBundleCheck(checkBundles(root))
134
- assert.match(output, /### Issues found/)
135
- assert.match(output, /node:crypto/)
136
- fs.rmSync(root, { recursive: true, force: true })
137
- })