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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -0
- package/cli/commands/build.js +254 -216
- package/cli/commands/bundle.js +1228 -1183
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -167
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +7264 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6252
- package/dist/zquery.min.js +8 -601
- package/index.d.ts +570 -365
- package/index.js +311 -232
- package/package.json +76 -69
- package/src/component.js +1709 -1454
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -254
- package/src/router.js +843 -773
- package/src/ssr.js +418 -418
- package/src/store.js +318 -272
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1023
- package/tests/compare.test.js +497 -0
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -0
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -0
- package/tests/electron-features.test.js +864 -0
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -145
- package/types/utils.d.ts +245 -245
- 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('<script>');
|
|
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('<script>');
|
|
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('"');
|
|
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('"');
|
|
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('&<>"'');
|
|
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('<script>');
|
|
782
|
-
expect(html).toContain('"injected" & <dangerous>');
|
|
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('<script>');
|
|
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('<script>');
|
|
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('"');
|
|
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('"');
|
|
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('&<>"'');
|
|
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('<script>');
|
|
782
|
+
expect(html).toContain('"injected" & <dangerous>');
|
|
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
|
+
});
|