zero-query 0.9.8 → 1.0.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.
Files changed (99) hide show
  1. package/README.md +55 -31
  2. package/cli/args.js +1 -1
  3. package/cli/commands/build.js +2 -2
  4. package/cli/commands/bundle.js +15 -15
  5. package/cli/commands/create.js +41 -7
  6. package/cli/commands/dev/devtools/index.js +1 -1
  7. package/cli/commands/dev/devtools/js/core.js +14 -14
  8. package/cli/commands/dev/devtools/js/elements.js +4 -4
  9. package/cli/commands/dev/devtools/js/stats.js +1 -1
  10. package/cli/commands/dev/devtools/styles.css +2 -2
  11. package/cli/commands/dev/index.js +2 -2
  12. package/cli/commands/dev/logger.js +1 -1
  13. package/cli/commands/dev/overlay.js +21 -14
  14. package/cli/commands/dev/server.js +5 -5
  15. package/cli/commands/dev/validator.js +7 -7
  16. package/cli/commands/dev/watcher.js +6 -6
  17. package/cli/help.js +4 -2
  18. package/cli/index.js +2 -2
  19. package/cli/scaffold/default/app/app.js +17 -18
  20. package/cli/scaffold/default/app/components/about.js +9 -9
  21. package/cli/scaffold/default/app/components/api-demo.js +6 -6
  22. package/cli/scaffold/default/app/components/contact-card.js +4 -4
  23. package/cli/scaffold/default/app/components/contacts/contacts.css +2 -2
  24. package/cli/scaffold/default/app/components/contacts/contacts.html +3 -3
  25. package/cli/scaffold/default/app/components/contacts/contacts.js +11 -11
  26. package/cli/scaffold/default/app/components/counter.js +8 -8
  27. package/cli/scaffold/default/app/components/home.js +13 -13
  28. package/cli/scaffold/default/app/components/not-found.js +1 -1
  29. package/cli/scaffold/default/app/components/playground/playground.css +1 -1
  30. package/cli/scaffold/default/app/components/playground/playground.html +11 -11
  31. package/cli/scaffold/default/app/components/playground/playground.js +11 -11
  32. package/cli/scaffold/default/app/components/todos.js +8 -8
  33. package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
  34. package/cli/scaffold/default/app/components/toolkit/toolkit.html +4 -4
  35. package/cli/scaffold/default/app/components/toolkit/toolkit.js +7 -7
  36. package/cli/scaffold/default/app/routes.js +1 -1
  37. package/cli/scaffold/default/app/store.js +1 -1
  38. package/cli/scaffold/default/global.css +2 -2
  39. package/cli/scaffold/default/index.html +2 -2
  40. package/cli/scaffold/minimal/app/app.js +6 -7
  41. package/cli/scaffold/minimal/app/components/about.js +5 -5
  42. package/cli/scaffold/minimal/app/components/counter.js +6 -6
  43. package/cli/scaffold/minimal/app/components/home.js +8 -8
  44. package/cli/scaffold/minimal/app/components/not-found.js +1 -1
  45. package/cli/scaffold/minimal/app/routes.js +1 -1
  46. package/cli/scaffold/minimal/app/store.js +1 -1
  47. package/cli/scaffold/minimal/global.css +2 -2
  48. package/cli/scaffold/minimal/index.html +1 -1
  49. package/cli/scaffold/ssr/app/app.js +29 -0
  50. package/cli/scaffold/ssr/app/components/about.js +28 -0
  51. package/cli/scaffold/ssr/app/components/home.js +37 -0
  52. package/cli/scaffold/ssr/app/components/not-found.js +15 -0
  53. package/cli/scaffold/ssr/app/routes.js +6 -0
  54. package/cli/scaffold/ssr/global.css +113 -0
  55. package/cli/scaffold/ssr/index.html +31 -0
  56. package/cli/scaffold/ssr/package.json +8 -0
  57. package/cli/scaffold/ssr/server/index.js +118 -0
  58. package/cli/utils.js +6 -6
  59. package/dist/zquery.dist.zip +0 -0
  60. package/dist/zquery.js +565 -228
  61. package/dist/zquery.min.js +2 -2
  62. package/index.d.ts +25 -12
  63. package/index.js +11 -7
  64. package/package.json +9 -3
  65. package/src/component.js +64 -63
  66. package/src/core.js +15 -15
  67. package/src/diff.js +38 -38
  68. package/src/errors.js +72 -18
  69. package/src/expression.js +15 -17
  70. package/src/http.js +4 -4
  71. package/src/package.json +1 -0
  72. package/src/reactive.js +75 -9
  73. package/src/router.js +104 -24
  74. package/src/ssr.js +133 -39
  75. package/src/store.js +103 -21
  76. package/src/utils.js +64 -12
  77. package/tests/audit.test.js +143 -15
  78. package/tests/cli.test.js +20 -20
  79. package/tests/component.test.js +121 -121
  80. package/tests/core.test.js +56 -56
  81. package/tests/diff.test.js +42 -42
  82. package/tests/errors.test.js +425 -147
  83. package/tests/expression.test.js +58 -53
  84. package/tests/http.test.js +20 -20
  85. package/tests/reactive.test.js +185 -24
  86. package/tests/router.test.js +501 -74
  87. package/tests/ssr.test.js +444 -10
  88. package/tests/store.test.js +264 -23
  89. package/tests/utils.test.js +163 -26
  90. package/types/collection.d.ts +2 -2
  91. package/types/component.d.ts +5 -5
  92. package/types/errors.d.ts +36 -4
  93. package/types/http.d.ts +3 -3
  94. package/types/misc.d.ts +9 -9
  95. package/types/reactive.d.ts +25 -3
  96. package/types/router.d.ts +10 -6
  97. package/types/ssr.d.ts +22 -2
  98. package/types/store.d.ts +40 -5
  99. package/types/utils.d.ts +1 -1
package/tests/ssr.test.js CHANGED
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { createSSRApp, renderToString } from '../src/ssr.js';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createSSRApp, renderToString, escapeHtml } from '../src/ssr.js';
3
+ import { ZQueryError, ErrorCode, onError } from '../src/errors.js';
3
4
 
4
5
 
5
6
  // ---------------------------------------------------------------------------
@@ -13,6 +14,14 @@ describe('createSSRApp', () => {
13
14
  expect(typeof app.component).toBe('function');
14
15
  expect(typeof app.renderToString).toBe('function');
15
16
  expect(typeof app.renderPage).toBe('function');
17
+ expect(typeof app.renderBatch).toBe('function');
18
+ expect(typeof app.has).toBe('function');
19
+ });
20
+
21
+ it('each call returns a fresh instance', () => {
22
+ const a = createSSRApp();
23
+ const b = createSSRApp();
24
+ expect(a).not.toBe(b);
16
25
  });
17
26
  });
18
27
 
@@ -21,7 +30,7 @@ describe('createSSRApp', () => {
21
30
  // component registration
22
31
  // ---------------------------------------------------------------------------
23
32
 
24
- describe('SSRApp component', () => {
33
+ describe('SSRApp - component', () => {
25
34
  it('registers a component and returns self for chaining', () => {
26
35
  const app = createSSRApp();
27
36
  const result = app.component('my-comp', {
@@ -30,10 +39,58 @@ describe('SSRApp — component', () => {
30
39
  expect(result).toBe(app);
31
40
  });
32
41
 
33
- it('throws when rendering unregistered component', async () => {
42
+ it('throws ZQueryError when rendering unregistered component', async () => {
34
43
  const app = createSSRApp();
44
+ await expect(app.renderToString('nonexistent')).rejects.toThrow(ZQueryError);
35
45
  await expect(app.renderToString('nonexistent')).rejects.toThrow('not registered');
36
46
  });
47
+
48
+ it('throws ZQueryError for invalid component name (empty string)', () => {
49
+ const app = createSSRApp();
50
+ expect(() => app.component('', {})).toThrow(ZQueryError);
51
+ });
52
+
53
+ it('throws ZQueryError for non-string component name', () => {
54
+ const app = createSSRApp();
55
+ expect(() => app.component(123, {})).toThrow(ZQueryError);
56
+ });
57
+
58
+ it('throws ZQueryError for invalid definition (null)', () => {
59
+ const app = createSSRApp();
60
+ expect(() => app.component('my-comp', null)).toThrow(ZQueryError);
61
+ });
62
+
63
+ it('throws ZQueryError for non-object definition', () => {
64
+ const app = createSSRApp();
65
+ expect(() => app.component('my-comp', 'not-an-object')).toThrow(ZQueryError);
66
+ });
67
+
68
+ it('allows re-registering a component (override)', async () => {
69
+ const app = createSSRApp();
70
+ app.component('my-comp', { render() { return '<p>v1</p>'; } });
71
+ app.component('my-comp', { render() { return '<p>v2</p>'; } });
72
+ const html = await app.renderToString('my-comp');
73
+ expect(html).toContain('v2');
74
+ expect(html).not.toContain('v1');
75
+ });
76
+ });
77
+
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // SSRApp.has()
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe('SSRApp - has', () => {
84
+ it('returns false for unregistered', () => {
85
+ const app = createSSRApp();
86
+ expect(app.has('nope')).toBe(false);
87
+ });
88
+
89
+ it('returns true for registered', () => {
90
+ const app = createSSRApp();
91
+ app.component('my-comp', { render() { return ''; } });
92
+ expect(app.has('my-comp')).toBe(true);
93
+ });
37
94
  });
38
95
 
39
96
 
@@ -41,7 +98,7 @@ describe('SSRApp — component', () => {
41
98
  // renderToString (app method)
42
99
  // ---------------------------------------------------------------------------
43
100
 
44
- describe('SSRApp renderToString', () => {
101
+ describe('SSRApp - renderToString', () => {
45
102
  it('renders basic component', async () => {
46
103
  const app = createSSRApp();
47
104
  app.component('my-page', {
@@ -100,6 +157,69 @@ describe('SSRApp — renderToString', () => {
100
157
  const html = await app.renderToString('my-comp');
101
158
  expect(html).not.toContain('@click');
102
159
  });
160
+
161
+ it('strips z-on: event bindings', async () => {
162
+ const app = createSSRApp();
163
+ app.component('my-comp', {
164
+ render() { return '<button z-on:click="handle">Click</button>'; }
165
+ });
166
+ const html = await app.renderToString('my-comp');
167
+ expect(html).not.toContain('z-on:');
168
+ });
169
+
170
+ it('renders empty string when no render function', async () => {
171
+ const app = createSSRApp();
172
+ app.component('empty', { state: () => ({}) });
173
+ const html = await app.renderToString('empty');
174
+ expect(html).toContain('<empty');
175
+ expect(html).toContain('</empty>');
176
+ });
177
+
178
+ it('fragment mode returns inner HTML only', async () => {
179
+ const app = createSSRApp();
180
+ app.component('frag', { render() { return '<p>inner</p>'; } });
181
+ const html = await app.renderToString('frag', {}, { mode: 'fragment' });
182
+ expect(html).toBe('<p>inner</p>');
183
+ expect(html).not.toContain('<frag');
184
+ });
185
+
186
+ it('error in render() produces comment and reports via error system', async () => {
187
+ const handler = vi.fn();
188
+ onError(handler);
189
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
190
+
191
+ const app = createSSRApp();
192
+ app.component('bad', {
193
+ render() { throw new Error('render boom'); }
194
+ });
195
+ const html = await app.renderToString('bad');
196
+ expect(html).toContain('<!-- SSR render error -->');
197
+ expect(html).not.toContain('render boom');
198
+ expect(handler).toHaveBeenCalled();
199
+ const err = handler.mock.calls[0][0];
200
+ expect(err.code).toBe(ErrorCode.SSR_RENDER);
201
+
202
+ spy.mockRestore();
203
+ onError(null);
204
+ });
205
+
206
+ it('error in init() is reported but does not crash', async () => {
207
+ const handler = vi.fn();
208
+ onError(handler);
209
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
210
+
211
+ const app = createSSRApp();
212
+ app.component('bad-init', {
213
+ init() { throw new Error('init boom'); },
214
+ render() { return '<div>ok</div>'; }
215
+ });
216
+ const html = await app.renderToString('bad-init');
217
+ expect(html).toContain('<div>ok</div>');
218
+ expect(handler).toHaveBeenCalled();
219
+
220
+ spy.mockRestore();
221
+ onError(null);
222
+ });
103
223
  });
104
224
 
105
225
 
@@ -120,6 +240,27 @@ describe('renderToString (standalone)', () => {
120
240
  const html = renderToString({ state: {} });
121
241
  expect(html).toBe('');
122
242
  });
243
+
244
+ it('throws ZQueryError for invalid definition', () => {
245
+ expect(() => renderToString(null)).toThrow(ZQueryError);
246
+ expect(() => renderToString('string')).toThrow(ZQueryError);
247
+ });
248
+
249
+ it('works with props', () => {
250
+ const html = renderToString({
251
+ render() { return `<span>${this.props.x}</span>`; }
252
+ }, { x: 42 });
253
+ expect(html).toContain('42');
254
+ });
255
+
256
+ it('props are frozen', () => {
257
+ let frozen = false;
258
+ renderToString({
259
+ init() { frozen = Object.isFrozen(this.props); },
260
+ render() { return ''; }
261
+ }, { a: 1 });
262
+ expect(frozen).toBe(true);
263
+ });
123
264
  });
124
265
 
125
266
 
@@ -127,7 +268,7 @@ describe('renderToString (standalone)', () => {
127
268
  // State factory
128
269
  // ---------------------------------------------------------------------------
129
270
 
130
- describe('SSR state factory', () => {
271
+ describe('SSR - state factory', () => {
131
272
  it('calls state function for initial state', async () => {
132
273
  const app = createSSRApp();
133
274
  let callCount = 0;
@@ -149,6 +290,38 @@ describe('SSR — state factory', () => {
149
290
  const html = await app.renderToString('comp');
150
291
  expect(html).toContain('1');
151
292
  });
293
+
294
+ it('each render gets fresh state from factory', async () => {
295
+ const app = createSSRApp();
296
+ app.component('comp', {
297
+ state: () => ({ count: 0 }),
298
+ init() { this.state.count++; },
299
+ render() { return `<p>${this.state.count}</p>`; }
300
+ });
301
+ const html1 = await app.renderToString('comp');
302
+ const html2 = await app.renderToString('comp');
303
+ expect(html1).toContain('<p>1</p>');
304
+ expect(html2).toContain('<p>1</p>');
305
+ });
306
+
307
+ it('has __raw property on state', () => {
308
+ let hasRaw = false;
309
+ renderToString({
310
+ init() { hasRaw = this.state.__raw === this.state; },
311
+ render() { return ''; }
312
+ });
313
+ expect(hasRaw).toBe(true);
314
+ });
315
+
316
+ it('__raw is non-enumerable', () => {
317
+ let keys = [];
318
+ renderToString({
319
+ state: () => ({ a: 1 }),
320
+ init() { keys = Object.keys(this.state); },
321
+ render() { return ''; }
322
+ });
323
+ expect(keys).not.toContain('__raw');
324
+ });
152
325
  });
153
326
 
154
327
 
@@ -156,7 +329,7 @@ describe('SSR — state factory', () => {
156
329
  // Computed properties
157
330
  // ---------------------------------------------------------------------------
158
331
 
159
- describe('SSR computed', () => {
332
+ describe('SSR - computed', () => {
160
333
  it('computes derived values', async () => {
161
334
  const app = createSSRApp();
162
335
  app.component('comp', {
@@ -169,6 +342,67 @@ describe('SSR — computed', () => {
169
342
  const html = await app.renderToString('comp');
170
343
  expect(html).toContain('Jane Doe');
171
344
  });
345
+
346
+ it('computed has access to this context', async () => {
347
+ const app = createSSRApp();
348
+ app.component('comp', {
349
+ state: () => ({ items: [1, 2, 3] }),
350
+ computed: {
351
+ count() { return this.state.items.length; }
352
+ },
353
+ render() { return `<p>${this.computed.count}</p>`; }
354
+ });
355
+ const html = await app.renderToString('comp');
356
+ expect(html).toContain('<p>3</p>');
357
+ });
358
+
359
+ it('error in computed is reported and returns undefined', async () => {
360
+ const handler = vi.fn();
361
+ onError(handler);
362
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
363
+
364
+ const app = createSSRApp();
365
+ app.component('comp', {
366
+ state: () => ({}),
367
+ computed: {
368
+ broken() { throw new Error('computed boom'); }
369
+ },
370
+ render() { return `<p>${this.computed.broken}</p>`; }
371
+ });
372
+ const html = await app.renderToString('comp');
373
+ expect(html).toContain('undefined');
374
+ expect(handler).toHaveBeenCalled();
375
+
376
+ spy.mockRestore();
377
+ onError(null);
378
+ });
379
+ });
380
+
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // User methods
384
+ // ---------------------------------------------------------------------------
385
+
386
+ describe('SSR - user methods', () => {
387
+ it('binds user methods and they can be called in render', async () => {
388
+ const app = createSSRApp();
389
+ app.component('comp', {
390
+ state: () => ({ items: ['a', 'b'] }),
391
+ getCount() { return this.state.items.length; },
392
+ render() { return `<p>${this.getCount()}</p>`; }
393
+ });
394
+ const html = await app.renderToString('comp');
395
+ expect(html).toContain('<p>2</p>');
396
+ });
397
+
398
+ it('does not bind reserved keys', () => {
399
+ let hasMounted = false;
400
+ renderToString({
401
+ mounted() { hasMounted = true; },
402
+ render() { return ''; }
403
+ });
404
+ expect(hasMounted).toBe(false);
405
+ });
172
406
  });
173
407
 
174
408
 
@@ -176,7 +410,7 @@ describe('SSR — computed', () => {
176
410
  // Init lifecycle
177
411
  // ---------------------------------------------------------------------------
178
412
 
179
- describe('SSR init lifecycle', () => {
413
+ describe('SSR - init lifecycle', () => {
180
414
  it('calls init() during construction', () => {
181
415
  let initCalled = false;
182
416
  renderToString({
@@ -185,6 +419,74 @@ describe('SSR — init lifecycle', () => {
185
419
  });
186
420
  expect(initCalled).toBe(true);
187
421
  });
422
+
423
+ it('init can modify state before render', async () => {
424
+ const app = createSSRApp();
425
+ app.component('comp', {
426
+ state: () => ({ msg: 'before' }),
427
+ init() { this.state.msg = 'after'; },
428
+ render() { return `<p>${this.state.msg}</p>`; }
429
+ });
430
+ const html = await app.renderToString('comp');
431
+ expect(html).toContain('after');
432
+ });
433
+
434
+ it('init has access to props', () => {
435
+ let receivedProps = null;
436
+ renderToString({
437
+ init() { receivedProps = this.props; },
438
+ render() { return ''; }
439
+ }, { x: 42 });
440
+ expect(receivedProps.x).toBe(42);
441
+ });
442
+ });
443
+
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // {{expression}} interpolation
447
+ // ---------------------------------------------------------------------------
448
+
449
+ describe('SSR - expression interpolation', () => {
450
+ it('interpolates {{state.key}} patterns', () => {
451
+ const html = renderToString({
452
+ state: () => ({ name: 'World' }),
453
+ render() { return '<p>Hello {{name}}</p>'; }
454
+ });
455
+ expect(html).toContain('Hello World');
456
+ });
457
+
458
+ it('escapes HTML in interpolated values', () => {
459
+ const html = renderToString({
460
+ state: () => ({ xss: '<script>alert(1)</script>' }),
461
+ render() { return '<p>{{xss}}</p>'; }
462
+ });
463
+ expect(html).not.toContain('<script>');
464
+ expect(html).toContain('&lt;script&gt;');
465
+ });
466
+
467
+ it('renders empty string for null/undefined expressions', () => {
468
+ const html = renderToString({
469
+ state: () => ({ x: null }),
470
+ render() { return '<p>{{x}}</p>'; }
471
+ });
472
+ expect(html).toContain('<p></p>');
473
+ });
474
+
475
+ it('expression error is reported and produces empty string', () => {
476
+ const handler = vi.fn();
477
+ onError(handler);
478
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
479
+
480
+ const html = renderToString({
481
+ state: () => ({}),
482
+ render() { return '<p>{{nonexistent.deep.path}}</p>'; }
483
+ });
484
+ // Should still produce valid HTML (empty interpolation)
485
+ expect(html).toContain('<p>');
486
+
487
+ spy.mockRestore();
488
+ onError(null);
489
+ });
188
490
  });
189
491
 
190
492
 
@@ -192,7 +494,7 @@ describe('SSR — init lifecycle', () => {
192
494
  // XSS sanitization via _escapeHtml
193
495
  // ---------------------------------------------------------------------------
194
496
 
195
- describe('SSR XSS sanitization', () => {
497
+ describe('SSR - XSS sanitization', () => {
196
498
  it('escapes HTML in renderPage title', async () => {
197
499
  const app = createSSRApp();
198
500
  const html = await app.renderPage({ title: '<script>alert("xss")</script>' });
@@ -217,6 +519,20 @@ describe('SSR — XSS sanitization', () => {
217
519
  const html = await app.renderPage({ bodyAttrs: 'data-x="javascript:void(0)"' });
218
520
  expect(html).not.toContain('javascript:');
219
521
  });
522
+
523
+ it('escapes description in meta tag', async () => {
524
+ const app = createSSRApp();
525
+ const html = await app.renderPage({ description: '"><script>xss</script>' });
526
+ expect(html).toContain('&quot;');
527
+ expect(html).not.toContain('"><script>');
528
+ });
529
+
530
+ it('escapes OG tag values', async () => {
531
+ const app = createSSRApp();
532
+ const html = await app.renderPage({ head: { og: { title: '"><script>xss</script>' } } });
533
+ expect(html).toContain('og:title');
534
+ expect(html).not.toContain('"><script>');
535
+ });
220
536
  });
221
537
 
222
538
 
@@ -224,7 +540,7 @@ describe('SSR — XSS sanitization', () => {
224
540
  // renderPage
225
541
  // ---------------------------------------------------------------------------
226
542
 
227
- describe('SSRApp renderPage', () => {
543
+ describe('SSRApp - renderPage', () => {
228
544
  it('renders a full HTML page', async () => {
229
545
  const app = createSSRApp();
230
546
  app.component('page', { render() { return '<h1>Home</h1>'; } });
@@ -258,4 +574,122 @@ describe('SSRApp — renderPage', () => {
258
574
  const html = await app.renderPage({ scripts: ['app.js'] });
259
575
  expect(html).toContain('src="app.js"');
260
576
  });
577
+
578
+ it('includes meta description when provided', async () => {
579
+ const app = createSSRApp();
580
+ const html = await app.renderPage({ description: 'A cool page' });
581
+ expect(html).toContain('<meta name="description" content="A cool page">');
582
+ });
583
+
584
+ it('includes canonical URL', async () => {
585
+ const app = createSSRApp();
586
+ const html = await app.renderPage({ head: { canonical: 'https://example.com/' } });
587
+ expect(html).toContain('<link rel="canonical" href="https://example.com/">');
588
+ });
589
+
590
+ it('includes Open Graph tags', async () => {
591
+ const app = createSSRApp();
592
+ const html = await app.renderPage({
593
+ head: { og: { title: 'My Page', image: 'https://example.com/img.png' } }
594
+ });
595
+ expect(html).toContain('property="og:title"');
596
+ expect(html).toContain('content="My Page"');
597
+ expect(html).toContain('property="og:image"');
598
+ });
599
+
600
+ it('gracefully handles render failure in renderPage', async () => {
601
+ const handler = vi.fn();
602
+ onError(handler);
603
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
604
+
605
+ const app = createSSRApp();
606
+ app.component('bad-page', { render() { throw new Error('page boom'); } });
607
+ const html = await app.renderPage({ component: 'bad-page', title: 'Oops' });
608
+ expect(html).toContain('<!DOCTYPE html>');
609
+ expect(html).toContain('<!-- SSR render error -->');
610
+ expect(html).not.toContain('page boom');
611
+ expect(handler).toHaveBeenCalled();
612
+
613
+ spy.mockRestore();
614
+ onError(null);
615
+ });
616
+
617
+ it('has correct structure: doctype, html, head, body', async () => {
618
+ const app = createSSRApp();
619
+ const html = await app.renderPage({});
620
+ expect(html).toMatch(/^<!DOCTYPE html>/);
621
+ expect(html).toContain('<html');
622
+ expect(html).toContain('<head>');
623
+ expect(html).toContain('</head>');
624
+ expect(html).toContain('<body');
625
+ expect(html).toContain('</body>');
626
+ expect(html).toContain('</html>');
627
+ });
628
+
629
+ it('defaults lang to "en"', async () => {
630
+ const app = createSSRApp();
631
+ const html = await app.renderPage({});
632
+ expect(html).toContain('lang="en"');
633
+ });
634
+ });
635
+
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // renderBatch
639
+ // ---------------------------------------------------------------------------
640
+
641
+ describe('SSRApp - renderBatch', () => {
642
+ it('renders multiple components at once', async () => {
643
+ const app = createSSRApp();
644
+ app.component('header-el', { render() { return '<header>Head</header>'; } });
645
+ app.component('footer-el', { render() { return '<footer>Foot</footer>'; } });
646
+
647
+ const results = await app.renderBatch([
648
+ { name: 'header-el' },
649
+ { name: 'footer-el' }
650
+ ]);
651
+ expect(results).toHaveLength(2);
652
+ expect(results[0]).toContain('Head');
653
+ expect(results[1]).toContain('Foot');
654
+ });
655
+
656
+ it('passes props per entry', async () => {
657
+ const app = createSSRApp();
658
+ app.component('greeting', {
659
+ render() { return `<span>${this.props.msg}</span>`; }
660
+ });
661
+ const results = await app.renderBatch([
662
+ { name: 'greeting', props: { msg: 'Hello' } },
663
+ { name: 'greeting', props: { msg: 'Bye' } }
664
+ ]);
665
+ expect(results[0]).toContain('Hello');
666
+ expect(results[1]).toContain('Bye');
667
+ });
668
+
669
+ it('rejects if any component is unregistered', async () => {
670
+ const app = createSSRApp();
671
+ app.component('ok-comp', { render() { return '<p>ok</p>'; } });
672
+ await expect(
673
+ app.renderBatch([{ name: 'ok-comp' }, { name: 'missing' }])
674
+ ).rejects.toThrow('not registered');
675
+ });
676
+ });
677
+
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // escapeHtml (exported utility)
681
+ // ---------------------------------------------------------------------------
682
+
683
+ describe('escapeHtml (exported)', () => {
684
+ it('escapes all dangerous characters', () => {
685
+ expect(escapeHtml('&<>"\'')).toBe('&amp;&lt;&gt;&quot;&#39;');
686
+ });
687
+
688
+ it('leaves safe strings unchanged', () => {
689
+ expect(escapeHtml('hello world')).toBe('hello world');
690
+ });
691
+
692
+ it('coerces numbers to string', () => {
693
+ expect(escapeHtml(42)).toBe('42');
694
+ });
261
695
  });