zero-query 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -1,1650 +1,1664 @@
1
- /**
2
- * Documentation validation test suite.
3
- *
4
- * Validates every property table, code example, and API claim in the
5
- * documentation section data against the actual framework implementation.
6
- * If the tests fail, the docs or framework need updating.
7
- */
8
-
9
- import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
10
-
11
- // --- Framework imports (the actual code under test) --------------------------
12
- import { morph, morphElement } from '../src/diff.js';
13
- import { reactive, Signal, signal, computed, effect, batch, untracked } from '../src/reactive.js';
14
- import { safeEval } from '../src/expression.js';
15
- import {
16
- debounce, throttle, pipe, once, sleep,
17
- escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
18
- deepClone, deepMerge, isEqual, param, parseQuery,
19
- storage, session, EventBus, bus,
20
- range, unique, chunk, groupBy,
21
- pick, omit, getPath, setPath, isEmpty,
22
- capitalize, truncate, clamp,
23
- memoize, retry, timeout,
24
- } from '../src/utils.js';
25
- import { createRouter, getRouter, matchRoute } from '../src/router.js';
26
- import { component, mount, mountAll, destroy, prefetch, getInstance, getRegistry, style } from '../src/component.js';
27
- import { createStore, getStore } from '../src/store.js';
28
- import { http } from '../src/http.js';
29
- import { ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError } from '../src/errors.js';
30
- import { createSSRApp, renderToString } from '../src/ssr.js';
31
-
32
- // --- Doc sections need $ global (website store.js references $.libSize) ------
33
- // Set up a minimal global $ before dynamic import
34
- let SECTIONS;
35
- beforeAll(async () => {
36
- const mod = await import('../index.js');
37
- globalThis.$ = mod.$;
38
- const sectionsMod = await import('../zquery-website/app/components/docs/sections/index.js');
39
- SECTIONS = sectionsMod.SECTIONS;
40
- });
41
-
42
-
43
- // ===========================================================================
44
- // Helpers
45
- // ===========================================================================
46
- function el(html) {
47
- const div = document.createElement('div');
48
- div.innerHTML = html;
49
- return div;
50
- }
51
-
52
- /** Verify section exists and has required shape */
53
- function assertSection(sec) {
54
- expect(sec).toBeDefined();
55
- expect(sec.id).toBeTypeOf('string');
56
- expect(sec.label).toBeTypeOf('string');
57
- expect(Array.isArray(sec.headings)).toBe(true);
58
- expect(sec.content).toBeTypeOf('function');
59
- const html = sec.content();
60
- expect(html).toBeTypeOf('string');
61
- expect(html.length).toBeGreaterThan(0);
62
- }
63
-
64
-
65
- // ===========================================================================
66
- // 1. SECTION STRUCTURE VALIDATION
67
- // ===========================================================================
68
- describe('Documentation sections structure', () => {
69
-
70
- it('exports SECTIONS array with all 17 sections', () => {
71
- expect(Array.isArray(SECTIONS)).toBe(true);
72
- expect(SECTIONS.length).toBe(17);
73
- });
74
-
75
- const expectedIds = [
76
- 'getting-started', 'dev-workflow', 'devtools', 'error-handling',
77
- 'cli-bundler', 'project-structure', 'router', 'components',
78
- 'directives', 'store', 'reactive', 'selectors', 'ssr',
79
- 'http', 'utils', 'security', 'environment',
80
- ];
81
-
82
- it.each(expectedIds)('section "%s" exists with valid structure', (id) => {
83
- const sec = SECTIONS.find(s => s.id === id);
84
- assertSection(sec);
85
- });
86
-
87
- it('every section has unique id', () => {
88
- const ids = SECTIONS.map(s => s.id);
89
- expect(new Set(ids).size).toBe(ids.length);
90
- });
91
-
92
- it('every heading has id and text', () => {
93
- for (const sec of SECTIONS) {
94
- for (const h of sec.headings) {
95
- expect(h.id).toBeTypeOf('string');
96
- expect(h.text).toBeTypeOf('string');
97
- expect(h.id.length).toBeGreaterThan(0);
98
- expect(h.text.length).toBeGreaterThan(0);
99
- }
100
- }
101
- });
102
-
103
- it('every section content() returns valid HTML with headings', () => {
104
- for (const sec of SECTIONS) {
105
- const html = sec.content();
106
- expect(html).toBeTypeOf('string');
107
- // Every section should have at least one heading
108
- expect(html).toMatch(/<h[23]/);
109
- }
110
- });
111
- });
112
-
113
-
114
- // ===========================================================================
115
- // 2. REACTIVE DOCS — Validate property tables & examples
116
- // ===========================================================================
117
- describe('Reactive docs validation', () => {
118
-
119
- it('$.reactive() creates a deeply reactive proxy', () => {
120
- const changes = [];
121
- const data = reactive({ user: { name: 'Alice', score: 0 }, items: ['a', 'b', 'c'] },
122
- (prop, value, oldValue) => changes.push({ prop, value, oldValue })
123
- );
124
- data.user.score = 42;
125
- expect(changes.some(c => c.prop === 'score' && c.value === 42 && c.oldValue === 0)).toBe(true);
126
- });
127
-
128
- it('$.reactive() proxy has __raw and __isReactive', () => {
129
- const data = reactive({ x: 1 }, () => {});
130
- expect(data.__isReactive).toBe(true);
131
- expect(data.__raw).toBeDefined();
132
- expect(data.__raw.x).toBe(1);
133
- });
134
-
135
- it('$.reactive() on non-object returns as-is', () => {
136
- const fn = () => {};
137
- expect(reactive(42, fn)).toBe(42);
138
- expect(reactive(null, fn)).toBe(null);
139
- });
140
-
141
- it('$.signal() creates a signal with .value', () => {
142
- const count = signal(0);
143
- expect(count.value).toBe(0);
144
- count.value = 5;
145
- expect(count.value).toBe(5);
146
- });
147
-
148
- it('signal .subscribe() returns unsubscribe function', () => {
149
- const count = signal(0);
150
- const calls = [];
151
- const unsub = count.subscribe(() => calls.push(count.value));
152
- count.value = 1;
153
- expect(calls).toContain(1);
154
- unsub();
155
- count.value = 2;
156
- expect(calls).not.toContain(2);
157
- });
158
-
159
- it('signal equality check - same value is no-op', () => {
160
- const s = signal(5);
161
- const calls = [];
162
- s.subscribe(() => calls.push('fired'));
163
- s.value = 5; // same value
164
- expect(calls.length).toBe(0);
165
- });
166
-
167
- it('signal .peek() reads without tracking', () => {
168
- const count = signal(0);
169
- const label = signal('Count');
170
- let runs = 0;
171
-
172
- const dispose = effect(() => {
173
- label.value; // tracked
174
- count.peek(); // NOT tracked
175
- runs++;
176
- });
177
-
178
- expect(runs).toBe(1);
179
- count.value = 10; // should NOT re-run effect
180
- expect(runs).toBe(1);
181
- label.value = 'New Label'; // SHOULD re-run
182
- expect(runs).toBe(2);
183
- dispose();
184
- });
185
-
186
- it('$.Signal constructor is same class as $.signal()', () => {
187
- const a = new Signal(0);
188
- const b = signal(0);
189
- expect(a instanceof Signal).toBe(true);
190
- expect(b instanceof Signal).toBe(true);
191
- });
192
-
193
- it('$.computed() derives from signals', () => {
194
- const price = signal(29.99);
195
- const quantity = signal(3);
196
- const total = computed(() => price.value * quantity.value);
197
- expect(total.value).toBeCloseTo(89.97);
198
- quantity.value = 5;
199
- expect(total.value).toBeCloseTo(149.95);
200
- });
201
-
202
- it('chained computed signals', () => {
203
- const count = signal(2);
204
- const doubled = computed(() => count.value * 2);
205
- const quadrupled = computed(() => doubled.value * 2);
206
- expect(quadrupled.value).toBe(8);
207
- count.value = 3;
208
- expect(quadrupled.value).toBe(12);
209
- });
210
-
211
- it('$.effect() returns dispose function', () => {
212
- const theme = signal('dark');
213
- let result = '';
214
- const dispose = effect(() => { result = theme.value; });
215
- expect(result).toBe('dark');
216
- theme.value = 'light';
217
- expect(result).toBe('light');
218
- dispose();
219
- theme.value = 'blue';
220
- expect(result).toBe('light'); // no longer tracking
221
- });
222
-
223
- it('$.batch() defers signal notifications', () => {
224
- const a = signal(1);
225
- const b = signal(2);
226
- const results = [];
227
- effect(() => results.push(a.value + b.value));
228
- expect(results).toEqual([3]);
229
- batch(() => {
230
- a.value = 10;
231
- b.value = 20;
232
- });
233
- // Should fire once with 30, not intermediate 12
234
- expect(results[results.length - 1]).toBe(30);
235
- });
236
-
237
- it('$.batch() returns callback return value', () => {
238
- const a = signal(1);
239
- const result = batch(() => {
240
- a.value = 100;
241
- return a.value;
242
- });
243
- expect(result).toBe(100);
244
- });
245
-
246
- it('$.untracked() reads without creating dependencies', () => {
247
- const a = signal(1);
248
- const b = signal(10);
249
- let runs = 0;
250
- const dispose = effect(() => {
251
- a.value; // tracked
252
- untracked(() => b.value); // NOT tracked
253
- runs++;
254
- });
255
- expect(runs).toBe(1);
256
- b.value = 20; // should NOT re-run
257
- expect(runs).toBe(1);
258
- a.value = 2; // should re-run
259
- expect(runs).toBe(2);
260
- dispose();
261
- });
262
-
263
- it('error resilience - reactive onChange error is caught', () => {
264
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
265
- const data = reactive({ x: 0 }, () => { throw new Error('boom'); });
266
- data.x = 1; // should not throw
267
- expect(data.x).toBe(1);
268
- spy.mockRestore();
269
- });
270
-
271
- it('error resilience - signal subscriber error is caught', () => {
272
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
273
- const s = signal(0);
274
- s.subscribe(() => { throw new Error('boom'); });
275
- s.value = 1; // should not throw
276
- spy.mockRestore();
277
- });
278
-
279
- it('error resilience - effect error is caught', () => {
280
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
281
- const s = signal(0);
282
- effect(() => {
283
- if (s.value > 0) throw new Error('boom');
284
- s.value; // track
285
- });
286
- s.value = 1; // should not throw
287
- spy.mockRestore();
288
- });
289
- });
290
-
291
-
292
- // ===========================================================================
293
- // 3. STORE DOCS Validate property tables & examples
294
- // ===========================================================================
295
- describe('Store docs validation', () => {
296
-
297
- beforeEach(() => {
298
- // Clear any previously registered stores
299
- vi.spyOn(console, 'error').mockImplementation(() => {});
300
- });
301
- afterEach(() => vi.restoreAllMocks());
302
-
303
- it('$.store() creates a store with state, actions, getters', () => {
304
- const store = createStore('test-basic', {
305
- state: { user: null, theme: 'dark', count: 0 },
306
- getters: {
307
- isLoggedIn: (state) => !!state.user,
308
- displayName: (state) => state.user?.name || 'Guest',
309
- },
310
- actions: {
311
- login(state, user) { state.user = user; },
312
- logout(state) { state.user = null; },
313
- increment(state) { state.count++; },
314
- setTheme(state, t) { state.theme = t; },
315
- }
316
- });
317
- expect(store.state.count).toBe(0);
318
- expect(store.state.theme).toBe('dark');
319
- expect(store.getters.isLoggedIn).toBe(false);
320
- expect(store.getters.displayName).toBe('Guest');
321
- });
322
-
323
- it('store.dispatch() runs actions and returns result', () => {
324
- const store = createStore('test-dispatch', {
325
- state: { count: 0 },
326
- actions: {
327
- increment(state) { state.count++; },
328
- compute(state) { return state.count * 2; },
329
- }
330
- });
331
- store.dispatch('increment');
332
- expect(store.state.count).toBe(1);
333
- const result = store.dispatch('compute');
334
- expect(result).toBe(2);
335
- });
336
-
337
- it('store.subscribe() - key-specific and wildcard callback args (key, value, old)', () => {
338
- const store = createStore('test-sub-args', {
339
- state: { count: 0 },
340
- actions: { inc(s) { s.count++; } }
341
- });
342
-
343
- // Key-specific subscriber: (key, value, old) per actual source
344
- const keyArgs = [];
345
- store.subscribe('count', (key, value, old) => keyArgs.push({ key, value, old }));
346
-
347
- // Wildcard subscriber: (key, value, old)
348
- const wildArgs = [];
349
- store.subscribe((key, value, old) => wildArgs.push({ key, value, old }));
350
-
351
- store.dispatch('inc');
352
-
353
- // Both should get (key, value, old) - same arg order
354
- expect(keyArgs.length).toBeGreaterThan(0);
355
- expect(keyArgs[0].key).toBe('count');
356
- expect(keyArgs[0].value).toBe(1);
357
- expect(keyArgs[0].old).toBe(0);
358
-
359
- expect(wildArgs.length).toBeGreaterThan(0);
360
- expect(wildArgs[0].key).toBe('count');
361
- expect(wildArgs[0].value).toBe(1);
362
- expect(wildArgs[0].old).toBe(0);
363
- });
364
-
365
- it('store.subscribe() returns unsubscribe function', () => {
366
- const store = createStore('test-unsub', {
367
- state: { x: 0 }, actions: { set(s, v) { s.x = v; } }
368
- });
369
- const calls = [];
370
- const unsub = store.subscribe('x', (k, v) => calls.push(v));
371
- store.dispatch('set', 1);
372
- expect(calls.length).toBe(1);
373
- unsub();
374
- store.dispatch('set', 2);
375
- expect(calls.length).toBe(1); // no more calls
376
- });
377
-
378
- it('middleware can block actions', () => {
379
- const store = createStore('test-mw', {
380
- state: { count: 0 },
381
- actions: { increment(s) { s.count++; } }
382
- });
383
- store.use((actionName, args, state) => {
384
- if (actionName === 'increment' && state.count >= 2) return false;
385
- });
386
- store.dispatch('increment');
387
- store.dispatch('increment');
388
- store.dispatch('increment'); // blocked
389
- expect(store.state.count).toBe(2);
390
- });
391
-
392
- it('middleware use() is chainable', () => {
393
- const store = createStore('test-mw-chain', {
394
- state: { x: 0 }, actions: { set(s, v) { s.x = v; } }
395
- });
396
- const result = store.use(() => {}).use(() => {});
397
- expect(result).toBe(store);
398
- });
399
-
400
- it('named stores via $.store(name, config) and $.getStore(name)', () => {
401
- createStore('auth', { state: { user: null } });
402
- createStore('cart', { state: { items: [] } });
403
- expect(getStore('auth')).not.toBeNull();
404
- expect(getStore('cart')).not.toBeNull();
405
- expect(getStore('nonexistent')).toBeNull();
406
- });
407
-
408
- it('default store when no name given', () => {
409
- createStore({ state: { x: 1 } });
410
- expect(getStore()).not.toBeNull();
411
- expect(getStore('default')).not.toBeNull();
412
- });
413
-
414
- it('store.batch() groups mutations - subscribers fire once', () => {
415
- const store = createStore('test-batch', {
416
- state: { count: 0 },
417
- actions: {}
418
- });
419
- const calls = [];
420
- store.subscribe('count', (k, v) => calls.push(v));
421
- store.batch(state => {
422
- state.count = 1;
423
- state.count = 2;
424
- state.count = 3;
425
- });
426
- // Only final value should trigger
427
- expect(calls.length).toBe(1);
428
- expect(calls[0]).toBe(3);
429
- });
430
-
431
- it('checkpoint / undo / redo', () => {
432
- const store = createStore('test-undo', {
433
- state: { text: '', color: 'blue' },
434
- actions: {
435
- setText(state, val) { state.text = val; },
436
- setColor(state, val) { state.color = val; }
437
- }
438
- });
439
- store.checkpoint();
440
- store.dispatch('setText', 'hello');
441
- store.dispatch('setColor', 'red');
442
- expect(store.canUndo).toBe(true);
443
- store.undo();
444
- expect(store.state.text).toBe('');
445
- expect(store.state.color).toBe('blue');
446
- expect(store.canRedo).toBe(true);
447
- store.redo();
448
- expect(store.state.text).toBe('hello');
449
- expect(store.state.color).toBe('red');
450
- });
451
-
452
- it('canUndo / canRedo getters', () => {
453
- const store = createStore('test-can', {
454
- state: { x: 0 }, actions: {}
455
- });
456
- expect(store.canUndo).toBe(false);
457
- expect(store.canRedo).toBe(false);
458
- store.checkpoint();
459
- store.state.x = 1;
460
- expect(store.canUndo).toBe(true);
461
- });
462
-
463
- it('store.snapshot() returns deep clone', () => {
464
- const store = createStore('test-snap', {
465
- state: { items: [1, 2, 3] }, actions: {}
466
- });
467
- const snap = store.snapshot();
468
- expect(snap.items).toEqual([1, 2, 3]);
469
- snap.items.push(4);
470
- expect(store.state.items.length).toBe(3); // original unchanged
471
- });
472
-
473
- it('store.replaceState() replaces entire state', () => {
474
- const store = createStore('test-replace', {
475
- state: { a: 1, b: 2 }, actions: {}
476
- });
477
- store.replaceState({ a: 10, b: 20 });
478
- expect(store.state.a).toBe(10);
479
- expect(store.state.b).toBe(20);
480
- });
481
-
482
- it('store.reset() without args resets to initial state', () => {
483
- const store = createStore('test-reset', {
484
- state: { count: 0 },
485
- actions: { inc(s) { s.count++; } }
486
- });
487
- store.dispatch('inc');
488
- store.dispatch('inc');
489
- expect(store.state.count).toBe(2);
490
- store.reset();
491
- expect(store.state.count).toBe(0);
492
- });
493
-
494
- it('store.history returns action log', () => {
495
- const store = createStore('test-history', {
496
- state: { x: 0 },
497
- actions: { inc(s) { s.x++; } }
498
- });
499
- store.dispatch('inc');
500
- store.dispatch('inc');
501
- const hist = store.history;
502
- expect(Array.isArray(hist)).toBe(true);
503
- expect(hist.length).toBe(2);
504
- expect(hist[0].action).toBe('inc');
505
- expect(hist[0].timestamp).toBeTypeOf('number');
506
- });
507
-
508
- it('error resilience - unknown action reports error', () => {
509
- const store = createStore('test-err', {
510
- state: {}, actions: {}
511
- });
512
- store.dispatch('nonexistent'); // should not throw
513
- });
514
- });
515
-
516
-
517
- // ===========================================================================
518
- // 4. UTILS DOCS — Validate all 37+ utility functions
519
- // ===========================================================================
520
- describe('Utils docs validation', () => {
521
-
522
- // --- Function utilities ---
523
- it('debounce delays execution and has .cancel()', async () => {
524
- let called = 0;
525
- const fn = debounce(() => called++, 50);
526
- expect(fn.cancel).toBeTypeOf('function');
527
- fn();
528
- fn();
529
- fn();
530
- expect(called).toBe(0);
531
- await sleep(100);
532
- expect(called).toBe(1);
533
- });
534
-
535
- it('throttle limits execution rate', async () => {
536
- let called = 0;
537
- const fn = throttle(() => called++, 50);
538
- fn(); fn(); fn();
539
- expect(called).toBe(1);
540
- await sleep(100);
541
- fn();
542
- expect(called).toBeGreaterThanOrEqual(2);
543
- });
544
-
545
- it('pipe composes left-to-right', () => {
546
- const fn = pipe(x => x + 1, x => x * 2);
547
- expect(fn(3)).toBe(8);
548
- });
549
-
550
- it('once runs only once', () => {
551
- let calls = 0;
552
- const fn = once(() => ++calls);
553
- expect(fn()).toBe(1);
554
- expect(fn()).toBe(1);
555
- expect(calls).toBe(1);
556
- });
557
-
558
- it('sleep returns a promise', async () => {
559
- const start = Date.now();
560
- await sleep(50);
561
- expect(Date.now() - start).toBeGreaterThanOrEqual(40);
562
- });
563
-
564
- // --- String utilities ---
565
- it('escapeHtml escapes & < > " \'', () => {
566
- expect(escapeHtml('&<>"\''))
567
- .toBe('&amp;&lt;&gt;&quot;&#39;');
568
- });
569
-
570
- it('stripHtml removes tags', () => {
571
- expect(stripHtml('<p>Hello <b>world</b></p>')).toBe('Hello world');
572
- });
573
-
574
- it('html template tag auto-escapes values', () => {
575
- const user = '<script>alert("xss")</script>';
576
- const result = html`<div>${user}</div>`;
577
- expect(result).not.toContain('<script>');
578
- expect(result).toContain('&lt;script&gt;');
579
- });
580
-
581
- it('trust() bypasses html escaping', () => {
582
- const safe = trust('<b>bold</b>');
583
- const result = html`<div>${safe}</div>`;
584
- expect(result).toContain('<b>bold</b>');
585
- });
586
-
587
- it('TrustedHTML class works with instanceof', () => {
588
- const t = trust('<b>ok</b>');
589
- expect(t instanceof TrustedHTML).toBe(true);
590
- expect(t.toString()).toBe('<b>ok</b>');
591
- });
592
-
593
- it('uuid generates valid v4 format', () => {
594
- const id = uuid();
595
- expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
596
- });
597
-
598
- it('camelCase converts kebab to camel', () => {
599
- expect(camelCase('my-component')).toBe('myComponent');
600
- });
601
-
602
- it('kebabCase converts camel to kebab', () => {
603
- expect(kebabCase('myComponent')).toBe('my-component');
604
- });
605
-
606
- it('capitalize first letter and lowercases rest', () => {
607
- expect(capitalize('hello')).toBe('Hello');
608
- expect(capitalize('HELLO')).toBe('Hello');
609
- expect(capitalize('')).toBe('');
610
- });
611
-
612
- it('truncate with default suffix', () => {
613
- expect(truncate('Hello World', 5)).toBe('Hell…');
614
- expect(truncate('Hi', 10)).toBe('Hi');
615
- });
616
-
617
- // --- Object utilities ---
618
- it('deepClone creates independent copy', () => {
619
- const obj = { a: { b: [1, 2] } };
620
- const clone = deepClone(obj);
621
- clone.a.b.push(3);
622
- expect(obj.a.b.length).toBe(2);
623
- });
624
-
625
- it('deepMerge merges recursively', () => {
626
- const target = { a: { x: 1 }, b: 2 };
627
- const result = deepMerge(target, { a: { y: 2 }, c: 3 });
628
- expect(result.a.x).toBe(1);
629
- expect(result.a.y).toBe(2);
630
- expect(result.c).toBe(3);
631
- });
632
-
633
- it('deepMerge blocks __proto__ poisoning', () => {
634
- const target = {};
635
- const malicious = JSON.parse('{"__proto__": {"polluted": true}}');
636
- deepMerge(target, malicious);
637
- expect(({}).polluted).toBeUndefined();
638
- });
639
-
640
- it('isEqual deep comparison', () => {
641
- expect(isEqual({ a: [1, 2] }, { a: [1, 2] })).toBe(true);
642
- expect(isEqual({ a: 1 }, { a: 2 })).toBe(false);
643
- expect(isEqual(null, null)).toBe(true);
644
- });
645
-
646
- it('pick extracts specified keys', () => {
647
- expect(pick({ a: 1, b: 2, c: 3 }, ['a', 'c'])).toEqual({ a: 1, c: 3 });
648
- });
649
-
650
- it('omit excludes specified keys', () => {
651
- expect(omit({ a: 1, b: 2, c: 3 }, ['b'])).toEqual({ a: 1, c: 3 });
652
- });
653
-
654
- it('getPath follows dot path', () => {
655
- expect(getPath({ a: { b: { c: 42 } } }, 'a.b.c')).toBe(42);
656
- expect(getPath({ a: 1 }, 'x.y', 'fallback')).toBe('fallback');
657
- });
658
-
659
- it('setPath creates nested structure', () => {
660
- const obj = {};
661
- setPath(obj, 'a.b.c', 42);
662
- expect(obj.a.b.c).toBe(42);
663
- });
664
-
665
- it('setPath blocks __proto__ poisoning', () => {
666
- const obj = {};
667
- setPath(obj, '__proto__.polluted', true);
668
- expect(({}).polluted).toBeUndefined();
669
- });
670
-
671
- it('isEmpty checks emptiness', () => {
672
- expect(isEmpty(null)).toBe(true);
673
- expect(isEmpty(undefined)).toBe(true);
674
- expect(isEmpty('')).toBe(true);
675
- expect(isEmpty([])).toBe(true);
676
- expect(isEmpty({})).toBe(true);
677
- expect(isEmpty(new Map())).toBe(true);
678
- expect(isEmpty(new Set())).toBe(true);
679
- expect(isEmpty('x')).toBe(false);
680
- expect(isEmpty([1])).toBe(false);
681
- expect(isEmpty({ a: 1 })).toBe(false);
682
- });
683
-
684
- // --- URL utilities ---
685
- it('param serializes object to query string', () => {
686
- const result = param({ page: 2, limit: 10 });
687
- expect(result).toContain('page=2');
688
- expect(result).toContain('limit=10');
689
- });
690
-
691
- it('parseQuery parses query string', () => {
692
- expect(parseQuery('page=2&limit=10')).toEqual({ page: '2', limit: '10' });
693
- });
694
-
695
- // --- Storage wrappers ---
696
- it('storage has get/set/remove/clear methods', () => {
697
- expect(storage.get).toBeTypeOf('function');
698
- expect(storage.set).toBeTypeOf('function');
699
- expect(storage.remove).toBeTypeOf('function');
700
- expect(storage.clear).toBeTypeOf('function');
701
- });
702
-
703
- it('storage auto-parses JSON', () => {
704
- storage.set('test-key', { a: 1 });
705
- expect(storage.get('test-key')).toEqual({ a: 1 });
706
- storage.remove('test-key');
707
- expect(storage.get('test-key')).toBeNull();
708
- expect(storage.get('test-key', 'fallback')).toBe('fallback');
709
- });
710
-
711
- it('session has same API as storage', () => {
712
- expect(session.get).toBeTypeOf('function');
713
- expect(session.set).toBeTypeOf('function');
714
- expect(session.remove).toBeTypeOf('function');
715
- expect(session.clear).toBeTypeOf('function');
716
- });
717
-
718
- // --- Event bus ---
719
- it('bus has on/off/emit/once/clear', () => {
720
- expect(bus.on).toBeTypeOf('function');
721
- expect(bus.off).toBeTypeOf('function');
722
- expect(bus.emit).toBeTypeOf('function');
723
- expect(bus.once).toBeTypeOf('function');
724
- expect(bus.clear).toBeTypeOf('function');
725
- });
726
-
727
- it('bus.on returns unsubscribe function', () => {
728
- const calls = [];
729
- const unsub = bus.on('test', (v) => calls.push(v));
730
- bus.emit('test', 1);
731
- expect(calls).toEqual([1]);
732
- unsub();
733
- bus.emit('test', 2);
734
- expect(calls).toEqual([1]);
735
- bus.clear();
736
- });
737
-
738
- it('bus.once fires only once', () => {
739
- const calls = [];
740
- bus.once('test-once', (v) => calls.push(v));
741
- bus.emit('test-once', 'a');
742
- bus.emit('test-once', 'b');
743
- expect(calls).toEqual(['a']);
744
- bus.clear();
745
- });
746
-
747
- it('EventBus class can be instantiated', () => {
748
- const myBus = new EventBus();
749
- const calls = [];
750
- myBus.on('x', (v) => calls.push(v));
751
- myBus.emit('x', 42);
752
- expect(calls).toEqual([42]);
753
- });
754
-
755
- // --- Array utilities ---
756
- it('range generates number arrays', () => {
757
- expect(range(5)).toEqual([0, 1, 2, 3, 4]);
758
- expect(range(2, 5)).toEqual([2, 3, 4]);
759
- expect(range(0, 10, 3)).toEqual([0, 3, 6, 9]);
760
- expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]);
761
- });
762
-
763
- it('unique deduplicates', () => {
764
- expect(unique([1, 2, 2, 3, 3])).toEqual([1, 2, 3]);
765
- });
766
-
767
- it('unique with keyFn', () => {
768
- const items = [{ id: 1, n: 'a' }, { id: 2, n: 'b' }, { id: 1, n: 'c' }];
769
- expect(unique(items, i => i.id).length).toBe(2);
770
- });
771
-
772
- it('chunk splits array', () => {
773
- expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
774
- });
775
-
776
- it('groupBy groups by key function', () => {
777
- const result = groupBy(['one', 'two', 'three'], w => String(w.length));
778
- expect(result['3']).toEqual(['one', 'two']);
779
- expect(result['5']).toEqual(['three']);
780
- });
781
-
782
- // --- Number utilities ---
783
- it('clamp constrains to range', () => {
784
- expect(clamp(5, 0, 10)).toBe(5);
785
- expect(clamp(-5, 0, 10)).toBe(0);
786
- expect(clamp(15, 0, 10)).toBe(10);
787
- });
788
-
789
- // --- Memoize ---
790
- it('memoize caches results and has .clear()', () => {
791
- let calls = 0;
792
- const fn = memoize((x) => { calls++; return x * 2; });
793
- expect(fn(5)).toBe(10);
794
- expect(fn(5)).toBe(10);
795
- expect(calls).toBe(1);
796
- fn.clear();
797
- expect(fn(5)).toBe(10);
798
- expect(calls).toBe(2);
799
- });
800
-
801
- it('memoize with maxSize evicts old entries', () => {
802
- let calls = 0;
803
- const fn = memoize((x) => { calls++; return x; }, { maxSize: 2 });
804
- fn(1); fn(2); fn(3); // 1 should be evicted
805
- calls = 0;
806
- fn(1); // should recompute
807
- expect(calls).toBe(1);
808
- });
809
-
810
- // --- Retry ---
811
- it('retry retries on failure', async () => {
812
- let attempt = 0;
813
- const result = await retry((n) => {
814
- attempt = n;
815
- if (n < 3) return Promise.reject(new Error('fail'));
816
- return Promise.resolve('ok');
817
- }, { attempts: 3, delay: 10 });
818
- expect(result).toBe('ok');
819
- expect(attempt).toBe(3);
820
- });
821
-
822
- // --- Timeout ---
823
- it('timeout rejects if promise is too slow', async () => {
824
- const slow = new Promise(r => setTimeout(() => r('done'), 500));
825
- await expect(timeout(slow, 50)).rejects.toThrow(/Timed out/);
826
- });
827
- });
828
-
829
-
830
- // ===========================================================================
831
- // 5. HTTP DOCS Validate API shape
832
- // ===========================================================================
833
- describe('HTTP docs validation', () => {
834
-
835
- it('http has all documented methods', () => {
836
- expect(http.get).toBeTypeOf('function');
837
- expect(http.post).toBeTypeOf('function');
838
- expect(http.put).toBeTypeOf('function');
839
- expect(http.patch).toBeTypeOf('function');
840
- expect(http.delete).toBeTypeOf('function');
841
- expect(http.head).toBeTypeOf('function');
842
- });
843
-
844
- it('http.configure() sets defaults', () => {
845
- http.configure({
846
- baseURL: 'https://test.example.com',
847
- headers: { 'X-Test': 'yes' },
848
- timeout: 5000,
849
- });
850
- const config = http.getConfig();
851
- expect(config.baseURL).toBe('https://test.example.com');
852
- expect(config.headers['X-Test']).toBe('yes');
853
- expect(config.timeout).toBe(5000);
854
- // Reset
855
- http.configure({ baseURL: '', timeout: 30000 });
856
- });
857
-
858
- it('http.getConfig() returns safe copy', () => {
859
- const cfg1 = http.getConfig();
860
- const cfg2 = http.getConfig();
861
- expect(cfg1).not.toBe(cfg2); // different object
862
- expect(cfg1.headers).not.toBe(cfg2.headers);
863
- });
864
-
865
- it('http.onRequest() returns unsubscribe function', () => {
866
- const unsub = http.onRequest(() => {});
867
- expect(unsub).toBeTypeOf('function');
868
- unsub();
869
- });
870
-
871
- it('http.onResponse() returns unsubscribe function', () => {
872
- const unsub = http.onResponse(() => {});
873
- expect(unsub).toBeTypeOf('function');
874
- unsub();
875
- });
876
-
877
- it('http.clearInterceptors() clears all or by type', () => {
878
- http.onRequest(() => {});
879
- http.onResponse(() => {});
880
- http.clearInterceptors('request');
881
- http.clearInterceptors('response');
882
- http.clearInterceptors(); // clear all
883
- });
884
-
885
- it('http.all() batches promises', async () => {
886
- const results = await http.all([
887
- Promise.resolve({ ok: true, data: 1 }),
888
- Promise.resolve({ ok: true, data: 2 }),
889
- ]);
890
- expect(results.length).toBe(2);
891
- });
892
-
893
- it('http.createAbort() returns AbortController', () => {
894
- const ac = http.createAbort();
895
- expect(ac).toBeInstanceOf(AbortController);
896
- expect(ac.signal).toBeInstanceOf(AbortSignal);
897
- });
898
-
899
- it('http.raw is a function', () => {
900
- expect(http.raw).toBeTypeOf('function');
901
- });
902
- });
903
-
904
-
905
- // ===========================================================================
906
- // 6. ERROR HANDLING DOCS — Validate error codes, classes, functions
907
- // ===========================================================================
908
- describe('Error handling docs validation', () => {
909
-
910
- afterEach(() => { onError(null); });
911
-
912
- it('ErrorCode has all documented codes', () => {
913
- const expected = [
914
- 'REACTIVE_CALLBACK', 'SIGNAL_CALLBACK', 'EFFECT_EXEC',
915
- 'EXPR_PARSE', 'EXPR_EVAL', 'EXPR_UNSAFE_ACCESS',
916
- 'COMP_INVALID_NAME', 'COMP_NOT_FOUND', 'COMP_MOUNT_TARGET', 'COMP_RENDER',
917
- 'COMP_LIFECYCLE', 'COMP_RESOURCE', 'COMP_DIRECTIVE',
918
- 'ROUTER_LOAD', 'ROUTER_GUARD', 'ROUTER_RESOLVE',
919
- 'STORE_ACTION', 'STORE_MIDDLEWARE', 'STORE_SUBSCRIBE',
920
- 'HTTP_REQUEST', 'HTTP_TIMEOUT', 'HTTP_INTERCEPTOR', 'HTTP_PARSE',
921
- 'SSR_RENDER', 'SSR_COMPONENT', 'SSR_HYDRATION', 'SSR_PAGE',
922
- 'INVALID_ARGUMENT',
923
- ];
924
- for (const code of expected) {
925
- expect(ErrorCode[code]).toBeDefined();
926
- expect(ErrorCode[code]).toMatch(/^ZQ_/);
927
- }
928
- });
929
-
930
- it('ErrorCode is frozen', () => {
931
- expect(Object.isFrozen(ErrorCode)).toBe(true);
932
- });
933
-
934
- it('ZQueryError has code, context, cause', () => {
935
- const cause = new Error('original');
936
- const err = new ZQueryError(ErrorCode.COMP_RENDER, 'Render failed', { component: 'test' }, cause);
937
- expect(err.name).toBe('ZQueryError');
938
- expect(err.code).toBe('ZQ_COMP_RENDER');
939
- expect(err.context.component).toBe('test');
940
- expect(err.cause).toBe(cause);
941
- expect(err.message).toBe('Render failed');
942
- expect(err instanceof Error).toBe(true);
943
- });
944
-
945
- it('onError registers handler and returns unsubscribe', () => {
946
- const errors = [];
947
- const unsub = onError((err) => errors.push(err));
948
- reportError(ErrorCode.INVALID_ARGUMENT, 'test error');
949
- expect(errors.length).toBe(1);
950
- expect(errors[0] instanceof ZQueryError).toBe(true);
951
- unsub();
952
- reportError(ErrorCode.INVALID_ARGUMENT, 'test error 2');
953
- expect(errors.length).toBe(1);
954
- });
955
-
956
- it('onError(null) clears all handlers', () => {
957
- const calls = [];
958
- onError(() => calls.push(1));
959
- onError(() => calls.push(2));
960
- onError(null);
961
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
962
- reportError(ErrorCode.INVALID_ARGUMENT, 'test');
963
- expect(calls.length).toBe(0);
964
- spy.mockRestore();
965
- });
966
-
967
- it('guardCallback wraps errors', () => {
968
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
969
- const fn = guardCallback(() => { throw new Error('boom'); }, ErrorCode.COMP_RENDER);
970
- expect(fn()).toBeUndefined(); // does not throw
971
- spy.mockRestore();
972
- });
973
-
974
- it('guardAsync wraps async errors', async () => {
975
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
976
- const fn = guardAsync(async () => { throw new Error('boom'); }, ErrorCode.COMP_RENDER);
977
- const result = await fn();
978
- expect(result).toBeUndefined();
979
- spy.mockRestore();
980
- });
981
-
982
- it('validate throws on missing value', () => {
983
- expect(() => validate(null, 'name')).toThrow(ZQueryError);
984
- expect(() => validate(undefined, 'name')).toThrow(ZQueryError);
985
- });
986
-
987
- it('validate throws on wrong type', () => {
988
- expect(() => validate(42, 'name', 'string')).toThrow(ZQueryError);
989
- expect(() => validate('hello', 'name', 'string')).not.toThrow();
990
- });
991
-
992
- it('formatError returns structured object', () => {
993
- const err = new ZQueryError(ErrorCode.COMP_RENDER, 'test', { x: 1 });
994
- const formatted = formatError(err);
995
- expect(formatted.code).toBe('ZQ_COMP_RENDER');
996
- expect(formatted.type).toBe('ZQueryError');
997
- expect(formatted.message).toBe('test');
998
- expect(formatted.context).toEqual({ x: 1 });
999
- expect(formatted.stack).toBeTypeOf('string');
1000
- });
1001
-
1002
- it('formatError handles nested cause', () => {
1003
- const inner = new ZQueryError(ErrorCode.EXPR_EVAL, 'inner');
1004
- const outer = new ZQueryError(ErrorCode.COMP_RENDER, 'outer', {}, inner);
1005
- const formatted = formatError(outer);
1006
- expect(formatted.cause).not.toBeNull();
1007
- expect(formatted.cause.code).toBe('ZQ_EXPR_EVAL');
1008
- });
1009
- });
1010
-
1011
-
1012
- // ===========================================================================
1013
- // 7. EXPRESSION PARSER DOCS
1014
- // ===========================================================================
1015
- describe('Expression parser docs validation', () => {
1016
-
1017
- const eval_ = (expr, ...scopes) => safeEval(expr, scopes.length ? scopes : [{}]);
1018
-
1019
- it('property access', () => {
1020
- expect(eval_('user.name', { user: { name: 'Alice' } })).toBe('Alice');
1021
- });
1022
-
1023
- it('array indexing', () => {
1024
- expect(eval_('items[0]', { items: ['a', 'b'] })).toBe('a');
1025
- });
1026
-
1027
- it('arithmetic', () => {
1028
- expect(eval_('a + b', { a: 2, b: 3 })).toBe(5);
1029
- expect(eval_('count * 2', { count: 5 })).toBe(10);
1030
- });
1031
-
1032
- it('comparison operators', () => {
1033
- expect(eval_('a === b', { a: 1, b: 1 })).toBe(true);
1034
- expect(eval_('x != null', { x: 5 })).toBe(true);
1035
- expect(eval_('count > 0', { count: 5 })).toBe(true);
1036
- });
1037
-
1038
- it('logical operators', () => {
1039
- expect(eval_('a && b', { a: true, b: false })).toBe(false);
1040
- expect(eval_('a || b', { a: false, b: true })).toBe(true);
1041
- expect(eval_('!a', { a: false })).toBe(true);
1042
- });
1043
-
1044
- it('ternary', () => {
1045
- expect(eval_('a ? "yes" : "no"', { a: true })).toBe('yes');
1046
- expect(eval_('a ? "yes" : "no"', { a: false })).toBe('no');
1047
- });
1048
-
1049
- it('typeof', () => {
1050
- expect(eval_('typeof x', { x: 'hello' })).toBe('string');
1051
- expect(eval_('typeof x', { x: 42 })).toBe('number');
1052
- });
1053
-
1054
- it('literals', () => {
1055
- expect(eval_('42')).toBe(42);
1056
- expect(eval_("'hello'")).toBe('hello');
1057
- expect(eval_('true')).toBe(true);
1058
- expect(eval_('false')).toBe(false);
1059
- expect(eval_('null')).toBe(null);
1060
- expect(eval_('undefined')).toBe(undefined);
1061
- });
1062
-
1063
- it('template literals', () => {
1064
- expect(eval_('`Hello ${name}`', { name: 'World' })).toBe('Hello World');
1065
- });
1066
-
1067
- it('array literals', () => {
1068
- expect(eval_('[1, 2, 3]')).toEqual([1, 2, 3]);
1069
- });
1070
-
1071
- it('object literals', () => {
1072
- expect(eval_("{ foo: 'bar' }")).toEqual({ foo: 'bar' });
1073
- });
1074
-
1075
- it('nullish coalescing', () => {
1076
- expect(eval_('a ?? "default"', { a: null })).toBe('default');
1077
- expect(eval_('a ?? "default"', { a: 'value' })).toBe('value');
1078
- });
1079
-
1080
- it('optional chaining', () => {
1081
- expect(eval_('a?.b', { a: null })).toBeUndefined();
1082
- expect(eval_('a?.b', { a: { b: 42 } })).toBe(42);
1083
- });
1084
-
1085
- it('arrow functions', () => {
1086
- const fn = eval_('x => x * 2');
1087
- expect(fn(5)).toBe(10);
1088
- });
1089
-
1090
- it('whitelisted globals are accessible', () => {
1091
- expect(eval_('Math.max(1, 2, 3)')).toBe(3);
1092
- expect(eval_('parseInt("42")')).toBe(42);
1093
- expect(eval_('isNaN(NaN)')).toBe(true);
1094
- });
1095
-
1096
- it('blocks __proto__ access', () => {
1097
- const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
1098
- expect(eval_('obj.__proto__', { obj: {} })).toBeUndefined();
1099
- spy.mockRestore();
1100
- });
1101
-
1102
- it('blocks constructor access', () => {
1103
- const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
1104
- expect(eval_('obj.constructor', { obj: {} })).toBeUndefined();
1105
- spy.mockRestore();
1106
- });
1107
- });
1108
-
1109
-
1110
- // ===========================================================================
1111
- // 8. COMPONENT DOCS Validate API shape & lifecycle
1112
- // ===========================================================================
1113
- describe('Component docs validation', () => {
1114
-
1115
- beforeEach(() => {
1116
- document.body.innerHTML = '';
1117
- vi.spyOn(console, 'error').mockImplementation(() => {});
1118
- });
1119
- afterEach(() => vi.restoreAllMocks());
1120
-
1121
- it('$.component() registers and $.components() retrieves registry', () => {
1122
- component('test-comp-doc', { render: () => '<p>test</p>' });
1123
- const registry = getRegistry();
1124
- expect(registry['test-comp-doc']).toBeDefined();
1125
- });
1126
-
1127
- it('component definition keys are recognized', () => {
1128
- component('test-keys-doc', {
1129
- state: { count: 0 },
1130
- computed: { double() { return this.state.count * 2; } },
1131
- watch: { count(v, old) {} },
1132
- init() {},
1133
- mounted() {},
1134
- updated() {},
1135
- destroyed() {},
1136
- styles: '.card { color: red; }',
1137
- increment() { this.state.count++; },
1138
- render() { return '<p>test</p>'; }
1139
- });
1140
- const registry = getRegistry();
1141
- expect(registry['test-keys-doc']).toBeDefined();
1142
- });
1143
-
1144
- it('$.mount() mounts component and returns instance', () => {
1145
- component('test-mount-doc', { render: () => '<p>mounted</p>' });
1146
- document.body.innerHTML = '<div id="test-root"></div>';
1147
- const inst = mount('#test-root', 'test-mount-doc');
1148
- expect(inst).toBeDefined();
1149
- expect(document.querySelector('#test-root p').textContent).toBe('mounted');
1150
- });
1151
-
1152
- it('$.getInstance() retrieves instance', () => {
1153
- component('test-getinst-doc', { render: () => '<p>get</p>' });
1154
- document.body.innerHTML = '<div id="inst-root"></div>';
1155
- mount('#inst-root', 'test-getinst-doc');
1156
- const inst = getInstance('#inst-root');
1157
- expect(inst).toBeDefined();
1158
- });
1159
-
1160
- it('$.destroy() removes component', () => {
1161
- component('test-destroy-doc', {
1162
- state: { x: 1 },
1163
- render() { return '<p>alive</p>'; }
1164
- });
1165
- document.body.innerHTML = '<div id="destroy-root"></div>';
1166
- mount('#destroy-root', 'test-destroy-doc');
1167
- destroy('#destroy-root');
1168
- expect(getInstance('#destroy-root')).toBeNull();
1169
- });
1170
-
1171
- it('component with props', () => {
1172
- component('test-props-doc', {
1173
- state: { label: '' },
1174
- mounted() {
1175
- this.state.label = this.props.label || 'default';
1176
- },
1177
- render() { return `<span>${this.state.label}</span>`; }
1178
- });
1179
- document.body.innerHTML = '<div id="props-root"></div>';
1180
- const inst = mount('#props-root', 'test-props-doc', { label: 'Hello' });
1181
- expect(inst.props.label).toBe('Hello');
1182
- });
1183
-
1184
- it('component emit() dispatches custom event', () => {
1185
- component('test-emit-doc', {
1186
- fire() { this.emit('my-event', 42); },
1187
- render() { return '<button @click="fire">go</button>'; }
1188
- });
1189
- document.body.innerHTML = '<div id="emit-root"></div>';
1190
- const inst = mount('#emit-root', 'test-emit-doc');
1191
- let detail = null;
1192
- document.querySelector('#emit-root').addEventListener('my-event', (e) => {
1193
- detail = e.detail;
1194
- });
1195
- inst.fire();
1196
- expect(detail).toBe(42);
1197
- });
1198
-
1199
- it('component setState({}) forces re-render', async () => {
1200
- let renderCount = 0;
1201
- component('test-setstate-doc', {
1202
- state: { x: 0 },
1203
- render() { renderCount++; return `<p>${this.state.x}</p>`; }
1204
- });
1205
- document.body.innerHTML = '<div id="ss-root"></div>';
1206
- const inst = mount('#ss-root', 'test-setstate-doc');
1207
- const initial = renderCount;
1208
- inst.setState({});
1209
- await new Promise(r => setTimeout(r, 50));
1210
- expect(renderCount).toBeGreaterThan(initial);
1211
- });
1212
-
1213
- it('$.mountAll() scans container and auto-mounts registered tags', () => {
1214
- component('ma-card', { render: () => '<p>card</p>' });
1215
- component('ma-badge', { render: () => '<span>badge</span>' });
1216
- document.body.innerHTML =
1217
- '<div id="ma-root"><ma-card></ma-card><ma-badge></ma-badge></div>';
1218
- mountAll(document.getElementById('ma-root'));
1219
- expect(document.querySelector('ma-card p').textContent).toBe('card');
1220
- expect(document.querySelector('ma-badge span').textContent).toBe('badge');
1221
- });
1222
-
1223
- it('$.mountAll() skips already-mounted elements', () => {
1224
- let mounts = 0;
1225
- component('ma-once', { mounted() { mounts++; }, render: () => '<p>once</p>' });
1226
- document.body.innerHTML = '<ma-once></ma-once>';
1227
- mountAll();
1228
- const first = mounts;
1229
- mountAll();
1230
- expect(mounts).toBe(first); // no double-mount
1231
- });
1232
-
1233
- it('$.mountAll() extracts static props from attributes', () => {
1234
- component('ma-props', {
1235
- render() { return `<p>${this.props.label}</p>`; }
1236
- });
1237
- document.body.innerHTML = '<ma-props label="hello"></ma-props>';
1238
- mountAll();
1239
- expect(document.querySelector('ma-props p').textContent).toBe('hello');
1240
- });
1241
-
1242
- it('$.prefetch() resolves without error for registered component', async () => {
1243
- component('pf-comp', { render: () => '<p>pf</p>' });
1244
- await expect(prefetch('pf-comp')).resolves.toBeUndefined();
1245
- });
1246
-
1247
- it('$.prefetch() is a no-op for unregistered names', async () => {
1248
- await expect(prefetch('pf-nonexistent')).resolves.toBeUndefined();
1249
- });
1250
-
1251
- it('$.style() returns object with ready promise and remove()', () => {
1252
- const result = style('/test.css', { critical: false });
1253
- expect(result).toBeDefined();
1254
- expect(result.ready).toBeInstanceOf(Promise);
1255
- expect(result.remove).toBeTypeOf('function');
1256
- // Verify <link> was added to <head>
1257
- const link = document.querySelector('link[data-zq-style][href="/test.css"]');
1258
- expect(link).not.toBeNull();
1259
- result.remove();
1260
- expect(document.querySelector('link[data-zq-style][href="/test.css"]')).toBeNull();
1261
- });
1262
-
1263
- it('$.style() with critical mode injects visibility-hidden style', () => {
1264
- const result = style('/critical.css');
1265
- const critStyle = document.querySelector('style[data-zq-critical]');
1266
- expect(critStyle).not.toBeNull();
1267
- expect(critStyle.textContent).toContain('visibility:hidden');
1268
- result.remove();
1269
- });
1270
-
1271
- it('$.style() deduplicates same URL', () => {
1272
- const a = style('/dup.css', { critical: false });
1273
- const b = style('/dup.css', { critical: false });
1274
- const links = document.querySelectorAll('link[data-zq-style][href="/dup.css"]');
1275
- expect(links.length).toBe(1);
1276
- a.remove();
1277
- b.remove();
1278
- });
1279
- });
1280
-
1281
-
1282
- // ===========================================================================
1283
- // 9. ROUTER DOCS — Validate API shape
1284
- // ===========================================================================
1285
- describe('Router docs validation', () => {
1286
-
1287
- beforeEach(() => {
1288
- vi.spyOn(console, 'error').mockImplementation(() => {});
1289
- document.body.innerHTML = '<div id="router-outlet"></div>';
1290
- });
1291
- afterEach(() => {
1292
- const r = getRouter();
1293
- if (r) r.destroy();
1294
- vi.restoreAllMocks();
1295
- });
1296
-
1297
- it('createRouter returns router instance', () => {
1298
- // Register route components first
1299
- component('r-home', { render: () => '<p>home</p>' });
1300
- component('r-about', { render: () => '<p>about</p>' });
1301
- const router = createRouter({
1302
- el: '#router-outlet',
1303
- mode: 'hash',
1304
- routes: [
1305
- { path: '/', component: 'r-home' },
1306
- { path: '/about', component: 'r-about' },
1307
- ]
1308
- });
1309
- expect(router).toBeDefined();
1310
- expect(router.navigate).toBeTypeOf('function');
1311
- expect(router.replace).toBeTypeOf('function');
1312
- expect(router.back).toBeTypeOf('function');
1313
- expect(router.forward).toBeTypeOf('function');
1314
- expect(router.go).toBeTypeOf('function');
1315
- });
1316
-
1317
- it('getRouter() returns active router', () => {
1318
- component('r-home2', { render: () => '<p>h</p>' });
1319
- createRouter({
1320
- el: '#router-outlet',
1321
- mode: 'hash',
1322
- routes: [{ path: '/', component: 'r-home2' }]
1323
- });
1324
- expect(getRouter()).not.toBeNull();
1325
- });
1326
-
1327
- it('router has beforeEach/afterEach guards', () => {
1328
- component('r-guard', { render: () => '<p>guard</p>' });
1329
- const router = createRouter({
1330
- el: '#router-outlet',
1331
- mode: 'hash',
1332
- routes: [{ path: '/', component: 'r-guard' }]
1333
- });
1334
- expect(router.beforeEach).toBeTypeOf('function');
1335
- expect(router.afterEach).toBeTypeOf('function');
1336
- });
1337
-
1338
- it('router.onChange returns unsubscribe function', () => {
1339
- component('r-onchange', { render: () => '<p>test</p>' });
1340
- const router = createRouter({
1341
- el: '#router-outlet',
1342
- mode: 'hash',
1343
- routes: [{ path: '/', component: 'r-onchange' }]
1344
- });
1345
- const unsub = router.onChange(() => {});
1346
- expect(unsub).toBeTypeOf('function');
1347
- });
1348
-
1349
- it('router has add/remove methods', () => {
1350
- component('r-addrem', { render: () => '<p>x</p>' });
1351
- const router = createRouter({
1352
- el: '#router-outlet',
1353
- mode: 'hash',
1354
- routes: [{ path: '/', component: 'r-addrem' }]
1355
- });
1356
- expect(router.add).toBeTypeOf('function');
1357
- expect(router.remove).toBeTypeOf('function');
1358
- });
1359
-
1360
- it('router has current, path, query getters', () => {
1361
- component('r-getters', { render: () => '<p>x</p>' });
1362
- const router = createRouter({
1363
- el: '#router-outlet',
1364
- mode: 'hash',
1365
- routes: [{ path: '/', component: 'r-getters' }]
1366
- });
1367
- expect(router.current).toBeDefined();
1368
- expect(router.path).toBeTypeOf('string');
1369
- });
1370
-
1371
- it('matchRoute works without DOM', () => {
1372
- const routes = [
1373
- { path: '/', component: 'home' },
1374
- { path: '/users/:id', component: 'user' },
1375
- ];
1376
- const match = matchRoute(routes, '/users/42');
1377
- expect(match).toBeDefined();
1378
- expect(match.params.id).toBe('42');
1379
- });
1380
- });
1381
-
1382
-
1383
- // ===========================================================================
1384
- // 10. DOM MORPHING DOCS
1385
- // ===========================================================================
1386
- describe('Morph docs validation', () => {
1387
-
1388
- it('morph(rootEl, newHTML) patches DOM', () => {
1389
- const root = el('<p>old</p>');
1390
- morph(root, '<p>new</p>');
1391
- expect(root.querySelector('p').textContent).toBe('new');
1392
- });
1393
-
1394
- it('morphElement preserves identity when tag matches', () => {
1395
- const root = el('<div class="card"><p>old</p></div>');
1396
- const child = root.firstElementChild;
1397
- const result = morphElement(child, '<div class="card updated"><p>new</p></div>');
1398
- expect(result).toBe(child); // same node
1399
- expect(child.classList.contains('updated')).toBe(true);
1400
- });
1401
-
1402
- it('z-key enables keyed reconciliation', () => {
1403
- const root = el(
1404
- '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>'
1405
- );
1406
- const nodeA = root.children[0];
1407
- const nodeC = root.children[2];
1408
- morph(root, '<div z-key="c">C</div><div z-key="a">A</div><div z-key="b">B</div>');
1409
- expect(root.children[0]).toBe(nodeC);
1410
- expect(root.children[1]).toBe(nodeA);
1411
- });
1412
-
1413
- it('z-skip prevents morphing subtree', () => {
1414
- const root = el('<div z-skip><span>original</span></div><p>text</p>');
1415
- const skipChild = root.children[0].firstChild;
1416
- morph(root, '<div z-skip><span>changed</span></div><p>updated</p>');
1417
- expect(root.children[0].firstChild).toBe(skipChild);
1418
- expect(root.querySelector('p').textContent).toBe('updated');
1419
- });
1420
- });
1421
-
1422
-
1423
- // ===========================================================================
1424
- // 11. SSR DOCS — Basic validation
1425
- // ===========================================================================
1426
- describe('SSR docs validation', () => {
1427
-
1428
- it('createSSRApp returns app with component/renderToString/renderPage', () => {
1429
- const app = createSSRApp();
1430
- expect(app.component).toBeTypeOf('function');
1431
- expect(app.renderToString).toBeTypeOf('function');
1432
- expect(app.renderPage).toBeTypeOf('function');
1433
- });
1434
-
1435
- it('standalone renderToString renders a component', async () => {
1436
- const html = await renderToString({
1437
- state: { name: 'World' },
1438
- render() { return `<p>Hello {{name}}</p>`; }
1439
- });
1440
- expect(html).toContain('Hello');
1441
- expect(html).toContain('World');
1442
- });
1443
-
1444
- it('SSR app component registration and render', async () => {
1445
- const app = createSSRApp();
1446
- app.component('ssr-hello', {
1447
- state: { greeting: 'Hi' },
1448
- render() { return `<div>{{greeting}}</div>`; }
1449
- });
1450
- const html = await app.renderToString('ssr-hello');
1451
- expect(html).toContain('Hi');
1452
- });
1453
- });
1454
-
1455
-
1456
- // ===========================================================================
1457
- // 12. SECURITY DOCS — Validate XSS & prototype pollution protection
1458
- // ===========================================================================
1459
- describe('Security docs validation', () => {
1460
-
1461
- it('escapeHtml prevents XSS in template output', () => {
1462
- const malicious = '<script>alert("xss")</script>';
1463
- const safe = escapeHtml(malicious);
1464
- expect(safe).not.toContain('<script>');
1465
- expect(safe).toContain('&lt;script&gt;');
1466
- });
1467
-
1468
- it('html template tag auto-escapes by default', () => {
1469
- const malicious = '<img onerror="alert(1)" src=x>';
1470
- const result = html`<p>${malicious}</p>`;
1471
- // The angle brackets and quotes are escaped, preventing execution
1472
- expect(result).not.toContain('<img');
1473
- expect(result).toContain('&lt;img');
1474
- expect(result).toContain('&quot;');
1475
- });
1476
-
1477
- it('trust() explicitly opts into raw HTML', () => {
1478
- const safe = trust('<b>bold</b>');
1479
- const result = html`<p>${safe}</p>`;
1480
- expect(result).toContain('<b>bold</b>');
1481
- });
1482
-
1483
- it('expression parser blocks prototype pollution vectors', () => {
1484
- const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
1485
- expect(safeEval('obj.__proto__', [{ obj: {} }])).toBeUndefined();
1486
- expect(safeEval('obj.constructor', [{ obj: {} }])).toBeUndefined();
1487
- expect(safeEval('obj.prototype', [{ obj: {} }])).toBeUndefined();
1488
- spy.mockRestore();
1489
- });
1490
-
1491
- it('deepMerge blocks __proto__ poisoning', () => {
1492
- const target = {};
1493
- const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
1494
- deepMerge(target, payload);
1495
- expect(({}).isAdmin).toBeUndefined();
1496
- });
1497
-
1498
- it('setPath blocks __proto__ poisoning', () => {
1499
- const obj = {};
1500
- setPath(obj, '__proto__.isAdmin', true);
1501
- expect(({}).isAdmin).toBeUndefined();
1502
- });
1503
-
1504
- it('setPath blocks constructor poisoning', () => {
1505
- const obj = {};
1506
- setPath(obj, 'constructor.polluted', true);
1507
- expect(Object.polluted).toBeUndefined();
1508
- });
1509
- });
1510
-
1511
-
1512
- // ===========================================================================
1513
- // 13. INDEX.JS EXPORTS — Validate all documented $ namespace properties
1514
- // ===========================================================================
1515
- describe('Index.js $ namespace completeness', () => {
1516
-
1517
- // Import the assembled $ from index.js
1518
- let $;
1519
- beforeEach(async () => {
1520
- const mod = await import('../index.js');
1521
- $ = mod.$;
1522
- });
1523
-
1524
- it('$ is a function', () => {
1525
- expect($).toBeTypeOf('function');
1526
- });
1527
-
1528
- it('$.all is a function', () => {
1529
- expect($.all).toBeTypeOf('function');
1530
- });
1531
-
1532
- it('has quick-ref shortcuts', () => {
1533
- expect($.id).toBeTypeOf('function');
1534
- expect($.class).toBeTypeOf('function');
1535
- expect($.classes).toBeTypeOf('function');
1536
- expect($.tag).toBeTypeOf('function');
1537
- expect($.name).toBeTypeOf('function');
1538
- expect($.children).toBeTypeOf('function');
1539
- expect($.qs).toBeTypeOf('function');
1540
- expect($.qsa).toBeTypeOf('function');
1541
- });
1542
-
1543
- it('has DOM helpers', () => {
1544
- expect($.create).toBeTypeOf('function');
1545
- expect($.ready).toBeTypeOf('function');
1546
- expect($.on).toBeTypeOf('function');
1547
- expect($.off).toBeTypeOf('function');
1548
- expect($.fn).toBeDefined();
1549
- });
1550
-
1551
- it('has reactive primitives', () => {
1552
- expect($.reactive).toBeTypeOf('function');
1553
- expect($.Signal).toBeTypeOf('function');
1554
- expect($.signal).toBeTypeOf('function');
1555
- expect($.computed).toBeTypeOf('function');
1556
- expect($.effect).toBeTypeOf('function');
1557
- expect($.batch).toBeTypeOf('function');
1558
- expect($.untracked).toBeTypeOf('function');
1559
- });
1560
-
1561
- it('has component functions', () => {
1562
- expect($.component).toBeTypeOf('function');
1563
- expect($.mount).toBeTypeOf('function');
1564
- expect($.mountAll).toBeTypeOf('function');
1565
- expect($.getInstance).toBeTypeOf('function');
1566
- expect($.destroy).toBeTypeOf('function');
1567
- expect($.components).toBeTypeOf('function');
1568
- expect($.prefetch).toBeTypeOf('function');
1569
- expect($.style).toBeTypeOf('function');
1570
- expect($.morph).toBeTypeOf('function');
1571
- expect($.morphElement).toBeTypeOf('function');
1572
- expect($.safeEval).toBeTypeOf('function');
1573
- });
1574
-
1575
- it('has router functions', () => {
1576
- expect($.router).toBeTypeOf('function');
1577
- expect($.getRouter).toBeTypeOf('function');
1578
- expect($.matchRoute).toBeTypeOf('function');
1579
- });
1580
-
1581
- it('has store functions', () => {
1582
- expect($.store).toBeTypeOf('function');
1583
- expect($.getStore).toBeTypeOf('function');
1584
- });
1585
-
1586
- it('has HTTP methods', () => {
1587
- expect($.http).toBeDefined();
1588
- expect($.get).toBeTypeOf('function');
1589
- expect($.post).toBeTypeOf('function');
1590
- expect($.put).toBeTypeOf('function');
1591
- expect($.patch).toBeTypeOf('function');
1592
- expect($.delete).toBeTypeOf('function');
1593
- expect($.head).toBeTypeOf('function');
1594
- });
1595
-
1596
- it('has all utility functions', () => {
1597
- expect($.debounce).toBeTypeOf('function');
1598
- expect($.throttle).toBeTypeOf('function');
1599
- expect($.pipe).toBeTypeOf('function');
1600
- expect($.once).toBeTypeOf('function');
1601
- expect($.sleep).toBeTypeOf('function');
1602
- expect($.escapeHtml).toBeTypeOf('function');
1603
- expect($.stripHtml).toBeTypeOf('function');
1604
- expect($.html).toBeTypeOf('function');
1605
- expect($.trust).toBeTypeOf('function');
1606
- expect($.TrustedHTML).toBeTypeOf('function');
1607
- expect($.uuid).toBeTypeOf('function');
1608
- expect($.camelCase).toBeTypeOf('function');
1609
- expect($.kebabCase).toBeTypeOf('function');
1610
- expect($.deepClone).toBeTypeOf('function');
1611
- expect($.deepMerge).toBeTypeOf('function');
1612
- expect($.isEqual).toBeTypeOf('function');
1613
- expect($.param).toBeTypeOf('function');
1614
- expect($.parseQuery).toBeTypeOf('function');
1615
- expect($.storage).toBeDefined();
1616
- expect($.session).toBeDefined();
1617
- expect($.EventBus).toBeTypeOf('function');
1618
- expect($.bus).toBeDefined();
1619
- expect($.range).toBeTypeOf('function');
1620
- expect($.unique).toBeTypeOf('function');
1621
- expect($.chunk).toBeTypeOf('function');
1622
- expect($.groupBy).toBeTypeOf('function');
1623
- expect($.pick).toBeTypeOf('function');
1624
- expect($.omit).toBeTypeOf('function');
1625
- expect($.getPath).toBeTypeOf('function');
1626
- expect($.setPath).toBeTypeOf('function');
1627
- expect($.isEmpty).toBeTypeOf('function');
1628
- expect($.capitalize).toBeTypeOf('function');
1629
- expect($.truncate).toBeTypeOf('function');
1630
- expect($.clamp).toBeTypeOf('function');
1631
- expect($.memoize).toBeTypeOf('function');
1632
- expect($.retry).toBeTypeOf('function');
1633
- expect($.timeout).toBeTypeOf('function');
1634
- });
1635
-
1636
- it('has error handling functions', () => {
1637
- expect($.onError).toBeTypeOf('function');
1638
- expect($.ZQueryError).toBeTypeOf('function');
1639
- expect($.ErrorCode).toBeDefined();
1640
- expect($.guardCallback).toBeTypeOf('function');
1641
- expect($.guardAsync).toBeTypeOf('function');
1642
- expect($.validate).toBeTypeOf('function');
1643
- expect($.formatError).toBeTypeOf('function');
1644
- });
1645
-
1646
- it('has meta properties', () => {
1647
- expect($.version).toBeTypeOf('string');
1648
- expect($.noConflict).toBeTypeOf('function');
1649
- });
1650
- });
1
+ /**
2
+ * Documentation validation test suite.
3
+ *
4
+ * Validates every property table, code example, and API claim in the
5
+ * documentation section data against the actual framework implementation.
6
+ * If the tests fail, the docs or framework need updating.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
10
+ import { existsSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, resolve } from 'node:path';
13
+
14
+ // The docs section sources live in the website workspace, which is gitignored.
15
+ // On CI / fresh checkouts those files won't exist — skip the suite gracefully
16
+ // instead of failing the build.
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const SECTIONS_INDEX = resolve(__dirname, '../zquery-website/app/components/docs/sections/index.js');
19
+ const SECTIONS_AVAILABLE = existsSync(SECTIONS_INDEX);
20
+
21
+ // --- Framework imports (the actual code under test) --------------------------
22
+ import { morph, morphElement } from '../src/diff.js';
23
+ import { reactive, Signal, signal, computed, effect, batch, untracked } from '../src/reactive.js';
24
+ import { safeEval } from '../src/expression.js';
25
+ import {
26
+ debounce, throttle, pipe, once, sleep,
27
+ escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
28
+ deepClone, deepMerge, isEqual, param, parseQuery,
29
+ storage, session, EventBus, bus,
30
+ range, unique, chunk, groupBy,
31
+ pick, omit, getPath, setPath, isEmpty,
32
+ capitalize, truncate, clamp,
33
+ memoize, retry, timeout,
34
+ } from '../src/utils.js';
35
+ import { createRouter, getRouter, matchRoute } from '../src/router.js';
36
+ import { component, mount, mountAll, destroy, prefetch, getInstance, getRegistry, style } from '../src/component.js';
37
+ import { createStore, getStore } from '../src/store.js';
38
+ import { http } from '../src/http.js';
39
+ import { ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError } from '../src/errors.js';
40
+ import { createSSRApp, renderToString } from '../src/ssr.js';
41
+
42
+ // --- Doc sections need $ global (website store.js references $.libSize) ------
43
+ // Set up a minimal global $ before dynamic import
44
+ let SECTIONS;
45
+ beforeAll(async () => {
46
+ if (!SECTIONS_AVAILABLE) return;
47
+ const mod = await import('../index.js');
48
+ globalThis.$ = mod.$;
49
+ // Dynamic path keeps Vite's static import analysis from failing when the
50
+ // website workspace isn't present (e.g. on CI / fresh checkouts).
51
+ const sectionsPath = '../zquery-website/app/components/docs/sections/index.js';
52
+ const sectionsMod = await import(/* @vite-ignore */ sectionsPath);
53
+ SECTIONS = sectionsMod.SECTIONS;
54
+ });
55
+
56
+
57
+ // ===========================================================================
58
+ // Helpers
59
+ // ===========================================================================
60
+ function el(html) {
61
+ const div = document.createElement('div');
62
+ div.innerHTML = html;
63
+ return div;
64
+ }
65
+
66
+ /** Verify section exists and has required shape */
67
+ function assertSection(sec) {
68
+ expect(sec).toBeDefined();
69
+ expect(sec.id).toBeTypeOf('string');
70
+ expect(sec.label).toBeTypeOf('string');
71
+ expect(Array.isArray(sec.headings)).toBe(true);
72
+ expect(sec.content).toBeTypeOf('function');
73
+ const html = sec.content();
74
+ expect(html).toBeTypeOf('string');
75
+ expect(html.length).toBeGreaterThan(0);
76
+ }
77
+
78
+
79
+ // ===========================================================================
80
+ // 1. SECTION STRUCTURE VALIDATION
81
+ // ===========================================================================
82
+ describe.skipIf(!SECTIONS_AVAILABLE)('Documentation sections structure', () => {
83
+
84
+ it('exports SECTIONS array with all 18 sections', () => {
85
+ expect(Array.isArray(SECTIONS)).toBe(true);
86
+ expect(SECTIONS.length).toBe(18);
87
+ });
88
+
89
+ const expectedIds = [
90
+ 'getting-started', 'dev-workflow', 'devtools', 'error-handling',
91
+ 'cli-bundler', 'project-structure', 'router', 'components',
92
+ 'directives', 'store', 'reactive', 'selectors', 'ssr',
93
+ 'http', 'utils', 'security', 'environment', 'webrtc',
94
+ ];
95
+
96
+ it.each(expectedIds)('section "%s" exists with valid structure', (id) => {
97
+ const sec = SECTIONS.find(s => s.id === id);
98
+ assertSection(sec);
99
+ });
100
+
101
+ it('every section has unique id', () => {
102
+ const ids = SECTIONS.map(s => s.id);
103
+ expect(new Set(ids).size).toBe(ids.length);
104
+ });
105
+
106
+ it('every heading has id and text', () => {
107
+ for (const sec of SECTIONS) {
108
+ for (const h of sec.headings) {
109
+ expect(h.id).toBeTypeOf('string');
110
+ expect(h.text).toBeTypeOf('string');
111
+ expect(h.id.length).toBeGreaterThan(0);
112
+ expect(h.text.length).toBeGreaterThan(0);
113
+ }
114
+ }
115
+ });
116
+
117
+ it('every section content() returns valid HTML with headings', () => {
118
+ for (const sec of SECTIONS) {
119
+ const html = sec.content();
120
+ expect(html).toBeTypeOf('string');
121
+ // Every section should have at least one heading
122
+ expect(html).toMatch(/<h[23]/);
123
+ }
124
+ });
125
+ });
126
+
127
+
128
+ // ===========================================================================
129
+ // 2. REACTIVE DOCS Validate property tables & examples
130
+ // ===========================================================================
131
+ describe.skipIf(!SECTIONS_AVAILABLE)('Reactive docs validation', () => {
132
+
133
+ it('$.reactive() creates a deeply reactive proxy', () => {
134
+ const changes = [];
135
+ const data = reactive({ user: { name: 'Alice', score: 0 }, items: ['a', 'b', 'c'] },
136
+ (prop, value, oldValue) => changes.push({ prop, value, oldValue })
137
+ );
138
+ data.user.score = 42;
139
+ expect(changes.some(c => c.prop === 'score' && c.value === 42 && c.oldValue === 0)).toBe(true);
140
+ });
141
+
142
+ it('$.reactive() proxy has __raw and __isReactive', () => {
143
+ const data = reactive({ x: 1 }, () => {});
144
+ expect(data.__isReactive).toBe(true);
145
+ expect(data.__raw).toBeDefined();
146
+ expect(data.__raw.x).toBe(1);
147
+ });
148
+
149
+ it('$.reactive() on non-object returns as-is', () => {
150
+ const fn = () => {};
151
+ expect(reactive(42, fn)).toBe(42);
152
+ expect(reactive(null, fn)).toBe(null);
153
+ });
154
+
155
+ it('$.signal() creates a signal with .value', () => {
156
+ const count = signal(0);
157
+ expect(count.value).toBe(0);
158
+ count.value = 5;
159
+ expect(count.value).toBe(5);
160
+ });
161
+
162
+ it('signal .subscribe() returns unsubscribe function', () => {
163
+ const count = signal(0);
164
+ const calls = [];
165
+ const unsub = count.subscribe(() => calls.push(count.value));
166
+ count.value = 1;
167
+ expect(calls).toContain(1);
168
+ unsub();
169
+ count.value = 2;
170
+ expect(calls).not.toContain(2);
171
+ });
172
+
173
+ it('signal equality check - same value is no-op', () => {
174
+ const s = signal(5);
175
+ const calls = [];
176
+ s.subscribe(() => calls.push('fired'));
177
+ s.value = 5; // same value
178
+ expect(calls.length).toBe(0);
179
+ });
180
+
181
+ it('signal .peek() reads without tracking', () => {
182
+ const count = signal(0);
183
+ const label = signal('Count');
184
+ let runs = 0;
185
+
186
+ const dispose = effect(() => {
187
+ label.value; // tracked
188
+ count.peek(); // NOT tracked
189
+ runs++;
190
+ });
191
+
192
+ expect(runs).toBe(1);
193
+ count.value = 10; // should NOT re-run effect
194
+ expect(runs).toBe(1);
195
+ label.value = 'New Label'; // SHOULD re-run
196
+ expect(runs).toBe(2);
197
+ dispose();
198
+ });
199
+
200
+ it('$.Signal constructor is same class as $.signal()', () => {
201
+ const a = new Signal(0);
202
+ const b = signal(0);
203
+ expect(a instanceof Signal).toBe(true);
204
+ expect(b instanceof Signal).toBe(true);
205
+ });
206
+
207
+ it('$.computed() derives from signals', () => {
208
+ const price = signal(29.99);
209
+ const quantity = signal(3);
210
+ const total = computed(() => price.value * quantity.value);
211
+ expect(total.value).toBeCloseTo(89.97);
212
+ quantity.value = 5;
213
+ expect(total.value).toBeCloseTo(149.95);
214
+ });
215
+
216
+ it('chained computed signals', () => {
217
+ const count = signal(2);
218
+ const doubled = computed(() => count.value * 2);
219
+ const quadrupled = computed(() => doubled.value * 2);
220
+ expect(quadrupled.value).toBe(8);
221
+ count.value = 3;
222
+ expect(quadrupled.value).toBe(12);
223
+ });
224
+
225
+ it('$.effect() returns dispose function', () => {
226
+ const theme = signal('dark');
227
+ let result = '';
228
+ const dispose = effect(() => { result = theme.value; });
229
+ expect(result).toBe('dark');
230
+ theme.value = 'light';
231
+ expect(result).toBe('light');
232
+ dispose();
233
+ theme.value = 'blue';
234
+ expect(result).toBe('light'); // no longer tracking
235
+ });
236
+
237
+ it('$.batch() defers signal notifications', () => {
238
+ const a = signal(1);
239
+ const b = signal(2);
240
+ const results = [];
241
+ effect(() => results.push(a.value + b.value));
242
+ expect(results).toEqual([3]);
243
+ batch(() => {
244
+ a.value = 10;
245
+ b.value = 20;
246
+ });
247
+ // Should fire once with 30, not intermediate 12
248
+ expect(results[results.length - 1]).toBe(30);
249
+ });
250
+
251
+ it('$.batch() returns callback return value', () => {
252
+ const a = signal(1);
253
+ const result = batch(() => {
254
+ a.value = 100;
255
+ return a.value;
256
+ });
257
+ expect(result).toBe(100);
258
+ });
259
+
260
+ it('$.untracked() reads without creating dependencies', () => {
261
+ const a = signal(1);
262
+ const b = signal(10);
263
+ let runs = 0;
264
+ const dispose = effect(() => {
265
+ a.value; // tracked
266
+ untracked(() => b.value); // NOT tracked
267
+ runs++;
268
+ });
269
+ expect(runs).toBe(1);
270
+ b.value = 20; // should NOT re-run
271
+ expect(runs).toBe(1);
272
+ a.value = 2; // should re-run
273
+ expect(runs).toBe(2);
274
+ dispose();
275
+ });
276
+
277
+ it('error resilience - reactive onChange error is caught', () => {
278
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
279
+ const data = reactive({ x: 0 }, () => { throw new Error('boom'); });
280
+ data.x = 1; // should not throw
281
+ expect(data.x).toBe(1);
282
+ spy.mockRestore();
283
+ });
284
+
285
+ it('error resilience - signal subscriber error is caught', () => {
286
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
287
+ const s = signal(0);
288
+ s.subscribe(() => { throw new Error('boom'); });
289
+ s.value = 1; // should not throw
290
+ spy.mockRestore();
291
+ });
292
+
293
+ it('error resilience - effect error is caught', () => {
294
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
295
+ const s = signal(0);
296
+ effect(() => {
297
+ if (s.value > 0) throw new Error('boom');
298
+ s.value; // track
299
+ });
300
+ s.value = 1; // should not throw
301
+ spy.mockRestore();
302
+ });
303
+ });
304
+
305
+
306
+ // ===========================================================================
307
+ // 3. STORE DOCS — Validate property tables & examples
308
+ // ===========================================================================
309
+ describe.skipIf(!SECTIONS_AVAILABLE)('Store docs validation', () => {
310
+
311
+ beforeEach(() => {
312
+ // Clear any previously registered stores
313
+ vi.spyOn(console, 'error').mockImplementation(() => {});
314
+ });
315
+ afterEach(() => vi.restoreAllMocks());
316
+
317
+ it('$.store() creates a store with state, actions, getters', () => {
318
+ const store = createStore('test-basic', {
319
+ state: { user: null, theme: 'dark', count: 0 },
320
+ getters: {
321
+ isLoggedIn: (state) => !!state.user,
322
+ displayName: (state) => state.user?.name || 'Guest',
323
+ },
324
+ actions: {
325
+ login(state, user) { state.user = user; },
326
+ logout(state) { state.user = null; },
327
+ increment(state) { state.count++; },
328
+ setTheme(state, t) { state.theme = t; },
329
+ }
330
+ });
331
+ expect(store.state.count).toBe(0);
332
+ expect(store.state.theme).toBe('dark');
333
+ expect(store.getters.isLoggedIn).toBe(false);
334
+ expect(store.getters.displayName).toBe('Guest');
335
+ });
336
+
337
+ it('store.dispatch() runs actions and returns result', () => {
338
+ const store = createStore('test-dispatch', {
339
+ state: { count: 0 },
340
+ actions: {
341
+ increment(state) { state.count++; },
342
+ compute(state) { return state.count * 2; },
343
+ }
344
+ });
345
+ store.dispatch('increment');
346
+ expect(store.state.count).toBe(1);
347
+ const result = store.dispatch('compute');
348
+ expect(result).toBe(2);
349
+ });
350
+
351
+ it('store.subscribe() - key-specific and wildcard callback args (key, value, old)', () => {
352
+ const store = createStore('test-sub-args', {
353
+ state: { count: 0 },
354
+ actions: { inc(s) { s.count++; } }
355
+ });
356
+
357
+ // Key-specific subscriber: (key, value, old) per actual source
358
+ const keyArgs = [];
359
+ store.subscribe('count', (key, value, old) => keyArgs.push({ key, value, old }));
360
+
361
+ // Wildcard subscriber: (key, value, old)
362
+ const wildArgs = [];
363
+ store.subscribe((key, value, old) => wildArgs.push({ key, value, old }));
364
+
365
+ store.dispatch('inc');
366
+
367
+ // Both should get (key, value, old) - same arg order
368
+ expect(keyArgs.length).toBeGreaterThan(0);
369
+ expect(keyArgs[0].key).toBe('count');
370
+ expect(keyArgs[0].value).toBe(1);
371
+ expect(keyArgs[0].old).toBe(0);
372
+
373
+ expect(wildArgs.length).toBeGreaterThan(0);
374
+ expect(wildArgs[0].key).toBe('count');
375
+ expect(wildArgs[0].value).toBe(1);
376
+ expect(wildArgs[0].old).toBe(0);
377
+ });
378
+
379
+ it('store.subscribe() returns unsubscribe function', () => {
380
+ const store = createStore('test-unsub', {
381
+ state: { x: 0 }, actions: { set(s, v) { s.x = v; } }
382
+ });
383
+ const calls = [];
384
+ const unsub = store.subscribe('x', (k, v) => calls.push(v));
385
+ store.dispatch('set', 1);
386
+ expect(calls.length).toBe(1);
387
+ unsub();
388
+ store.dispatch('set', 2);
389
+ expect(calls.length).toBe(1); // no more calls
390
+ });
391
+
392
+ it('middleware can block actions', () => {
393
+ const store = createStore('test-mw', {
394
+ state: { count: 0 },
395
+ actions: { increment(s) { s.count++; } }
396
+ });
397
+ store.use((actionName, args, state) => {
398
+ if (actionName === 'increment' && state.count >= 2) return false;
399
+ });
400
+ store.dispatch('increment');
401
+ store.dispatch('increment');
402
+ store.dispatch('increment'); // blocked
403
+ expect(store.state.count).toBe(2);
404
+ });
405
+
406
+ it('middleware use() is chainable', () => {
407
+ const store = createStore('test-mw-chain', {
408
+ state: { x: 0 }, actions: { set(s, v) { s.x = v; } }
409
+ });
410
+ const result = store.use(() => {}).use(() => {});
411
+ expect(result).toBe(store);
412
+ });
413
+
414
+ it('named stores via $.store(name, config) and $.getStore(name)', () => {
415
+ createStore('auth', { state: { user: null } });
416
+ createStore('cart', { state: { items: [] } });
417
+ expect(getStore('auth')).not.toBeNull();
418
+ expect(getStore('cart')).not.toBeNull();
419
+ expect(getStore('nonexistent')).toBeNull();
420
+ });
421
+
422
+ it('default store when no name given', () => {
423
+ createStore({ state: { x: 1 } });
424
+ expect(getStore()).not.toBeNull();
425
+ expect(getStore('default')).not.toBeNull();
426
+ });
427
+
428
+ it('store.batch() groups mutations - subscribers fire once', () => {
429
+ const store = createStore('test-batch', {
430
+ state: { count: 0 },
431
+ actions: {}
432
+ });
433
+ const calls = [];
434
+ store.subscribe('count', (k, v) => calls.push(v));
435
+ store.batch(state => {
436
+ state.count = 1;
437
+ state.count = 2;
438
+ state.count = 3;
439
+ });
440
+ // Only final value should trigger
441
+ expect(calls.length).toBe(1);
442
+ expect(calls[0]).toBe(3);
443
+ });
444
+
445
+ it('checkpoint / undo / redo', () => {
446
+ const store = createStore('test-undo', {
447
+ state: { text: '', color: 'blue' },
448
+ actions: {
449
+ setText(state, val) { state.text = val; },
450
+ setColor(state, val) { state.color = val; }
451
+ }
452
+ });
453
+ store.checkpoint();
454
+ store.dispatch('setText', 'hello');
455
+ store.dispatch('setColor', 'red');
456
+ expect(store.canUndo).toBe(true);
457
+ store.undo();
458
+ expect(store.state.text).toBe('');
459
+ expect(store.state.color).toBe('blue');
460
+ expect(store.canRedo).toBe(true);
461
+ store.redo();
462
+ expect(store.state.text).toBe('hello');
463
+ expect(store.state.color).toBe('red');
464
+ });
465
+
466
+ it('canUndo / canRedo getters', () => {
467
+ const store = createStore('test-can', {
468
+ state: { x: 0 }, actions: {}
469
+ });
470
+ expect(store.canUndo).toBe(false);
471
+ expect(store.canRedo).toBe(false);
472
+ store.checkpoint();
473
+ store.state.x = 1;
474
+ expect(store.canUndo).toBe(true);
475
+ });
476
+
477
+ it('store.snapshot() returns deep clone', () => {
478
+ const store = createStore('test-snap', {
479
+ state: { items: [1, 2, 3] }, actions: {}
480
+ });
481
+ const snap = store.snapshot();
482
+ expect(snap.items).toEqual([1, 2, 3]);
483
+ snap.items.push(4);
484
+ expect(store.state.items.length).toBe(3); // original unchanged
485
+ });
486
+
487
+ it('store.replaceState() replaces entire state', () => {
488
+ const store = createStore('test-replace', {
489
+ state: { a: 1, b: 2 }, actions: {}
490
+ });
491
+ store.replaceState({ a: 10, b: 20 });
492
+ expect(store.state.a).toBe(10);
493
+ expect(store.state.b).toBe(20);
494
+ });
495
+
496
+ it('store.reset() without args resets to initial state', () => {
497
+ const store = createStore('test-reset', {
498
+ state: { count: 0 },
499
+ actions: { inc(s) { s.count++; } }
500
+ });
501
+ store.dispatch('inc');
502
+ store.dispatch('inc');
503
+ expect(store.state.count).toBe(2);
504
+ store.reset();
505
+ expect(store.state.count).toBe(0);
506
+ });
507
+
508
+ it('store.history returns action log', () => {
509
+ const store = createStore('test-history', {
510
+ state: { x: 0 },
511
+ actions: { inc(s) { s.x++; } }
512
+ });
513
+ store.dispatch('inc');
514
+ store.dispatch('inc');
515
+ const hist = store.history;
516
+ expect(Array.isArray(hist)).toBe(true);
517
+ expect(hist.length).toBe(2);
518
+ expect(hist[0].action).toBe('inc');
519
+ expect(hist[0].timestamp).toBeTypeOf('number');
520
+ });
521
+
522
+ it('error resilience - unknown action reports error', () => {
523
+ const store = createStore('test-err', {
524
+ state: {}, actions: {}
525
+ });
526
+ store.dispatch('nonexistent'); // should not throw
527
+ });
528
+ });
529
+
530
+
531
+ // ===========================================================================
532
+ // 4. UTILS DOCS — Validate all 37+ utility functions
533
+ // ===========================================================================
534
+ describe.skipIf(!SECTIONS_AVAILABLE)('Utils docs validation', () => {
535
+
536
+ // --- Function utilities ---
537
+ it('debounce delays execution and has .cancel()', async () => {
538
+ let called = 0;
539
+ const fn = debounce(() => called++, 50);
540
+ expect(fn.cancel).toBeTypeOf('function');
541
+ fn();
542
+ fn();
543
+ fn();
544
+ expect(called).toBe(0);
545
+ await sleep(100);
546
+ expect(called).toBe(1);
547
+ });
548
+
549
+ it('throttle limits execution rate', async () => {
550
+ let called = 0;
551
+ const fn = throttle(() => called++, 50);
552
+ fn(); fn(); fn();
553
+ expect(called).toBe(1);
554
+ await sleep(100);
555
+ fn();
556
+ expect(called).toBeGreaterThanOrEqual(2);
557
+ });
558
+
559
+ it('pipe composes left-to-right', () => {
560
+ const fn = pipe(x => x + 1, x => x * 2);
561
+ expect(fn(3)).toBe(8);
562
+ });
563
+
564
+ it('once runs only once', () => {
565
+ let calls = 0;
566
+ const fn = once(() => ++calls);
567
+ expect(fn()).toBe(1);
568
+ expect(fn()).toBe(1);
569
+ expect(calls).toBe(1);
570
+ });
571
+
572
+ it('sleep returns a promise', async () => {
573
+ const start = Date.now();
574
+ await sleep(50);
575
+ expect(Date.now() - start).toBeGreaterThanOrEqual(40);
576
+ });
577
+
578
+ // --- String utilities ---
579
+ it('escapeHtml escapes & < > " \'', () => {
580
+ expect(escapeHtml('&<>"\''))
581
+ .toBe('&amp;&lt;&gt;&quot;&#39;');
582
+ });
583
+
584
+ it('stripHtml removes tags', () => {
585
+ expect(stripHtml('<p>Hello <b>world</b></p>')).toBe('Hello world');
586
+ });
587
+
588
+ it('html template tag auto-escapes values', () => {
589
+ const user = '<script>alert("xss")</script>';
590
+ const result = html`<div>${user}</div>`;
591
+ expect(result).not.toContain('<script>');
592
+ expect(result).toContain('&lt;script&gt;');
593
+ });
594
+
595
+ it('trust() bypasses html escaping', () => {
596
+ const safe = trust('<b>bold</b>');
597
+ const result = html`<div>${safe}</div>`;
598
+ expect(result).toContain('<b>bold</b>');
599
+ });
600
+
601
+ it('TrustedHTML class works with instanceof', () => {
602
+ const t = trust('<b>ok</b>');
603
+ expect(t instanceof TrustedHTML).toBe(true);
604
+ expect(t.toString()).toBe('<b>ok</b>');
605
+ });
606
+
607
+ it('uuid generates valid v4 format', () => {
608
+ const id = uuid();
609
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
610
+ });
611
+
612
+ it('camelCase converts kebab to camel', () => {
613
+ expect(camelCase('my-component')).toBe('myComponent');
614
+ });
615
+
616
+ it('kebabCase converts camel to kebab', () => {
617
+ expect(kebabCase('myComponent')).toBe('my-component');
618
+ });
619
+
620
+ it('capitalize first letter and lowercases rest', () => {
621
+ expect(capitalize('hello')).toBe('Hello');
622
+ expect(capitalize('HELLO')).toBe('Hello');
623
+ expect(capitalize('')).toBe('');
624
+ });
625
+
626
+ it('truncate with default suffix', () => {
627
+ expect(truncate('Hello World', 5)).toBe('Hell…');
628
+ expect(truncate('Hi', 10)).toBe('Hi');
629
+ });
630
+
631
+ // --- Object utilities ---
632
+ it('deepClone creates independent copy', () => {
633
+ const obj = { a: { b: [1, 2] } };
634
+ const clone = deepClone(obj);
635
+ clone.a.b.push(3);
636
+ expect(obj.a.b.length).toBe(2);
637
+ });
638
+
639
+ it('deepMerge merges recursively', () => {
640
+ const target = { a: { x: 1 }, b: 2 };
641
+ const result = deepMerge(target, { a: { y: 2 }, c: 3 });
642
+ expect(result.a.x).toBe(1);
643
+ expect(result.a.y).toBe(2);
644
+ expect(result.c).toBe(3);
645
+ });
646
+
647
+ it('deepMerge blocks __proto__ poisoning', () => {
648
+ const target = {};
649
+ const malicious = JSON.parse('{"__proto__": {"polluted": true}}');
650
+ deepMerge(target, malicious);
651
+ expect(({}).polluted).toBeUndefined();
652
+ });
653
+
654
+ it('isEqual deep comparison', () => {
655
+ expect(isEqual({ a: [1, 2] }, { a: [1, 2] })).toBe(true);
656
+ expect(isEqual({ a: 1 }, { a: 2 })).toBe(false);
657
+ expect(isEqual(null, null)).toBe(true);
658
+ });
659
+
660
+ it('pick extracts specified keys', () => {
661
+ expect(pick({ a: 1, b: 2, c: 3 }, ['a', 'c'])).toEqual({ a: 1, c: 3 });
662
+ });
663
+
664
+ it('omit excludes specified keys', () => {
665
+ expect(omit({ a: 1, b: 2, c: 3 }, ['b'])).toEqual({ a: 1, c: 3 });
666
+ });
667
+
668
+ it('getPath follows dot path', () => {
669
+ expect(getPath({ a: { b: { c: 42 } } }, 'a.b.c')).toBe(42);
670
+ expect(getPath({ a: 1 }, 'x.y', 'fallback')).toBe('fallback');
671
+ });
672
+
673
+ it('setPath creates nested structure', () => {
674
+ const obj = {};
675
+ setPath(obj, 'a.b.c', 42);
676
+ expect(obj.a.b.c).toBe(42);
677
+ });
678
+
679
+ it('setPath blocks __proto__ poisoning', () => {
680
+ const obj = {};
681
+ setPath(obj, '__proto__.polluted', true);
682
+ expect(({}).polluted).toBeUndefined();
683
+ });
684
+
685
+ it('isEmpty checks emptiness', () => {
686
+ expect(isEmpty(null)).toBe(true);
687
+ expect(isEmpty(undefined)).toBe(true);
688
+ expect(isEmpty('')).toBe(true);
689
+ expect(isEmpty([])).toBe(true);
690
+ expect(isEmpty({})).toBe(true);
691
+ expect(isEmpty(new Map())).toBe(true);
692
+ expect(isEmpty(new Set())).toBe(true);
693
+ expect(isEmpty('x')).toBe(false);
694
+ expect(isEmpty([1])).toBe(false);
695
+ expect(isEmpty({ a: 1 })).toBe(false);
696
+ });
697
+
698
+ // --- URL utilities ---
699
+ it('param serializes object to query string', () => {
700
+ const result = param({ page: 2, limit: 10 });
701
+ expect(result).toContain('page=2');
702
+ expect(result).toContain('limit=10');
703
+ });
704
+
705
+ it('parseQuery parses query string', () => {
706
+ expect(parseQuery('page=2&limit=10')).toEqual({ page: '2', limit: '10' });
707
+ });
708
+
709
+ // --- Storage wrappers ---
710
+ it('storage has get/set/remove/clear methods', () => {
711
+ expect(storage.get).toBeTypeOf('function');
712
+ expect(storage.set).toBeTypeOf('function');
713
+ expect(storage.remove).toBeTypeOf('function');
714
+ expect(storage.clear).toBeTypeOf('function');
715
+ });
716
+
717
+ it('storage auto-parses JSON', () => {
718
+ storage.set('test-key', { a: 1 });
719
+ expect(storage.get('test-key')).toEqual({ a: 1 });
720
+ storage.remove('test-key');
721
+ expect(storage.get('test-key')).toBeNull();
722
+ expect(storage.get('test-key', 'fallback')).toBe('fallback');
723
+ });
724
+
725
+ it('session has same API as storage', () => {
726
+ expect(session.get).toBeTypeOf('function');
727
+ expect(session.set).toBeTypeOf('function');
728
+ expect(session.remove).toBeTypeOf('function');
729
+ expect(session.clear).toBeTypeOf('function');
730
+ });
731
+
732
+ // --- Event bus ---
733
+ it('bus has on/off/emit/once/clear', () => {
734
+ expect(bus.on).toBeTypeOf('function');
735
+ expect(bus.off).toBeTypeOf('function');
736
+ expect(bus.emit).toBeTypeOf('function');
737
+ expect(bus.once).toBeTypeOf('function');
738
+ expect(bus.clear).toBeTypeOf('function');
739
+ });
740
+
741
+ it('bus.on returns unsubscribe function', () => {
742
+ const calls = [];
743
+ const unsub = bus.on('test', (v) => calls.push(v));
744
+ bus.emit('test', 1);
745
+ expect(calls).toEqual([1]);
746
+ unsub();
747
+ bus.emit('test', 2);
748
+ expect(calls).toEqual([1]);
749
+ bus.clear();
750
+ });
751
+
752
+ it('bus.once fires only once', () => {
753
+ const calls = [];
754
+ bus.once('test-once', (v) => calls.push(v));
755
+ bus.emit('test-once', 'a');
756
+ bus.emit('test-once', 'b');
757
+ expect(calls).toEqual(['a']);
758
+ bus.clear();
759
+ });
760
+
761
+ it('EventBus class can be instantiated', () => {
762
+ const myBus = new EventBus();
763
+ const calls = [];
764
+ myBus.on('x', (v) => calls.push(v));
765
+ myBus.emit('x', 42);
766
+ expect(calls).toEqual([42]);
767
+ });
768
+
769
+ // --- Array utilities ---
770
+ it('range generates number arrays', () => {
771
+ expect(range(5)).toEqual([0, 1, 2, 3, 4]);
772
+ expect(range(2, 5)).toEqual([2, 3, 4]);
773
+ expect(range(0, 10, 3)).toEqual([0, 3, 6, 9]);
774
+ expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]);
775
+ });
776
+
777
+ it('unique deduplicates', () => {
778
+ expect(unique([1, 2, 2, 3, 3])).toEqual([1, 2, 3]);
779
+ });
780
+
781
+ it('unique with keyFn', () => {
782
+ const items = [{ id: 1, n: 'a' }, { id: 2, n: 'b' }, { id: 1, n: 'c' }];
783
+ expect(unique(items, i => i.id).length).toBe(2);
784
+ });
785
+
786
+ it('chunk splits array', () => {
787
+ expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
788
+ });
789
+
790
+ it('groupBy groups by key function', () => {
791
+ const result = groupBy(['one', 'two', 'three'], w => String(w.length));
792
+ expect(result['3']).toEqual(['one', 'two']);
793
+ expect(result['5']).toEqual(['three']);
794
+ });
795
+
796
+ // --- Number utilities ---
797
+ it('clamp constrains to range', () => {
798
+ expect(clamp(5, 0, 10)).toBe(5);
799
+ expect(clamp(-5, 0, 10)).toBe(0);
800
+ expect(clamp(15, 0, 10)).toBe(10);
801
+ });
802
+
803
+ // --- Memoize ---
804
+ it('memoize caches results and has .clear()', () => {
805
+ let calls = 0;
806
+ const fn = memoize((x) => { calls++; return x * 2; });
807
+ expect(fn(5)).toBe(10);
808
+ expect(fn(5)).toBe(10);
809
+ expect(calls).toBe(1);
810
+ fn.clear();
811
+ expect(fn(5)).toBe(10);
812
+ expect(calls).toBe(2);
813
+ });
814
+
815
+ it('memoize with maxSize evicts old entries', () => {
816
+ let calls = 0;
817
+ const fn = memoize((x) => { calls++; return x; }, { maxSize: 2 });
818
+ fn(1); fn(2); fn(3); // 1 should be evicted
819
+ calls = 0;
820
+ fn(1); // should recompute
821
+ expect(calls).toBe(1);
822
+ });
823
+
824
+ // --- Retry ---
825
+ it('retry retries on failure', async () => {
826
+ let attempt = 0;
827
+ const result = await retry((n) => {
828
+ attempt = n;
829
+ if (n < 3) return Promise.reject(new Error('fail'));
830
+ return Promise.resolve('ok');
831
+ }, { attempts: 3, delay: 10 });
832
+ expect(result).toBe('ok');
833
+ expect(attempt).toBe(3);
834
+ });
835
+
836
+ // --- Timeout ---
837
+ it('timeout rejects if promise is too slow', async () => {
838
+ const slow = new Promise(r => setTimeout(() => r('done'), 500));
839
+ await expect(timeout(slow, 50)).rejects.toThrow(/Timed out/);
840
+ });
841
+ });
842
+
843
+
844
+ // ===========================================================================
845
+ // 5. HTTP DOCS — Validate API shape
846
+ // ===========================================================================
847
+ describe.skipIf(!SECTIONS_AVAILABLE)('HTTP docs validation', () => {
848
+
849
+ it('http has all documented methods', () => {
850
+ expect(http.get).toBeTypeOf('function');
851
+ expect(http.post).toBeTypeOf('function');
852
+ expect(http.put).toBeTypeOf('function');
853
+ expect(http.patch).toBeTypeOf('function');
854
+ expect(http.delete).toBeTypeOf('function');
855
+ expect(http.head).toBeTypeOf('function');
856
+ });
857
+
858
+ it('http.configure() sets defaults', () => {
859
+ http.configure({
860
+ baseURL: 'https://test.example.com',
861
+ headers: { 'X-Test': 'yes' },
862
+ timeout: 5000,
863
+ });
864
+ const config = http.getConfig();
865
+ expect(config.baseURL).toBe('https://test.example.com');
866
+ expect(config.headers['X-Test']).toBe('yes');
867
+ expect(config.timeout).toBe(5000);
868
+ // Reset
869
+ http.configure({ baseURL: '', timeout: 30000 });
870
+ });
871
+
872
+ it('http.getConfig() returns safe copy', () => {
873
+ const cfg1 = http.getConfig();
874
+ const cfg2 = http.getConfig();
875
+ expect(cfg1).not.toBe(cfg2); // different object
876
+ expect(cfg1.headers).not.toBe(cfg2.headers);
877
+ });
878
+
879
+ it('http.onRequest() returns unsubscribe function', () => {
880
+ const unsub = http.onRequest(() => {});
881
+ expect(unsub).toBeTypeOf('function');
882
+ unsub();
883
+ });
884
+
885
+ it('http.onResponse() returns unsubscribe function', () => {
886
+ const unsub = http.onResponse(() => {});
887
+ expect(unsub).toBeTypeOf('function');
888
+ unsub();
889
+ });
890
+
891
+ it('http.clearInterceptors() clears all or by type', () => {
892
+ http.onRequest(() => {});
893
+ http.onResponse(() => {});
894
+ http.clearInterceptors('request');
895
+ http.clearInterceptors('response');
896
+ http.clearInterceptors(); // clear all
897
+ });
898
+
899
+ it('http.all() batches promises', async () => {
900
+ const results = await http.all([
901
+ Promise.resolve({ ok: true, data: 1 }),
902
+ Promise.resolve({ ok: true, data: 2 }),
903
+ ]);
904
+ expect(results.length).toBe(2);
905
+ });
906
+
907
+ it('http.createAbort() returns AbortController', () => {
908
+ const ac = http.createAbort();
909
+ expect(ac).toBeInstanceOf(AbortController);
910
+ expect(ac.signal).toBeInstanceOf(AbortSignal);
911
+ });
912
+
913
+ it('http.raw is a function', () => {
914
+ expect(http.raw).toBeTypeOf('function');
915
+ });
916
+ });
917
+
918
+
919
+ // ===========================================================================
920
+ // 6. ERROR HANDLING DOCS — Validate error codes, classes, functions
921
+ // ===========================================================================
922
+ describe.skipIf(!SECTIONS_AVAILABLE)('Error handling docs validation', () => {
923
+
924
+ afterEach(() => { onError(null); });
925
+
926
+ it('ErrorCode has all documented codes', () => {
927
+ const expected = [
928
+ 'REACTIVE_CALLBACK', 'SIGNAL_CALLBACK', 'EFFECT_EXEC',
929
+ 'EXPR_PARSE', 'EXPR_EVAL', 'EXPR_UNSAFE_ACCESS',
930
+ 'COMP_INVALID_NAME', 'COMP_NOT_FOUND', 'COMP_MOUNT_TARGET', 'COMP_RENDER',
931
+ 'COMP_LIFECYCLE', 'COMP_RESOURCE', 'COMP_DIRECTIVE',
932
+ 'ROUTER_LOAD', 'ROUTER_GUARD', 'ROUTER_RESOLVE',
933
+ 'STORE_ACTION', 'STORE_MIDDLEWARE', 'STORE_SUBSCRIBE',
934
+ 'HTTP_REQUEST', 'HTTP_TIMEOUT', 'HTTP_INTERCEPTOR', 'HTTP_PARSE',
935
+ 'SSR_RENDER', 'SSR_COMPONENT', 'SSR_HYDRATION', 'SSR_PAGE',
936
+ 'INVALID_ARGUMENT',
937
+ ];
938
+ for (const code of expected) {
939
+ expect(ErrorCode[code]).toBeDefined();
940
+ expect(ErrorCode[code]).toMatch(/^ZQ_/);
941
+ }
942
+ });
943
+
944
+ it('ErrorCode is frozen', () => {
945
+ expect(Object.isFrozen(ErrorCode)).toBe(true);
946
+ });
947
+
948
+ it('ZQueryError has code, context, cause', () => {
949
+ const cause = new Error('original');
950
+ const err = new ZQueryError(ErrorCode.COMP_RENDER, 'Render failed', { component: 'test' }, cause);
951
+ expect(err.name).toBe('ZQueryError');
952
+ expect(err.code).toBe('ZQ_COMP_RENDER');
953
+ expect(err.context.component).toBe('test');
954
+ expect(err.cause).toBe(cause);
955
+ expect(err.message).toBe('Render failed');
956
+ expect(err instanceof Error).toBe(true);
957
+ });
958
+
959
+ it('onError registers handler and returns unsubscribe', () => {
960
+ const errors = [];
961
+ const unsub = onError((err) => errors.push(err));
962
+ reportError(ErrorCode.INVALID_ARGUMENT, 'test error');
963
+ expect(errors.length).toBe(1);
964
+ expect(errors[0] instanceof ZQueryError).toBe(true);
965
+ unsub();
966
+ reportError(ErrorCode.INVALID_ARGUMENT, 'test error 2');
967
+ expect(errors.length).toBe(1);
968
+ });
969
+
970
+ it('onError(null) clears all handlers', () => {
971
+ const calls = [];
972
+ onError(() => calls.push(1));
973
+ onError(() => calls.push(2));
974
+ onError(null);
975
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
976
+ reportError(ErrorCode.INVALID_ARGUMENT, 'test');
977
+ expect(calls.length).toBe(0);
978
+ spy.mockRestore();
979
+ });
980
+
981
+ it('guardCallback wraps errors', () => {
982
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
983
+ const fn = guardCallback(() => { throw new Error('boom'); }, ErrorCode.COMP_RENDER);
984
+ expect(fn()).toBeUndefined(); // does not throw
985
+ spy.mockRestore();
986
+ });
987
+
988
+ it('guardAsync wraps async errors', async () => {
989
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
990
+ const fn = guardAsync(async () => { throw new Error('boom'); }, ErrorCode.COMP_RENDER);
991
+ const result = await fn();
992
+ expect(result).toBeUndefined();
993
+ spy.mockRestore();
994
+ });
995
+
996
+ it('validate throws on missing value', () => {
997
+ expect(() => validate(null, 'name')).toThrow(ZQueryError);
998
+ expect(() => validate(undefined, 'name')).toThrow(ZQueryError);
999
+ });
1000
+
1001
+ it('validate throws on wrong type', () => {
1002
+ expect(() => validate(42, 'name', 'string')).toThrow(ZQueryError);
1003
+ expect(() => validate('hello', 'name', 'string')).not.toThrow();
1004
+ });
1005
+
1006
+ it('formatError returns structured object', () => {
1007
+ const err = new ZQueryError(ErrorCode.COMP_RENDER, 'test', { x: 1 });
1008
+ const formatted = formatError(err);
1009
+ expect(formatted.code).toBe('ZQ_COMP_RENDER');
1010
+ expect(formatted.type).toBe('ZQueryError');
1011
+ expect(formatted.message).toBe('test');
1012
+ expect(formatted.context).toEqual({ x: 1 });
1013
+ expect(formatted.stack).toBeTypeOf('string');
1014
+ });
1015
+
1016
+ it('formatError handles nested cause', () => {
1017
+ const inner = new ZQueryError(ErrorCode.EXPR_EVAL, 'inner');
1018
+ const outer = new ZQueryError(ErrorCode.COMP_RENDER, 'outer', {}, inner);
1019
+ const formatted = formatError(outer);
1020
+ expect(formatted.cause).not.toBeNull();
1021
+ expect(formatted.cause.code).toBe('ZQ_EXPR_EVAL');
1022
+ });
1023
+ });
1024
+
1025
+
1026
+ // ===========================================================================
1027
+ // 7. EXPRESSION PARSER DOCS
1028
+ // ===========================================================================
1029
+ describe.skipIf(!SECTIONS_AVAILABLE)('Expression parser docs validation', () => {
1030
+
1031
+ const eval_ = (expr, ...scopes) => safeEval(expr, scopes.length ? scopes : [{}]);
1032
+
1033
+ it('property access', () => {
1034
+ expect(eval_('user.name', { user: { name: 'Alice' } })).toBe('Alice');
1035
+ });
1036
+
1037
+ it('array indexing', () => {
1038
+ expect(eval_('items[0]', { items: ['a', 'b'] })).toBe('a');
1039
+ });
1040
+
1041
+ it('arithmetic', () => {
1042
+ expect(eval_('a + b', { a: 2, b: 3 })).toBe(5);
1043
+ expect(eval_('count * 2', { count: 5 })).toBe(10);
1044
+ });
1045
+
1046
+ it('comparison operators', () => {
1047
+ expect(eval_('a === b', { a: 1, b: 1 })).toBe(true);
1048
+ expect(eval_('x != null', { x: 5 })).toBe(true);
1049
+ expect(eval_('count > 0', { count: 5 })).toBe(true);
1050
+ });
1051
+
1052
+ it('logical operators', () => {
1053
+ expect(eval_('a && b', { a: true, b: false })).toBe(false);
1054
+ expect(eval_('a || b', { a: false, b: true })).toBe(true);
1055
+ expect(eval_('!a', { a: false })).toBe(true);
1056
+ });
1057
+
1058
+ it('ternary', () => {
1059
+ expect(eval_('a ? "yes" : "no"', { a: true })).toBe('yes');
1060
+ expect(eval_('a ? "yes" : "no"', { a: false })).toBe('no');
1061
+ });
1062
+
1063
+ it('typeof', () => {
1064
+ expect(eval_('typeof x', { x: 'hello' })).toBe('string');
1065
+ expect(eval_('typeof x', { x: 42 })).toBe('number');
1066
+ });
1067
+
1068
+ it('literals', () => {
1069
+ expect(eval_('42')).toBe(42);
1070
+ expect(eval_("'hello'")).toBe('hello');
1071
+ expect(eval_('true')).toBe(true);
1072
+ expect(eval_('false')).toBe(false);
1073
+ expect(eval_('null')).toBe(null);
1074
+ expect(eval_('undefined')).toBe(undefined);
1075
+ });
1076
+
1077
+ it('template literals', () => {
1078
+ expect(eval_('`Hello ${name}`', { name: 'World' })).toBe('Hello World');
1079
+ });
1080
+
1081
+ it('array literals', () => {
1082
+ expect(eval_('[1, 2, 3]')).toEqual([1, 2, 3]);
1083
+ });
1084
+
1085
+ it('object literals', () => {
1086
+ expect(eval_("{ foo: 'bar' }")).toEqual({ foo: 'bar' });
1087
+ });
1088
+
1089
+ it('nullish coalescing', () => {
1090
+ expect(eval_('a ?? "default"', { a: null })).toBe('default');
1091
+ expect(eval_('a ?? "default"', { a: 'value' })).toBe('value');
1092
+ });
1093
+
1094
+ it('optional chaining', () => {
1095
+ expect(eval_('a?.b', { a: null })).toBeUndefined();
1096
+ expect(eval_('a?.b', { a: { b: 42 } })).toBe(42);
1097
+ });
1098
+
1099
+ it('arrow functions', () => {
1100
+ const fn = eval_('x => x * 2');
1101
+ expect(fn(5)).toBe(10);
1102
+ });
1103
+
1104
+ it('whitelisted globals are accessible', () => {
1105
+ expect(eval_('Math.max(1, 2, 3)')).toBe(3);
1106
+ expect(eval_('parseInt("42")')).toBe(42);
1107
+ expect(eval_('isNaN(NaN)')).toBe(true);
1108
+ });
1109
+
1110
+ it('blocks __proto__ access', () => {
1111
+ const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
1112
+ expect(eval_('obj.__proto__', { obj: {} })).toBeUndefined();
1113
+ spy.mockRestore();
1114
+ });
1115
+
1116
+ it('blocks constructor access', () => {
1117
+ const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
1118
+ expect(eval_('obj.constructor', { obj: {} })).toBeUndefined();
1119
+ spy.mockRestore();
1120
+ });
1121
+ });
1122
+
1123
+
1124
+ // ===========================================================================
1125
+ // 8. COMPONENT DOCS — Validate API shape & lifecycle
1126
+ // ===========================================================================
1127
+ describe.skipIf(!SECTIONS_AVAILABLE)('Component docs validation', () => {
1128
+
1129
+ beforeEach(() => {
1130
+ document.body.innerHTML = '';
1131
+ vi.spyOn(console, 'error').mockImplementation(() => {});
1132
+ });
1133
+ afterEach(() => vi.restoreAllMocks());
1134
+
1135
+ it('$.component() registers and $.components() retrieves registry', () => {
1136
+ component('test-comp-doc', { render: () => '<p>test</p>' });
1137
+ const registry = getRegistry();
1138
+ expect(registry['test-comp-doc']).toBeDefined();
1139
+ });
1140
+
1141
+ it('component definition keys are recognized', () => {
1142
+ component('test-keys-doc', {
1143
+ state: { count: 0 },
1144
+ computed: { double() { return this.state.count * 2; } },
1145
+ watch: { count(v, old) {} },
1146
+ init() {},
1147
+ mounted() {},
1148
+ updated() {},
1149
+ destroyed() {},
1150
+ styles: '.card { color: red; }',
1151
+ increment() { this.state.count++; },
1152
+ render() { return '<p>test</p>'; }
1153
+ });
1154
+ const registry = getRegistry();
1155
+ expect(registry['test-keys-doc']).toBeDefined();
1156
+ });
1157
+
1158
+ it('$.mount() mounts component and returns instance', () => {
1159
+ component('test-mount-doc', { render: () => '<p>mounted</p>' });
1160
+ document.body.innerHTML = '<div id="test-root"></div>';
1161
+ const inst = mount('#test-root', 'test-mount-doc');
1162
+ expect(inst).toBeDefined();
1163
+ expect(document.querySelector('#test-root p').textContent).toBe('mounted');
1164
+ });
1165
+
1166
+ it('$.getInstance() retrieves instance', () => {
1167
+ component('test-getinst-doc', { render: () => '<p>get</p>' });
1168
+ document.body.innerHTML = '<div id="inst-root"></div>';
1169
+ mount('#inst-root', 'test-getinst-doc');
1170
+ const inst = getInstance('#inst-root');
1171
+ expect(inst).toBeDefined();
1172
+ });
1173
+
1174
+ it('$.destroy() removes component', () => {
1175
+ component('test-destroy-doc', {
1176
+ state: { x: 1 },
1177
+ render() { return '<p>alive</p>'; }
1178
+ });
1179
+ document.body.innerHTML = '<div id="destroy-root"></div>';
1180
+ mount('#destroy-root', 'test-destroy-doc');
1181
+ destroy('#destroy-root');
1182
+ expect(getInstance('#destroy-root')).toBeNull();
1183
+ });
1184
+
1185
+ it('component with props', () => {
1186
+ component('test-props-doc', {
1187
+ state: { label: '' },
1188
+ mounted() {
1189
+ this.state.label = this.props.label || 'default';
1190
+ },
1191
+ render() { return `<span>${this.state.label}</span>`; }
1192
+ });
1193
+ document.body.innerHTML = '<div id="props-root"></div>';
1194
+ const inst = mount('#props-root', 'test-props-doc', { label: 'Hello' });
1195
+ expect(inst.props.label).toBe('Hello');
1196
+ });
1197
+
1198
+ it('component emit() dispatches custom event', () => {
1199
+ component('test-emit-doc', {
1200
+ fire() { this.emit('my-event', 42); },
1201
+ render() { return '<button @click="fire">go</button>'; }
1202
+ });
1203
+ document.body.innerHTML = '<div id="emit-root"></div>';
1204
+ const inst = mount('#emit-root', 'test-emit-doc');
1205
+ let detail = null;
1206
+ document.querySelector('#emit-root').addEventListener('my-event', (e) => {
1207
+ detail = e.detail;
1208
+ });
1209
+ inst.fire();
1210
+ expect(detail).toBe(42);
1211
+ });
1212
+
1213
+ it('component setState({}) forces re-render', async () => {
1214
+ let renderCount = 0;
1215
+ component('test-setstate-doc', {
1216
+ state: { x: 0 },
1217
+ render() { renderCount++; return `<p>${this.state.x}</p>`; }
1218
+ });
1219
+ document.body.innerHTML = '<div id="ss-root"></div>';
1220
+ const inst = mount('#ss-root', 'test-setstate-doc');
1221
+ const initial = renderCount;
1222
+ inst.setState({});
1223
+ await new Promise(r => setTimeout(r, 50));
1224
+ expect(renderCount).toBeGreaterThan(initial);
1225
+ });
1226
+
1227
+ it('$.mountAll() scans container and auto-mounts registered tags', () => {
1228
+ component('ma-card', { render: () => '<p>card</p>' });
1229
+ component('ma-badge', { render: () => '<span>badge</span>' });
1230
+ document.body.innerHTML =
1231
+ '<div id="ma-root"><ma-card></ma-card><ma-badge></ma-badge></div>';
1232
+ mountAll(document.getElementById('ma-root'));
1233
+ expect(document.querySelector('ma-card p').textContent).toBe('card');
1234
+ expect(document.querySelector('ma-badge span').textContent).toBe('badge');
1235
+ });
1236
+
1237
+ it('$.mountAll() skips already-mounted elements', () => {
1238
+ let mounts = 0;
1239
+ component('ma-once', { mounted() { mounts++; }, render: () => '<p>once</p>' });
1240
+ document.body.innerHTML = '<ma-once></ma-once>';
1241
+ mountAll();
1242
+ const first = mounts;
1243
+ mountAll();
1244
+ expect(mounts).toBe(first); // no double-mount
1245
+ });
1246
+
1247
+ it('$.mountAll() extracts static props from attributes', () => {
1248
+ component('ma-props', {
1249
+ render() { return `<p>${this.props.label}</p>`; }
1250
+ });
1251
+ document.body.innerHTML = '<ma-props label="hello"></ma-props>';
1252
+ mountAll();
1253
+ expect(document.querySelector('ma-props p').textContent).toBe('hello');
1254
+ });
1255
+
1256
+ it('$.prefetch() resolves without error for registered component', async () => {
1257
+ component('pf-comp', { render: () => '<p>pf</p>' });
1258
+ await expect(prefetch('pf-comp')).resolves.toBeUndefined();
1259
+ });
1260
+
1261
+ it('$.prefetch() is a no-op for unregistered names', async () => {
1262
+ await expect(prefetch('pf-nonexistent')).resolves.toBeUndefined();
1263
+ });
1264
+
1265
+ it('$.style() returns object with ready promise and remove()', () => {
1266
+ const result = style('/test.css', { critical: false });
1267
+ expect(result).toBeDefined();
1268
+ expect(result.ready).toBeInstanceOf(Promise);
1269
+ expect(result.remove).toBeTypeOf('function');
1270
+ // Verify <link> was added to <head>
1271
+ const link = document.querySelector('link[data-zq-style][href="/test.css"]');
1272
+ expect(link).not.toBeNull();
1273
+ result.remove();
1274
+ expect(document.querySelector('link[data-zq-style][href="/test.css"]')).toBeNull();
1275
+ });
1276
+
1277
+ it('$.style() with critical mode injects visibility-hidden style', () => {
1278
+ const result = style('/critical.css');
1279
+ const critStyle = document.querySelector('style[data-zq-critical]');
1280
+ expect(critStyle).not.toBeNull();
1281
+ expect(critStyle.textContent).toContain('visibility:hidden');
1282
+ result.remove();
1283
+ });
1284
+
1285
+ it('$.style() deduplicates same URL', () => {
1286
+ const a = style('/dup.css', { critical: false });
1287
+ const b = style('/dup.css', { critical: false });
1288
+ const links = document.querySelectorAll('link[data-zq-style][href="/dup.css"]');
1289
+ expect(links.length).toBe(1);
1290
+ a.remove();
1291
+ b.remove();
1292
+ });
1293
+ });
1294
+
1295
+
1296
+ // ===========================================================================
1297
+ // 9. ROUTER DOCS Validate API shape
1298
+ // ===========================================================================
1299
+ describe.skipIf(!SECTIONS_AVAILABLE)('Router docs validation', () => {
1300
+
1301
+ beforeEach(() => {
1302
+ vi.spyOn(console, 'error').mockImplementation(() => {});
1303
+ document.body.innerHTML = '<div id="router-outlet"></div>';
1304
+ });
1305
+ afterEach(() => {
1306
+ const r = getRouter();
1307
+ if (r) r.destroy();
1308
+ vi.restoreAllMocks();
1309
+ });
1310
+
1311
+ it('createRouter returns router instance', () => {
1312
+ // Register route components first
1313
+ component('r-home', { render: () => '<p>home</p>' });
1314
+ component('r-about', { render: () => '<p>about</p>' });
1315
+ const router = createRouter({
1316
+ el: '#router-outlet',
1317
+ mode: 'hash',
1318
+ routes: [
1319
+ { path: '/', component: 'r-home' },
1320
+ { path: '/about', component: 'r-about' },
1321
+ ]
1322
+ });
1323
+ expect(router).toBeDefined();
1324
+ expect(router.navigate).toBeTypeOf('function');
1325
+ expect(router.replace).toBeTypeOf('function');
1326
+ expect(router.back).toBeTypeOf('function');
1327
+ expect(router.forward).toBeTypeOf('function');
1328
+ expect(router.go).toBeTypeOf('function');
1329
+ });
1330
+
1331
+ it('getRouter() returns active router', () => {
1332
+ component('r-home2', { render: () => '<p>h</p>' });
1333
+ createRouter({
1334
+ el: '#router-outlet',
1335
+ mode: 'hash',
1336
+ routes: [{ path: '/', component: 'r-home2' }]
1337
+ });
1338
+ expect(getRouter()).not.toBeNull();
1339
+ });
1340
+
1341
+ it('router has beforeEach/afterEach guards', () => {
1342
+ component('r-guard', { render: () => '<p>guard</p>' });
1343
+ const router = createRouter({
1344
+ el: '#router-outlet',
1345
+ mode: 'hash',
1346
+ routes: [{ path: '/', component: 'r-guard' }]
1347
+ });
1348
+ expect(router.beforeEach).toBeTypeOf('function');
1349
+ expect(router.afterEach).toBeTypeOf('function');
1350
+ });
1351
+
1352
+ it('router.onChange returns unsubscribe function', () => {
1353
+ component('r-onchange', { render: () => '<p>test</p>' });
1354
+ const router = createRouter({
1355
+ el: '#router-outlet',
1356
+ mode: 'hash',
1357
+ routes: [{ path: '/', component: 'r-onchange' }]
1358
+ });
1359
+ const unsub = router.onChange(() => {});
1360
+ expect(unsub).toBeTypeOf('function');
1361
+ });
1362
+
1363
+ it('router has add/remove methods', () => {
1364
+ component('r-addrem', { render: () => '<p>x</p>' });
1365
+ const router = createRouter({
1366
+ el: '#router-outlet',
1367
+ mode: 'hash',
1368
+ routes: [{ path: '/', component: 'r-addrem' }]
1369
+ });
1370
+ expect(router.add).toBeTypeOf('function');
1371
+ expect(router.remove).toBeTypeOf('function');
1372
+ });
1373
+
1374
+ it('router has current, path, query getters', () => {
1375
+ component('r-getters', { render: () => '<p>x</p>' });
1376
+ const router = createRouter({
1377
+ el: '#router-outlet',
1378
+ mode: 'hash',
1379
+ routes: [{ path: '/', component: 'r-getters' }]
1380
+ });
1381
+ expect(router.current).toBeDefined();
1382
+ expect(router.path).toBeTypeOf('string');
1383
+ });
1384
+
1385
+ it('matchRoute works without DOM', () => {
1386
+ const routes = [
1387
+ { path: '/', component: 'home' },
1388
+ { path: '/users/:id', component: 'user' },
1389
+ ];
1390
+ const match = matchRoute(routes, '/users/42');
1391
+ expect(match).toBeDefined();
1392
+ expect(match.params.id).toBe('42');
1393
+ });
1394
+ });
1395
+
1396
+
1397
+ // ===========================================================================
1398
+ // 10. DOM MORPHING DOCS
1399
+ // ===========================================================================
1400
+ describe.skipIf(!SECTIONS_AVAILABLE)('Morph docs validation', () => {
1401
+
1402
+ it('morph(rootEl, newHTML) patches DOM', () => {
1403
+ const root = el('<p>old</p>');
1404
+ morph(root, '<p>new</p>');
1405
+ expect(root.querySelector('p').textContent).toBe('new');
1406
+ });
1407
+
1408
+ it('morphElement preserves identity when tag matches', () => {
1409
+ const root = el('<div class="card"><p>old</p></div>');
1410
+ const child = root.firstElementChild;
1411
+ const result = morphElement(child, '<div class="card updated"><p>new</p></div>');
1412
+ expect(result).toBe(child); // same node
1413
+ expect(child.classList.contains('updated')).toBe(true);
1414
+ });
1415
+
1416
+ it('z-key enables keyed reconciliation', () => {
1417
+ const root = el(
1418
+ '<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>'
1419
+ );
1420
+ const nodeA = root.children[0];
1421
+ const nodeC = root.children[2];
1422
+ morph(root, '<div z-key="c">C</div><div z-key="a">A</div><div z-key="b">B</div>');
1423
+ expect(root.children[0]).toBe(nodeC);
1424
+ expect(root.children[1]).toBe(nodeA);
1425
+ });
1426
+
1427
+ it('z-skip prevents morphing subtree', () => {
1428
+ const root = el('<div z-skip><span>original</span></div><p>text</p>');
1429
+ const skipChild = root.children[0].firstChild;
1430
+ morph(root, '<div z-skip><span>changed</span></div><p>updated</p>');
1431
+ expect(root.children[0].firstChild).toBe(skipChild);
1432
+ expect(root.querySelector('p').textContent).toBe('updated');
1433
+ });
1434
+ });
1435
+
1436
+
1437
+ // ===========================================================================
1438
+ // 11. SSR DOCS Basic validation
1439
+ // ===========================================================================
1440
+ describe.skipIf(!SECTIONS_AVAILABLE)('SSR docs validation', () => {
1441
+
1442
+ it('createSSRApp returns app with component/renderToString/renderPage', () => {
1443
+ const app = createSSRApp();
1444
+ expect(app.component).toBeTypeOf('function');
1445
+ expect(app.renderToString).toBeTypeOf('function');
1446
+ expect(app.renderPage).toBeTypeOf('function');
1447
+ });
1448
+
1449
+ it('standalone renderToString renders a component', async () => {
1450
+ const html = await renderToString({
1451
+ state: { name: 'World' },
1452
+ render() { return `<p>Hello {{name}}</p>`; }
1453
+ });
1454
+ expect(html).toContain('Hello');
1455
+ expect(html).toContain('World');
1456
+ });
1457
+
1458
+ it('SSR app component registration and render', async () => {
1459
+ const app = createSSRApp();
1460
+ app.component('ssr-hello', {
1461
+ state: { greeting: 'Hi' },
1462
+ render() { return `<div>{{greeting}}</div>`; }
1463
+ });
1464
+ const html = await app.renderToString('ssr-hello');
1465
+ expect(html).toContain('Hi');
1466
+ });
1467
+ });
1468
+
1469
+
1470
+ // ===========================================================================
1471
+ // 12. SECURITY DOCS Validate XSS & prototype pollution protection
1472
+ // ===========================================================================
1473
+ describe.skipIf(!SECTIONS_AVAILABLE)('Security docs validation', () => {
1474
+
1475
+ it('escapeHtml prevents XSS in template output', () => {
1476
+ const malicious = '<script>alert("xss")</script>';
1477
+ const safe = escapeHtml(malicious);
1478
+ expect(safe).not.toContain('<script>');
1479
+ expect(safe).toContain('&lt;script&gt;');
1480
+ });
1481
+
1482
+ it('html template tag auto-escapes by default', () => {
1483
+ const malicious = '<img onerror="alert(1)" src=x>';
1484
+ const result = html`<p>${malicious}</p>`;
1485
+ // The angle brackets and quotes are escaped, preventing execution
1486
+ expect(result).not.toContain('<img');
1487
+ expect(result).toContain('&lt;img');
1488
+ expect(result).toContain('&quot;');
1489
+ });
1490
+
1491
+ it('trust() explicitly opts into raw HTML', () => {
1492
+ const safe = trust('<b>bold</b>');
1493
+ const result = html`<p>${safe}</p>`;
1494
+ expect(result).toContain('<b>bold</b>');
1495
+ });
1496
+
1497
+ it('expression parser blocks prototype pollution vectors', () => {
1498
+ const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
1499
+ expect(safeEval('obj.__proto__', [{ obj: {} }])).toBeUndefined();
1500
+ expect(safeEval('obj.constructor', [{ obj: {} }])).toBeUndefined();
1501
+ expect(safeEval('obj.prototype', [{ obj: {} }])).toBeUndefined();
1502
+ spy.mockRestore();
1503
+ });
1504
+
1505
+ it('deepMerge blocks __proto__ poisoning', () => {
1506
+ const target = {};
1507
+ const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
1508
+ deepMerge(target, payload);
1509
+ expect(({}).isAdmin).toBeUndefined();
1510
+ });
1511
+
1512
+ it('setPath blocks __proto__ poisoning', () => {
1513
+ const obj = {};
1514
+ setPath(obj, '__proto__.isAdmin', true);
1515
+ expect(({}).isAdmin).toBeUndefined();
1516
+ });
1517
+
1518
+ it('setPath blocks constructor poisoning', () => {
1519
+ const obj = {};
1520
+ setPath(obj, 'constructor.polluted', true);
1521
+ expect(Object.polluted).toBeUndefined();
1522
+ });
1523
+ });
1524
+
1525
+
1526
+ // ===========================================================================
1527
+ // 13. INDEX.JS EXPORTS — Validate all documented $ namespace properties
1528
+ // ===========================================================================
1529
+ describe.skipIf(!SECTIONS_AVAILABLE)('Index.js $ namespace completeness', () => {
1530
+
1531
+ // Import the assembled $ from index.js
1532
+ let $;
1533
+ beforeEach(async () => {
1534
+ const mod = await import('../index.js');
1535
+ $ = mod.$;
1536
+ });
1537
+
1538
+ it('$ is a function', () => {
1539
+ expect($).toBeTypeOf('function');
1540
+ });
1541
+
1542
+ it('$.all is a function', () => {
1543
+ expect($.all).toBeTypeOf('function');
1544
+ });
1545
+
1546
+ it('has quick-ref shortcuts', () => {
1547
+ expect($.id).toBeTypeOf('function');
1548
+ expect($.class).toBeTypeOf('function');
1549
+ expect($.classes).toBeTypeOf('function');
1550
+ expect($.tag).toBeTypeOf('function');
1551
+ expect($.name).toBeTypeOf('function');
1552
+ expect($.children).toBeTypeOf('function');
1553
+ expect($.qs).toBeTypeOf('function');
1554
+ expect($.qsa).toBeTypeOf('function');
1555
+ });
1556
+
1557
+ it('has DOM helpers', () => {
1558
+ expect($.create).toBeTypeOf('function');
1559
+ expect($.ready).toBeTypeOf('function');
1560
+ expect($.on).toBeTypeOf('function');
1561
+ expect($.off).toBeTypeOf('function');
1562
+ expect($.fn).toBeDefined();
1563
+ });
1564
+
1565
+ it('has reactive primitives', () => {
1566
+ expect($.reactive).toBeTypeOf('function');
1567
+ expect($.Signal).toBeTypeOf('function');
1568
+ expect($.signal).toBeTypeOf('function');
1569
+ expect($.computed).toBeTypeOf('function');
1570
+ expect($.effect).toBeTypeOf('function');
1571
+ expect($.batch).toBeTypeOf('function');
1572
+ expect($.untracked).toBeTypeOf('function');
1573
+ });
1574
+
1575
+ it('has component functions', () => {
1576
+ expect($.component).toBeTypeOf('function');
1577
+ expect($.mount).toBeTypeOf('function');
1578
+ expect($.mountAll).toBeTypeOf('function');
1579
+ expect($.getInstance).toBeTypeOf('function');
1580
+ expect($.destroy).toBeTypeOf('function');
1581
+ expect($.components).toBeTypeOf('function');
1582
+ expect($.prefetch).toBeTypeOf('function');
1583
+ expect($.style).toBeTypeOf('function');
1584
+ expect($.morph).toBeTypeOf('function');
1585
+ expect($.morphElement).toBeTypeOf('function');
1586
+ expect($.safeEval).toBeTypeOf('function');
1587
+ });
1588
+
1589
+ it('has router functions', () => {
1590
+ expect($.router).toBeTypeOf('function');
1591
+ expect($.getRouter).toBeTypeOf('function');
1592
+ expect($.matchRoute).toBeTypeOf('function');
1593
+ });
1594
+
1595
+ it('has store functions', () => {
1596
+ expect($.store).toBeTypeOf('function');
1597
+ expect($.getStore).toBeTypeOf('function');
1598
+ });
1599
+
1600
+ it('has HTTP methods', () => {
1601
+ expect($.http).toBeDefined();
1602
+ expect($.get).toBeTypeOf('function');
1603
+ expect($.post).toBeTypeOf('function');
1604
+ expect($.put).toBeTypeOf('function');
1605
+ expect($.patch).toBeTypeOf('function');
1606
+ expect($.delete).toBeTypeOf('function');
1607
+ expect($.head).toBeTypeOf('function');
1608
+ });
1609
+
1610
+ it('has all utility functions', () => {
1611
+ expect($.debounce).toBeTypeOf('function');
1612
+ expect($.throttle).toBeTypeOf('function');
1613
+ expect($.pipe).toBeTypeOf('function');
1614
+ expect($.once).toBeTypeOf('function');
1615
+ expect($.sleep).toBeTypeOf('function');
1616
+ expect($.escapeHtml).toBeTypeOf('function');
1617
+ expect($.stripHtml).toBeTypeOf('function');
1618
+ expect($.html).toBeTypeOf('function');
1619
+ expect($.trust).toBeTypeOf('function');
1620
+ expect($.TrustedHTML).toBeTypeOf('function');
1621
+ expect($.uuid).toBeTypeOf('function');
1622
+ expect($.camelCase).toBeTypeOf('function');
1623
+ expect($.kebabCase).toBeTypeOf('function');
1624
+ expect($.deepClone).toBeTypeOf('function');
1625
+ expect($.deepMerge).toBeTypeOf('function');
1626
+ expect($.isEqual).toBeTypeOf('function');
1627
+ expect($.param).toBeTypeOf('function');
1628
+ expect($.parseQuery).toBeTypeOf('function');
1629
+ expect($.storage).toBeDefined();
1630
+ expect($.session).toBeDefined();
1631
+ expect($.EventBus).toBeTypeOf('function');
1632
+ expect($.bus).toBeDefined();
1633
+ expect($.range).toBeTypeOf('function');
1634
+ expect($.unique).toBeTypeOf('function');
1635
+ expect($.chunk).toBeTypeOf('function');
1636
+ expect($.groupBy).toBeTypeOf('function');
1637
+ expect($.pick).toBeTypeOf('function');
1638
+ expect($.omit).toBeTypeOf('function');
1639
+ expect($.getPath).toBeTypeOf('function');
1640
+ expect($.setPath).toBeTypeOf('function');
1641
+ expect($.isEmpty).toBeTypeOf('function');
1642
+ expect($.capitalize).toBeTypeOf('function');
1643
+ expect($.truncate).toBeTypeOf('function');
1644
+ expect($.clamp).toBeTypeOf('function');
1645
+ expect($.memoize).toBeTypeOf('function');
1646
+ expect($.retry).toBeTypeOf('function');
1647
+ expect($.timeout).toBeTypeOf('function');
1648
+ });
1649
+
1650
+ it('has error handling functions', () => {
1651
+ expect($.onError).toBeTypeOf('function');
1652
+ expect($.ZQueryError).toBeTypeOf('function');
1653
+ expect($.ErrorCode).toBeDefined();
1654
+ expect($.guardCallback).toBeTypeOf('function');
1655
+ expect($.guardAsync).toBeTypeOf('function');
1656
+ expect($.validate).toBeTypeOf('function');
1657
+ expect($.formatError).toBeTypeOf('function');
1658
+ });
1659
+
1660
+ it('has meta properties', () => {
1661
+ expect($.version).toBeTypeOf('string');
1662
+ expect($.noConflict).toBeTypeOf('function');
1663
+ });
1664
+ });