zero-query 1.0.9 → 1.2.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 (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -0
  5. package/cli/commands/build.js +254 -216
  6. package/cli/commands/bundle.js +1228 -1183
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -167
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +7264 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6252
  81. package/dist/zquery.min.js +8 -601
  82. package/index.d.ts +570 -365
  83. package/index.js +311 -232
  84. package/package.json +76 -69
  85. package/src/component.js +1709 -1454
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -254
  93. package/src/router.js +843 -773
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -272
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1023
  115. package/tests/compare.test.js +497 -0
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -0
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -0
  121. package/tests/electron-features.test.js +864 -0
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -145
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
package/tests/ssr.test.js CHANGED
@@ -1,870 +1,870 @@
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';
4
-
5
-
6
- // ---------------------------------------------------------------------------
7
- // createSSRApp
8
- // ---------------------------------------------------------------------------
9
-
10
- describe('createSSRApp', () => {
11
- it('returns an SSRApp instance', () => {
12
- const app = createSSRApp();
13
- expect(app).toBeDefined();
14
- expect(typeof app.component).toBe('function');
15
- expect(typeof app.renderToString).toBe('function');
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);
25
- });
26
- });
27
-
28
-
29
- // ---------------------------------------------------------------------------
30
- // component registration
31
- // ---------------------------------------------------------------------------
32
-
33
- describe('SSRApp - component', () => {
34
- it('registers a component and returns self for chaining', () => {
35
- const app = createSSRApp();
36
- const result = app.component('my-comp', {
37
- render() { return '<div>hello</div>'; }
38
- });
39
- expect(result).toBe(app);
40
- });
41
-
42
- it('throws ZQueryError when rendering unregistered component', async () => {
43
- const app = createSSRApp();
44
- await expect(app.renderToString('nonexistent')).rejects.toThrow(ZQueryError);
45
- await expect(app.renderToString('nonexistent')).rejects.toThrow('not registered');
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
- });
94
- });
95
-
96
-
97
- // ---------------------------------------------------------------------------
98
- // renderToString (app method)
99
- // ---------------------------------------------------------------------------
100
-
101
- describe('SSRApp - renderToString', () => {
102
- it('renders basic component', async () => {
103
- const app = createSSRApp();
104
- app.component('my-page', {
105
- state: () => ({ title: 'Hello' }),
106
- render() { return `<h1>${this.state.title}</h1>`; }
107
- });
108
- const html = await app.renderToString('my-page');
109
- expect(html).toContain('<h1>Hello</h1>');
110
- });
111
-
112
- it('adds hydration marker by default', async () => {
113
- const app = createSSRApp();
114
- app.component('my-comp', { render() { return '<p>test</p>'; } });
115
- const html = await app.renderToString('my-comp');
116
- expect(html).toContain('data-zq-ssr');
117
- });
118
-
119
- it('omits hydration marker when hydrate=false', async () => {
120
- const app = createSSRApp();
121
- app.component('my-comp', { render() { return '<p>test</p>'; } });
122
- const html = await app.renderToString('my-comp', {}, { hydrate: false });
123
- expect(html).not.toContain('data-zq-ssr');
124
- });
125
-
126
- it('wraps output in component tag', async () => {
127
- const app = createSSRApp();
128
- app.component('custom-tag', { render() { return '<span>ok</span>'; } });
129
- const html = await app.renderToString('custom-tag');
130
- expect(html).toMatch(/^<custom-tag[^>]*>.*<\/custom-tag>$/);
131
- });
132
-
133
- it('passes props to component', async () => {
134
- const app = createSSRApp();
135
- app.component('greet', {
136
- render() { return `<span>${this.props.name}</span>`; }
137
- });
138
- const html = await app.renderToString('greet', { name: 'World' });
139
- expect(html).toContain('World');
140
- });
141
-
142
- it('strips z-cloak attributes', async () => {
143
- const app = createSSRApp();
144
- app.component('my-comp', {
145
- render() { return '<div z-cloak>content</div>'; }
146
- });
147
- const html = await app.renderToString('my-comp');
148
- expect(html).not.toContain('z-cloak');
149
- expect(html).toContain('content');
150
- });
151
-
152
- it('strips event bindings (@click, z-on:click)', async () => {
153
- const app = createSSRApp();
154
- app.component('my-comp', {
155
- render() { return '<button @click="handle">Click</button>'; }
156
- });
157
- const html = await app.renderToString('my-comp');
158
- expect(html).not.toContain('@click');
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
- });
223
- });
224
-
225
-
226
- // ---------------------------------------------------------------------------
227
- // renderToString (standalone function)
228
- // ---------------------------------------------------------------------------
229
-
230
- describe('renderToString (standalone)', () => {
231
- it('renders a definition directly', () => {
232
- const html = renderToString({
233
- state: () => ({ msg: 'hi' }),
234
- render() { return `<p>${this.state.msg}</p>`; }
235
- });
236
- expect(html).toContain('<p>hi</p>');
237
- });
238
-
239
- it('returns empty string when no render function', () => {
240
- const html = renderToString({ state: {} });
241
- expect(html).toBe('');
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
- });
264
- });
265
-
266
-
267
- // ---------------------------------------------------------------------------
268
- // State factory
269
- // ---------------------------------------------------------------------------
270
-
271
- describe('SSR - state factory', () => {
272
- it('calls state function for initial state', async () => {
273
- const app = createSSRApp();
274
- let callCount = 0;
275
- app.component('comp', {
276
- state: () => { callCount++; return { x: 1 }; },
277
- render() { return `<div>${this.state.x}</div>`; }
278
- });
279
- await app.renderToString('comp');
280
- expect(callCount).toBe(1);
281
- });
282
-
283
- it('supports state as object (copied)', async () => {
284
- const app = createSSRApp();
285
- const shared = { x: 1 };
286
- app.component('comp', {
287
- state: shared,
288
- render() { return `<div>${this.state.x}</div>`; }
289
- });
290
- const html = await app.renderToString('comp');
291
- expect(html).toContain('1');
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
- });
325
- });
326
-
327
-
328
- // ---------------------------------------------------------------------------
329
- // Computed properties
330
- // ---------------------------------------------------------------------------
331
-
332
- describe('SSR - computed', () => {
333
- it('computes derived values', async () => {
334
- const app = createSSRApp();
335
- app.component('comp', {
336
- state: () => ({ first: 'Jane', last: 'Doe' }),
337
- computed: {
338
- full(state) { return `${state.first} ${state.last}`; }
339
- },
340
- render() { return `<span>${this.computed.full}</span>`; }
341
- });
342
- const html = await app.renderToString('comp');
343
- expect(html).toContain('Jane Doe');
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
- });
406
- });
407
-
408
-
409
- // ---------------------------------------------------------------------------
410
- // Init lifecycle
411
- // ---------------------------------------------------------------------------
412
-
413
- describe('SSR - init lifecycle', () => {
414
- it('calls init() during construction', () => {
415
- let initCalled = false;
416
- renderToString({
417
- init() { initCalled = true; },
418
- render() { return '<div></div>'; }
419
- });
420
- expect(initCalled).toBe(true);
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
- });
490
- });
491
-
492
-
493
- // ---------------------------------------------------------------------------
494
- // XSS sanitization via _escapeHtml
495
- // ---------------------------------------------------------------------------
496
-
497
- describe('SSR - XSS sanitization', () => {
498
- it('escapes HTML in renderPage title', async () => {
499
- const app = createSSRApp();
500
- const html = await app.renderPage({ title: '<script>alert("xss")</script>' });
501
- expect(html).not.toContain('<script>alert("xss")</script>');
502
- expect(html).toContain('&lt;script&gt;');
503
- });
504
-
505
- it('escapes script paths in renderPage', async () => {
506
- const app = createSSRApp();
507
- const html = await app.renderPage({ scripts: ['"><script>alert(1)</script>'] });
508
- expect(html).toContain('&quot;');
509
- });
510
-
511
- it('strips onXxx attributes from bodyAttrs', async () => {
512
- const app = createSSRApp();
513
- const html = await app.renderPage({ bodyAttrs: 'onclick="alert(1)"' });
514
- expect(html).not.toContain('onclick');
515
- });
516
-
517
- it('strips javascript: from bodyAttrs', async () => {
518
- const app = createSSRApp();
519
- const html = await app.renderPage({ bodyAttrs: 'data-x="javascript:void(0)"' });
520
- expect(html).not.toContain('javascript:');
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
- });
536
- });
537
-
538
-
539
- // ---------------------------------------------------------------------------
540
- // renderPage
541
- // ---------------------------------------------------------------------------
542
-
543
- describe('SSRApp - renderPage', () => {
544
- it('renders a full HTML page', async () => {
545
- const app = createSSRApp();
546
- app.component('page', { render() { return '<h1>Home</h1>'; } });
547
- const html = await app.renderPage({
548
- component: 'page',
549
- title: 'My App',
550
- lang: 'en'
551
- });
552
- expect(html).toContain('<!DOCTYPE html>');
553
- expect(html).toContain('<title>My App</title>');
554
- expect(html).toContain('<h1>Home</h1>');
555
- expect(html).toContain('lang="en"');
556
- });
557
-
558
- it('renders without component', async () => {
559
- const app = createSSRApp();
560
- const html = await app.renderPage({ title: 'Empty' });
561
- expect(html).toContain('<title>Empty</title>');
562
- expect(html).toContain('<div id="app"></div>');
563
- });
564
-
565
- it('includes style links', async () => {
566
- const app = createSSRApp();
567
- const html = await app.renderPage({ styles: ['style.css', 'theme.css'] });
568
- expect(html).toContain('href="style.css"');
569
- expect(html).toContain('href="theme.css"');
570
- });
571
-
572
- it('includes script tags', async () => {
573
- const app = createSSRApp();
574
- const html = await app.renderPage({ scripts: ['app.js'] });
575
- expect(html).toContain('src="app.js"');
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
- });
695
- });
696
-
697
-
698
- // ---------------------------------------------------------------------------
699
- // SSRApp - renderShell
700
- // ---------------------------------------------------------------------------
701
-
702
- describe('SSRApp - renderShell', () => {
703
- const shell = `<!DOCTYPE html>
704
- <html lang="en">
705
- <head>
706
- <title>Default Title</title>
707
- <meta name="description" content="default desc">
708
- <meta property="og:title" content="Default OG">
709
- <meta property="og:description" content="">
710
- <meta property="og:type" content="website">
711
- </head>
712
- <body>
713
- <z-outlet></z-outlet>
714
- </body>
715
- </html>`;
716
-
717
- let app;
718
- beforeEach(() => {
719
- app = createSSRApp();
720
- app.component('test-page', {
721
- state: () => ({ greeting: 'Hello' }),
722
- render() { return `<h1>${this.state.greeting}</h1>`; }
723
- });
724
- });
725
-
726
- it('renders a component into <z-outlet>', async () => {
727
- const html = await app.renderShell(shell, { component: 'test-page' });
728
- expect(html).toContain('<z-outlet><test-page data-zq-ssr><h1>Hello</h1></test-page></z-outlet>');
729
- });
730
-
731
- it('replaces the <title> tag', async () => {
732
- const html = await app.renderShell(shell, { component: 'test-page', title: 'My App — Home' });
733
- expect(html).toContain('<title>My App — Home</title>');
734
- expect(html).not.toContain('Default Title');
735
- });
736
-
737
- it('replaces the meta description', async () => {
738
- const html = await app.renderShell(shell, { component: 'test-page', description: 'A great page' });
739
- expect(html).toContain('<meta name="description" content="A great page">');
740
- expect(html).not.toContain('default desc');
741
- });
742
-
743
- it('replaces existing Open Graph tags', async () => {
744
- const html = await app.renderShell(shell, {
745
- component: 'test-page',
746
- og: { title: 'OG Title', description: 'OG Desc', type: 'article' },
747
- });
748
- expect(html).toContain('<meta property="og:title" content="OG Title">');
749
- expect(html).toContain('<meta property="og:description" content="OG Desc">');
750
- expect(html).toContain('<meta property="og:type" content="article">');
751
- });
752
-
753
- it('injects new OG tags when they do not exist in the shell', async () => {
754
- const html = await app.renderShell(shell, {
755
- component: 'test-page',
756
- og: { image: 'https://example.com/img.png' },
757
- });
758
- expect(html).toContain('<meta property="og:image" content="https://example.com/img.png">');
759
- });
760
-
761
- it('injects window.__SSR_DATA__ when ssrData is provided', async () => {
762
- const data = { component: 'test-page', params: {}, props: {} };
763
- const html = await app.renderShell(shell, { component: 'test-page', ssrData: data });
764
- expect(html).toContain('window.__SSR_DATA__=');
765
- expect(html).toContain('"component":"test-page"');
766
- // Script is injected before </head>
767
- expect(html.indexOf('__SSR_DATA__')).toBeLessThan(html.indexOf('</head>'));
768
- });
769
-
770
- it('does not inject ssrData when not provided', async () => {
771
- const html = await app.renderShell(shell, { component: 'test-page' });
772
- expect(html).not.toContain('__SSR_DATA__');
773
- });
774
-
775
- it('escapes title and description for XSS safety', async () => {
776
- const html = await app.renderShell(shell, {
777
- component: 'test-page',
778
- title: '<script>alert("xss")</script>',
779
- description: '"injected" & <dangerous>',
780
- });
781
- expect(html).toContain('&lt;script&gt;');
782
- expect(html).toContain('&quot;injected&quot; &amp; &lt;dangerous&gt;');
783
- expect(html).not.toContain('<script>alert');
784
- });
785
-
786
- it('leaves title and description alone when not provided', async () => {
787
- const html = await app.renderShell(shell, { component: 'test-page' });
788
- expect(html).toContain('<title>Default Title</title>');
789
- expect(html).toContain('content="default desc"');
790
- });
791
-
792
- it('passes props to the rendered component', async () => {
793
- app.component('greeting-page', {
794
- render() { return `<p>Hi ${this.props.name}</p>`; }
795
- });
796
- const html = await app.renderShell(shell, { component: 'greeting-page', props: { name: 'Tony' } });
797
- expect(html).toContain('<p>Hi Tony</p>');
798
- });
799
-
800
- it('passes renderOptions through to renderToString', async () => {
801
- const html = await app.renderShell(shell, {
802
- component: 'test-page',
803
- renderOptions: { hydrate: false },
804
- });
805
- expect(html).toContain('<test-page><h1>Hello</h1></test-page>');
806
- expect(html).not.toContain('data-zq-ssr');
807
- });
808
-
809
- it('handles a missing component gracefully', async () => {
810
- const html = await app.renderShell(shell, { component: 'nonexistent' });
811
- expect(html).toContain('<!-- SSR error:');
812
- });
813
-
814
- it('returns the shell untouched when no options are provided', async () => {
815
- const html = await app.renderShell(shell);
816
- expect(html).toContain('<title>Default Title</title>');
817
- expect(html).toContain('<z-outlet></z-outlet>');
818
- });
819
-
820
- it('escapes </script> in ssrData to prevent script injection', async () => {
821
- const html = await app.renderShell(shell, {
822
- component: 'test-page',
823
- ssrData: { payload: '</script><script>alert(1)</script>' },
824
- });
825
- expect(html).not.toContain('</script><script>alert(1)');
826
- expect(html).toContain('<\\/script>');
827
- // The JSON should still be parseable when unescaped
828
- const match = html.match(/window\.__SSR_DATA__=(.+?);/);
829
- expect(match).toBeTruthy();
830
- });
831
-
832
- it('escapes <!-- in ssrData to prevent HTML comment injection', async () => {
833
- const html = await app.renderShell(shell, {
834
- component: 'test-page',
835
- ssrData: { payload: '<!-- injected -->' },
836
- });
837
- expect(html).not.toContain('<!-- injected');
838
- expect(html).toContain('<\\!--');
839
- });
840
-
841
- it('sanitizes OG keys to prevent ReDoS and attribute injection', async () => {
842
- const html = await app.renderShell(shell, {
843
- component: 'test-page',
844
- og: {
845
- 'title" onload="alert(1)': 'attack', // attribute breakout attempt
846
- 'valid-key': 'safe value',
847
- '': 'empty key should be skipped', // empty after sanitization
848
- },
849
- });
850
- // Attribute injection should be neutralized (quotes stripped from key)
851
- expect(html).not.toContain('onload=');
852
- expect(html).toContain('og:titleonloadalert1');
853
- // Valid key should work fine
854
- expect(html).toContain('og:valid-key');
855
- expect(html).toContain('safe value');
856
- // Empty key should be skipped
857
- const emptyOgCount = (html.match(/og:""/g) || []).length;
858
- expect(emptyOgCount).toBe(0);
859
- });
860
-
861
- it('handles $ substitution patterns in component output safely', async () => {
862
- app.component('dollar-page', {
863
- render() { return "<p>Price: $1.00 and $' and $` tricks</p>"; }
864
- });
865
- const html = await app.renderShell(shell, { component: 'dollar-page' });
866
- expect(html).toContain("$1.00");
867
- expect(html).toContain("$'");
868
- expect(html).toContain("$`");
869
- });
870
- });
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';
4
+
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // createSSRApp
8
+ // ---------------------------------------------------------------------------
9
+
10
+ describe('createSSRApp', () => {
11
+ it('returns an SSRApp instance', () => {
12
+ const app = createSSRApp();
13
+ expect(app).toBeDefined();
14
+ expect(typeof app.component).toBe('function');
15
+ expect(typeof app.renderToString).toBe('function');
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);
25
+ });
26
+ });
27
+
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // component registration
31
+ // ---------------------------------------------------------------------------
32
+
33
+ describe('SSRApp - component', () => {
34
+ it('registers a component and returns self for chaining', () => {
35
+ const app = createSSRApp();
36
+ const result = app.component('my-comp', {
37
+ render() { return '<div>hello</div>'; }
38
+ });
39
+ expect(result).toBe(app);
40
+ });
41
+
42
+ it('throws ZQueryError when rendering unregistered component', async () => {
43
+ const app = createSSRApp();
44
+ await expect(app.renderToString('nonexistent')).rejects.toThrow(ZQueryError);
45
+ await expect(app.renderToString('nonexistent')).rejects.toThrow('not registered');
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
+ });
94
+ });
95
+
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // renderToString (app method)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe('SSRApp - renderToString', () => {
102
+ it('renders basic component', async () => {
103
+ const app = createSSRApp();
104
+ app.component('my-page', {
105
+ state: () => ({ title: 'Hello' }),
106
+ render() { return `<h1>${this.state.title}</h1>`; }
107
+ });
108
+ const html = await app.renderToString('my-page');
109
+ expect(html).toContain('<h1>Hello</h1>');
110
+ });
111
+
112
+ it('adds hydration marker by default', async () => {
113
+ const app = createSSRApp();
114
+ app.component('my-comp', { render() { return '<p>test</p>'; } });
115
+ const html = await app.renderToString('my-comp');
116
+ expect(html).toContain('data-zq-ssr');
117
+ });
118
+
119
+ it('omits hydration marker when hydrate=false', async () => {
120
+ const app = createSSRApp();
121
+ app.component('my-comp', { render() { return '<p>test</p>'; } });
122
+ const html = await app.renderToString('my-comp', {}, { hydrate: false });
123
+ expect(html).not.toContain('data-zq-ssr');
124
+ });
125
+
126
+ it('wraps output in component tag', async () => {
127
+ const app = createSSRApp();
128
+ app.component('custom-tag', { render() { return '<span>ok</span>'; } });
129
+ const html = await app.renderToString('custom-tag');
130
+ expect(html).toMatch(/^<custom-tag[^>]*>.*<\/custom-tag>$/);
131
+ });
132
+
133
+ it('passes props to component', async () => {
134
+ const app = createSSRApp();
135
+ app.component('greet', {
136
+ render() { return `<span>${this.props.name}</span>`; }
137
+ });
138
+ const html = await app.renderToString('greet', { name: 'World' });
139
+ expect(html).toContain('World');
140
+ });
141
+
142
+ it('strips z-cloak attributes', async () => {
143
+ const app = createSSRApp();
144
+ app.component('my-comp', {
145
+ render() { return '<div z-cloak>content</div>'; }
146
+ });
147
+ const html = await app.renderToString('my-comp');
148
+ expect(html).not.toContain('z-cloak');
149
+ expect(html).toContain('content');
150
+ });
151
+
152
+ it('strips event bindings (@click, z-on:click)', async () => {
153
+ const app = createSSRApp();
154
+ app.component('my-comp', {
155
+ render() { return '<button @click="handle">Click</button>'; }
156
+ });
157
+ const html = await app.renderToString('my-comp');
158
+ expect(html).not.toContain('@click');
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
+ });
223
+ });
224
+
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // renderToString (standalone function)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('renderToString (standalone)', () => {
231
+ it('renders a definition directly', () => {
232
+ const html = renderToString({
233
+ state: () => ({ msg: 'hi' }),
234
+ render() { return `<p>${this.state.msg}</p>`; }
235
+ });
236
+ expect(html).toContain('<p>hi</p>');
237
+ });
238
+
239
+ it('returns empty string when no render function', () => {
240
+ const html = renderToString({ state: {} });
241
+ expect(html).toBe('');
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
+ });
264
+ });
265
+
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // State factory
269
+ // ---------------------------------------------------------------------------
270
+
271
+ describe('SSR - state factory', () => {
272
+ it('calls state function for initial state', async () => {
273
+ const app = createSSRApp();
274
+ let callCount = 0;
275
+ app.component('comp', {
276
+ state: () => { callCount++; return { x: 1 }; },
277
+ render() { return `<div>${this.state.x}</div>`; }
278
+ });
279
+ await app.renderToString('comp');
280
+ expect(callCount).toBe(1);
281
+ });
282
+
283
+ it('supports state as object (copied)', async () => {
284
+ const app = createSSRApp();
285
+ const shared = { x: 1 };
286
+ app.component('comp', {
287
+ state: shared,
288
+ render() { return `<div>${this.state.x}</div>`; }
289
+ });
290
+ const html = await app.renderToString('comp');
291
+ expect(html).toContain('1');
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
+ });
325
+ });
326
+
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Computed properties
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe('SSR - computed', () => {
333
+ it('computes derived values', async () => {
334
+ const app = createSSRApp();
335
+ app.component('comp', {
336
+ state: () => ({ first: 'Jane', last: 'Doe' }),
337
+ computed: {
338
+ full(state) { return `${state.first} ${state.last}`; }
339
+ },
340
+ render() { return `<span>${this.computed.full}</span>`; }
341
+ });
342
+ const html = await app.renderToString('comp');
343
+ expect(html).toContain('Jane Doe');
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
+ });
406
+ });
407
+
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Init lifecycle
411
+ // ---------------------------------------------------------------------------
412
+
413
+ describe('SSR - init lifecycle', () => {
414
+ it('calls init() during construction', () => {
415
+ let initCalled = false;
416
+ renderToString({
417
+ init() { initCalled = true; },
418
+ render() { return '<div></div>'; }
419
+ });
420
+ expect(initCalled).toBe(true);
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
+ });
490
+ });
491
+
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // XSS sanitization via _escapeHtml
495
+ // ---------------------------------------------------------------------------
496
+
497
+ describe('SSR - XSS sanitization', () => {
498
+ it('escapes HTML in renderPage title', async () => {
499
+ const app = createSSRApp();
500
+ const html = await app.renderPage({ title: '<script>alert("xss")</script>' });
501
+ expect(html).not.toContain('<script>alert("xss")</script>');
502
+ expect(html).toContain('&lt;script&gt;');
503
+ });
504
+
505
+ it('escapes script paths in renderPage', async () => {
506
+ const app = createSSRApp();
507
+ const html = await app.renderPage({ scripts: ['"><script>alert(1)</script>'] });
508
+ expect(html).toContain('&quot;');
509
+ });
510
+
511
+ it('strips onXxx attributes from bodyAttrs', async () => {
512
+ const app = createSSRApp();
513
+ const html = await app.renderPage({ bodyAttrs: 'onclick="alert(1)"' });
514
+ expect(html).not.toContain('onclick');
515
+ });
516
+
517
+ it('strips javascript: from bodyAttrs', async () => {
518
+ const app = createSSRApp();
519
+ const html = await app.renderPage({ bodyAttrs: 'data-x="javascript:void(0)"' });
520
+ expect(html).not.toContain('javascript:');
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
+ });
536
+ });
537
+
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // renderPage
541
+ // ---------------------------------------------------------------------------
542
+
543
+ describe('SSRApp - renderPage', () => {
544
+ it('renders a full HTML page', async () => {
545
+ const app = createSSRApp();
546
+ app.component('page', { render() { return '<h1>Home</h1>'; } });
547
+ const html = await app.renderPage({
548
+ component: 'page',
549
+ title: 'My App',
550
+ lang: 'en'
551
+ });
552
+ expect(html).toContain('<!DOCTYPE html>');
553
+ expect(html).toContain('<title>My App</title>');
554
+ expect(html).toContain('<h1>Home</h1>');
555
+ expect(html).toContain('lang="en"');
556
+ });
557
+
558
+ it('renders without component', async () => {
559
+ const app = createSSRApp();
560
+ const html = await app.renderPage({ title: 'Empty' });
561
+ expect(html).toContain('<title>Empty</title>');
562
+ expect(html).toContain('<div id="app"></div>');
563
+ });
564
+
565
+ it('includes style links', async () => {
566
+ const app = createSSRApp();
567
+ const html = await app.renderPage({ styles: ['style.css', 'theme.css'] });
568
+ expect(html).toContain('href="style.css"');
569
+ expect(html).toContain('href="theme.css"');
570
+ });
571
+
572
+ it('includes script tags', async () => {
573
+ const app = createSSRApp();
574
+ const html = await app.renderPage({ scripts: ['app.js'] });
575
+ expect(html).toContain('src="app.js"');
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
+ });
695
+ });
696
+
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // SSRApp - renderShell
700
+ // ---------------------------------------------------------------------------
701
+
702
+ describe('SSRApp - renderShell', () => {
703
+ const shell = `<!DOCTYPE html>
704
+ <html lang="en">
705
+ <head>
706
+ <title>Default Title</title>
707
+ <meta name="description" content="default desc">
708
+ <meta property="og:title" content="Default OG">
709
+ <meta property="og:description" content="">
710
+ <meta property="og:type" content="website">
711
+ </head>
712
+ <body>
713
+ <z-outlet></z-outlet>
714
+ </body>
715
+ </html>`;
716
+
717
+ let app;
718
+ beforeEach(() => {
719
+ app = createSSRApp();
720
+ app.component('test-page', {
721
+ state: () => ({ greeting: 'Hello' }),
722
+ render() { return `<h1>${this.state.greeting}</h1>`; }
723
+ });
724
+ });
725
+
726
+ it('renders a component into <z-outlet>', async () => {
727
+ const html = await app.renderShell(shell, { component: 'test-page' });
728
+ expect(html).toContain('<z-outlet><test-page data-zq-ssr><h1>Hello</h1></test-page></z-outlet>');
729
+ });
730
+
731
+ it('replaces the <title> tag', async () => {
732
+ const html = await app.renderShell(shell, { component: 'test-page', title: 'My App — Home' });
733
+ expect(html).toContain('<title>My App — Home</title>');
734
+ expect(html).not.toContain('Default Title');
735
+ });
736
+
737
+ it('replaces the meta description', async () => {
738
+ const html = await app.renderShell(shell, { component: 'test-page', description: 'A great page' });
739
+ expect(html).toContain('<meta name="description" content="A great page">');
740
+ expect(html).not.toContain('default desc');
741
+ });
742
+
743
+ it('replaces existing Open Graph tags', async () => {
744
+ const html = await app.renderShell(shell, {
745
+ component: 'test-page',
746
+ og: { title: 'OG Title', description: 'OG Desc', type: 'article' },
747
+ });
748
+ expect(html).toContain('<meta property="og:title" content="OG Title">');
749
+ expect(html).toContain('<meta property="og:description" content="OG Desc">');
750
+ expect(html).toContain('<meta property="og:type" content="article">');
751
+ });
752
+
753
+ it('injects new OG tags when they do not exist in the shell', async () => {
754
+ const html = await app.renderShell(shell, {
755
+ component: 'test-page',
756
+ og: { image: 'https://example.com/img.png' },
757
+ });
758
+ expect(html).toContain('<meta property="og:image" content="https://example.com/img.png">');
759
+ });
760
+
761
+ it('injects window.__SSR_DATA__ when ssrData is provided', async () => {
762
+ const data = { component: 'test-page', params: {}, props: {} };
763
+ const html = await app.renderShell(shell, { component: 'test-page', ssrData: data });
764
+ expect(html).toContain('window.__SSR_DATA__=');
765
+ expect(html).toContain('"component":"test-page"');
766
+ // Script is injected before </head>
767
+ expect(html.indexOf('__SSR_DATA__')).toBeLessThan(html.indexOf('</head>'));
768
+ });
769
+
770
+ it('does not inject ssrData when not provided', async () => {
771
+ const html = await app.renderShell(shell, { component: 'test-page' });
772
+ expect(html).not.toContain('__SSR_DATA__');
773
+ });
774
+
775
+ it('escapes title and description for XSS safety', async () => {
776
+ const html = await app.renderShell(shell, {
777
+ component: 'test-page',
778
+ title: '<script>alert("xss")</script>',
779
+ description: '"injected" & <dangerous>',
780
+ });
781
+ expect(html).toContain('&lt;script&gt;');
782
+ expect(html).toContain('&quot;injected&quot; &amp; &lt;dangerous&gt;');
783
+ expect(html).not.toContain('<script>alert');
784
+ });
785
+
786
+ it('leaves title and description alone when not provided', async () => {
787
+ const html = await app.renderShell(shell, { component: 'test-page' });
788
+ expect(html).toContain('<title>Default Title</title>');
789
+ expect(html).toContain('content="default desc"');
790
+ });
791
+
792
+ it('passes props to the rendered component', async () => {
793
+ app.component('greeting-page', {
794
+ render() { return `<p>Hi ${this.props.name}</p>`; }
795
+ });
796
+ const html = await app.renderShell(shell, { component: 'greeting-page', props: { name: 'Tony' } });
797
+ expect(html).toContain('<p>Hi Tony</p>');
798
+ });
799
+
800
+ it('passes renderOptions through to renderToString', async () => {
801
+ const html = await app.renderShell(shell, {
802
+ component: 'test-page',
803
+ renderOptions: { hydrate: false },
804
+ });
805
+ expect(html).toContain('<test-page><h1>Hello</h1></test-page>');
806
+ expect(html).not.toContain('data-zq-ssr');
807
+ });
808
+
809
+ it('handles a missing component gracefully', async () => {
810
+ const html = await app.renderShell(shell, { component: 'nonexistent' });
811
+ expect(html).toContain('<!-- SSR error:');
812
+ });
813
+
814
+ it('returns the shell untouched when no options are provided', async () => {
815
+ const html = await app.renderShell(shell);
816
+ expect(html).toContain('<title>Default Title</title>');
817
+ expect(html).toContain('<z-outlet></z-outlet>');
818
+ });
819
+
820
+ it('escapes </script> in ssrData to prevent script injection', async () => {
821
+ const html = await app.renderShell(shell, {
822
+ component: 'test-page',
823
+ ssrData: { payload: '</script><script>alert(1)</script>' },
824
+ });
825
+ expect(html).not.toContain('</script><script>alert(1)');
826
+ expect(html).toContain('<\\/script>');
827
+ // The JSON should still be parseable when unescaped
828
+ const match = html.match(/window\.__SSR_DATA__=(.+?);/);
829
+ expect(match).toBeTruthy();
830
+ });
831
+
832
+ it('escapes <!-- in ssrData to prevent HTML comment injection', async () => {
833
+ const html = await app.renderShell(shell, {
834
+ component: 'test-page',
835
+ ssrData: { payload: '<!-- injected -->' },
836
+ });
837
+ expect(html).not.toContain('<!-- injected');
838
+ expect(html).toContain('<\\!--');
839
+ });
840
+
841
+ it('sanitizes OG keys to prevent ReDoS and attribute injection', async () => {
842
+ const html = await app.renderShell(shell, {
843
+ component: 'test-page',
844
+ og: {
845
+ 'title" onload="alert(1)': 'attack', // attribute breakout attempt
846
+ 'valid-key': 'safe value',
847
+ '': 'empty key should be skipped', // empty after sanitization
848
+ },
849
+ });
850
+ // Attribute injection should be neutralized (quotes stripped from key)
851
+ expect(html).not.toContain('onload=');
852
+ expect(html).toContain('og:titleonloadalert1');
853
+ // Valid key should work fine
854
+ expect(html).toContain('og:valid-key');
855
+ expect(html).toContain('safe value');
856
+ // Empty key should be skipped
857
+ const emptyOgCount = (html.match(/og:""/g) || []).length;
858
+ expect(emptyOgCount).toBe(0);
859
+ });
860
+
861
+ it('handles $ substitution patterns in component output safely', async () => {
862
+ app.component('dollar-page', {
863
+ render() { return "<p>Price: $1.00 and $' and $` tricks</p>"; }
864
+ });
865
+ const html = await app.renderShell(shell, { component: 'dollar-page' });
866
+ expect(html).toContain("$1.00");
867
+ expect(html).toContain("$'");
868
+ expect(html).toContain("$`");
869
+ });
870
+ });