@invisibleloop/pulse 0.20.38 → 0.20.39

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.
@@ -1,1069 +0,0 @@
1
- /**
2
- * Pulse 2 — Runtime tests
3
- * run: node src/runtime/runtime.test.js
4
- *
5
- * Uses a minimal DOM shim — no browser, no jsdom dependency.
6
- */
7
-
8
- import { mount, validateFields, debounce, throttle } from './index.js'
9
-
10
- // ---------------------------------------------------------------------------
11
- // Minimal DOM shim
12
- // ---------------------------------------------------------------------------
13
-
14
- class FakeElement {
15
- constructor() {
16
- this.innerHTML = ''
17
- this._listeners = {}
18
- }
19
-
20
- querySelectorAll(selector) {
21
- // Parse our own innerHTML to find matching elements
22
- return parseElements(this.innerHTML, selector)
23
- }
24
-
25
- addEventListener() {}
26
- }
27
-
28
- /**
29
- * Tiny HTML parser — finds elements with a given attribute.
30
- * Good enough for our tests. Not a real DOM parser.
31
- */
32
- function parseElements(html, selector) {
33
- const attr = selector.match(/\[([^\]]+)\]/)?.[1]
34
- if (!attr) return []
35
-
36
- const regex = new RegExp(`<[^>]+${attr}="([^"]*)"[^>]*>`, 'g')
37
- const matches = []
38
- let m
39
-
40
- while ((m = regex.exec(html)) !== null) {
41
- const attrValue = m[1]
42
- const el = {
43
- dataset: {},
44
- _fired: {},
45
- addEventListener(event, fn) { this._fn = fn; this._event = event },
46
- dispatchEvent(event, data) { this._fn?.(data) }
47
- }
48
-
49
- if (attr === 'data-event') el.dataset.event = attrValue
50
- if (attr === 'data-action') el.dataset.action = attrValue
51
- matches.push(el)
52
- }
53
-
54
- return matches
55
- }
56
-
57
- /**
58
- * A more realistic element for exercising bindEvents() delegation directly —
59
- * unlike FakeElement above (which stubs addEventListener as a no-op and is
60
- * only used to assert on rendered innerHTML), this one wires real listeners
61
- * via the native EventTarget and supports closest()/dataset/pointer capture,
62
- * so pointerdown/pointermove/pointerup/keydown routing can be verified.
63
- */
64
- class DelegatingElement extends EventTarget {
65
- constructor() {
66
- super()
67
- this.innerHTML = ''
68
- this._captured = new Map()
69
- }
70
- }
71
-
72
- class ChildElement extends EventTarget {
73
- constructor(attrs = {}, parent = null) {
74
- super()
75
- this.dataset = {}
76
- for (const [k, v] of Object.entries(attrs)) this.dataset[k] = v
77
- this._parent = parent
78
- }
79
- closest(selector) {
80
- const attr = selector.match(/\[([^\]]+)\]/)?.[1]
81
- const key = attr === 'data-event' ? 'event'
82
- : attr === 'data-action' ? 'action'
83
- : attr === 'data-store-event' ? 'storeEvent'
84
- : attr === 'data-dialog-open' ? 'dialogOpen'
85
- : attr === 'data-dialog-close' ? 'dialogClose'
86
- : null
87
- let node = this
88
- while (node) {
89
- if (key && node.dataset && node.dataset[key] !== undefined) return node
90
- node = node._parent
91
- }
92
- return null
93
- }
94
- setPointerCapture(id) { this._root?._captured.set(id, this) }
95
- releasePointerCapture(id) { this._root?._captured.delete(id) }
96
- }
97
-
98
- function fireOn(root, target, type, props = {}) {
99
- const event = Object.assign(new Event(type, { bubbles: true }), props)
100
- Object.defineProperty(event, 'target', { value: target, configurable: true })
101
- root.dispatchEvent(event)
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // Test runner
106
- // ---------------------------------------------------------------------------
107
-
108
- let passed = 0
109
- let failed = 0
110
-
111
- function test(label, fn) {
112
- try {
113
- fn()
114
- console.log(` ✓ ${label}`)
115
- passed++
116
- } catch (e) {
117
- console.log(` ✗ ${label}`)
118
- console.log(` ${e.message}`)
119
- failed++
120
- }
121
- }
122
-
123
- async function testAsync(label, fn) {
124
- try {
125
- await fn()
126
- console.log(` ✓ ${label}`)
127
- passed++
128
- } catch (e) {
129
- console.log(` ✗ ${label}`)
130
- console.log(` ${e.message}`)
131
- failed++
132
- }
133
- }
134
-
135
- function assert(condition, msg) {
136
- if (!condition) throw new Error(msg || 'Assertion failed')
137
- }
138
-
139
- // ---------------------------------------------------------------------------
140
- // Browser globals shim (localStorage, location)
141
- // ---------------------------------------------------------------------------
142
-
143
- const _store = {}
144
- global.localStorage = {
145
- getItem: (k) => _store[k] ?? null,
146
- setItem: (k, v) => { _store[k] = v },
147
- removeItem: (k) => { delete _store[k] },
148
- }
149
- global.location = { pathname: '/test' }
150
-
151
- // ---------------------------------------------------------------------------
152
- // Counter spec — used across multiple tests
153
- // ---------------------------------------------------------------------------
154
-
155
- const counterSpec = {
156
- route: '/counter',
157
- state: { count: 0 },
158
- constraints: {
159
- count: { min: 0, max: 10 }
160
- },
161
- view: (state) =>
162
- `<div>
163
- <button data-event="decrement">-</button>
164
- <span id="count">${state.count}</span>
165
- <button data-event="increment">+</button>
166
- <button data-event="reset">reset</button>
167
- </div>`,
168
- mutations: {
169
- increment: (state) => ({ count: state.count + 1 }),
170
- decrement: (state) => ({ count: state.count - 1 }),
171
- reset: () => ({ count: 0 })
172
- }
173
- }
174
-
175
- // ---------------------------------------------------------------------------
176
-
177
- console.log('\nMount & initial render\n')
178
-
179
- test('renders initial state to element', () => {
180
- const el = new FakeElement()
181
- mount(counterSpec, el)
182
- assert(el.innerHTML.includes('>0<'), `Expected count 0 in HTML, got: ${el.innerHTML}`)
183
- })
184
-
185
- test('throws on invalid spec', () => {
186
- const el = new FakeElement()
187
- let threw = false
188
- try { mount({ route: '/bad' }, el) } catch { threw = true }
189
- assert(threw, 'Expected mount to throw on invalid spec')
190
- })
191
-
192
- test('accepts server state', () => {
193
- const el = new FakeElement()
194
- const spec = {
195
- route: '/hello',
196
- state: {},
197
- view: (state, server) => `<h1>${server.greeting}</h1>`
198
- }
199
- mount(spec, el, { greeting: 'Hello world' })
200
- assert(el.innerHTML.includes('Hello world'), `Got: ${el.innerHTML}`)
201
- })
202
-
203
- // ---------------------------------------------------------------------------
204
-
205
- console.log('\nMutations\n')
206
-
207
- test('dispatch applies mutation and re-renders', () => {
208
- const el = new FakeElement()
209
- const instance = mount(counterSpec, el)
210
-
211
- instance.dispatch('increment')
212
- assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
213
-
214
- instance.dispatch('increment')
215
- assert(el.innerHTML.includes('>2<'), `Expected count 2, got: ${el.innerHTML}`)
216
- })
217
-
218
- test('mutation receives current state', () => {
219
- const el = new FakeElement()
220
- const instance = mount(counterSpec, el)
221
-
222
- instance.dispatch('increment')
223
- instance.dispatch('increment')
224
- instance.dispatch('decrement')
225
- assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
226
- })
227
-
228
- test('reset mutation works', () => {
229
- const el = new FakeElement()
230
- const instance = mount(counterSpec, el)
231
-
232
- instance.dispatch('increment')
233
- instance.dispatch('increment')
234
- instance.dispatch('reset')
235
- assert(el.innerHTML.includes('>0<'), `Expected count 0 after reset, got: ${el.innerHTML}`)
236
- })
237
-
238
- test('getState returns deep clone of current state', () => {
239
- const el = new FakeElement()
240
- const instance = mount(counterSpec, el)
241
-
242
- instance.dispatch('increment')
243
- const state = instance.getState()
244
- assert(state.count === 1, `Expected count 1, got ${state.count}`)
245
-
246
- // Mutating returned state should not affect internal state
247
- state.count = 999
248
- assert(instance.getState().count === 1, 'getState should return a clone, not a reference')
249
- })
250
-
251
- test('warns on unknown mutation', () => {
252
- const el = new FakeElement()
253
- const instance = mount(counterSpec, el)
254
- const warnings = []
255
- const orig = console.warn
256
- console.warn = (...args) => warnings.push(args.join(' '))
257
-
258
- instance.dispatch('nonexistent')
259
-
260
- console.warn = orig
261
- assert(warnings.some(w => w.includes('nonexistent')), 'Expected warning for unknown mutation')
262
- })
263
-
264
- // ---------------------------------------------------------------------------
265
-
266
- console.log('\nConstraints\n')
267
-
268
- test('max constraint prevents count exceeding limit', () => {
269
- const el = new FakeElement()
270
- const instance = mount(counterSpec, el)
271
-
272
- for (let i = 0; i < 15; i++) instance.dispatch('increment')
273
-
274
- const state = instance.getState()
275
- assert(state.count === 10, `Expected count capped at 10, got ${state.count}`)
276
- })
277
-
278
- test('min constraint prevents count going below 0', () => {
279
- const el = new FakeElement()
280
- const instance = mount(counterSpec, el)
281
-
282
- for (let i = 0; i < 5; i++) instance.dispatch('decrement')
283
-
284
- const state = instance.getState()
285
- assert(state.count === 0, `Expected count floored at 0, got ${state.count}`)
286
- })
287
-
288
- test('constraints on nested paths', () => {
289
- const el = new FakeElement()
290
- const spec = {
291
- route: '/nested',
292
- state: { slider: { value: 5 } },
293
- constraints: { 'slider.value': { min: 0, max: 100 } },
294
- view: (s) => `<span>${s.slider.value}</span>`,
295
- mutations: {
296
- set: (state, val) => ({ slider: { ...state.slider, value: val } })
297
- }
298
- }
299
- const instance = mount(spec, el)
300
- instance.dispatch('set', 150)
301
- assert(instance.getState().slider.value === 100, `Expected 100, got ${instance.getState().slider.value}`)
302
- })
303
-
304
- test('render is skipped when mutation produces no state change', () => {
305
- const el = new FakeElement()
306
- let renderCount = 0
307
- const spec = {
308
- route: '/nochange',
309
- state: { count: 10 },
310
- constraints: { count: { min: 0, max: 10 } },
311
- view: (s) => { renderCount++; return `<span>${s.count}</span>` },
312
- mutations: {
313
- increment: (state) => ({ count: state.count + 1 }), // clamped to 10
314
- noop: () => ({}), // empty partial
315
- }
316
- }
317
- const instance = mount(spec, el)
318
- renderCount = 0 // reset after initial render
319
-
320
- instance.dispatch('increment') // already at max — state unchanged
321
- instance.dispatch('noop') // empty partial — state unchanged
322
- assert(renderCount === 0, `Expected 0 renders for no-change dispatches, got ${renderCount}`)
323
-
324
- instance.dispatch('increment') // state unchanged by constraint
325
- assert(instance.getState().count === 10)
326
- })
327
-
328
- // ---------------------------------------------------------------------------
329
-
330
- console.log('\nActions\n')
331
-
332
- await testAsync('action run is called and onSuccess updates state', async () => {
333
- const el = new FakeElement()
334
- let ran = false
335
-
336
- const spec = {
337
- route: '/form',
338
- state: { status: 'idle' },
339
- view: (s) => `<form data-action="submit"><span>${s.status}</span></form>`,
340
- actions: {
341
- submit: {
342
- run: async () => { ran = true },
343
- onSuccess: () => ({ status: 'success' }),
344
- onError: () => ({ status: 'error' })
345
- }
346
- }
347
- }
348
-
349
- const instance = mount(spec, el)
350
- await instance.dispatch('submit')
351
-
352
- assert(ran, 'Expected action.run to be called')
353
- assert(instance.getState().status === 'success', `Expected status "success", got "${instance.getState().status}"`)
354
- })
355
-
356
- await testAsync('onSuccess receives the return value of run()', async () => {
357
- const el = new FakeElement()
358
-
359
- const spec = {
360
- route: '/api-test',
361
- state: { items: [] },
362
- view: (s) => `<div>${JSON.stringify(s.items)}</div>`,
363
- actions: {
364
- load: {
365
- run: async () => [{ id: 1, name: 'transformed' }],
366
- onSuccess: (state, result) => ({ items: result }),
367
- onError: () => ({})
368
- }
369
- }
370
- }
371
-
372
- const instance = mount(spec, el)
373
- await instance.dispatch('load')
374
-
375
- const { items } = instance.getState()
376
- assert(Array.isArray(items) && items.length === 1, `Expected 1 item, got ${items.length}`)
377
- assert(items[0].name === 'transformed', `Expected transformed data, got ${JSON.stringify(items[0])}`)
378
- })
379
-
380
- await testAsync('action onError is called when run throws', async () => {
381
- const el = new FakeElement()
382
-
383
- const spec = {
384
- route: '/form',
385
- state: { status: 'idle' },
386
- view: (s) => `<span>${s.status}</span>`,
387
- actions: {
388
- submit: {
389
- run: async () => { throw new Error('network error') },
390
- onSuccess: () => ({ status: 'success' }),
391
- onError: () => ({ status: 'error' })
392
- }
393
- }
394
- }
395
-
396
- const instance = mount(spec, el)
397
-
398
- const errors = []
399
- const orig = console.error
400
- console.error = (...args) => errors.push(args)
401
-
402
- await instance.dispatch('submit')
403
-
404
- console.error = orig
405
- assert(instance.getState().status === 'error', `Expected "error", got "${instance.getState().status}"`)
406
- })
407
-
408
- await testAsync('onStart fires immediately before async work', async () => {
409
- const el = new FakeElement()
410
- const states = []
411
-
412
- const spec = {
413
- route: '/form',
414
- state: { status: 'idle' },
415
- view: (s) => `<span>${s.status}</span>`,
416
- actions: {
417
- submit: {
418
- onStart: () => ({ status: 'loading' }),
419
- run: async () => { states.push('during') },
420
- onSuccess: () => ({ status: 'done' }),
421
- onError: () => ({ status: 'error' })
422
- }
423
- }
424
- }
425
-
426
- const instance = mount(spec, el)
427
-
428
- // Capture state at each render
429
- let renderCount = 0
430
- const origRefresh = instance.refresh
431
- const capturedStates = []
432
-
433
- await instance.dispatch('submit')
434
-
435
- assert(instance.getState().status === 'done', `Final status should be "done", got "${instance.getState().status}"`)
436
- })
437
-
438
- await testAsync('validate:true blocks action when fields invalid', async () => {
439
- const el = new FakeElement()
440
- let ran = false
441
-
442
- const spec = {
443
- route: '/form',
444
- state: { fields: { email: 'not-an-email' } },
445
- validation: { 'fields.email': { format: 'email' } },
446
- view: (s) => `<span>${s.fields.email}</span>`,
447
- actions: {
448
- submit: {
449
- validate: true,
450
- run: async () => { ran = true },
451
- onSuccess: () => ({}),
452
- onError: () => ({})
453
- }
454
- }
455
- }
456
-
457
- const instance = mount(spec, el)
458
-
459
- const warnings = []
460
- const orig = console.warn
461
- console.warn = (...args) => warnings.push(args.join(' '))
462
- await instance.dispatch('submit')
463
- console.warn = orig
464
-
465
- assert(!ran, 'Expected action.run NOT to be called when validation fails')
466
- assert(warnings.some(w => w.includes('Validation failed')), 'Expected validation warning')
467
- })
468
-
469
- // ---------------------------------------------------------------------------
470
-
471
- console.log('\nValidation\n')
472
-
473
- test('required rule catches empty string', () => {
474
- const errors = validateFields(
475
- { fields: { name: '' } },
476
- { 'fields.name': { required: true } }
477
- )
478
- assert(errors.length === 1, `Expected 1 error, got ${errors.length}`)
479
- assert(errors[0].rule === 'required', `Expected required error`)
480
- })
481
-
482
- test('email format rule', () => {
483
- const errors = validateFields(
484
- { fields: { email: 'notanemail' } },
485
- { 'fields.email': { format: 'email' } }
486
- )
487
- assert(errors.length === 1, `Expected 1 error`)
488
- assert(errors[0].rule === 'format', `Expected format error`)
489
- })
490
-
491
- test('valid email passes', () => {
492
- const errors = validateFields(
493
- { fields: { email: 'test@example.com' } },
494
- { 'fields.email': { format: 'email' } }
495
- )
496
- assert(errors.length === 0, `Expected no errors, got ${errors.length}`)
497
- })
498
-
499
- test('minLength rule', () => {
500
- const errors = validateFields(
501
- { fields: { message: 'hi' } },
502
- { 'fields.message': { minLength: 10 } }
503
- )
504
- assert(errors.length === 1, `Expected 1 error`)
505
- assert(errors[0].rule === 'minLength')
506
- })
507
-
508
- test('maxLength rule', () => {
509
- const errors = validateFields(
510
- { fields: { message: 'a'.repeat(1001) } },
511
- { 'fields.message': { maxLength: 1000 } }
512
- )
513
- assert(errors.length === 1, `Expected 1 error`)
514
- assert(errors[0].rule === 'maxLength')
515
- })
516
-
517
- test('multiple rules — all errors returned', () => {
518
- const errors = validateFields(
519
- { fields: { name: '', email: 'bad', message: 'hi' } },
520
- {
521
- 'fields.name': { required: true },
522
- 'fields.email': { required: true, format: 'email' },
523
- 'fields.message': { minLength: 10 }
524
- }
525
- )
526
- assert(errors.length === 3, `Expected 3 errors, got ${errors.length}: ${errors.map(e => e.rule).join(', ')}`)
527
- })
528
-
529
- test('no errors on valid state', () => {
530
- const errors = validateFields(
531
- { fields: { name: 'Andy', email: 'andy@example.com', message: 'Hello world this is a message' } },
532
- {
533
- 'fields.name': { required: true, minLength: 2 },
534
- 'fields.email': { required: true, format: 'email' },
535
- 'fields.message': { required: true, minLength: 10, maxLength: 1000 }
536
- }
537
- )
538
- assert(errors.length === 0, `Expected no errors, got: ${errors.map(e => e.message).join(', ')}`)
539
- })
540
-
541
- // ---------------------------------------------------------------------------
542
-
543
- console.log('\nDestroy\n')
544
-
545
- test('destroy clears the element', () => {
546
- const el = new FakeElement()
547
- const instance = mount(counterSpec, el)
548
- assert(el.innerHTML !== '', 'Should have content after mount')
549
- instance.destroy()
550
- assert(el.innerHTML === '', 'Should be empty after destroy')
551
- })
552
-
553
- // ---------------------------------------------------------------------------
554
-
555
- console.log('\nPersistence\n')
556
-
557
- const persistSpec = {
558
- route: '/persist-test',
559
- state: { count: 0, name: 'default' },
560
- persist: ['count'],
561
- view: (s) => `<span>${s.count}</span>`,
562
- mutations: {
563
- increment: (s) => ({ count: s.count + 1 }),
564
- }
565
- }
566
-
567
- test('persist writes state to localStorage after mutation', () => {
568
- localStorage.removeItem('pulse:/persist-test')
569
- const el = new FakeElement()
570
- const instance = mount(persistSpec, el)
571
- instance.dispatch('increment')
572
- const saved = JSON.parse(localStorage.getItem('pulse:/persist-test'))
573
- assert(saved?.count === 1, `Expected saved count 1, got ${saved?.count}`)
574
- })
575
-
576
- test('persist restores state from localStorage on mount', () => {
577
- localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 7 }))
578
- const el = new FakeElement()
579
- const instance = mount(persistSpec, el)
580
- assert(instance.getState().count === 7, `Expected restored count 7, got ${instance.getState().count}`)
581
- })
582
-
583
- test('persist only saves declared keys, not full state', () => {
584
- localStorage.removeItem('pulse:/persist-test')
585
- const el = new FakeElement()
586
- const instance = mount(persistSpec, el)
587
- instance.dispatch('increment')
588
- const saved = JSON.parse(localStorage.getItem('pulse:/persist-test'))
589
- assert(!('name' in saved), `Should not persist undeclared key "name"`)
590
- })
591
-
592
- test('persist re-renders on SSR mount when saved value differs from default', () => {
593
- localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 5 }))
594
- const el = new FakeElement()
595
- mount(persistSpec, el, {}, { ssr: true })
596
- assert(el.innerHTML.includes('5'), `Expected restored count 5 in HTML, got: ${el.innerHTML}`)
597
- })
598
-
599
- test('persist skips re-render on SSR mount when saved value matches default', () => {
600
- localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 0 }))
601
- const el = new FakeElement()
602
- el.innerHTML = '<span>server-rendered</span>'
603
- mount(persistSpec, el, {}, { ssr: true })
604
- assert(el.innerHTML === '<span>server-rendered</span>', 'Should preserve SSR HTML when state matches default')
605
- })
606
-
607
- test('spec without persist does not write to localStorage', () => {
608
- const key = 'pulse:/counter'
609
- localStorage.removeItem(key)
610
- const el = new FakeElement()
611
- const instance = mount(counterSpec, el)
612
- instance.dispatch('increment')
613
- assert(localStorage.getItem(key) === null, 'Should not write to localStorage without persist')
614
- })
615
-
616
- // ---------------------------------------------------------------------------
617
-
618
- console.log('\nStore mutations\n')
619
-
620
- import { registerStoreMutations, dispatchStoreMutation, getStoreState, updateStore, initLiveStore } from './store.js'
621
-
622
- // Register all mutations once — the singleton ignores subsequent calls (by design)
623
- registerStoreMutations({
624
- bump: (store) => ({ count: (store.count ?? 0) + 1 }),
625
- setTheme: (store, e) => ({ theme: e.target?.value ?? e }),
626
- inc: (store) => ({ counter: (store.counter ?? 0) + 1 }),
627
- })
628
-
629
- test('registerStoreMutations registers mutation functions', () => {
630
- dispatchStoreMutation('bump')
631
- assert(getStoreState().count === 1, `Expected count 1, got ${getStoreState().count}`)
632
- })
633
-
634
- test('dispatchStoreMutation with payload passes it to the mutation', () => {
635
- dispatchStoreMutation('setTheme', { target: { value: 'light' } })
636
- assert(getStoreState().theme === 'light', `Expected theme "light", got ${getStoreState().theme}`)
637
- })
638
-
639
- test('dispatchStoreMutation warns on unknown mutation', () => {
640
- const warnings = []
641
- const orig = console.warn
642
- console.warn = (...a) => warnings.push(a.join(' '))
643
- dispatchStoreMutation('doesNotExist')
644
- console.warn = orig
645
- assert(warnings.some(w => w.includes('doesNotExist')), 'Expected warning for unknown store mutation')
646
- })
647
-
648
- await testAsync('dispatchStoreMutation notifies subscribed pages', async () => {
649
- updateStore({ counter: 0 })
650
- const el = new FakeElement()
651
- const spec = {
652
- route: '/store-sub',
653
- store: ['counter'],
654
- state: {},
655
- view: (state, server) => `<span>${server.counter ?? 0}</span>`,
656
- }
657
- mount(spec, el, { counter: 0 })
658
- // store.js is lazy-loaded by mount() when spec.store is non-empty — its
659
- // subscription is wired up after the dynamic import() resolves. Resolving
660
- // a dynamic import is scheduled as a macrotask even for an already-cached
661
- // module (verified empirically — microtask-only yields are not enough),
662
- // so a setTimeout(0) tick-yield is required here, not just Promise.resolve().
663
- await new Promise(r => setTimeout(r, 0))
664
- const before = el.innerHTML
665
- dispatchStoreMutation('inc')
666
- assert(el.innerHTML !== before, `Page should re-render after store mutation`)
667
- assert(el.innerHTML.includes('1'), `Expected count 1 in HTML, got: ${el.innerHTML}`)
668
- })
669
-
670
- test('registerStoreMutations is a no-op after first call', () => {
671
- const before = getStoreState().count
672
- registerStoreMutations({ bump: () => ({ count: 999 }) }) // different fn — should be ignored
673
- dispatchStoreMutation('bump')
674
- assert(getStoreState().count === before + 1, `Should still use original mutation, got ${getStoreState().count}`)
675
- })
676
-
677
- console.log('\nLive store push (client)\n')
678
-
679
- await testAsync('initLiveStore connects once, merges SSE store events, survives bad frames', async () => {
680
- const instances = []
681
- globalThis.EventSource = class {
682
- constructor(url) { this.url = url; this._listeners = {}; instances.push(this) }
683
- addEventListener(type, fn) { this._listeners[type] = fn }
684
- }
685
-
686
- initLiveStore('/__pulse/live')
687
- initLiveStore('/__pulse/live') // singleton — second call is a no-op
688
- assert(instances.length === 1, `Expected one EventSource, got ${instances.length}`)
689
- assert(instances[0].url === '/__pulse/live', `Wrong URL: ${instances[0].url}`)
690
-
691
- // A store event merges into the client store and notifies subscribers
692
- const el = new FakeElement()
693
- mount({ route: '/stock', store: ['stock'], state: {}, view: (s, server) => `<b>${server.stock ?? '—'}</b>` }, el, {})
694
- // store.js is lazy-loaded when spec.store is non-empty — wait for the dynamic
695
- // import to resolve before the subscription (wired inside it) is live.
696
- await new Promise(r => setTimeout(r, 0))
697
- instances[0]._listeners.store({ data: JSON.stringify({ stock: 7 }) })
698
- assert(getStoreState().stock === 7, `Expected stock 7, got ${getStoreState().stock}`)
699
- assert(el.innerHTML.includes('7'), `Subscribed page should re-render: ${el.innerHTML}`)
700
-
701
- // Malformed frames must never throw or break the page
702
- instances[0]._listeners.store({ data: 'not-json' })
703
- instances[0]._listeners.store({ data: '"a-string"' })
704
- assert(getStoreState().stock === 7, 'Bad frames must not corrupt the store')
705
-
706
- delete globalThis.EventSource
707
- })
708
-
709
- console.log('\nLazy store loading (through mount())\n')
710
-
711
- await testAsync('mount() with spec.store lazy-loads store.js and wires reactivity end to end', async () => {
712
- updateStore({ counter: 100 })
713
- const el = new FakeElement()
714
- const spec = {
715
- route: '/lazy-store',
716
- store: ['counter'],
717
- state: {},
718
- view: (state, server) => `<span>${server.counter ?? 0}</span>`,
719
- }
720
-
721
- const instance = mount(spec, el, { counter: 100 })
722
-
723
- // mount() returns synchronously, before store.js's dynamic import has resolved —
724
- // the view renders with the page-level serverState snapshot passed to mount()
725
- // (not yet the live store slice), and store-driven reactivity is not wired yet.
726
- assert(el.innerHTML.includes('100'), `Expected initial render to use the snapshot: ${el.innerHTML}`)
727
-
728
- // Yield a macrotask so the lazy import('./store.js') resolves. This is the
729
- // "one microtask/tick later" cost documented for the lazy-loading change —
730
- // after this point store subscription, registerStoreMutations, and (when
731
- // window.__PULSE_LIVE__ is set) initLiveStore have all been wired up.
732
- await new Promise(r => setTimeout(r, 0))
733
-
734
- // Store-driven re-render now works correctly end to end through mount():
735
- // a mutation dispatched on the singleton reaches this page's subscription.
736
- const before = el.innerHTML
737
- dispatchStoreMutation('inc')
738
- assert(el.innerHTML !== before, 'Page should re-render once store.js has loaded and subscribed')
739
- assert(el.innerHTML.includes('101'), `Expected counter 101 in HTML, got: ${el.innerHTML}`)
740
-
741
- instance.destroy()
742
- })
743
-
744
- // ---------------------------------------------------------------------------
745
-
746
- console.log('\nDebounce / throttle\n')
747
-
748
- await testAsync('debounce: fires once after delay, not on every call', async () => {
749
- let calls = 0
750
- const fn = debounce(() => { calls++ }, 20)
751
- fn(); fn(); fn()
752
- assert(calls === 0, 'Should not have fired yet')
753
- await new Promise(r => setTimeout(r, 40))
754
- assert(calls === 1, `Expected 1 call, got ${calls}`)
755
- })
756
-
757
- await testAsync('debounce: resets timer on each call', async () => {
758
- let calls = 0
759
- const fn = debounce(() => { calls++ }, 30)
760
- fn()
761
- await new Promise(r => setTimeout(r, 15))
762
- fn() // reset
763
- await new Promise(r => setTimeout(r, 15))
764
- assert(calls === 0, 'Should not have fired — timer was reset')
765
- await new Promise(r => setTimeout(r, 25))
766
- assert(calls === 1, `Expected 1 call after full delay, got ${calls}`)
767
- })
768
-
769
- await testAsync('debounce: passes latest args to handler', async () => {
770
- let last
771
- const fn = debounce((v) => { last = v }, 20)
772
- fn('a'); fn('b'); fn('c')
773
- await new Promise(r => setTimeout(r, 40))
774
- assert(last === 'c', `Expected "c", got "${last}"`)
775
- })
776
-
777
- await testAsync('throttle: fires immediately on first call', async () => {
778
- let calls = 0
779
- const fn = throttle(() => { calls++ }, 50)
780
- fn()
781
- assert(calls === 1, `Expected 1 call, got ${calls}`)
782
- })
783
-
784
- await testAsync('throttle: suppresses calls within the delay window', async () => {
785
- let calls = 0
786
- const fn = throttle(() => { calls++ }, 50)
787
- fn(); fn(); fn()
788
- assert(calls === 1, `Expected 1 call, got ${calls}`)
789
- })
790
-
791
- await testAsync('throttle: allows another call after delay has passed', async () => {
792
- let calls = 0
793
- const fn = throttle(() => { calls++ }, 30)
794
- fn()
795
- await new Promise(r => setTimeout(r, 40))
796
- fn()
797
- assert(calls === 2, `Expected 2 calls, got ${calls}`)
798
- })
799
-
800
- // ---------------------------------------------------------------------------
801
-
802
- console.log('\nError boundaries\n')
803
-
804
- test('view error: renders default fallback when view throws', () => {
805
- const el = new FakeElement()
806
- const spec = {
807
- route: '/err',
808
- state: {},
809
- view: () => { throw new Error('boom') }
810
- }
811
- mount(spec, el)
812
- assert(el.innerHTML.includes('View error'), `Expected fallback HTML, got: ${el.innerHTML}`)
813
- assert(el.innerHTML.includes('boom'), `Expected error message in fallback, got: ${el.innerHTML}`)
814
- })
815
-
816
- test('view error: calls spec.onViewError when view throws', () => {
817
- const el = new FakeElement()
818
- let capturedErr, capturedState
819
- const spec = {
820
- route: '/err',
821
- state: { x: 42 },
822
- view: () => { throw new Error('oops') },
823
- onViewError: (err, state) => {
824
- capturedErr = err
825
- capturedState = state
826
- return '<p>custom fallback</p>'
827
- }
828
- }
829
- mount(spec, el)
830
- assert(el.innerHTML.includes('custom fallback'), `Expected custom fallback, got: ${el.innerHTML}`)
831
- assert(capturedErr?.message === 'oops', `Expected err.message "oops", got "${capturedErr?.message}"`)
832
- assert(capturedState?.x === 42, `Expected state.x 42, got ${capturedState?.x}`)
833
- })
834
-
835
- test('view error: continues working after recovery — next dispatch re-renders', () => {
836
- const el = new FakeElement()
837
- let shouldThrow = true
838
- const spec = {
839
- route: '/err',
840
- state: { ok: false },
841
- view: (state) => {
842
- if (shouldThrow) throw new Error('transient')
843
- return `<p>${state.ok}</p>`
844
- },
845
- mutations: { fix: () => ({ ok: true }) }
846
- }
847
- const app = mount(spec, el)
848
- assert(el.innerHTML.includes('View error'), 'Should show fallback initially')
849
- shouldThrow = false
850
- app.dispatch('fix')
851
- assert(el.innerHTML.includes('true'), `Expected recovered render, got: ${el.innerHTML}`)
852
- })
853
-
854
- // ---------------------------------------------------------------------------
855
-
856
- console.log('\nToast (_toast)\n')
857
-
858
- test('_toast in mutation is stripped from spec state', () => {
859
- const el = new FakeElement()
860
- const spec = {
861
- route: '/t',
862
- state: { count: 0 },
863
- view: (state) => `<p>${state.count}${state._toast ?? ''}</p>`,
864
- mutations: {
865
- inc: (state) => ({ count: state.count + 1, _toast: { message: 'Done!' } })
866
- }
867
- }
868
- const app = mount(spec, el)
869
- app.dispatch('inc')
870
- assert(app.getState().count === 1, 'count should increment')
871
- assert(app.getState()._toast === undefined, '_toast must not be in state')
872
- })
873
-
874
- await testAsync('_toast in onSuccess is stripped from spec state', async () => {
875
- const el = new FakeElement()
876
- const spec = {
877
- route: '/t',
878
- state: { saved: false },
879
- view: (state) => `<p>${state.saved}${state._toast ?? ''}</p>`,
880
- actions: {
881
- save: {
882
- run: async () => 'ok',
883
- onSuccess: () => ({ saved: true, _toast: { message: 'Saved!', variant: 'success' } }),
884
- onError: () => ({ saved: false, _toast: { message: 'Error', variant: 'error' } }),
885
- }
886
- }
887
- }
888
- const app = mount(spec, el)
889
- await app.dispatch('save')
890
- assert(app.getState().saved === true, 'saved should be true')
891
- assert(app.getState()._toast === undefined, '_toast must not be in state')
892
- })
893
-
894
- await testAsync('_toast in onError is stripped from spec state', async () => {
895
- const el = new FakeElement()
896
- const spec = {
897
- route: '/t',
898
- state: { failed: false },
899
- view: (state) => `${state.failed}`,
900
- actions: {
901
- save: {
902
- run: async () => { throw new Error('network') },
903
- onSuccess: () => ({}),
904
- onError: () => ({ failed: true, _toast: { message: 'Failed', variant: 'error' } }),
905
- }
906
- }
907
- }
908
- const app = mount(spec, el)
909
- await app.dispatch('save')
910
- assert(app.getState().failed === true, 'failed should be true')
911
- assert(app.getState()._toast === undefined, '_toast must not be in state')
912
- })
913
-
914
- // ---------------------------------------------------------------------------
915
-
916
- console.log('\nPointer / keyboard event binding\n')
917
-
918
- test('pointerdown dispatches mutation and captures the pointer', () => {
919
- const el = new DelegatingElement()
920
- const dragSpec = {
921
- route: '/drag',
922
- state: { dragging: false },
923
- view: (state) => `<div data-event="pointerdown:startDrag">${state.dragging}</div>`,
924
- mutations: { startDrag: (state) => ({ dragging: true }) },
925
- }
926
- mount(dragSpec, el)
927
-
928
- const child = new ChildElement({ event: 'pointerdown:startDrag' })
929
- child._root = el
930
- let captured = null
931
- child.setPointerCapture = (id) => { captured = id }
932
- fireOn(el, child, 'pointerdown', { pointerId: 1 })
933
-
934
- assert(el.innerHTML.includes('true'), `Expected dragging=true, got: ${el.innerHTML}`)
935
- assert(captured === 1, `Expected pointer capture with id 1, got ${captured}`)
936
- })
937
-
938
- test('pointermove dispatches mutation while captured, ignores elements without data-event', () => {
939
- const el = new DelegatingElement()
940
- const dragSpec = {
941
- route: '/drag-move',
942
- state: { x: 0 },
943
- view: (state) => `<div data-event="pointermove:move">${state.x}</div>`,
944
- mutations: { move: (state, e) => ({ x: e.clientX }) },
945
- }
946
- mount(dragSpec, el)
947
-
948
- const child = new ChildElement({ event: 'pointermove:move' })
949
- child._root = el
950
- fireOn(el, child, 'pointermove', { clientX: 42 })
951
- assert(el.innerHTML.includes('42'), `Expected x=42, got: ${el.innerHTML}`)
952
-
953
- const plainChild = new ChildElement({})
954
- plainChild._root = el
955
- fireOn(el, plainChild, 'pointermove', { clientX: 999 })
956
- assert(el.innerHTML.includes('42'), `Element without data-event should not dispatch: ${el.innerHTML}`)
957
- })
958
-
959
- test('pointerup dispatches mutation and releases capture', () => {
960
- const el = new DelegatingElement()
961
- const dragSpec = {
962
- route: '/drag-up',
963
- state: { dragging: true },
964
- view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
965
- mutations: { endDrag: () => ({ dragging: false }) },
966
- }
967
- mount(dragSpec, el)
968
-
969
- const child = new ChildElement({ event: 'pointerup:endDrag' })
970
- child._root = el
971
- let released = null
972
- child.releasePointerCapture = (id) => { released = id }
973
- fireOn(el, child, 'pointerup', { pointerId: 7 })
974
-
975
- assert(el.innerHTML.includes('false'), `Expected dragging=false, got: ${el.innerHTML}`)
976
- assert(released === 7, `Expected pointer capture released with id 7, got ${released}`)
977
- })
978
-
979
- test('pointercancel dispatches the same mutation as pointerup and releases capture', () => {
980
- const el = new DelegatingElement()
981
- const dragSpec = {
982
- route: '/drag-cancel',
983
- state: { dragging: true },
984
- view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
985
- mutations: { endDrag: () => ({ dragging: false }) },
986
- }
987
- mount(dragSpec, el)
988
-
989
- const child = new ChildElement({ event: 'pointerup:endDrag' })
990
- child._root = el
991
- let released = null
992
- child.releasePointerCapture = (id) => { released = id }
993
- fireOn(el, child, 'pointercancel', { pointerId: 3 })
994
-
995
- assert(el.innerHTML.includes('false'), `Expected dragging=false after cancel, got: ${el.innerHTML}`)
996
- assert(released === 3, `Expected pointer capture released on cancel, got ${released}`)
997
- })
998
-
999
- test('keydown dispatches mutation with the key', () => {
1000
- const el = new DelegatingElement()
1001
- const keySpec = {
1002
- route: '/key',
1003
- state: { key: '' },
1004
- view: (state) => `<div data-event="keydown:onKey">${state.key}</div>`,
1005
- mutations: { onKey: (state, e) => ({ key: e.key }) },
1006
- }
1007
- mount(keySpec, el)
1008
-
1009
- const child = new ChildElement({ event: 'keydown:onKey' })
1010
- child._root = el
1011
- fireOn(el, child, 'keydown', { key: 'ArrowLeft' })
1012
-
1013
- assert(el.innerHTML.includes('ArrowLeft'), `Expected key=ArrowLeft, got: ${el.innerHTML}`)
1014
- })
1015
-
1016
- test('data-event supports multiple space-separated bindings on one element', () => {
1017
- const el = new DelegatingElement()
1018
- const dragSpec = {
1019
- route: '/drag-multi',
1020
- state: { dragging: false, x: 0 },
1021
- view: (state) => `<div data-event="pointerdown:startDrag pointermove:dragMove pointerup:endDrag">${state.dragging}:${state.x}</div>`,
1022
- mutations: {
1023
- startDrag: () => ({ dragging: true }),
1024
- dragMove: (state, e) => state.dragging ? { x: e.clientX } : {},
1025
- endDrag: () => ({ dragging: false }),
1026
- },
1027
- }
1028
- mount(dragSpec, el)
1029
-
1030
- const child = new ChildElement({ event: 'pointerdown:startDrag pointermove:dragMove pointerup:endDrag' })
1031
- child._root = el
1032
-
1033
- fireOn(el, child, 'pointerdown', { pointerId: 1 })
1034
- assert(el.innerHTML.includes('true:0'), `Expected dragging=true, got: ${el.innerHTML}`)
1035
-
1036
- fireOn(el, child, 'pointermove', { clientX: 55 })
1037
- assert(el.innerHTML.includes('true:55'), `Expected x=55 while dragging, got: ${el.innerHTML}`)
1038
-
1039
- fireOn(el, child, 'pointerup', { pointerId: 1 })
1040
- assert(el.innerHTML.includes('false:55'), `Expected dragging=false, got: ${el.innerHTML}`)
1041
-
1042
- // pointermove after release is a no-op because the mutation gates on `dragging`
1043
- fireOn(el, child, 'pointermove', { clientX: 999 })
1044
- assert(el.innerHTML.includes('false:55'), `Expected no change after release, got: ${el.innerHTML}`)
1045
- })
1046
-
1047
- test('pointerdown/pointerup ignore wrong event type on the same element', () => {
1048
- const el = new DelegatingElement()
1049
- const spec = {
1050
- route: '/only-down',
1051
- state: { count: 0 },
1052
- view: (state) => `<div data-event="pointerdown:bump">${state.count}</div>`,
1053
- mutations: { bump: (state) => ({ count: state.count + 1 }) },
1054
- }
1055
- mount(spec, el)
1056
-
1057
- const child = new ChildElement({ event: 'pointerdown:bump' })
1058
- child._root = el
1059
- fireOn(el, child, 'pointerup', { pointerId: 1 }) // wrong type — should not fire
1060
- assert(el.innerHTML.includes('>0<'), `Should not have dispatched on pointerup: ${el.innerHTML}`)
1061
-
1062
- fireOn(el, child, 'pointerdown', { pointerId: 1 }) // correct type — should fire
1063
- assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
1064
- })
1065
-
1066
- // ---------------------------------------------------------------------------
1067
-
1068
- console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
1069
- if (failed > 0) process.exit(1)