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,864 +1,864 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { createStore, getStore, connectStore } from '../src/store.js';
3
- import { component, mount, mountAll, getInstance, destroy, getRegistry } from '../src/component.js';
4
- import { createRouter, getRouter } from '../src/router.js';
5
-
6
-
7
- // ===========================================================================
8
- // Feature 1: Multi-Key Store Subscriptions
9
- // ===========================================================================
10
-
11
- describe('Store - multi-key subscriptions', () => {
12
- it('subscribes to multiple keys with array syntax', () => {
13
- const store = createStore('multi-key-1', {
14
- state: { files: [], isProcessing: false, operation: '' },
15
- actions: {
16
- setFiles(state, v) { state.files = v; },
17
- setProcessing(state, v) { state.isProcessing = v; },
18
- setOperation(state, v) { state.operation = v; },
19
- },
20
- });
21
- const calls = [];
22
- store.subscribe(['files', 'isProcessing'], (key, value, old) => {
23
- calls.push({ key, newVal: value, oldVal: old });
24
- });
25
-
26
- store.dispatch('setFiles', ['a.mp3']);
27
- store.dispatch('setProcessing', true);
28
- store.dispatch('setOperation', 'encode');
29
-
30
- // Should only fire for 'files' and 'isProcessing', NOT 'operation'
31
- expect(calls.length).toBe(2);
32
- expect(calls[0].key).toBe('files');
33
- expect(calls[1].key).toBe('isProcessing');
34
- expect(calls[1].newVal).toBe(true);
35
- });
36
-
37
- it('unsubscribes from multi-key subscription', () => {
38
- const store = createStore('multi-key-2', {
39
- state: { a: 0, b: 0 },
40
- actions: {
41
- setA(state, v) { state.a = v; },
42
- setB(state, v) { state.b = v; },
43
- },
44
- });
45
- const fn = vi.fn();
46
- const unsub = store.subscribe(['a', 'b'], fn);
47
-
48
- store.dispatch('setA', 1);
49
- expect(fn).toHaveBeenCalledTimes(1);
50
-
51
- unsub();
52
- store.dispatch('setB', 2);
53
- expect(fn).toHaveBeenCalledTimes(1); // still 1, unsubscribed
54
- });
55
-
56
- it('multi-key subscription does not fire for unlisted keys', () => {
57
- const store = createStore('multi-key-3', {
58
- state: { x: 0, y: 0, z: 0 },
59
- actions: {
60
- setX(state, v) { state.x = v; },
61
- setY(state, v) { state.y = v; },
62
- setZ(state, v) { state.z = v; },
63
- },
64
- });
65
- const fn = vi.fn();
66
- store.subscribe(['x'], fn);
67
-
68
- store.dispatch('setY', 1);
69
- store.dispatch('setZ', 2);
70
- expect(fn).not.toHaveBeenCalled();
71
-
72
- store.dispatch('setX', 5);
73
- expect(fn).toHaveBeenCalledWith('x', 5, 0);
74
- });
75
-
76
- it('mixed: single-key, multi-key, and wildcard subscribers all fire correctly', () => {
77
- const store = createStore('multi-key-4', {
78
- state: { a: 0, b: 0, c: 0 },
79
- actions: {
80
- setA(state, v) { state.a = v; },
81
- setB(state, v) { state.b = v; },
82
- setC(state, v) { state.c = v; },
83
- },
84
- });
85
- const singleFn = vi.fn();
86
- const multiFn = vi.fn();
87
- const wildcardFn = vi.fn();
88
-
89
- store.subscribe('a', singleFn);
90
- store.subscribe(['a', 'b'], multiFn);
91
- store.subscribe(wildcardFn);
92
-
93
- store.dispatch('setA', 10);
94
- expect(singleFn).toHaveBeenCalledTimes(1);
95
- expect(multiFn).toHaveBeenCalledTimes(1);
96
- expect(wildcardFn).toHaveBeenCalledTimes(1);
97
-
98
- store.dispatch('setC', 30);
99
- expect(singleFn).toHaveBeenCalledTimes(1);
100
- expect(multiFn).toHaveBeenCalledTimes(1);
101
- expect(wildcardFn).toHaveBeenCalledTimes(2);
102
- });
103
-
104
- it('multi-key subscription works with batch', () => {
105
- const store = createStore('multi-key-batch', {
106
- state: { a: 0, b: 0 },
107
- });
108
- const fn = vi.fn();
109
- store.subscribe(['a', 'b'], fn);
110
-
111
- store.batch(state => {
112
- state.a = 1;
113
- state.b = 2;
114
- });
115
-
116
- // Batch deduplicates per key, so each key fires once
117
- expect(fn).toHaveBeenCalledTimes(2);
118
- });
119
-
120
- it('empty array subscription never fires', () => {
121
- const store = createStore('multi-key-empty', {
122
- state: { x: 0 },
123
- actions: { setX(state, v) { state.x = v; } },
124
- });
125
- const fn = vi.fn();
126
- store.subscribe([], fn);
127
- store.dispatch('setX', 1);
128
- expect(fn).not.toHaveBeenCalled();
129
- });
130
- });
131
-
132
-
133
- // ===========================================================================
134
- // Feature 2: Reactive Component Props
135
- // ===========================================================================
136
-
137
- describe('Component - reactive props', () => {
138
- beforeEach(() => {
139
- document.body.innerHTML = '';
140
- });
141
-
142
- it('reads props from element attributes with type coercion', () => {
143
- component('prop-card', {
144
- props: {
145
- label: { type: String, default: '' },
146
- value: { type: Number, default: 0 },
147
- active: { type: Boolean, default: false },
148
- },
149
- render() {
150
- return `<div>${this.props.label}: ${this.props.value} (${this.props.active})</div>`;
151
- },
152
- });
153
- document.body.innerHTML = '<prop-card id="pc" label="Count" value="42" active="true"></prop-card>';
154
- const inst = mount('#pc', 'prop-card');
155
- expect(inst.props.label).toBe('Count');
156
- expect(inst.props.value).toBe(42);
157
- expect(inst.props.active).toBe(true);
158
- });
159
-
160
- it('uses default values when attributes are missing', () => {
161
- component('prop-defaults', {
162
- props: {
163
- label: { type: String, default: 'untitled' },
164
- count: { type: Number, default: 5 },
165
- },
166
- render() {
167
- return `<div>${this.props.label}: ${this.props.count}</div>`;
168
- },
169
- });
170
- document.body.innerHTML = '<prop-defaults id="pd"></prop-defaults>';
171
- const inst = mount('#pd', 'prop-defaults');
172
- expect(inst.props.label).toBe('untitled');
173
- expect(inst.props.count).toBe(5);
174
- });
175
-
176
- it('passed props override attributes', () => {
177
- component('prop-override', {
178
- props: {
179
- title: { type: String, default: '' },
180
- },
181
- render() {
182
- return `<h1>${this.props.title}</h1>`;
183
- },
184
- });
185
- document.body.innerHTML = '<prop-override id="po" title="from-attr"></prop-override>';
186
- const inst = mount('#po', 'prop-override', { title: 'from-mount' });
187
- expect(inst.props.title).toBe('from-mount');
188
- });
189
-
190
- it('coerces Boolean props correctly', () => {
191
- component('prop-bool', {
192
- props: {
193
- enabled: { type: Boolean, default: false },
194
- disabled: { type: Boolean, default: true },
195
- },
196
- render() { return '<div></div>'; },
197
- });
198
- document.body.innerHTML = '<prop-bool id="pb" enabled="true" disabled="false"></prop-bool>';
199
- const inst = mount('#pb', 'prop-bool');
200
- expect(inst.props.enabled).toBe(true);
201
- expect(inst.props.disabled).toBe(false);
202
- });
203
-
204
- it('coerces Object/JSON props', () => {
205
- component('prop-json', {
206
- props: {
207
- config: { type: Object, default: () => ({}) },
208
- },
209
- render() { return '<div></div>'; },
210
- });
211
- document.body.innerHTML = `<prop-json id="pj" config='{"theme":"dark"}'></prop-json>`;
212
- const inst = mount('#pj', 'prop-json');
213
- expect(inst.props.config).toEqual({ theme: 'dark' });
214
- });
215
-
216
- it('props are frozen (read-only)', () => {
217
- component('prop-frozen', {
218
- props: {
219
- name: { type: String, default: 'test' },
220
- },
221
- render() { return '<div></div>'; },
222
- });
223
- document.body.innerHTML = '<prop-frozen id="pf" name="hello"></prop-frozen>';
224
- const inst = mount('#pf', 'prop-frozen');
225
- expect(() => { inst.props.name = 'changed'; }).toThrow();
226
- });
227
-
228
- it('re-reads props when attributes change (MutationObserver)', async () => {
229
- component('prop-observe', {
230
- props: {
231
- label: { type: String, default: '' },
232
- },
233
- render() {
234
- return `<span>${this.props.label}</span>`;
235
- },
236
- });
237
- document.body.innerHTML = '<prop-observe id="pob" label="initial"></prop-observe>';
238
- const inst = mount('#pob', 'prop-observe');
239
- expect(inst.props.label).toBe('initial');
240
-
241
- // Change the attribute
242
- document.querySelector('#pob').setAttribute('label', 'updated');
243
- // MutationObserver fires asynchronously
244
- await new Promise(r => setTimeout(r, 50));
245
- expect(inst.props.label).toBe('updated');
246
- });
247
-
248
- it('supports shorthand type-only syntax', () => {
249
- component('prop-short', {
250
- props: {
251
- name: String,
252
- count: Number,
253
- },
254
- render() { return '<div></div>'; },
255
- });
256
- document.body.innerHTML = '<prop-short id="ps" name="test" count="7"></prop-short>';
257
- const inst = mount('#ps', 'prop-short');
258
- expect(inst.props.name).toBe('test');
259
- expect(inst.props.count).toBe(7);
260
- });
261
-
262
- it('legacy frozen props work when no props definition', () => {
263
- component('prop-legacy', {
264
- render() { return `<div>${this.props.title}</div>`; },
265
- });
266
- document.body.innerHTML = '<prop-legacy id="pl"></prop-legacy>';
267
- const inst = mount('#pl', 'prop-legacy', { title: 'hello' });
268
- expect(inst.props.title).toBe('hello');
269
- expect(Object.isFrozen(inst.props)).toBe(true);
270
- });
271
-
272
- it('cleans up MutationObserver on destroy', () => {
273
- component('prop-cleanup', {
274
- props: {
275
- val: { type: String, default: '' },
276
- },
277
- render() { return '<div></div>'; },
278
- });
279
- document.body.innerHTML = '<prop-cleanup id="pcl" val="x"></prop-cleanup>';
280
- const inst = mount('#pcl', 'prop-cleanup');
281
- expect(inst._propObserver).toBeDefined();
282
- inst.destroy();
283
- expect(inst._propObserver).toBeNull();
284
- });
285
- });
286
-
287
-
288
- // ===========================================================================
289
- // Feature 3: Store-Component Connector
290
- // ===========================================================================
291
-
292
- describe('connectStore - store-component connector', () => {
293
- beforeEach(() => {
294
- document.body.innerHTML = '';
295
- });
296
-
297
- it('creates a connector descriptor', () => {
298
- const store = createStore('conn-test-1', { state: { x: 1 } });
299
- const desc = connectStore(store, ['x']);
300
- expect(desc._zqConnector).toBe(true);
301
- expect(desc.store).toBe(store);
302
- expect(desc.keys).toEqual(['x']);
303
- });
304
-
305
- it('auto-syncs store state to component.stores on mount', () => {
306
- const myStore = createStore('conn-test-2', {
307
- state: { files: ['a.mp3', 'b.mp3'], mode: 'normal' },
308
- actions: { setMode(state, v) { state.mode = v; } },
309
- });
310
-
311
- component('store-comp', {
312
- stores: {
313
- app: connectStore(myStore, ['files', 'mode']),
314
- },
315
- render() {
316
- return `<div>${this.stores.app.files.length} files, ${this.stores.app.mode}</div>`;
317
- },
318
- });
319
-
320
- document.body.innerHTML = '<store-comp id="sc"></store-comp>';
321
- const inst = mount('#sc', 'store-comp');
322
-
323
- expect(inst.stores.app.files).toEqual(['a.mp3', 'b.mp3']);
324
- expect(inst.stores.app.mode).toBe('normal');
325
- });
326
-
327
- it('updates component when store state changes', async () => {
328
- const myStore = createStore('conn-test-3', {
329
- state: { count: 0 },
330
- actions: { inc(state) { state.count++; } },
331
- });
332
-
333
- component('store-reactive', {
334
- stores: {
335
- data: connectStore(myStore, ['count']),
336
- },
337
- render() {
338
- return `<span class="count">${this.stores.data.count}</span>`;
339
- },
340
- });
341
-
342
- document.body.innerHTML = '<store-reactive id="sr"></store-reactive>';
343
- const inst = mount('#sr', 'store-reactive');
344
- expect(inst.stores.data.count).toBe(0);
345
-
346
- myStore.dispatch('inc');
347
- expect(inst.stores.data.count).toBe(1);
348
-
349
- // Wait for microtask re-render
350
- await new Promise(r => queueMicrotask(r));
351
- expect(document.querySelector('.count').textContent).toBe('1');
352
- });
353
-
354
- it('cleans up subscriptions on destroy', () => {
355
- const myStore = createStore('conn-test-4', {
356
- state: { x: 0 },
357
- actions: { setX(state, v) { state.x = v; } },
358
- });
359
-
360
- component('store-cleanup', {
361
- stores: {
362
- s: connectStore(myStore, ['x']),
363
- },
364
- render() { return '<div></div>'; },
365
- });
366
-
367
- document.body.innerHTML = '<store-cleanup id="scl"></store-cleanup>';
368
- const inst = mount('#scl', 'store-cleanup');
369
-
370
- const wildcardsBefore = myStore._wildcards.size;
371
- inst.destroy();
372
- const wildcardsAfter = myStore._wildcards.size;
373
- expect(wildcardsAfter).toBe(wildcardsBefore - 1);
374
- });
375
-
376
- it('supports multiple store connections', () => {
377
- const storeA = createStore('conn-a', {
378
- state: { mode: 'edit' },
379
- });
380
- const storeB = createStore('conn-b', {
381
- state: { theme: 'dark' },
382
- });
383
-
384
- component('multi-store', {
385
- stores: {
386
- app: connectStore(storeA, ['mode']),
387
- ui: connectStore(storeB, ['theme']),
388
- },
389
- render() {
390
- return `<div>${this.stores.app.mode} - ${this.stores.ui.theme}</div>`;
391
- },
392
- });
393
-
394
- document.body.innerHTML = '<multi-store id="ms"></multi-store>';
395
- const inst = mount('#ms', 'multi-store');
396
- expect(inst.stores.app.mode).toBe('edit');
397
- expect(inst.stores.ui.theme).toBe('dark');
398
- });
399
-
400
- it('only subscribes to listed keys, not all', () => {
401
- const myStore = createStore('conn-selective', {
402
- state: { a: 0, b: 0, c: 0 },
403
- actions: { setC(state, v) { state.c = v; } },
404
- });
405
-
406
- const updateSpy = vi.fn();
407
- component('store-selective', {
408
- stores: {
409
- s: connectStore(myStore, ['a', 'b']),
410
- },
411
- updated: updateSpy,
412
- render() { return '<div></div>'; },
413
- });
414
-
415
- document.body.innerHTML = '<store-selective id="ss"></store-selective>';
416
- mount('#ss', 'store-selective');
417
-
418
- // Change a key NOT in the subscription list
419
- myStore.dispatch('setC', 99);
420
-
421
- // updated() should NOT be called for key 'c'
422
- expect(updateSpy).not.toHaveBeenCalled();
423
- });
424
- });
425
-
426
-
427
- // ===========================================================================
428
- // Feature 4: Router keepAlive
429
- // ===========================================================================
430
-
431
- describe('Router - keepAlive', () => {
432
- beforeEach(() => {
433
- document.body.innerHTML = '<div id="outlet"></div>';
434
- window.location.hash = '#/';
435
- });
436
-
437
- afterEach(() => {
438
- const router = getRouter();
439
- if (router) router.destroy();
440
- });
441
-
442
- it('caches keep-alive route components', async () => {
443
- component('player-page', {
444
- state: () => ({ playCount: 0 }),
445
- render() { return `<div class="player">${this.state.playCount}</div>`; },
446
- });
447
- component('dash-page', {
448
- render() { return '<div class="dash">Dashboard</div>'; },
449
- });
450
-
451
- const router = createRouter({
452
- el: '#outlet',
453
- mode: 'hash',
454
- routes: [
455
- { path: '/player', component: 'player-page', keepAlive: true },
456
- { path: '/', component: 'dash-page' },
457
- ],
458
- });
459
-
460
- // Navigate to player
461
- router.navigate('/player');
462
- await new Promise(r => setTimeout(r, 50));
463
- expect(document.querySelector('.player')).not.toBeNull();
464
-
465
- // Mutate player state
466
- const playerInst = router._instance;
467
- playerInst.state.playCount = 5;
468
-
469
- // Navigate away
470
- router.navigate('/');
471
- await new Promise(r => setTimeout(r, 50));
472
-
473
- // Navigate back to player - should reuse cached instance
474
- router.navigate('/player');
475
- await new Promise(r => setTimeout(r, 50));
476
- expect(router._instance).toBe(playerInst);
477
- expect(router._instance.state.playCount).toBe(5); // state preserved
478
- });
479
-
480
- it('calls activated/deactivated lifecycle hooks', async () => {
481
- const activated = vi.fn();
482
- const deactivated = vi.fn();
483
-
484
- component('ka-comp', {
485
- activated,
486
- deactivated,
487
- render() { return '<div>keepAlive comp</div>'; },
488
- });
489
- component('other-comp', {
490
- render() { return '<div>other</div>'; },
491
- });
492
-
493
- const router = createRouter({
494
- el: '#outlet',
495
- mode: 'hash',
496
- routes: [
497
- { path: '/ka', component: 'ka-comp', keepAlive: true },
498
- { path: '/', component: 'other-comp' },
499
- ],
500
- });
501
-
502
- // First visit - activated on mount
503
- router.navigate('/ka');
504
- await new Promise(r => setTimeout(r, 50));
505
- expect(activated).toHaveBeenCalledTimes(1);
506
-
507
- // Navigate away - deactivated
508
- router.navigate('/');
509
- await new Promise(r => setTimeout(r, 50));
510
- expect(deactivated).toHaveBeenCalledTimes(1);
511
-
512
- // Navigate back - activated again
513
- router.navigate('/ka');
514
- await new Promise(r => setTimeout(r, 50));
515
- expect(activated).toHaveBeenCalledTimes(2);
516
- });
517
-
518
- it('non-keepAlive routes destroy normally', async () => {
519
- const destroyFn = vi.fn();
520
- component('normal-page', {
521
- destroyed: destroyFn,
522
- render() { return '<div>normal</div>'; },
523
- });
524
- component('ka-page', {
525
- render() { return '<div>ka</div>'; },
526
- });
527
-
528
- const router = createRouter({
529
- el: '#outlet',
530
- mode: 'hash',
531
- routes: [
532
- { path: '/a', component: 'normal-page' },
533
- { path: '/b', component: 'ka-page', keepAlive: true },
534
- ],
535
- });
536
-
537
- router.navigate('/a');
538
- await new Promise(r => setTimeout(r, 50));
539
-
540
- router.navigate('/b');
541
- await new Promise(r => setTimeout(r, 50));
542
- expect(destroyFn).toHaveBeenCalledTimes(1);
543
- });
544
-
545
- it('hides keep-alive container with display:none on deactivation', async () => {
546
- component('ka-hide', {
547
- render() { return '<div>hidden-test</div>'; },
548
- });
549
- component('other-hide', {
550
- render() { return '<div>other</div>'; },
551
- });
552
-
553
- const router = createRouter({
554
- el: '#outlet',
555
- mode: 'hash',
556
- routes: [
557
- { path: '/ka', component: 'ka-hide', keepAlive: true },
558
- { path: '/', component: 'other-hide' },
559
- ],
560
- });
561
-
562
- router.navigate('/ka');
563
- await new Promise(r => setTimeout(r, 50));
564
- const kaContainer = document.querySelector('ka-hide');
565
- expect(kaContainer).not.toBeNull();
566
-
567
- router.navigate('/');
568
- await new Promise(r => setTimeout(r, 50));
569
- // The ka container should still be in the DOM but hidden
570
- const hiddenKa = document.querySelector('ka-hide');
571
- expect(hiddenKa).not.toBeNull();
572
- expect(hiddenKa.style.display).toBe('none');
573
- });
574
- });
575
-
576
-
577
- // ===========================================================================
578
- // Feature 5: Transition Directives
579
- // ===========================================================================
580
-
581
- describe('Component - transitions', () => {
582
- beforeEach(() => {
583
- document.body.innerHTML = '';
584
- });
585
-
586
- it('adds enter transition classes on z-if=true with z-transition', () => {
587
- component('trans-if', {
588
- state: () => ({ show: true }),
589
- render() {
590
- return `<div z-if="show" z-transition="fade">Content</div>`;
591
- },
592
- });
593
- document.body.innerHTML = '<trans-if id="ti"></trans-if>';
594
- const inst = mount('#ti', 'trans-if');
595
- const el = document.querySelector('#ti div');
596
- // On enter, should have enter-active and enter-from/enter-to classes
597
- expect(el.classList.contains('fade-enter-active') || el.classList.contains('fade-enter-from')).toBe(true);
598
- });
599
-
600
- it('component-level transition config applies enter class', () => {
601
- component('trans-cfg', {
602
- state: () => ({ show: true }),
603
- transition: {
604
- enter: 'animate-fade-in',
605
- leave: 'animate-fade-out',
606
- duration: 10,
607
- },
608
- render() {
609
- return `<div z-if="show" z-transition="custom">Content</div>`;
610
- },
611
- });
612
- document.body.innerHTML = '<trans-cfg id="tc"></trans-cfg>';
613
- mount('#tc', 'trans-cfg');
614
- // Component-level transition should apply enter class
615
- const el = document.querySelector('#tc div');
616
- expect(el).not.toBeNull();
617
- expect(el.classList.contains('animate-fade-in')).toBe(true);
618
- });
619
-
620
- it('z-show with z-transition sets enter classes on visible elements', () => {
621
- component('trans-show', {
622
- state: () => ({ visible: true }),
623
- render() {
624
- return `<div z-show="visible" z-transition="slide" data-zq-hidden="">Content</div>`;
625
- },
626
- });
627
- document.body.innerHTML = '<trans-show id="ts"></trans-show>';
628
- mount('#ts', 'trans-show');
629
- const el = document.querySelector('#ts div');
630
- expect(el).not.toBeNull();
631
- // Should be visible (not hidden)
632
- expect(el.style.display).not.toBe('none');
633
- });
634
- });
635
-
636
-
637
- // ===========================================================================
638
- // Feature 6: Component Scoped Events (emit/on)
639
- // ===========================================================================
640
-
641
- describe('Component - scoped events (emit/on)', () => {
642
- beforeEach(() => {
643
- document.body.innerHTML = '';
644
- });
645
-
646
- it('emit() dispatches CustomEvent that bubbles', () => {
647
- component('emit-child', {
648
- render() { return '<button>click</button>'; },
649
- doEmit() {
650
- this.emit('item-selected', { id: 42 });
651
- },
652
- });
653
-
654
- document.body.innerHTML = '<emit-child id="ec"></emit-child>';
655
- const inst = mount('#ec', 'emit-child');
656
-
657
- const handler = vi.fn();
658
- document.body.addEventListener('item-selected', handler);
659
-
660
- inst.doEmit();
661
- expect(handler).toHaveBeenCalledTimes(1);
662
- expect(handler.mock.calls[0][0].detail).toEqual({ id: 42 });
663
-
664
- document.body.removeEventListener('item-selected', handler);
665
- });
666
-
667
- it('emit() event is cancelable', () => {
668
- component('emit-cancel', {
669
- render() { return '<div></div>'; },
670
- fire() { return this.emit('my-event', { x: 1 }); },
671
- });
672
-
673
- document.body.innerHTML = '<emit-cancel id="ecn"></emit-cancel>';
674
- const inst = mount('#ecn', 'emit-cancel');
675
-
676
- let prevented = false;
677
- document.querySelector('#ecn').addEventListener('my-event', (e) => {
678
- e.preventDefault();
679
- prevented = true;
680
- });
681
-
682
- inst.fire();
683
- expect(prevented).toBe(true);
684
- });
685
-
686
- it('parent @event binding catches child emit', async () => {
687
- component('child-emitter', {
688
- render() { return '<button @click="sendEvent">Go</button>'; },
689
- sendEvent() {
690
- this.emit('done', { result: 'ok' });
691
- },
692
- });
693
-
694
- component('parent-listener', {
695
- state: () => ({ received: '' }),
696
- render() {
697
- return `<child-emitter @done="onDone"></child-emitter><span>${this.state.received}</span>`;
698
- },
699
- onDone(e) {
700
- this.state.received = e.detail.result;
701
- },
702
- });
703
-
704
- document.body.innerHTML = '<parent-listener id="pl"></parent-listener>';
705
- const parentInst = mount('#pl', 'parent-listener');
706
-
707
- // Get the child instance and trigger emit
708
- const childEl = document.querySelector('child-emitter');
709
- const childInst = getInstance(childEl);
710
- childInst.sendEvent();
711
-
712
- // Wait for re-render
713
- await new Promise(r => queueMicrotask(r));
714
- expect(parentInst.state.received).toBe('ok');
715
- });
716
- });
717
-
718
-
719
- // ===========================================================================
720
- // Feature 7: Electron Environment Detection
721
- // ===========================================================================
722
-
723
- describe('Electron environment detection', () => {
724
- it('$.isElectron is false in jsdom/browser', async () => {
725
- // Dynamic import to test the evaluated values
726
- const mod = await import('../index.js');
727
- const $ = mod.default;
728
- expect($.isElectron).toBe(false);
729
- });
730
-
731
- it('$.platform is "browser" in jsdom', async () => {
732
- const mod = await import('../index.js');
733
- const $ = mod.default;
734
- expect($.platform).toBe('browser');
735
- });
736
-
737
- it('$.isElectron detects Electron via navigator.userAgent', () => {
738
- // Simulate Electron user agent
739
- const original = navigator.userAgent;
740
- Object.defineProperty(navigator, 'userAgent', {
741
- value: 'Mozilla/5.0 Electron/25.0.0',
742
- configurable: true,
743
- });
744
-
745
- // Re-evaluate the detection logic
746
- const isElectron = /Electron/i.test(navigator.userAgent);
747
- expect(isElectron).toBe(true);
748
-
749
- // Restore
750
- Object.defineProperty(navigator, 'userAgent', {
751
- value: original,
752
- configurable: true,
753
- });
754
- });
755
-
756
- it('$.isElectron detects Electron via process.versions.electron', () => {
757
- // Simulate Electron process object
758
- const origProcess = globalThis.process;
759
- globalThis.process = { versions: { electron: '25.0.0', node: '18.0.0' } };
760
-
761
- const isElectron = typeof process !== 'undefined' && process.versions != null && !!process.versions.electron;
762
- expect(isElectron).toBe(true);
763
-
764
- globalThis.process = origProcess;
765
- });
766
-
767
- it('platform resolves correctly for different environments', () => {
768
- // browser: window exists, not electron
769
- const platformBrowser = (false) ? 'electron' : (typeof window !== 'undefined') ? 'browser' : 'node';
770
- expect(platformBrowser).toBe('browser');
771
- });
772
- });
773
-
774
-
775
- // ===========================================================================
776
- // Feature: connectStore is exported
777
- // ===========================================================================
778
-
779
- describe('connectStore export', () => {
780
- it('is importable from store module', () => {
781
- expect(typeof connectStore).toBe('function');
782
- });
783
-
784
- it('is available on $ namespace', async () => {
785
- const mod = await import('../index.js');
786
- const $ = mod.default;
787
- expect(typeof $.connectStore).toBe('function');
788
- });
789
- });
790
-
791
-
792
- // ===========================================================================
793
- // Integration: Full component with props + stores + emit
794
- // ===========================================================================
795
-
796
- describe('Integration - props + stores + emit', () => {
797
- beforeEach(() => {
798
- document.body.innerHTML = '';
799
- });
800
-
801
- it('component with reactive props and store connector renders correctly', async () => {
802
- const appStore = createStore('integ-store', {
803
- state: { theme: 'dark', count: 0 },
804
- actions: {
805
- setTheme(state, v) { state.theme = v; },
806
- inc(state) { state.count++; },
807
- },
808
- });
809
-
810
- component('integ-card', {
811
- props: {
812
- title: { type: String, default: 'Card' },
813
- size: { type: Number, default: 1 },
814
- },
815
- stores: {
816
- app: connectStore(appStore, ['theme', 'count']),
817
- },
818
- render() {
819
- return `<div class="card ${this.stores.app.theme}">
820
- <h2>${this.props.title} (${this.props.size})</h2>
821
- <span class="count">${this.stores.app.count}</span>
822
- </div>`;
823
- },
824
- });
825
-
826
- document.body.innerHTML = '<integ-card id="ic" title="Stats" size="3"></integ-card>';
827
- const inst = mount('#ic', 'integ-card');
828
-
829
- expect(inst.props.title).toBe('Stats');
830
- expect(inst.props.size).toBe(3);
831
- expect(inst.stores.app.theme).toBe('dark');
832
- expect(inst.stores.app.count).toBe(0);
833
-
834
- // Dispatch store action
835
- appStore.dispatch('inc');
836
- expect(inst.stores.app.count).toBe(1);
837
-
838
- // Wait for re-render
839
- await new Promise(r => queueMicrotask(r));
840
- expect(document.querySelector('.count').textContent).toBe('1');
841
- });
842
-
843
- it('emitting from connected component works', () => {
844
- const store = createStore('integ-emit', { state: { x: 0 } });
845
-
846
- component('emit-connected', {
847
- stores: {
848
- s: connectStore(store, ['x']),
849
- },
850
- render() { return '<div></div>'; },
851
- notify() { this.emit('status-change', { x: this.stores.s.x }); },
852
- });
853
-
854
- document.body.innerHTML = '<emit-connected id="emc"></emit-connected>';
855
- const inst = mount('#emc', 'emit-connected');
856
-
857
- const handler = vi.fn();
858
- document.querySelector('#emc').addEventListener('status-change', handler);
859
-
860
- inst.notify();
861
- expect(handler).toHaveBeenCalledTimes(1);
862
- expect(handler.mock.calls[0][0].detail).toEqual({ x: 0 });
863
- });
864
- });
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createStore, getStore, connectStore } from '../src/store.js';
3
+ import { component, mount, mountAll, getInstance, destroy, getRegistry } from '../src/component.js';
4
+ import { createRouter, getRouter } from '../src/router.js';
5
+
6
+
7
+ // ===========================================================================
8
+ // Feature 1: Multi-Key Store Subscriptions
9
+ // ===========================================================================
10
+
11
+ describe('Store - multi-key subscriptions', () => {
12
+ it('subscribes to multiple keys with array syntax', () => {
13
+ const store = createStore('multi-key-1', {
14
+ state: { files: [], isProcessing: false, operation: '' },
15
+ actions: {
16
+ setFiles(state, v) { state.files = v; },
17
+ setProcessing(state, v) { state.isProcessing = v; },
18
+ setOperation(state, v) { state.operation = v; },
19
+ },
20
+ });
21
+ const calls = [];
22
+ store.subscribe(['files', 'isProcessing'], (key, value, old) => {
23
+ calls.push({ key, newVal: value, oldVal: old });
24
+ });
25
+
26
+ store.dispatch('setFiles', ['a.mp3']);
27
+ store.dispatch('setProcessing', true);
28
+ store.dispatch('setOperation', 'encode');
29
+
30
+ // Should only fire for 'files' and 'isProcessing', NOT 'operation'
31
+ expect(calls.length).toBe(2);
32
+ expect(calls[0].key).toBe('files');
33
+ expect(calls[1].key).toBe('isProcessing');
34
+ expect(calls[1].newVal).toBe(true);
35
+ });
36
+
37
+ it('unsubscribes from multi-key subscription', () => {
38
+ const store = createStore('multi-key-2', {
39
+ state: { a: 0, b: 0 },
40
+ actions: {
41
+ setA(state, v) { state.a = v; },
42
+ setB(state, v) { state.b = v; },
43
+ },
44
+ });
45
+ const fn = vi.fn();
46
+ const unsub = store.subscribe(['a', 'b'], fn);
47
+
48
+ store.dispatch('setA', 1);
49
+ expect(fn).toHaveBeenCalledTimes(1);
50
+
51
+ unsub();
52
+ store.dispatch('setB', 2);
53
+ expect(fn).toHaveBeenCalledTimes(1); // still 1, unsubscribed
54
+ });
55
+
56
+ it('multi-key subscription does not fire for unlisted keys', () => {
57
+ const store = createStore('multi-key-3', {
58
+ state: { x: 0, y: 0, z: 0 },
59
+ actions: {
60
+ setX(state, v) { state.x = v; },
61
+ setY(state, v) { state.y = v; },
62
+ setZ(state, v) { state.z = v; },
63
+ },
64
+ });
65
+ const fn = vi.fn();
66
+ store.subscribe(['x'], fn);
67
+
68
+ store.dispatch('setY', 1);
69
+ store.dispatch('setZ', 2);
70
+ expect(fn).not.toHaveBeenCalled();
71
+
72
+ store.dispatch('setX', 5);
73
+ expect(fn).toHaveBeenCalledWith('x', 5, 0);
74
+ });
75
+
76
+ it('mixed: single-key, multi-key, and wildcard subscribers all fire correctly', () => {
77
+ const store = createStore('multi-key-4', {
78
+ state: { a: 0, b: 0, c: 0 },
79
+ actions: {
80
+ setA(state, v) { state.a = v; },
81
+ setB(state, v) { state.b = v; },
82
+ setC(state, v) { state.c = v; },
83
+ },
84
+ });
85
+ const singleFn = vi.fn();
86
+ const multiFn = vi.fn();
87
+ const wildcardFn = vi.fn();
88
+
89
+ store.subscribe('a', singleFn);
90
+ store.subscribe(['a', 'b'], multiFn);
91
+ store.subscribe(wildcardFn);
92
+
93
+ store.dispatch('setA', 10);
94
+ expect(singleFn).toHaveBeenCalledTimes(1);
95
+ expect(multiFn).toHaveBeenCalledTimes(1);
96
+ expect(wildcardFn).toHaveBeenCalledTimes(1);
97
+
98
+ store.dispatch('setC', 30);
99
+ expect(singleFn).toHaveBeenCalledTimes(1);
100
+ expect(multiFn).toHaveBeenCalledTimes(1);
101
+ expect(wildcardFn).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it('multi-key subscription works with batch', () => {
105
+ const store = createStore('multi-key-batch', {
106
+ state: { a: 0, b: 0 },
107
+ });
108
+ const fn = vi.fn();
109
+ store.subscribe(['a', 'b'], fn);
110
+
111
+ store.batch(state => {
112
+ state.a = 1;
113
+ state.b = 2;
114
+ });
115
+
116
+ // Batch deduplicates per key, so each key fires once
117
+ expect(fn).toHaveBeenCalledTimes(2);
118
+ });
119
+
120
+ it('empty array subscription never fires', () => {
121
+ const store = createStore('multi-key-empty', {
122
+ state: { x: 0 },
123
+ actions: { setX(state, v) { state.x = v; } },
124
+ });
125
+ const fn = vi.fn();
126
+ store.subscribe([], fn);
127
+ store.dispatch('setX', 1);
128
+ expect(fn).not.toHaveBeenCalled();
129
+ });
130
+ });
131
+
132
+
133
+ // ===========================================================================
134
+ // Feature 2: Reactive Component Props
135
+ // ===========================================================================
136
+
137
+ describe('Component - reactive props', () => {
138
+ beforeEach(() => {
139
+ document.body.innerHTML = '';
140
+ });
141
+
142
+ it('reads props from element attributes with type coercion', () => {
143
+ component('prop-card', {
144
+ props: {
145
+ label: { type: String, default: '' },
146
+ value: { type: Number, default: 0 },
147
+ active: { type: Boolean, default: false },
148
+ },
149
+ render() {
150
+ return `<div>${this.props.label}: ${this.props.value} (${this.props.active})</div>`;
151
+ },
152
+ });
153
+ document.body.innerHTML = '<prop-card id="pc" label="Count" value="42" active="true"></prop-card>';
154
+ const inst = mount('#pc', 'prop-card');
155
+ expect(inst.props.label).toBe('Count');
156
+ expect(inst.props.value).toBe(42);
157
+ expect(inst.props.active).toBe(true);
158
+ });
159
+
160
+ it('uses default values when attributes are missing', () => {
161
+ component('prop-defaults', {
162
+ props: {
163
+ label: { type: String, default: 'untitled' },
164
+ count: { type: Number, default: 5 },
165
+ },
166
+ render() {
167
+ return `<div>${this.props.label}: ${this.props.count}</div>`;
168
+ },
169
+ });
170
+ document.body.innerHTML = '<prop-defaults id="pd"></prop-defaults>';
171
+ const inst = mount('#pd', 'prop-defaults');
172
+ expect(inst.props.label).toBe('untitled');
173
+ expect(inst.props.count).toBe(5);
174
+ });
175
+
176
+ it('passed props override attributes', () => {
177
+ component('prop-override', {
178
+ props: {
179
+ title: { type: String, default: '' },
180
+ },
181
+ render() {
182
+ return `<h1>${this.props.title}</h1>`;
183
+ },
184
+ });
185
+ document.body.innerHTML = '<prop-override id="po" title="from-attr"></prop-override>';
186
+ const inst = mount('#po', 'prop-override', { title: 'from-mount' });
187
+ expect(inst.props.title).toBe('from-mount');
188
+ });
189
+
190
+ it('coerces Boolean props correctly', () => {
191
+ component('prop-bool', {
192
+ props: {
193
+ enabled: { type: Boolean, default: false },
194
+ disabled: { type: Boolean, default: true },
195
+ },
196
+ render() { return '<div></div>'; },
197
+ });
198
+ document.body.innerHTML = '<prop-bool id="pb" enabled="true" disabled="false"></prop-bool>';
199
+ const inst = mount('#pb', 'prop-bool');
200
+ expect(inst.props.enabled).toBe(true);
201
+ expect(inst.props.disabled).toBe(false);
202
+ });
203
+
204
+ it('coerces Object/JSON props', () => {
205
+ component('prop-json', {
206
+ props: {
207
+ config: { type: Object, default: () => ({}) },
208
+ },
209
+ render() { return '<div></div>'; },
210
+ });
211
+ document.body.innerHTML = `<prop-json id="pj" config='{"theme":"dark"}'></prop-json>`;
212
+ const inst = mount('#pj', 'prop-json');
213
+ expect(inst.props.config).toEqual({ theme: 'dark' });
214
+ });
215
+
216
+ it('props are frozen (read-only)', () => {
217
+ component('prop-frozen', {
218
+ props: {
219
+ name: { type: String, default: 'test' },
220
+ },
221
+ render() { return '<div></div>'; },
222
+ });
223
+ document.body.innerHTML = '<prop-frozen id="pf" name="hello"></prop-frozen>';
224
+ const inst = mount('#pf', 'prop-frozen');
225
+ expect(() => { inst.props.name = 'changed'; }).toThrow();
226
+ });
227
+
228
+ it('re-reads props when attributes change (MutationObserver)', async () => {
229
+ component('prop-observe', {
230
+ props: {
231
+ label: { type: String, default: '' },
232
+ },
233
+ render() {
234
+ return `<span>${this.props.label}</span>`;
235
+ },
236
+ });
237
+ document.body.innerHTML = '<prop-observe id="pob" label="initial"></prop-observe>';
238
+ const inst = mount('#pob', 'prop-observe');
239
+ expect(inst.props.label).toBe('initial');
240
+
241
+ // Change the attribute
242
+ document.querySelector('#pob').setAttribute('label', 'updated');
243
+ // MutationObserver fires asynchronously
244
+ await new Promise(r => setTimeout(r, 50));
245
+ expect(inst.props.label).toBe('updated');
246
+ });
247
+
248
+ it('supports shorthand type-only syntax', () => {
249
+ component('prop-short', {
250
+ props: {
251
+ name: String,
252
+ count: Number,
253
+ },
254
+ render() { return '<div></div>'; },
255
+ });
256
+ document.body.innerHTML = '<prop-short id="ps" name="test" count="7"></prop-short>';
257
+ const inst = mount('#ps', 'prop-short');
258
+ expect(inst.props.name).toBe('test');
259
+ expect(inst.props.count).toBe(7);
260
+ });
261
+
262
+ it('legacy frozen props work when no props definition', () => {
263
+ component('prop-legacy', {
264
+ render() { return `<div>${this.props.title}</div>`; },
265
+ });
266
+ document.body.innerHTML = '<prop-legacy id="pl"></prop-legacy>';
267
+ const inst = mount('#pl', 'prop-legacy', { title: 'hello' });
268
+ expect(inst.props.title).toBe('hello');
269
+ expect(Object.isFrozen(inst.props)).toBe(true);
270
+ });
271
+
272
+ it('cleans up MutationObserver on destroy', () => {
273
+ component('prop-cleanup', {
274
+ props: {
275
+ val: { type: String, default: '' },
276
+ },
277
+ render() { return '<div></div>'; },
278
+ });
279
+ document.body.innerHTML = '<prop-cleanup id="pcl" val="x"></prop-cleanup>';
280
+ const inst = mount('#pcl', 'prop-cleanup');
281
+ expect(inst._propObserver).toBeDefined();
282
+ inst.destroy();
283
+ expect(inst._propObserver).toBeNull();
284
+ });
285
+ });
286
+
287
+
288
+ // ===========================================================================
289
+ // Feature 3: Store-Component Connector
290
+ // ===========================================================================
291
+
292
+ describe('connectStore - store-component connector', () => {
293
+ beforeEach(() => {
294
+ document.body.innerHTML = '';
295
+ });
296
+
297
+ it('creates a connector descriptor', () => {
298
+ const store = createStore('conn-test-1', { state: { x: 1 } });
299
+ const desc = connectStore(store, ['x']);
300
+ expect(desc._zqConnector).toBe(true);
301
+ expect(desc.store).toBe(store);
302
+ expect(desc.keys).toEqual(['x']);
303
+ });
304
+
305
+ it('auto-syncs store state to component.stores on mount', () => {
306
+ const myStore = createStore('conn-test-2', {
307
+ state: { files: ['a.mp3', 'b.mp3'], mode: 'normal' },
308
+ actions: { setMode(state, v) { state.mode = v; } },
309
+ });
310
+
311
+ component('store-comp', {
312
+ stores: {
313
+ app: connectStore(myStore, ['files', 'mode']),
314
+ },
315
+ render() {
316
+ return `<div>${this.stores.app.files.length} files, ${this.stores.app.mode}</div>`;
317
+ },
318
+ });
319
+
320
+ document.body.innerHTML = '<store-comp id="sc"></store-comp>';
321
+ const inst = mount('#sc', 'store-comp');
322
+
323
+ expect(inst.stores.app.files).toEqual(['a.mp3', 'b.mp3']);
324
+ expect(inst.stores.app.mode).toBe('normal');
325
+ });
326
+
327
+ it('updates component when store state changes', async () => {
328
+ const myStore = createStore('conn-test-3', {
329
+ state: { count: 0 },
330
+ actions: { inc(state) { state.count++; } },
331
+ });
332
+
333
+ component('store-reactive', {
334
+ stores: {
335
+ data: connectStore(myStore, ['count']),
336
+ },
337
+ render() {
338
+ return `<span class="count">${this.stores.data.count}</span>`;
339
+ },
340
+ });
341
+
342
+ document.body.innerHTML = '<store-reactive id="sr"></store-reactive>';
343
+ const inst = mount('#sr', 'store-reactive');
344
+ expect(inst.stores.data.count).toBe(0);
345
+
346
+ myStore.dispatch('inc');
347
+ expect(inst.stores.data.count).toBe(1);
348
+
349
+ // Wait for microtask re-render
350
+ await new Promise(r => queueMicrotask(r));
351
+ expect(document.querySelector('.count').textContent).toBe('1');
352
+ });
353
+
354
+ it('cleans up subscriptions on destroy', () => {
355
+ const myStore = createStore('conn-test-4', {
356
+ state: { x: 0 },
357
+ actions: { setX(state, v) { state.x = v; } },
358
+ });
359
+
360
+ component('store-cleanup', {
361
+ stores: {
362
+ s: connectStore(myStore, ['x']),
363
+ },
364
+ render() { return '<div></div>'; },
365
+ });
366
+
367
+ document.body.innerHTML = '<store-cleanup id="scl"></store-cleanup>';
368
+ const inst = mount('#scl', 'store-cleanup');
369
+
370
+ const wildcardsBefore = myStore._wildcards.size;
371
+ inst.destroy();
372
+ const wildcardsAfter = myStore._wildcards.size;
373
+ expect(wildcardsAfter).toBe(wildcardsBefore - 1);
374
+ });
375
+
376
+ it('supports multiple store connections', () => {
377
+ const storeA = createStore('conn-a', {
378
+ state: { mode: 'edit' },
379
+ });
380
+ const storeB = createStore('conn-b', {
381
+ state: { theme: 'dark' },
382
+ });
383
+
384
+ component('multi-store', {
385
+ stores: {
386
+ app: connectStore(storeA, ['mode']),
387
+ ui: connectStore(storeB, ['theme']),
388
+ },
389
+ render() {
390
+ return `<div>${this.stores.app.mode} - ${this.stores.ui.theme}</div>`;
391
+ },
392
+ });
393
+
394
+ document.body.innerHTML = '<multi-store id="ms"></multi-store>';
395
+ const inst = mount('#ms', 'multi-store');
396
+ expect(inst.stores.app.mode).toBe('edit');
397
+ expect(inst.stores.ui.theme).toBe('dark');
398
+ });
399
+
400
+ it('only subscribes to listed keys, not all', () => {
401
+ const myStore = createStore('conn-selective', {
402
+ state: { a: 0, b: 0, c: 0 },
403
+ actions: { setC(state, v) { state.c = v; } },
404
+ });
405
+
406
+ const updateSpy = vi.fn();
407
+ component('store-selective', {
408
+ stores: {
409
+ s: connectStore(myStore, ['a', 'b']),
410
+ },
411
+ updated: updateSpy,
412
+ render() { return '<div></div>'; },
413
+ });
414
+
415
+ document.body.innerHTML = '<store-selective id="ss"></store-selective>';
416
+ mount('#ss', 'store-selective');
417
+
418
+ // Change a key NOT in the subscription list
419
+ myStore.dispatch('setC', 99);
420
+
421
+ // updated() should NOT be called for key 'c'
422
+ expect(updateSpy).not.toHaveBeenCalled();
423
+ });
424
+ });
425
+
426
+
427
+ // ===========================================================================
428
+ // Feature 4: Router keepAlive
429
+ // ===========================================================================
430
+
431
+ describe('Router - keepAlive', () => {
432
+ beforeEach(() => {
433
+ document.body.innerHTML = '<div id="outlet"></div>';
434
+ window.location.hash = '#/';
435
+ });
436
+
437
+ afterEach(() => {
438
+ const router = getRouter();
439
+ if (router) router.destroy();
440
+ });
441
+
442
+ it('caches keep-alive route components', async () => {
443
+ component('player-page', {
444
+ state: () => ({ playCount: 0 }),
445
+ render() { return `<div class="player">${this.state.playCount}</div>`; },
446
+ });
447
+ component('dash-page', {
448
+ render() { return '<div class="dash">Dashboard</div>'; },
449
+ });
450
+
451
+ const router = createRouter({
452
+ el: '#outlet',
453
+ mode: 'hash',
454
+ routes: [
455
+ { path: '/player', component: 'player-page', keepAlive: true },
456
+ { path: '/', component: 'dash-page' },
457
+ ],
458
+ });
459
+
460
+ // Navigate to player
461
+ router.navigate('/player');
462
+ await new Promise(r => setTimeout(r, 50));
463
+ expect(document.querySelector('.player')).not.toBeNull();
464
+
465
+ // Mutate player state
466
+ const playerInst = router._instance;
467
+ playerInst.state.playCount = 5;
468
+
469
+ // Navigate away
470
+ router.navigate('/');
471
+ await new Promise(r => setTimeout(r, 50));
472
+
473
+ // Navigate back to player - should reuse cached instance
474
+ router.navigate('/player');
475
+ await new Promise(r => setTimeout(r, 50));
476
+ expect(router._instance).toBe(playerInst);
477
+ expect(router._instance.state.playCount).toBe(5); // state preserved
478
+ });
479
+
480
+ it('calls activated/deactivated lifecycle hooks', async () => {
481
+ const activated = vi.fn();
482
+ const deactivated = vi.fn();
483
+
484
+ component('ka-comp', {
485
+ activated,
486
+ deactivated,
487
+ render() { return '<div>keepAlive comp</div>'; },
488
+ });
489
+ component('other-comp', {
490
+ render() { return '<div>other</div>'; },
491
+ });
492
+
493
+ const router = createRouter({
494
+ el: '#outlet',
495
+ mode: 'hash',
496
+ routes: [
497
+ { path: '/ka', component: 'ka-comp', keepAlive: true },
498
+ { path: '/', component: 'other-comp' },
499
+ ],
500
+ });
501
+
502
+ // First visit - activated on mount
503
+ router.navigate('/ka');
504
+ await new Promise(r => setTimeout(r, 50));
505
+ expect(activated).toHaveBeenCalledTimes(1);
506
+
507
+ // Navigate away - deactivated
508
+ router.navigate('/');
509
+ await new Promise(r => setTimeout(r, 50));
510
+ expect(deactivated).toHaveBeenCalledTimes(1);
511
+
512
+ // Navigate back - activated again
513
+ router.navigate('/ka');
514
+ await new Promise(r => setTimeout(r, 50));
515
+ expect(activated).toHaveBeenCalledTimes(2);
516
+ });
517
+
518
+ it('non-keepAlive routes destroy normally', async () => {
519
+ const destroyFn = vi.fn();
520
+ component('normal-page', {
521
+ destroyed: destroyFn,
522
+ render() { return '<div>normal</div>'; },
523
+ });
524
+ component('ka-page', {
525
+ render() { return '<div>ka</div>'; },
526
+ });
527
+
528
+ const router = createRouter({
529
+ el: '#outlet',
530
+ mode: 'hash',
531
+ routes: [
532
+ { path: '/a', component: 'normal-page' },
533
+ { path: '/b', component: 'ka-page', keepAlive: true },
534
+ ],
535
+ });
536
+
537
+ router.navigate('/a');
538
+ await new Promise(r => setTimeout(r, 50));
539
+
540
+ router.navigate('/b');
541
+ await new Promise(r => setTimeout(r, 50));
542
+ expect(destroyFn).toHaveBeenCalledTimes(1);
543
+ });
544
+
545
+ it('hides keep-alive container with display:none on deactivation', async () => {
546
+ component('ka-hide', {
547
+ render() { return '<div>hidden-test</div>'; },
548
+ });
549
+ component('other-hide', {
550
+ render() { return '<div>other</div>'; },
551
+ });
552
+
553
+ const router = createRouter({
554
+ el: '#outlet',
555
+ mode: 'hash',
556
+ routes: [
557
+ { path: '/ka', component: 'ka-hide', keepAlive: true },
558
+ { path: '/', component: 'other-hide' },
559
+ ],
560
+ });
561
+
562
+ router.navigate('/ka');
563
+ await new Promise(r => setTimeout(r, 50));
564
+ const kaContainer = document.querySelector('ka-hide');
565
+ expect(kaContainer).not.toBeNull();
566
+
567
+ router.navigate('/');
568
+ await new Promise(r => setTimeout(r, 50));
569
+ // The ka container should still be in the DOM but hidden
570
+ const hiddenKa = document.querySelector('ka-hide');
571
+ expect(hiddenKa).not.toBeNull();
572
+ expect(hiddenKa.style.display).toBe('none');
573
+ });
574
+ });
575
+
576
+
577
+ // ===========================================================================
578
+ // Feature 5: Transition Directives
579
+ // ===========================================================================
580
+
581
+ describe('Component - transitions', () => {
582
+ beforeEach(() => {
583
+ document.body.innerHTML = '';
584
+ });
585
+
586
+ it('adds enter transition classes on z-if=true with z-transition', () => {
587
+ component('trans-if', {
588
+ state: () => ({ show: true }),
589
+ render() {
590
+ return `<div z-if="show" z-transition="fade">Content</div>`;
591
+ },
592
+ });
593
+ document.body.innerHTML = '<trans-if id="ti"></trans-if>';
594
+ const inst = mount('#ti', 'trans-if');
595
+ const el = document.querySelector('#ti div');
596
+ // On enter, should have enter-active and enter-from/enter-to classes
597
+ expect(el.classList.contains('fade-enter-active') || el.classList.contains('fade-enter-from')).toBe(true);
598
+ });
599
+
600
+ it('component-level transition config applies enter class', () => {
601
+ component('trans-cfg', {
602
+ state: () => ({ show: true }),
603
+ transition: {
604
+ enter: 'animate-fade-in',
605
+ leave: 'animate-fade-out',
606
+ duration: 10,
607
+ },
608
+ render() {
609
+ return `<div z-if="show" z-transition="custom">Content</div>`;
610
+ },
611
+ });
612
+ document.body.innerHTML = '<trans-cfg id="tc"></trans-cfg>';
613
+ mount('#tc', 'trans-cfg');
614
+ // Component-level transition should apply enter class
615
+ const el = document.querySelector('#tc div');
616
+ expect(el).not.toBeNull();
617
+ expect(el.classList.contains('animate-fade-in')).toBe(true);
618
+ });
619
+
620
+ it('z-show with z-transition sets enter classes on visible elements', () => {
621
+ component('trans-show', {
622
+ state: () => ({ visible: true }),
623
+ render() {
624
+ return `<div z-show="visible" z-transition="slide" data-zq-hidden="">Content</div>`;
625
+ },
626
+ });
627
+ document.body.innerHTML = '<trans-show id="ts"></trans-show>';
628
+ mount('#ts', 'trans-show');
629
+ const el = document.querySelector('#ts div');
630
+ expect(el).not.toBeNull();
631
+ // Should be visible (not hidden)
632
+ expect(el.style.display).not.toBe('none');
633
+ });
634
+ });
635
+
636
+
637
+ // ===========================================================================
638
+ // Feature 6: Component Scoped Events (emit/on)
639
+ // ===========================================================================
640
+
641
+ describe('Component - scoped events (emit/on)', () => {
642
+ beforeEach(() => {
643
+ document.body.innerHTML = '';
644
+ });
645
+
646
+ it('emit() dispatches CustomEvent that bubbles', () => {
647
+ component('emit-child', {
648
+ render() { return '<button>click</button>'; },
649
+ doEmit() {
650
+ this.emit('item-selected', { id: 42 });
651
+ },
652
+ });
653
+
654
+ document.body.innerHTML = '<emit-child id="ec"></emit-child>';
655
+ const inst = mount('#ec', 'emit-child');
656
+
657
+ const handler = vi.fn();
658
+ document.body.addEventListener('item-selected', handler);
659
+
660
+ inst.doEmit();
661
+ expect(handler).toHaveBeenCalledTimes(1);
662
+ expect(handler.mock.calls[0][0].detail).toEqual({ id: 42 });
663
+
664
+ document.body.removeEventListener('item-selected', handler);
665
+ });
666
+
667
+ it('emit() event is cancelable', () => {
668
+ component('emit-cancel', {
669
+ render() { return '<div></div>'; },
670
+ fire() { return this.emit('my-event', { x: 1 }); },
671
+ });
672
+
673
+ document.body.innerHTML = '<emit-cancel id="ecn"></emit-cancel>';
674
+ const inst = mount('#ecn', 'emit-cancel');
675
+
676
+ let prevented = false;
677
+ document.querySelector('#ecn').addEventListener('my-event', (e) => {
678
+ e.preventDefault();
679
+ prevented = true;
680
+ });
681
+
682
+ inst.fire();
683
+ expect(prevented).toBe(true);
684
+ });
685
+
686
+ it('parent @event binding catches child emit', async () => {
687
+ component('child-emitter', {
688
+ render() { return '<button @click="sendEvent">Go</button>'; },
689
+ sendEvent() {
690
+ this.emit('done', { result: 'ok' });
691
+ },
692
+ });
693
+
694
+ component('parent-listener', {
695
+ state: () => ({ received: '' }),
696
+ render() {
697
+ return `<child-emitter @done="onDone"></child-emitter><span>${this.state.received}</span>`;
698
+ },
699
+ onDone(e) {
700
+ this.state.received = e.detail.result;
701
+ },
702
+ });
703
+
704
+ document.body.innerHTML = '<parent-listener id="pl"></parent-listener>';
705
+ const parentInst = mount('#pl', 'parent-listener');
706
+
707
+ // Get the child instance and trigger emit
708
+ const childEl = document.querySelector('child-emitter');
709
+ const childInst = getInstance(childEl);
710
+ childInst.sendEvent();
711
+
712
+ // Wait for re-render
713
+ await new Promise(r => queueMicrotask(r));
714
+ expect(parentInst.state.received).toBe('ok');
715
+ });
716
+ });
717
+
718
+
719
+ // ===========================================================================
720
+ // Feature 7: Electron Environment Detection
721
+ // ===========================================================================
722
+
723
+ describe('Electron environment detection', () => {
724
+ it('$.isElectron is false in jsdom/browser', async () => {
725
+ // Dynamic import to test the evaluated values
726
+ const mod = await import('../index.js');
727
+ const $ = mod.default;
728
+ expect($.isElectron).toBe(false);
729
+ });
730
+
731
+ it('$.platform is "browser" in jsdom', async () => {
732
+ const mod = await import('../index.js');
733
+ const $ = mod.default;
734
+ expect($.platform).toBe('browser');
735
+ });
736
+
737
+ it('$.isElectron detects Electron via navigator.userAgent', () => {
738
+ // Simulate Electron user agent
739
+ const original = navigator.userAgent;
740
+ Object.defineProperty(navigator, 'userAgent', {
741
+ value: 'Mozilla/5.0 Electron/25.0.0',
742
+ configurable: true,
743
+ });
744
+
745
+ // Re-evaluate the detection logic
746
+ const isElectron = /Electron/i.test(navigator.userAgent);
747
+ expect(isElectron).toBe(true);
748
+
749
+ // Restore
750
+ Object.defineProperty(navigator, 'userAgent', {
751
+ value: original,
752
+ configurable: true,
753
+ });
754
+ });
755
+
756
+ it('$.isElectron detects Electron via process.versions.electron', () => {
757
+ // Simulate Electron process object
758
+ const origProcess = globalThis.process;
759
+ globalThis.process = { versions: { electron: '25.0.0', node: '18.0.0' } };
760
+
761
+ const isElectron = typeof process !== 'undefined' && process.versions != null && !!process.versions.electron;
762
+ expect(isElectron).toBe(true);
763
+
764
+ globalThis.process = origProcess;
765
+ });
766
+
767
+ it('platform resolves correctly for different environments', () => {
768
+ // browser: window exists, not electron
769
+ const platformBrowser = (false) ? 'electron' : (typeof window !== 'undefined') ? 'browser' : 'node';
770
+ expect(platformBrowser).toBe('browser');
771
+ });
772
+ });
773
+
774
+
775
+ // ===========================================================================
776
+ // Feature: connectStore is exported
777
+ // ===========================================================================
778
+
779
+ describe('connectStore export', () => {
780
+ it('is importable from store module', () => {
781
+ expect(typeof connectStore).toBe('function');
782
+ });
783
+
784
+ it('is available on $ namespace', async () => {
785
+ const mod = await import('../index.js');
786
+ const $ = mod.default;
787
+ expect(typeof $.connectStore).toBe('function');
788
+ });
789
+ });
790
+
791
+
792
+ // ===========================================================================
793
+ // Integration: Full component with props + stores + emit
794
+ // ===========================================================================
795
+
796
+ describe('Integration - props + stores + emit', () => {
797
+ beforeEach(() => {
798
+ document.body.innerHTML = '';
799
+ });
800
+
801
+ it('component with reactive props and store connector renders correctly', async () => {
802
+ const appStore = createStore('integ-store', {
803
+ state: { theme: 'dark', count: 0 },
804
+ actions: {
805
+ setTheme(state, v) { state.theme = v; },
806
+ inc(state) { state.count++; },
807
+ },
808
+ });
809
+
810
+ component('integ-card', {
811
+ props: {
812
+ title: { type: String, default: 'Card' },
813
+ size: { type: Number, default: 1 },
814
+ },
815
+ stores: {
816
+ app: connectStore(appStore, ['theme', 'count']),
817
+ },
818
+ render() {
819
+ return `<div class="card ${this.stores.app.theme}">
820
+ <h2>${this.props.title} (${this.props.size})</h2>
821
+ <span class="count">${this.stores.app.count}</span>
822
+ </div>`;
823
+ },
824
+ });
825
+
826
+ document.body.innerHTML = '<integ-card id="ic" title="Stats" size="3"></integ-card>';
827
+ const inst = mount('#ic', 'integ-card');
828
+
829
+ expect(inst.props.title).toBe('Stats');
830
+ expect(inst.props.size).toBe(3);
831
+ expect(inst.stores.app.theme).toBe('dark');
832
+ expect(inst.stores.app.count).toBe(0);
833
+
834
+ // Dispatch store action
835
+ appStore.dispatch('inc');
836
+ expect(inst.stores.app.count).toBe(1);
837
+
838
+ // Wait for re-render
839
+ await new Promise(r => queueMicrotask(r));
840
+ expect(document.querySelector('.count').textContent).toBe('1');
841
+ });
842
+
843
+ it('emitting from connected component works', () => {
844
+ const store = createStore('integ-emit', { state: { x: 0 } });
845
+
846
+ component('emit-connected', {
847
+ stores: {
848
+ s: connectStore(store, ['x']),
849
+ },
850
+ render() { return '<div></div>'; },
851
+ notify() { this.emit('status-change', { x: this.stores.s.x }); },
852
+ });
853
+
854
+ document.body.innerHTML = '<emit-connected id="emc"></emit-connected>';
855
+ const inst = mount('#emc', 'emit-connected');
856
+
857
+ const handler = vi.fn();
858
+ document.querySelector('#emc').addEventListener('status-change', handler);
859
+
860
+ inst.notify();
861
+ expect(handler).toHaveBeenCalledTimes(1);
862
+ expect(handler.mock.calls[0][0].detail).toEqual({ x: 0 });
863
+ });
864
+ });