zero-query 0.9.0 → 0.9.5

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.
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createSSRApp, renderToString } from '../src/ssr.js';
3
+
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // createSSRApp
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe('createSSRApp', () => {
10
+ it('returns an SSRApp instance', () => {
11
+ const app = createSSRApp();
12
+ expect(app).toBeDefined();
13
+ expect(typeof app.component).toBe('function');
14
+ expect(typeof app.renderToString).toBe('function');
15
+ expect(typeof app.renderPage).toBe('function');
16
+ });
17
+ });
18
+
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // component registration
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe('SSRApp — component', () => {
25
+ it('registers a component and returns self for chaining', () => {
26
+ const app = createSSRApp();
27
+ const result = app.component('my-comp', {
28
+ render() { return '<div>hello</div>'; }
29
+ });
30
+ expect(result).toBe(app);
31
+ });
32
+
33
+ it('throws when rendering unregistered component', async () => {
34
+ const app = createSSRApp();
35
+ await expect(app.renderToString('nonexistent')).rejects.toThrow('not registered');
36
+ });
37
+ });
38
+
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // renderToString (app method)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe('SSRApp — renderToString', () => {
45
+ it('renders basic component', async () => {
46
+ const app = createSSRApp();
47
+ app.component('my-page', {
48
+ state: () => ({ title: 'Hello' }),
49
+ render() { return `<h1>${this.state.title}</h1>`; }
50
+ });
51
+ const html = await app.renderToString('my-page');
52
+ expect(html).toContain('<h1>Hello</h1>');
53
+ });
54
+
55
+ it('adds hydration marker by default', async () => {
56
+ const app = createSSRApp();
57
+ app.component('my-comp', { render() { return '<p>test</p>'; } });
58
+ const html = await app.renderToString('my-comp');
59
+ expect(html).toContain('data-zq-ssr');
60
+ });
61
+
62
+ it('omits hydration marker when hydrate=false', async () => {
63
+ const app = createSSRApp();
64
+ app.component('my-comp', { render() { return '<p>test</p>'; } });
65
+ const html = await app.renderToString('my-comp', {}, { hydrate: false });
66
+ expect(html).not.toContain('data-zq-ssr');
67
+ });
68
+
69
+ it('wraps output in component tag', async () => {
70
+ const app = createSSRApp();
71
+ app.component('custom-tag', { render() { return '<span>ok</span>'; } });
72
+ const html = await app.renderToString('custom-tag');
73
+ expect(html).toMatch(/^<custom-tag[^>]*>.*<\/custom-tag>$/);
74
+ });
75
+
76
+ it('passes props to component', async () => {
77
+ const app = createSSRApp();
78
+ app.component('greet', {
79
+ render() { return `<span>${this.props.name}</span>`; }
80
+ });
81
+ const html = await app.renderToString('greet', { name: 'World' });
82
+ expect(html).toContain('World');
83
+ });
84
+
85
+ it('strips z-cloak attributes', async () => {
86
+ const app = createSSRApp();
87
+ app.component('my-comp', {
88
+ render() { return '<div z-cloak>content</div>'; }
89
+ });
90
+ const html = await app.renderToString('my-comp');
91
+ expect(html).not.toContain('z-cloak');
92
+ expect(html).toContain('content');
93
+ });
94
+
95
+ it('strips event bindings (@click, z-on:click)', async () => {
96
+ const app = createSSRApp();
97
+ app.component('my-comp', {
98
+ render() { return '<button @click="handle">Click</button>'; }
99
+ });
100
+ const html = await app.renderToString('my-comp');
101
+ expect(html).not.toContain('@click');
102
+ });
103
+ });
104
+
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // renderToString (standalone function)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('renderToString (standalone)', () => {
111
+ it('renders a definition directly', () => {
112
+ const html = renderToString({
113
+ state: () => ({ msg: 'hi' }),
114
+ render() { return `<p>${this.state.msg}</p>`; }
115
+ });
116
+ expect(html).toContain('<p>hi</p>');
117
+ });
118
+
119
+ it('returns empty string when no render function', () => {
120
+ const html = renderToString({ state: {} });
121
+ expect(html).toBe('');
122
+ });
123
+ });
124
+
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // State factory
128
+ // ---------------------------------------------------------------------------
129
+
130
+ describe('SSR — state factory', () => {
131
+ it('calls state function for initial state', async () => {
132
+ const app = createSSRApp();
133
+ let callCount = 0;
134
+ app.component('comp', {
135
+ state: () => { callCount++; return { x: 1 }; },
136
+ render() { return `<div>${this.state.x}</div>`; }
137
+ });
138
+ await app.renderToString('comp');
139
+ expect(callCount).toBe(1);
140
+ });
141
+
142
+ it('supports state as object (copied)', async () => {
143
+ const app = createSSRApp();
144
+ const shared = { x: 1 };
145
+ app.component('comp', {
146
+ state: shared,
147
+ render() { return `<div>${this.state.x}</div>`; }
148
+ });
149
+ const html = await app.renderToString('comp');
150
+ expect(html).toContain('1');
151
+ });
152
+ });
153
+
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Computed properties
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe('SSR — computed', () => {
160
+ it('computes derived values', async () => {
161
+ const app = createSSRApp();
162
+ app.component('comp', {
163
+ state: () => ({ first: 'Jane', last: 'Doe' }),
164
+ computed: {
165
+ full(state) { return `${state.first} ${state.last}`; }
166
+ },
167
+ render() { return `<span>${this.computed.full}</span>`; }
168
+ });
169
+ const html = await app.renderToString('comp');
170
+ expect(html).toContain('Jane Doe');
171
+ });
172
+ });
173
+
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Init lifecycle
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('SSR — init lifecycle', () => {
180
+ it('calls init() during construction', () => {
181
+ let initCalled = false;
182
+ renderToString({
183
+ init() { initCalled = true; },
184
+ render() { return '<div></div>'; }
185
+ });
186
+ expect(initCalled).toBe(true);
187
+ });
188
+ });
189
+
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // XSS sanitization via _escapeHtml
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe('SSR — XSS sanitization', () => {
196
+ it('escapes HTML in renderPage title', async () => {
197
+ const app = createSSRApp();
198
+ const html = await app.renderPage({ title: '<script>alert("xss")</script>' });
199
+ expect(html).not.toContain('<script>alert("xss")</script>');
200
+ expect(html).toContain('&lt;script&gt;');
201
+ });
202
+
203
+ it('escapes script paths in renderPage', async () => {
204
+ const app = createSSRApp();
205
+ const html = await app.renderPage({ scripts: ['"><script>alert(1)</script>'] });
206
+ expect(html).toContain('&quot;');
207
+ });
208
+
209
+ it('strips onXxx attributes from bodyAttrs', async () => {
210
+ const app = createSSRApp();
211
+ const html = await app.renderPage({ bodyAttrs: 'onclick="alert(1)"' });
212
+ expect(html).not.toContain('onclick');
213
+ });
214
+
215
+ it('strips javascript: from bodyAttrs', async () => {
216
+ const app = createSSRApp();
217
+ const html = await app.renderPage({ bodyAttrs: 'data-x="javascript:void(0)"' });
218
+ expect(html).not.toContain('javascript:');
219
+ });
220
+ });
221
+
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // renderPage
225
+ // ---------------------------------------------------------------------------
226
+
227
+ describe('SSRApp — renderPage', () => {
228
+ it('renders a full HTML page', async () => {
229
+ const app = createSSRApp();
230
+ app.component('page', { render() { return '<h1>Home</h1>'; } });
231
+ const html = await app.renderPage({
232
+ component: 'page',
233
+ title: 'My App',
234
+ lang: 'en'
235
+ });
236
+ expect(html).toContain('<!DOCTYPE html>');
237
+ expect(html).toContain('<title>My App</title>');
238
+ expect(html).toContain('<h1>Home</h1>');
239
+ expect(html).toContain('lang="en"');
240
+ });
241
+
242
+ it('renders without component', async () => {
243
+ const app = createSSRApp();
244
+ const html = await app.renderPage({ title: 'Empty' });
245
+ expect(html).toContain('<title>Empty</title>');
246
+ expect(html).toContain('<div id="app"></div>');
247
+ });
248
+
249
+ it('includes style links', async () => {
250
+ const app = createSSRApp();
251
+ const html = await app.renderPage({ styles: ['style.css', 'theme.css'] });
252
+ expect(html).toContain('href="style.css"');
253
+ expect(html).toContain('href="theme.css"');
254
+ });
255
+
256
+ it('includes script tags', async () => {
257
+ const app = createSSRApp();
258
+ const html = await app.renderPage({ scripts: ['app.js'] });
259
+ expect(html).toContain('src="app.js"');
260
+ });
261
+ });
@@ -377,3 +377,213 @@ describe('Store — complex getters', () => {
377
377
  expect(store.getters.doubled).toBe(20);
378
378
  });
379
379
  });
380
+
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // PERF: history trim uses splice (in-place) instead of slice (copy)
384
+ // ---------------------------------------------------------------------------
385
+
386
+ describe('Store — history trim in-place', () => {
387
+ it('trims history to maxHistory without exceeding', () => {
388
+ const store = createStore('hist-trim', {
389
+ state: { n: 0 },
390
+ actions: { inc(s) { s.n++; } },
391
+ maxHistory: 5,
392
+ });
393
+ for (let i = 0; i < 10; i++) store.dispatch('inc');
394
+ expect(store.history.length).toBe(5);
395
+ // Newest actions should survive
396
+ expect(store.state.n).toBe(10);
397
+ });
398
+
399
+ it('maintains same array identity (splice, not slice)', () => {
400
+ const store = createStore('hist-identity', {
401
+ state: { n: 0 },
402
+ actions: { inc(s) { s.n++; } },
403
+ maxHistory: 3,
404
+ });
405
+ const ref = store._history;
406
+ for (let i = 0; i < 10; i++) store.dispatch('inc');
407
+ // splice modifies in-place so the array reference stays the same
408
+ expect(store._history).toBe(ref);
409
+ expect(store._history.length).toBe(3);
410
+ });
411
+ });
412
+
413
+
414
+ // ===========================================================================
415
+ // use() — middleware chaining
416
+ // ===========================================================================
417
+
418
+ describe('Store — use() chaining', () => {
419
+ it('returns the store for chaining', () => {
420
+ const store = createStore({
421
+ state: { x: 0 },
422
+ actions: { inc(state) { state.x++; } }
423
+ });
424
+ const result = store.use(() => {}).use(() => {});
425
+ expect(result).toBe(store);
426
+ });
427
+
428
+ it('multiple middleware run in order', () => {
429
+ const order = [];
430
+ const store = createStore({
431
+ state: { x: 0 },
432
+ actions: { inc(state) { state.x++; } }
433
+ });
434
+ store.use(() => { order.push('a'); });
435
+ store.use(() => { order.push('b'); });
436
+ store.dispatch('inc');
437
+ expect(order).toEqual(['a', 'b']);
438
+ });
439
+
440
+ it('middleware returning false blocks action', () => {
441
+ const store = createStore({
442
+ state: { x: 0 },
443
+ actions: { inc(state) { state.x++; } }
444
+ });
445
+ store.use(() => false);
446
+ store.dispatch('inc');
447
+ expect(store.state.x).toBe(0);
448
+ });
449
+ });
450
+
451
+
452
+ // ===========================================================================
453
+ // debug mode
454
+ // ===========================================================================
455
+
456
+ describe('Store — debug mode', () => {
457
+ it('logs when debug is true', () => {
458
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
459
+ const store = createStore({
460
+ state: { x: 0 },
461
+ actions: { inc(state) { state.x++; } },
462
+ debug: true
463
+ });
464
+ store.dispatch('inc');
465
+ expect(spy).toHaveBeenCalled();
466
+ const logStr = spy.mock.calls[0].join(' ');
467
+ expect(logStr).toContain('inc');
468
+ spy.mockRestore();
469
+ });
470
+ });
471
+
472
+
473
+ // ===========================================================================
474
+ // replaceState
475
+ // ===========================================================================
476
+
477
+ describe('Store — replaceState', () => {
478
+ it('replaces all keys', () => {
479
+ const store = createStore({
480
+ state: { a: 1, b: 2 }
481
+ });
482
+ store.replaceState({ c: 3 });
483
+ const snap = store.snapshot();
484
+ expect(snap).not.toHaveProperty('a');
485
+ expect(snap).not.toHaveProperty('b');
486
+ expect(snap.c).toBe(3);
487
+ });
488
+ });
489
+
490
+
491
+ // ===========================================================================
492
+ // wildcard subscription
493
+ // ===========================================================================
494
+
495
+ describe('Store — wildcard subscription', () => {
496
+ it('fires on any state change', () => {
497
+ const store = createStore({
498
+ state: { a: 1, b: 2 },
499
+ actions: {
500
+ setA(state, v) { state.a = v; },
501
+ setB(state, v) { state.b = v; }
502
+ }
503
+ });
504
+ const calls = [];
505
+ store.subscribe((key, val, old) => calls.push([key, val, old]));
506
+ store.dispatch('setA', 10);
507
+ store.dispatch('setB', 20);
508
+ expect(calls).toEqual([['a', 10, 1], ['b', 20, 2]]);
509
+ });
510
+
511
+ it('unsubscribes wildcard', () => {
512
+ const store = createStore({
513
+ state: { a: 1 },
514
+ actions: { setA(state, v) { state.a = v; } }
515
+ });
516
+ const fn = vi.fn();
517
+ const unsub = store.subscribe(fn);
518
+ store.dispatch('setA', 2);
519
+ expect(fn).toHaveBeenCalledTimes(1);
520
+ unsub();
521
+ store.dispatch('setA', 3);
522
+ expect(fn).toHaveBeenCalledTimes(1);
523
+ });
524
+ });
525
+
526
+
527
+ // ===========================================================================
528
+ // state as factory function
529
+ // ===========================================================================
530
+
531
+ describe('Store — state factory', () => {
532
+ it('calls state function for initial state', () => {
533
+ const store = createStore({
534
+ state: () => ({ count: 0 })
535
+ });
536
+ expect(store.state.count).toBe(0);
537
+ });
538
+ });
539
+
540
+
541
+ // ===========================================================================
542
+ // createStore — named stores
543
+ // ===========================================================================
544
+
545
+ describe('createStore — named stores', () => {
546
+ it('creates default store when no name given', () => {
547
+ const store = createStore({ state: { x: 1 } });
548
+ expect(store.state.x).toBe(1);
549
+ });
550
+
551
+ it('dispatch unknown action does not throw', () => {
552
+ const store = createStore({ state: { x: 1 }, actions: {} });
553
+ expect(() => store.dispatch('nope')).not.toThrow();
554
+ });
555
+ });
556
+
557
+
558
+ // ===========================================================================
559
+ // reset
560
+ // ===========================================================================
561
+
562
+ describe('Store — reset', () => {
563
+ it('resets state and clears history', () => {
564
+ const store = createStore({
565
+ state: { x: 0 },
566
+ actions: { inc(state) { state.x++; } }
567
+ });
568
+ store.dispatch('inc');
569
+ store.dispatch('inc');
570
+ expect(store.state.x).toBe(2);
571
+ expect(store.history.length).toBe(2);
572
+ store.reset({ x: 0 });
573
+ expect(store.state.x).toBe(0);
574
+ expect(store.history.length).toBe(0);
575
+ });
576
+ });
577
+
578
+
579
+ // ===========================================================================
580
+ // empty config
581
+ // ===========================================================================
582
+
583
+ describe('Store — empty config', () => {
584
+ it('creates store with no config', () => {
585
+ const store = createStore({});
586
+ expect(store.snapshot()).toEqual({});
587
+ expect(store.history).toEqual([]);
588
+ });
589
+ });