zero-query 1.0.5 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/cli/commands/build-api.js +442 -0
- package/cli/commands/build.js +33 -2
- package/cli/commands/bundle.js +174 -8
- package/cli/commands/dev/server.js +57 -3
- package/cli/scaffold/default/app/components/contacts/contacts.css +9 -9
- package/cli/scaffold/default/app/components/playground/playground.css +1 -1
- package/cli/scaffold/default/app/components/playground/playground.html +5 -5
- package/cli/scaffold/default/app/components/playground/playground.js +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +3 -3
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +4 -4
- package/cli/utils.js +16 -7
- package/dist/API.md +6603 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +387 -25
- package/dist/zquery.min.js +631 -2
- package/index.d.ts +9 -3
- package/index.js +10 -2
- package/package.json +3 -2
- package/src/component.js +243 -6
- package/src/reactive.js +4 -3
- package/src/router.js +79 -9
- package/src/store.js +49 -3
- package/tests/cli.test.js +343 -0
- package/tests/compare.test.js +486 -0
- package/tests/dev-server.test.js +489 -0
- package/tests/docs.test.js +1650 -0
- package/tests/electron-features.test.js +864 -0
- package/types/misc.d.ts +7 -7
- package/types/reactive.d.ts +1 -1
- package/types/store.d.ts +2 -1
|
@@ -0,0 +1,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
|
+
|
|
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('&<>"'');
|
|
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('<script>');
|
|
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('<script>');
|
|
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('<img');
|
|
1474
|
+
expect(result).toContain('"');
|
|
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
|
+
});
|