@hyperfixi/intent-element 2.4.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.
@@ -0,0 +1,879 @@
1
+ /**
2
+ * Tests for @hyperfixi/intent-element
3
+ *
4
+ * Uses jsdom for DOM simulation. Execution tests stub window.hyperfixi.evalLSENode
5
+ * so the suite runs without the full hyperfixi browser bundle.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { LSEIntentElement } from './lse-intent.js';
9
+ import { intentRegistry } from './schema-registry.js';
10
+ import { sandboxed } from './sandbox.js';
11
+
12
+ // Register the element for all tests
13
+ if (!customElements.get('lse-intent')) {
14
+ customElements.define('lse-intent', LSEIntentElement);
15
+ }
16
+
17
+ // ─── Helper ──────────────────────────────────────────────────────────────────
18
+
19
+ const VALID_JSON = JSON.stringify({
20
+ action: 'toggle',
21
+ roles: { patient: { type: 'selector', value: '.active' } },
22
+ });
23
+
24
+ function makeElement(inlineJson?: string): LSEIntentElement {
25
+ const el = document.createElement('lse-intent') as LSEIntentElement;
26
+ if (inlineJson !== undefined) {
27
+ const script = document.createElement('script');
28
+ script.type = 'application/lse+json';
29
+ script.textContent = inlineJson;
30
+ el.appendChild(script);
31
+ }
32
+ return el;
33
+ }
34
+
35
+ /** Wait for a CustomEvent, failing after `ms` milliseconds if it never fires. */
36
+ function waitForEvent(el: Element, type: string, ms = 1000): Promise<CustomEvent> {
37
+ return Promise.race([
38
+ new Promise<CustomEvent>(resolve =>
39
+ el.addEventListener(type, e => resolve(e as CustomEvent), { once: true })
40
+ ),
41
+ new Promise<CustomEvent>((_, reject) =>
42
+ setTimeout(() => reject(new Error(`"${type}" event not fired within ${ms}ms`)), ms)
43
+ ),
44
+ ]);
45
+ }
46
+
47
+ // ─── intentRegistry ──────────────────────────────────────────────────────────
48
+
49
+ describe('intentRegistry', () => {
50
+ beforeEach(() => intentRegistry.clear());
51
+
52
+ it('registers and retrieves a schema', () => {
53
+ const schema = {
54
+ action: 'toggle',
55
+ roles: [],
56
+ description: 'toggle',
57
+ primaryRole: 'patient',
58
+ category: 'dom',
59
+ };
60
+ intentRegistry.register(schema);
61
+ expect(intentRegistry.get('toggle')).toBe(schema);
62
+ });
63
+
64
+ it('has() returns false for unregistered actions', () => {
65
+ expect(intentRegistry.has('unknown')).toBe(false);
66
+ });
67
+
68
+ it('unregister removes a schema', () => {
69
+ const schema = {
70
+ action: 'add',
71
+ roles: [],
72
+ description: 'add',
73
+ primaryRole: 'patient',
74
+ category: 'dom',
75
+ };
76
+ intentRegistry.register(schema);
77
+ intentRegistry.unregister('add');
78
+ expect(intentRegistry.has('add')).toBe(false);
79
+ });
80
+
81
+ it('registerAll registers multiple schemas', () => {
82
+ const s1 = {
83
+ action: 'toggle',
84
+ roles: [],
85
+ description: 'toggle',
86
+ primaryRole: 'patient',
87
+ category: 'dom',
88
+ };
89
+ const s2 = {
90
+ action: 'add',
91
+ roles: [],
92
+ description: 'add',
93
+ primaryRole: 'patient',
94
+ category: 'dom',
95
+ };
96
+ intentRegistry.registerAll([s1, s2]);
97
+ expect(intentRegistry.size).toBe(2);
98
+ });
99
+
100
+ it('loadFrom() throws for non-array response', async () => {
101
+ global.fetch = vi.fn().mockResolvedValue({
102
+ ok: true,
103
+ json: async () => ({ action: 'toggle' }), // object, not array
104
+ } as unknown as Response);
105
+ await expect(intentRegistry.loadFrom('http://example.com/schemas.json')).rejects.toThrow(
106
+ 'Expected array'
107
+ );
108
+ });
109
+
110
+ it('loadFrom() throws for schema missing action field', async () => {
111
+ global.fetch = vi.fn().mockResolvedValue({
112
+ ok: true,
113
+ json: async () => [{ roles: [] }], // missing action
114
+ } as unknown as Response);
115
+ await expect(intentRegistry.loadFrom('http://example.com/schemas.json')).rejects.toThrow(
116
+ 'missing required fields'
117
+ );
118
+ });
119
+
120
+ it('loadFrom() throws for HTTP error', async () => {
121
+ global.fetch = vi.fn().mockResolvedValue({
122
+ ok: false,
123
+ status: 404,
124
+ statusText: 'Not Found',
125
+ } as unknown as Response);
126
+ await expect(intentRegistry.loadFrom('http://example.com/schemas.json')).rejects.toThrow('404');
127
+ });
128
+ });
129
+
130
+ // ─── sandboxed ───────────────────────────────────────────────────────────────
131
+
132
+ describe('sandboxed', () => {
133
+ it('returns ok:true for a successful async function', async () => {
134
+ const result = await sandboxed(async () => 42);
135
+ expect(result.ok).toBe(true);
136
+ expect(result.result).toBe(42);
137
+ });
138
+
139
+ it('returns ok:false with error for a thrown error', async () => {
140
+ const result = await sandboxed(async () => {
141
+ throw new Error('boom');
142
+ });
143
+ expect(result.ok).toBe(false);
144
+ expect(result.error?.message).toBe('boom');
145
+ });
146
+
147
+ it('returns ok:false with timedOut:true after timeout', async () => {
148
+ const result = await sandboxed(() => new Promise(resolve => setTimeout(resolve, 200)), 50);
149
+ expect(result.ok).toBe(false);
150
+ expect(result.timedOut).toBe(true);
151
+ });
152
+ });
153
+
154
+ // ─── LSEIntentElement — validation ───────────────────────────────────────────
155
+
156
+ describe('LSEIntentElement — validation', () => {
157
+ it('dispatches lse:validated for valid inline JSON', async () => {
158
+ const el = makeElement(VALID_JSON);
159
+ const validated = waitForEvent(el, 'lse:validated');
160
+ document.body.appendChild(el);
161
+ const event = await validated;
162
+ expect(event.detail.node).toBeDefined();
163
+ expect(event.detail.node.action).toBe('toggle');
164
+ document.body.removeChild(el);
165
+ });
166
+
167
+ it('dispatches lse:error for invalid JSON syntax', async () => {
168
+ const el = makeElement('{not valid json}');
169
+ const errored = waitForEvent(el, 'lse:error');
170
+ document.body.appendChild(el);
171
+ const event = await errored;
172
+ expect(event.detail.diagnostics.some((d: { code: string }) => d.code === 'INVALID_JSON')).toBe(
173
+ true
174
+ );
175
+ document.body.removeChild(el);
176
+ });
177
+
178
+ it('dispatches lse:error for invalid protocol JSON (missing action)', async () => {
179
+ const el = makeElement(JSON.stringify({ roles: {} }));
180
+ const errored = waitForEvent(el, 'lse:error');
181
+ document.body.appendChild(el);
182
+ const event = await errored;
183
+ expect(
184
+ event.detail.diagnostics.some((d: { code: string }) => d.code === 'MISSING_ACTION')
185
+ ).toBe(true);
186
+ document.body.removeChild(el);
187
+ });
188
+
189
+ it('node getter returns null before connect', () => {
190
+ const el = makeElement(VALID_JSON);
191
+ expect(el.node).toBeNull();
192
+ });
193
+
194
+ it('diagnostics getter returns empty array before connect', () => {
195
+ const el = makeElement(VALID_JSON);
196
+ expect(el.diagnostics).toHaveLength(0);
197
+ });
198
+
199
+ it('diagnostics getter returns a copy (external mutation does not affect element)', async () => {
200
+ delete (globalThis as Record<string, unknown>)['hyperfixi'];
201
+ const el = makeElement(VALID_JSON);
202
+ const validated = waitForEvent(el, 'lse:validated');
203
+ document.body.appendChild(el);
204
+ await validated;
205
+ await new Promise(r => setTimeout(r, 20));
206
+ const copy = el.diagnostics as IRDiagnostic[];
207
+ const originalLength = copy.length;
208
+ // Mutate the returned array — internal state must not change
209
+ (copy as IRDiagnostic[]).push({ severity: 'error', code: 'FAKE', message: 'injected' });
210
+ expect(el.diagnostics).toHaveLength(originalLength);
211
+ document.body.removeChild(el);
212
+ });
213
+
214
+ it('does nothing when disabled attribute is set', async () => {
215
+ const el = makeElement(VALID_JSON);
216
+ el.setAttribute('disabled', '');
217
+ const events: Event[] = [];
218
+ el.addEventListener('lse:validated', e => events.push(e));
219
+ el.addEventListener('lse:error', e => events.push(e));
220
+ document.body.appendChild(el);
221
+ await new Promise(r => setTimeout(r, 50));
222
+ expect(events).toHaveLength(0);
223
+ document.body.removeChild(el);
224
+ });
225
+
226
+ it('uses 5000ms default when timeout attribute is empty string', async () => {
227
+ // We can't directly test the timeout value, but we can confirm initialization
228
+ // succeeds (doesn't immediately time out) with timeout=""
229
+ (globalThis as Record<string, unknown>)['hyperfixi'] = {
230
+ evalLSENode: vi.fn().mockResolvedValue('ok'),
231
+ };
232
+ const el = makeElement(VALID_JSON);
233
+ el.setAttribute('timeout', '');
234
+ const executed = waitForEvent(el, 'lse:executed');
235
+ document.body.appendChild(el);
236
+ // If timeout were 0ms this would fire lse:error instead
237
+ const event = await executed;
238
+ expect(event.detail.result).toBe('ok');
239
+ document.body.removeChild(el);
240
+ delete (globalThis as Record<string, unknown>)['hyperfixi'];
241
+ });
242
+ });
243
+
244
+ // ─── LSEIntentElement — schema validation ─────────────────────────────────
245
+
246
+ describe('LSEIntentElement — schema validation', () => {
247
+ beforeEach(() => intentRegistry.clear());
248
+
249
+ it('adds schema diagnostics for missing required role', async () => {
250
+ intentRegistry.register({
251
+ action: 'toggle',
252
+ roles: [
253
+ { role: 'patient', required: true, expectedTypes: ['selector'], description: 'target' },
254
+ ],
255
+ description: 'toggle',
256
+ primaryRole: 'patient',
257
+ category: 'dom',
258
+ });
259
+
260
+ // JSON with no roles at all (passes wire validation, fails schema check)
261
+ const el = makeElement(JSON.stringify({ action: 'toggle', roles: {} }));
262
+ const validated = waitForEvent(el, 'lse:validated');
263
+ document.body.appendChild(el);
264
+ const event = await validated;
265
+ const schemaDiag = event.detail.diagnostics.find(
266
+ (d: { code: string }) => d.code === 'MISSING_REQUIRED_ROLE'
267
+ );
268
+ expect(schemaDiag).toBeDefined();
269
+ document.body.removeChild(el);
270
+ });
271
+
272
+ it('adds warning diagnostic for role with unexpected type', async () => {
273
+ intentRegistry.register({
274
+ action: 'toggle',
275
+ roles: [
276
+ { role: 'patient', required: true, expectedTypes: ['selector'], description: 'target' },
277
+ ],
278
+ description: 'toggle',
279
+ primaryRole: 'patient',
280
+ category: 'dom',
281
+ });
282
+
283
+ // patient is present but as a literal, not a selector
284
+ const el = makeElement(
285
+ JSON.stringify({ action: 'toggle', roles: { patient: { type: 'literal', value: 'foo' } } })
286
+ );
287
+ const validated = waitForEvent(el, 'lse:validated');
288
+ document.body.appendChild(el);
289
+ const event = await validated;
290
+ const typeDiag = event.detail.diagnostics.find(
291
+ (d: { code: string }) => d.code === 'UNEXPECTED_ROLE_TYPE'
292
+ );
293
+ expect(typeDiag).toBeDefined();
294
+ document.body.removeChild(el);
295
+ });
296
+ });
297
+
298
+ // ─── LSEIntentElement — execution ────────────────────────────────────────────
299
+
300
+ describe('LSEIntentElement — execution', () => {
301
+ beforeEach(() => {
302
+ (globalThis as Record<string, unknown>)['hyperfixi'] = {
303
+ evalLSENode: vi.fn().mockResolvedValue('executed'),
304
+ };
305
+ });
306
+
307
+ it('calls evalLSENode and dispatches lse:executed', async () => {
308
+ const el = makeElement(VALID_JSON);
309
+ const executed = waitForEvent(el, 'lse:executed');
310
+ document.body.appendChild(el);
311
+ const event = await executed;
312
+ expect(event.detail.result).toBe('executed');
313
+ expect(event.detail.node.action).toBe('toggle');
314
+ document.body.removeChild(el);
315
+ });
316
+
317
+ it('dispatches lse:error when evalLSENode throws', async () => {
318
+ (globalThis as Record<string, unknown>)['hyperfixi'] = {
319
+ evalLSENode: vi.fn().mockRejectedValue(new Error('runtime error')),
320
+ };
321
+ const el = makeElement(VALID_JSON);
322
+ const errored = waitForEvent(el, 'lse:error');
323
+ document.body.appendChild(el);
324
+ const event = await errored;
325
+ expect(
326
+ event.detail.diagnostics.some((d: { code: string }) => d.code === 'EXECUTION_ERROR')
327
+ ).toBe(true);
328
+ document.body.removeChild(el);
329
+ });
330
+
331
+ it('dispatches lse:error when evalLSENode times out', async () => {
332
+ (globalThis as Record<string, unknown>)['hyperfixi'] = {
333
+ evalLSENode: vi
334
+ .fn()
335
+ .mockImplementation(() => new Promise(resolve => setTimeout(resolve, 500))),
336
+ };
337
+ const el = makeElement(VALID_JSON);
338
+ el.setAttribute('timeout', '50');
339
+ const errored = waitForEvent(el, 'lse:error');
340
+ document.body.appendChild(el);
341
+ const event = await errored;
342
+ expect(
343
+ event.detail.diagnostics.some((d: { code: string }) => d.code === 'EXECUTION_TIMEOUT')
344
+ ).toBe(true);
345
+ document.body.removeChild(el);
346
+ });
347
+
348
+ it('emits NO_RUNTIME warning when window.hyperfixi is absent', async () => {
349
+ delete (globalThis as Record<string, unknown>)['hyperfixi'];
350
+ const el = makeElement(VALID_JSON);
351
+ const validated = waitForEvent(el, 'lse:validated');
352
+ document.body.appendChild(el);
353
+ await validated;
354
+ await new Promise(r => setTimeout(r, 20));
355
+ const hasWarning = el.diagnostics.some(d => d.code === 'NO_RUNTIME');
356
+ expect(hasWarning).toBe(true);
357
+ document.body.removeChild(el);
358
+ });
359
+
360
+ it('does not use _hyperscript fallback — only window.hyperfixi is supported', async () => {
361
+ delete (globalThis as Record<string, unknown>)['hyperfixi'];
362
+ (globalThis as Record<string, unknown>)['_hyperscript'] = {
363
+ evalLSENode: vi.fn().mockResolvedValue('should-not-run'),
364
+ };
365
+ const el = makeElement(VALID_JSON);
366
+ const validated = waitForEvent(el, 'lse:validated');
367
+ document.body.appendChild(el);
368
+ await validated;
369
+ await new Promise(r => setTimeout(r, 20));
370
+ // Should see NO_RUNTIME, not lse:executed
371
+ expect(el.diagnostics.some(d => d.code === 'NO_RUNTIME')).toBe(true);
372
+ delete (globalThis as Record<string, unknown>)['_hyperscript'];
373
+ document.body.removeChild(el);
374
+ });
375
+ });
376
+
377
+ // ─── LSEIntentElement — trigger modes ────────────────────────────────────────
378
+
379
+ describe('LSEIntentElement — trigger modes', () => {
380
+ let evalMock: ReturnType<typeof vi.fn>;
381
+
382
+ beforeEach(() => {
383
+ evalMock = vi.fn().mockResolvedValue('executed');
384
+ (globalThis as Record<string, unknown>)['hyperfixi'] = { evalLSENode: evalMock };
385
+ });
386
+
387
+ it('trigger="load" (default) fires immediately on connect', async () => {
388
+ const el = makeElement(VALID_JSON);
389
+ const executed = waitForEvent(el, 'lse:executed');
390
+ document.body.appendChild(el);
391
+ await executed;
392
+ expect(evalMock).toHaveBeenCalledTimes(1);
393
+ document.body.removeChild(el);
394
+ });
395
+
396
+ it('trigger="manual" does NOT execute on connect', async () => {
397
+ const el = makeElement(VALID_JSON);
398
+ el.setAttribute('trigger', 'manual');
399
+ const validated = waitForEvent(el, 'lse:validated');
400
+ document.body.appendChild(el);
401
+ await validated; // prepare phase completes
402
+ await new Promise(r => setTimeout(r, 20));
403
+ expect(evalMock).not.toHaveBeenCalled();
404
+ document.body.removeChild(el);
405
+ });
406
+
407
+ it('trigger="manual" executes via refresh()', async () => {
408
+ const el = makeElement(VALID_JSON);
409
+ el.setAttribute('trigger', 'manual');
410
+ document.body.appendChild(el);
411
+ await waitForEvent(el, 'lse:validated');
412
+ expect(evalMock).not.toHaveBeenCalled();
413
+
414
+ const executed = waitForEvent(el, 'lse:executed');
415
+ await el.refresh();
416
+ await executed;
417
+ expect(evalMock).toHaveBeenCalledTimes(1);
418
+ document.body.removeChild(el);
419
+ });
420
+
421
+ it('trigger="click" wires a click listener and fires on click', async () => {
422
+ const el = makeElement(VALID_JSON);
423
+ el.setAttribute('trigger', 'click');
424
+ document.body.appendChild(el);
425
+ await waitForEvent(el, 'lse:validated');
426
+ expect(evalMock).not.toHaveBeenCalled();
427
+
428
+ const executed = waitForEvent(el, 'lse:executed');
429
+ el.click();
430
+ await executed;
431
+ expect(evalMock).toHaveBeenCalledTimes(1);
432
+ document.body.removeChild(el);
433
+ });
434
+
435
+ it('trigger="click" with a nested trigger slot child bubbles up', async () => {
436
+ const el = makeElement(VALID_JSON);
437
+ el.setAttribute('trigger', 'click');
438
+ const button = document.createElement('button');
439
+ button.setAttribute('slot', 'trigger');
440
+ button.textContent = 'Toggle';
441
+ el.appendChild(button);
442
+
443
+ document.body.appendChild(el);
444
+ await waitForEvent(el, 'lse:validated');
445
+ expect(evalMock).not.toHaveBeenCalled();
446
+
447
+ const executed = waitForEvent(el, 'lse:executed');
448
+ button.click(); // click bubbles to the custom element
449
+ await executed;
450
+ expect(evalMock).toHaveBeenCalledTimes(1);
451
+ document.body.removeChild(el);
452
+ });
453
+
454
+ it('trigger="click" fires every time the element is clicked', async () => {
455
+ const el = makeElement(VALID_JSON);
456
+ el.setAttribute('trigger', 'click');
457
+ document.body.appendChild(el);
458
+ await waitForEvent(el, 'lse:validated');
459
+
460
+ el.click();
461
+ el.click();
462
+ el.click();
463
+ await new Promise(r => setTimeout(r, 30));
464
+ expect(evalMock).toHaveBeenCalledTimes(3);
465
+ document.body.removeChild(el);
466
+ });
467
+
468
+ it('trigger="submit" wires a submit listener on the ancestor form', async () => {
469
+ const form = document.createElement('form');
470
+ const el = makeElement(VALID_JSON);
471
+ el.setAttribute('trigger', 'submit');
472
+ form.appendChild(el);
473
+ document.body.appendChild(form);
474
+ await waitForEvent(el, 'lse:validated');
475
+ expect(evalMock).not.toHaveBeenCalled();
476
+
477
+ const executed = waitForEvent(el, 'lse:executed');
478
+ form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
479
+ await executed;
480
+ expect(evalMock).toHaveBeenCalledTimes(1);
481
+ document.body.removeChild(form);
482
+ });
483
+
484
+ it('trigger="submit" calls preventDefault synchronously', async () => {
485
+ const form = document.createElement('form');
486
+ const el = makeElement(VALID_JSON);
487
+ el.setAttribute('trigger', 'submit');
488
+ form.appendChild(el);
489
+ document.body.appendChild(form);
490
+ await waitForEvent(el, 'lse:validated');
491
+
492
+ const submitEvent = new Event('submit', { cancelable: true, bubbles: true });
493
+ form.dispatchEvent(submitEvent);
494
+ expect(submitEvent.defaultPrevented).toBe(true);
495
+ document.body.removeChild(form);
496
+ });
497
+
498
+ it('trigger="submit" emits a diagnostic warning when no ancestor form exists', async () => {
499
+ const el = makeElement(VALID_JSON);
500
+ el.setAttribute('trigger', 'submit');
501
+ const validated = waitForEvent(el, 'lse:validated');
502
+ document.body.appendChild(el);
503
+ await validated;
504
+ await new Promise(r => setTimeout(r, 20));
505
+ expect(el.diagnostics.some(d => d.code === 'NO_ANCESTOR_FORM')).toBe(true);
506
+ expect(evalMock).not.toHaveBeenCalled();
507
+ document.body.removeChild(el);
508
+ });
509
+
510
+ it('trigger="intersect" wires an IntersectionObserver', async () => {
511
+ const observers: Array<{
512
+ observe: ReturnType<typeof vi.fn>;
513
+ disconnect: ReturnType<typeof vi.fn>;
514
+ }> = [];
515
+ let capturedCallback: IntersectionObserverCallback | null = null;
516
+ class MockObserver {
517
+ observe: ReturnType<typeof vi.fn>;
518
+ disconnect: ReturnType<typeof vi.fn>;
519
+ constructor(cb: IntersectionObserverCallback) {
520
+ capturedCallback = cb;
521
+ this.observe = vi.fn();
522
+ this.disconnect = vi.fn();
523
+ observers.push(this);
524
+ }
525
+ }
526
+ const OriginalIO = globalThis.IntersectionObserver;
527
+ (globalThis as unknown as { IntersectionObserver: typeof MockObserver }).IntersectionObserver =
528
+ MockObserver;
529
+
530
+ try {
531
+ const el = makeElement(VALID_JSON);
532
+ el.setAttribute('trigger', 'intersect');
533
+ document.body.appendChild(el);
534
+ await waitForEvent(el, 'lse:validated');
535
+ expect(observers).toHaveLength(1);
536
+ expect(observers[0].observe).toHaveBeenCalledWith(el);
537
+ expect(evalMock).not.toHaveBeenCalled();
538
+
539
+ // Simulate an intersection
540
+ const executed = waitForEvent(el, 'lse:executed');
541
+ capturedCallback!(
542
+ [{ isIntersecting: true, target: el } as unknown as IntersectionObserverEntry],
543
+ observers[0] as unknown as IntersectionObserver
544
+ );
545
+ await executed;
546
+ expect(evalMock).toHaveBeenCalledTimes(1);
547
+ // One-shot: observer should disconnect after firing
548
+ expect(observers[0].disconnect).toHaveBeenCalled();
549
+ document.body.removeChild(el);
550
+ } finally {
551
+ (globalThis as unknown as { IntersectionObserver: typeof OriginalIO }).IntersectionObserver =
552
+ OriginalIO;
553
+ }
554
+ });
555
+
556
+ it('trigger="intersect" emits a diagnostic warning when IntersectionObserver is unavailable', async () => {
557
+ const OriginalIO = globalThis.IntersectionObserver;
558
+ (globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = undefined;
559
+ try {
560
+ const el = makeElement(VALID_JSON);
561
+ el.setAttribute('trigger', 'intersect');
562
+ const validated = waitForEvent(el, 'lse:validated');
563
+ document.body.appendChild(el);
564
+ await validated;
565
+ await new Promise(r => setTimeout(r, 20));
566
+ expect(el.diagnostics.some(d => d.code === 'NO_INTERSECTION_OBSERVER')).toBe(true);
567
+ expect(evalMock).not.toHaveBeenCalled();
568
+ document.body.removeChild(el);
569
+ } finally {
570
+ (globalThis as unknown as { IntersectionObserver: typeof OriginalIO }).IntersectionObserver =
571
+ OriginalIO;
572
+ }
573
+ });
574
+
575
+ it('arbitrary event name in trigger attribute wires addEventListener', async () => {
576
+ const el = makeElement(VALID_JSON);
577
+ el.setAttribute('trigger', 'mouseenter');
578
+ document.body.appendChild(el);
579
+ await waitForEvent(el, 'lse:validated');
580
+ expect(evalMock).not.toHaveBeenCalled();
581
+
582
+ const executed = waitForEvent(el, 'lse:executed');
583
+ el.dispatchEvent(new Event('mouseenter'));
584
+ await executed;
585
+ expect(evalMock).toHaveBeenCalledTimes(1);
586
+ document.body.removeChild(el);
587
+ });
588
+
589
+ it('trigger attribute is case-insensitive', async () => {
590
+ const el = makeElement(VALID_JSON);
591
+ el.setAttribute('trigger', 'CLICK');
592
+ document.body.appendChild(el);
593
+ await waitForEvent(el, 'lse:validated');
594
+ expect(evalMock).not.toHaveBeenCalled();
595
+
596
+ const executed = waitForEvent(el, 'lse:executed');
597
+ el.click();
598
+ await executed;
599
+ expect(evalMock).toHaveBeenCalledTimes(1);
600
+ document.body.removeChild(el);
601
+ });
602
+
603
+ it('auto-wires from JSON trigger.event sugar when attribute is absent', async () => {
604
+ const json = JSON.stringify({
605
+ action: 'toggle',
606
+ roles: { patient: { type: 'selector', value: '.active' } },
607
+ trigger: { event: 'click' },
608
+ });
609
+ const el = makeElement(json);
610
+ // No `trigger` attribute set — element must pick up the JSON sugar
611
+ document.body.appendChild(el);
612
+ await waitForEvent(el, 'lse:validated');
613
+ // Wire-format trigger.event means: wrap in event-handler. The element
614
+ // should NOT execute immediately; it should wire a click listener.
615
+ await new Promise(r => setTimeout(r, 20));
616
+ expect(evalMock).not.toHaveBeenCalled();
617
+
618
+ const executed = waitForEvent(el, 'lse:executed');
619
+ el.click();
620
+ await executed;
621
+ expect(evalMock).toHaveBeenCalledTimes(1);
622
+ document.body.removeChild(el);
623
+ });
624
+
625
+ it('trigger attribute takes precedence over JSON trigger.event sugar', async () => {
626
+ const json = JSON.stringify({
627
+ action: 'toggle',
628
+ roles: { patient: { type: 'selector', value: '.active' } },
629
+ trigger: { event: 'click' },
630
+ });
631
+ const el = makeElement(json);
632
+ el.setAttribute('trigger', 'load'); // attribute overrides JSON
633
+ const executed = waitForEvent(el, 'lse:executed');
634
+ document.body.appendChild(el);
635
+ await executed;
636
+ expect(evalMock).toHaveBeenCalledTimes(1); // fired on load, not on click
637
+ document.body.removeChild(el);
638
+ });
639
+
640
+ it('disconnect removes the click listener', async () => {
641
+ const el = makeElement(VALID_JSON);
642
+ el.setAttribute('trigger', 'click');
643
+ document.body.appendChild(el);
644
+ await waitForEvent(el, 'lse:validated');
645
+
646
+ document.body.removeChild(el);
647
+ // After disconnect, clicking should NOT trigger execution
648
+ el.click();
649
+ await new Promise(r => setTimeout(r, 20));
650
+ expect(evalMock).not.toHaveBeenCalled();
651
+ });
652
+
653
+ it('disconnect disconnects the intersection observer', async () => {
654
+ const observers: Array<{
655
+ observe: ReturnType<typeof vi.fn>;
656
+ disconnect: ReturnType<typeof vi.fn>;
657
+ }> = [];
658
+ class MockObserver {
659
+ observe: ReturnType<typeof vi.fn>;
660
+ disconnect: ReturnType<typeof vi.fn>;
661
+ constructor(_cb: IntersectionObserverCallback) {
662
+ this.observe = vi.fn();
663
+ this.disconnect = vi.fn();
664
+ observers.push(this);
665
+ }
666
+ }
667
+ const OriginalIO = globalThis.IntersectionObserver;
668
+ (globalThis as unknown as { IntersectionObserver: typeof MockObserver }).IntersectionObserver =
669
+ MockObserver;
670
+
671
+ try {
672
+ const el = makeElement(VALID_JSON);
673
+ el.setAttribute('trigger', 'intersect');
674
+ document.body.appendChild(el);
675
+ await waitForEvent(el, 'lse:validated');
676
+ expect(observers[0].disconnect).not.toHaveBeenCalled();
677
+
678
+ document.body.removeChild(el);
679
+ expect(observers[0].disconnect).toHaveBeenCalled();
680
+ } finally {
681
+ (globalThis as unknown as { IntersectionObserver: typeof OriginalIO }).IntersectionObserver =
682
+ OriginalIO;
683
+ }
684
+ });
685
+
686
+ it('changing the trigger attribute tears down the old wiring and wires the new one', async () => {
687
+ const el = makeElement(VALID_JSON);
688
+ el.setAttribute('trigger', 'click');
689
+ document.body.appendChild(el);
690
+ await waitForEvent(el, 'lse:validated');
691
+
692
+ // Switch to manual: clicks should no longer fire
693
+ el.setAttribute('trigger', 'manual');
694
+ await waitForEvent(el, 'lse:validated');
695
+ el.click();
696
+ await new Promise(r => setTimeout(r, 20));
697
+ expect(evalMock).not.toHaveBeenCalled();
698
+
699
+ // refresh() still works in manual mode
700
+ await el.refresh();
701
+ expect(evalMock).toHaveBeenCalledTimes(1);
702
+ document.body.removeChild(el);
703
+ });
704
+ });
705
+
706
+ // ─── LSEIntentElement — event-handler unwrap ────────────────────────────────
707
+
708
+ describe('LSEIntentElement — event-handler unwrap', () => {
709
+ let evalMock: ReturnType<typeof vi.fn>;
710
+
711
+ beforeEach(() => {
712
+ evalMock = vi.fn().mockResolvedValue('executed');
713
+ (globalThis as Record<string, unknown>)['hyperfixi'] = { evalLSENode: evalMock };
714
+ });
715
+
716
+ it('unwraps verbose event-handler form: body command is passed to evalLSENode, not the wrapper', async () => {
717
+ // Verbose protocol form: kind:"event-handler" with body:[command{toggle}].
718
+ // The element's own click listener provides the event wiring — the inner
719
+ // event-handler metadata is redundant. _execute must unwrap the body and
720
+ // pass the plain command node to evalLSENode, not the event-handler node.
721
+ const verboseJson = JSON.stringify({
722
+ kind: 'event-handler',
723
+ action: 'on',
724
+ roles: { event: { type: 'literal', value: 'click', dataType: 'string' } },
725
+ body: [
726
+ {
727
+ kind: 'command',
728
+ action: 'toggle',
729
+ roles: { patient: { type: 'selector', value: '.active' } },
730
+ },
731
+ ],
732
+ });
733
+
734
+ const el = makeElement(verboseJson);
735
+ el.setAttribute('trigger', 'click');
736
+ document.body.appendChild(el);
737
+ await waitForEvent(el, 'lse:validated');
738
+ expect(evalMock).not.toHaveBeenCalled();
739
+
740
+ const executed = waitForEvent(el, 'lse:executed');
741
+ el.click();
742
+ await executed;
743
+
744
+ expect(evalMock).toHaveBeenCalledTimes(1);
745
+ const [passedNode] = evalMock.mock.calls[0];
746
+ expect(passedNode.kind).toBe('command');
747
+ expect(passedNode.action).toBe('toggle');
748
+ document.body.removeChild(el);
749
+ });
750
+
751
+ it('unwraps compact trigger-sugar form: body command is passed to evalLSENode, not the wrapper', async () => {
752
+ // Compact protocol form: {action, roles, trigger:{event}}. This is what
753
+ // the compilation service now emits (Option A fix in @lokascript/intent).
754
+ // fromProtocolJSON still deserializes it into an event-handler SemanticNode,
755
+ // so _execute must unwrap it the same way as the verbose form above.
756
+ const compactJson = JSON.stringify({
757
+ action: 'toggle',
758
+ roles: { patient: { type: 'selector', value: '.active' } },
759
+ trigger: { event: 'click' },
760
+ });
761
+
762
+ const el = makeElement(compactJson);
763
+ el.setAttribute('trigger', 'click');
764
+ document.body.appendChild(el);
765
+ await waitForEvent(el, 'lse:validated');
766
+ expect(evalMock).not.toHaveBeenCalled();
767
+
768
+ const executed = waitForEvent(el, 'lse:executed');
769
+ el.click();
770
+ await executed;
771
+
772
+ expect(evalMock).toHaveBeenCalledTimes(1);
773
+ const [passedNode] = evalMock.mock.calls[0];
774
+ expect(passedNode.kind).toBe('command');
775
+ expect(passedNode.action).toBe('toggle');
776
+ document.body.removeChild(el);
777
+ });
778
+
779
+ it('passes bare command nodes to evalLSENode unchanged', async () => {
780
+ // Regression guard: bare command JSON (no kind, no trigger) was the one
781
+ // form that already worked pre-fix. Confirm the unwrap didn't break it.
782
+ const el = makeElement(VALID_JSON);
783
+ el.setAttribute('trigger', 'click');
784
+ document.body.appendChild(el);
785
+ await waitForEvent(el, 'lse:validated');
786
+
787
+ const executed = waitForEvent(el, 'lse:executed');
788
+ el.click();
789
+ await executed;
790
+
791
+ expect(evalMock).toHaveBeenCalledTimes(1);
792
+ const [passedNode] = evalMock.mock.calls[0];
793
+ expect(passedNode.kind).toBe('command');
794
+ expect(passedNode.action).toBe('toggle');
795
+ document.body.removeChild(el);
796
+ });
797
+
798
+ it('unwraps multi-command bodies and executes each in sequence', async () => {
799
+ // Verbose form with multiple body commands. Each should be executed
800
+ // sequentially. The final lse:executed event carries the results array.
801
+ const multiJson = JSON.stringify({
802
+ kind: 'event-handler',
803
+ action: 'on',
804
+ roles: { event: { type: 'literal', value: 'click', dataType: 'string' } },
805
+ body: [
806
+ {
807
+ kind: 'command',
808
+ action: 'add',
809
+ roles: { patient: { type: 'selector', value: '.loading' } },
810
+ },
811
+ {
812
+ kind: 'command',
813
+ action: 'remove',
814
+ roles: { patient: { type: 'selector', value: '.done' } },
815
+ },
816
+ ],
817
+ });
818
+
819
+ let callIndex = 0;
820
+ evalMock.mockImplementation(async () => `result-${callIndex++}`);
821
+
822
+ const el = makeElement(multiJson);
823
+ el.setAttribute('trigger', 'click');
824
+ document.body.appendChild(el);
825
+ await waitForEvent(el, 'lse:validated');
826
+
827
+ const executed = waitForEvent(el, 'lse:executed');
828
+ el.click();
829
+ const event = await executed;
830
+
831
+ expect(evalMock).toHaveBeenCalledTimes(2);
832
+ expect(evalMock.mock.calls[0][0].action).toBe('add');
833
+ expect(evalMock.mock.calls[1][0].action).toBe('remove');
834
+ expect(event.detail.results).toEqual(['result-0', 'result-1']);
835
+ document.body.removeChild(el);
836
+ });
837
+
838
+ it('stops execution on first body command failure and emits lse:error', async () => {
839
+ const multiJson = JSON.stringify({
840
+ kind: 'event-handler',
841
+ action: 'on',
842
+ roles: { event: { type: 'literal', value: 'click', dataType: 'string' } },
843
+ body: [
844
+ { kind: 'command', action: 'add', roles: { patient: { type: 'selector', value: '.a' } } },
845
+ { kind: 'command', action: 'fail', roles: {} },
846
+ {
847
+ kind: 'command',
848
+ action: 'remove',
849
+ roles: { patient: { type: 'selector', value: '.a' } },
850
+ },
851
+ ],
852
+ });
853
+
854
+ evalMock.mockImplementation(async (n: { action: string }) => {
855
+ if (n.action === 'fail') throw new Error('boom');
856
+ return 'ok';
857
+ });
858
+
859
+ const el = makeElement(multiJson);
860
+ el.setAttribute('trigger', 'click');
861
+ document.body.appendChild(el);
862
+ await waitForEvent(el, 'lse:validated');
863
+
864
+ const errored = waitForEvent(el, 'lse:error');
865
+ el.click();
866
+ await errored;
867
+
868
+ // Should have executed the first two (add, fail) but not the third (remove)
869
+ expect(evalMock).toHaveBeenCalledTimes(2);
870
+ expect(evalMock.mock.calls[0][0].action).toBe('add');
871
+ expect(evalMock.mock.calls[1][0].action).toBe('fail');
872
+ expect(el.diagnostics.some(d => d.code === 'EXECUTION_ERROR')).toBe(true);
873
+ document.body.removeChild(el);
874
+ });
875
+ });
876
+
877
+ // ─── Type helpers ─────────────────────────────────────────────────────────────
878
+
879
+ type IRDiagnostic = { severity: string; code: string; message: string };