pulse-js-framework 1.11.3 → 1.11.4

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 (48) hide show
  1. package/cli/analyze.js +21 -8
  2. package/cli/build.js +83 -56
  3. package/cli/dev.js +108 -94
  4. package/cli/docs-test.js +52 -33
  5. package/cli/index.js +81 -51
  6. package/cli/mobile.js +92 -40
  7. package/cli/release.js +64 -46
  8. package/cli/scaffold.js +14 -13
  9. package/compiler/lexer.js +55 -54
  10. package/compiler/parser/core.js +1 -0
  11. package/compiler/parser/state.js +6 -12
  12. package/compiler/parser/style.js +17 -20
  13. package/compiler/parser/view.js +1 -3
  14. package/compiler/preprocessor.js +124 -262
  15. package/compiler/sourcemap.js +10 -4
  16. package/compiler/transformer/expressions.js +122 -106
  17. package/compiler/transformer/index.js +2 -4
  18. package/compiler/transformer/style.js +74 -7
  19. package/compiler/transformer/view.js +86 -36
  20. package/loader/esbuild-plugin-server-components.js +209 -0
  21. package/loader/esbuild-plugin.js +41 -93
  22. package/loader/parcel-plugin.js +37 -97
  23. package/loader/rollup-plugin-server-components.js +30 -169
  24. package/loader/rollup-plugin.js +27 -78
  25. package/loader/shared.js +362 -0
  26. package/loader/swc-plugin.js +65 -82
  27. package/loader/vite-plugin-server-components.js +30 -171
  28. package/loader/vite-plugin.js +25 -10
  29. package/loader/webpack-loader-server-components.js +21 -134
  30. package/loader/webpack-loader.js +25 -80
  31. package/package.json +52 -12
  32. package/runtime/dom-selector.js +2 -1
  33. package/runtime/form.js +4 -3
  34. package/runtime/http.js +6 -1
  35. package/runtime/logger.js +44 -24
  36. package/runtime/router/utils.js +14 -7
  37. package/runtime/security.js +13 -1
  38. package/runtime/server-components/actions-server.js +23 -19
  39. package/runtime/server-components/error-sanitizer.js +18 -18
  40. package/runtime/server-components/security.js +41 -24
  41. package/runtime/ssr-preload.js +5 -3
  42. package/runtime/testing.js +759 -0
  43. package/runtime/utils.js +3 -2
  44. package/server/utils.js +15 -9
  45. package/sw/index.js +2 -0
  46. package/types/loaders.d.ts +1043 -0
  47. package/compiler/parser/_extract.js +0 -393
  48. package/loader/README.md +0 -509
@@ -0,0 +1,759 @@
1
+ /**
2
+ * Pulse Testing Utilities
3
+ *
4
+ * A testing library for Pulse applications that eliminates common boilerplate.
5
+ * Designed to work with Node.js built-in test runner (node:test).
6
+ *
7
+ * @module pulse-js-framework/testing
8
+ *
9
+ * @example
10
+ * import { setupTestDOM, trackEffect, assertSignal, spy } from 'pulse-js-framework/testing';
11
+ * import { pulse } from 'pulse-js-framework/runtime/pulse';
12
+ *
13
+ * test('reactive counter', (t) => {
14
+ * const { adapter } = setupTestDOM(t);
15
+ * const count = pulse(0);
16
+ * const tracker = trackEffect(() => count.get());
17
+ *
18
+ * count.set(5);
19
+ * assertSignal(count, 5);
20
+ * assert.strictEqual(tracker.count, 2);
21
+ * tracker.dispose();
22
+ * });
23
+ */
24
+
25
+ import assert from 'node:assert';
26
+ import {
27
+ MockDOMAdapter,
28
+ MockElement,
29
+ EnhancedMockAdapter,
30
+ setAdapter,
31
+ resetAdapter,
32
+ getAdapter
33
+ } from './dom-adapter.js';
34
+ import {
35
+ pulse as createPulse,
36
+ effect as createEffect,
37
+ computed as createComputed,
38
+ batch,
39
+ resetContext,
40
+ ReactiveContext,
41
+ withContext,
42
+ createContext
43
+ } from './pulse.js';
44
+
45
+ // =============================================================================
46
+ // 1. setupTestDOM
47
+ // =============================================================================
48
+
49
+ /**
50
+ * Set up a MockDOMAdapter and reset the reactive context.
51
+ * When a node:test context `t` is provided, cleanup is auto-registered via t.after().
52
+ *
53
+ * @param {import('node:test').TestContext} [t] - node:test context for auto-cleanup
54
+ * @param {object} [options]
55
+ * @param {boolean} [options.enhanced=false] - Use EnhancedMockAdapter
56
+ * @param {boolean} [options.resetCtx=true] - Call resetContext() on setup
57
+ * @returns {{ adapter: MockDOMAdapter, cleanup: Function }}
58
+ *
59
+ * @example
60
+ * test('my test', (t) => {
61
+ * const { adapter } = setupTestDOM(t);
62
+ * const el = adapter.createElement('div');
63
+ * });
64
+ */
65
+ export function setupTestDOM(t, options = {}) {
66
+ // Handle case where t is actually options (no test context)
67
+ if (t && typeof t.after !== 'function') {
68
+ options = t;
69
+ t = null;
70
+ }
71
+
72
+ const { enhanced = false, resetCtx = true } = options;
73
+
74
+ if (resetCtx) {
75
+ resetContext();
76
+ }
77
+
78
+ const adapter = enhanced ? new EnhancedMockAdapter() : new MockDOMAdapter();
79
+ setAdapter(adapter);
80
+
81
+ const cleanup = () => {
82
+ resetAdapter();
83
+ if (resetCtx) {
84
+ resetContext();
85
+ }
86
+ };
87
+
88
+ if (t) {
89
+ t.after(cleanup);
90
+ }
91
+
92
+ return { adapter, cleanup };
93
+ }
94
+
95
+ // =============================================================================
96
+ // 2. renderPulse
97
+ // =============================================================================
98
+
99
+ /**
100
+ * Render a Pulse component into a test container with query helpers.
101
+ *
102
+ * @param {Function} componentFn - Function returning a DOM node
103
+ * @param {import('node:test').TestContext} [t] - node:test context for auto-cleanup
104
+ * @param {object} [options]
105
+ * @param {MockDOMAdapter} [options.adapter] - Existing adapter (created if not provided)
106
+ * @returns {RenderResult}
107
+ *
108
+ * @typedef {object} RenderResult
109
+ * @property {MockElement} container - Root container element
110
+ * @property {MockDOMAdapter} adapter - DOM adapter in use
111
+ * @property {Function} getByText - Find element by text content (throws if not found)
112
+ * @property {Function} getBySelector - Find element via querySelector
113
+ * @property {Function} queryByText - Find element by text (returns null if not found)
114
+ * @property {Function} getAll - Find all elements via querySelectorAll
115
+ * @property {Function} unmount - Remove component from container
116
+ * @property {Function} rerender - Re-render with new component function
117
+ *
118
+ * @example
119
+ * test('renders greeting', (t) => {
120
+ * const name = pulse('World');
121
+ * const { getByText } = renderPulse(() => el('h1', () => `Hello ${name.get()}`), t);
122
+ * assert.ok(getByText('Hello World'));
123
+ * });
124
+ */
125
+ export function renderPulse(componentFn, t, options = {}) {
126
+ // Handle overloads: renderPulse(fn, options) without t
127
+ if (t && typeof t.after !== 'function') {
128
+ options = t;
129
+ t = null;
130
+ }
131
+
132
+ let adapter = options.adapter;
133
+ let cleanupAdapter = null;
134
+
135
+ if (!adapter) {
136
+ const setup = setupTestDOM(t, { resetCtx: !options.adapter });
137
+ adapter = setup.adapter;
138
+ cleanupAdapter = setup.cleanup;
139
+ }
140
+
141
+ const container = adapter.createElement('div');
142
+ container.className = 'pulse-test-container';
143
+ adapter.appendChild(adapter.getBody(), container);
144
+
145
+ function render(fn) {
146
+ // Clear container
147
+ while (container.childNodes && container.childNodes.length > 0) {
148
+ container.removeChild(container.childNodes[0]);
149
+ }
150
+ const node = fn();
151
+ if (node) {
152
+ adapter.appendChild(container, node);
153
+ }
154
+ }
155
+
156
+ render(componentFn);
157
+
158
+ /**
159
+ * Recursively get text content from a node tree
160
+ */
161
+ function getTextContent(node) {
162
+ if (!node) return '';
163
+ if (node.nodeType === 3) return node.textContent || '';
164
+ let text = node.textContent;
165
+ if (text !== undefined && text !== null) return String(text);
166
+ // Fallback: recurse children
167
+ let result = '';
168
+ const children = node.childNodes || [];
169
+ for (const child of children) {
170
+ result += getTextContent(child);
171
+ }
172
+ return result;
173
+ }
174
+
175
+ /**
176
+ * Recursively find elements matching a text predicate
177
+ */
178
+ function findByText(node, textOrRegex) {
179
+ const results = [];
180
+ if (!node) return results;
181
+
182
+ const nodeText = getTextContent(node);
183
+ const matches = textOrRegex instanceof RegExp
184
+ ? textOrRegex.test(nodeText)
185
+ : nodeText.includes(String(textOrRegex));
186
+
187
+ if (matches && node.nodeType === 1) {
188
+ results.push(node);
189
+ }
190
+
191
+ const children = node.childNodes || [];
192
+ for (const child of children) {
193
+ results.push(...findByText(child, textOrRegex));
194
+ }
195
+ return results;
196
+ }
197
+
198
+ function getByText(text) {
199
+ const found = findByText(container, text);
200
+ if (found.length === 0) {
201
+ throw new Error(
202
+ `getByText: Unable to find element with text "${text}" in container.\n` +
203
+ `Container text: "${getTextContent(container)}"`
204
+ );
205
+ }
206
+ // Return deepest (most specific) match
207
+ return found[found.length - 1];
208
+ }
209
+
210
+ function queryByText(text) {
211
+ const found = findByText(container, text);
212
+ return found.length > 0 ? found[found.length - 1] : null;
213
+ }
214
+
215
+ function getBySelector(selector) {
216
+ if (typeof container.querySelector === 'function') {
217
+ return container.querySelector(selector);
218
+ }
219
+ // Fallback for basic selectors on MockElement
220
+ const children = container.childNodes || [];
221
+ for (const child of children) {
222
+ if (child.tagName && child.tagName.toLowerCase() === selector.toLowerCase()) {
223
+ return child;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function getAll(selector) {
230
+ if (typeof container.querySelectorAll === 'function') {
231
+ return container.querySelectorAll(selector);
232
+ }
233
+ return [];
234
+ }
235
+
236
+ function unmount() {
237
+ while (container.childNodes && container.childNodes.length > 0) {
238
+ container.removeChild(container.childNodes[0]);
239
+ }
240
+ if (cleanupAdapter) {
241
+ cleanupAdapter();
242
+ }
243
+ }
244
+
245
+ function rerender(newComponentFn) {
246
+ render(newComponentFn);
247
+ }
248
+
249
+ return {
250
+ container,
251
+ adapter,
252
+ getByText,
253
+ getBySelector,
254
+ queryByText,
255
+ getAll,
256
+ unmount,
257
+ rerender
258
+ };
259
+ }
260
+
261
+ // =============================================================================
262
+ // 3. trackEffect
263
+ // =============================================================================
264
+
265
+ /**
266
+ * Create a tracked effect that records every execution.
267
+ *
268
+ * @param {Function} fn - Effect body (may access signals, may return a value)
269
+ * @param {object} [options]
270
+ * @param {string} [options.id] - Debug label
271
+ * @returns {EffectTracker}
272
+ *
273
+ * @typedef {object} EffectTracker
274
+ * @property {number} count - Number of times the effect has run
275
+ * @property {Array} values - Return values from each run
276
+ * @property {Function} dispose - Stop the effect
277
+ * @property {Function} reset - Reset count and values without stopping
278
+ * @property {Function} waitForRun - Promise that resolves when count reaches n
279
+ *
280
+ * @example
281
+ * const count = pulse(0);
282
+ * const tracker = trackEffect(() => count.get());
283
+ * count.set(1);
284
+ * assert.strictEqual(tracker.count, 2); // initial + one update
285
+ * tracker.dispose();
286
+ */
287
+ export function trackEffect(fn, options = {}) {
288
+ const tracker = {
289
+ count: 0,
290
+ values: [],
291
+ dispose: null,
292
+ reset() {
293
+ tracker.count = 0;
294
+ tracker.values = [];
295
+ },
296
+ waitForRun(n, timeoutMs = 2000) {
297
+ if (tracker.count >= n) return Promise.resolve();
298
+ return new Promise((resolve, reject) => {
299
+ const timeout = setTimeout(() => {
300
+ clearInterval(interval);
301
+ reject(new Error(`waitForRun: Expected ${n} runs but only got ${tracker.count} after ${timeoutMs}ms`));
302
+ }, timeoutMs);
303
+ const interval = setInterval(() => {
304
+ if (tracker.count >= n) {
305
+ clearTimeout(timeout);
306
+ clearInterval(interval);
307
+ resolve();
308
+ }
309
+ }, 5);
310
+ });
311
+ }
312
+ };
313
+
314
+ const dispose = createEffect(() => {
315
+ const result = fn();
316
+ tracker.count++;
317
+ tracker.values.push(result);
318
+ return result;
319
+ }, options);
320
+
321
+ tracker.dispose = dispose;
322
+ return tracker;
323
+ }
324
+
325
+ // =============================================================================
326
+ // 4. assertSignal / assertSignalDeep
327
+ // =============================================================================
328
+
329
+ /**
330
+ * Assert that a pulse signal's current value strictly equals expected.
331
+ * Uses .peek() to avoid creating reactive dependencies.
332
+ *
333
+ * @param {import('./pulse.js').Pulse} pulseInstance - Signal to check
334
+ * @param {*} expected - Expected value
335
+ * @param {string} [message] - Custom assertion message
336
+ */
337
+ export function assertSignal(pulseInstance, expected, message) {
338
+ const actual = pulseInstance.peek();
339
+ assert.strictEqual(
340
+ actual,
341
+ expected,
342
+ message ?? `Expected signal value ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
343
+ );
344
+ }
345
+
346
+ /**
347
+ * Assert that a pulse signal's current value is deeply equal to expected.
348
+ * Uses .peek() to avoid creating reactive dependencies.
349
+ *
350
+ * @param {import('./pulse.js').Pulse} pulseInstance - Signal to check
351
+ * @param {*} expected - Expected value
352
+ * @param {string} [message] - Custom assertion message
353
+ */
354
+ export function assertSignalDeep(pulseInstance, expected, message) {
355
+ const actual = pulseInstance.peek();
356
+ assert.deepStrictEqual(
357
+ actual,
358
+ expected,
359
+ message ?? `Expected signal value ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
360
+ );
361
+ }
362
+
363
+ // =============================================================================
364
+ // 5. spy
365
+ // =============================================================================
366
+
367
+ /**
368
+ * Create a spy function that records every call.
369
+ *
370
+ * @param {Function} [impl] - Optional implementation
371
+ * @returns {SpyFunction}
372
+ *
373
+ * @typedef {Function & SpyProps} SpyFunction
374
+ * @typedef {object} SpyProps
375
+ * @property {Array<{args: Array, thisArg: *, returnValue: *, timestamp: number}>} calls
376
+ * @property {number} callCount
377
+ * @property {Function} lastCall - Returns the most recent call record
378
+ * @property {Function} calledWith - Check if any call matched these args
379
+ * @property {Function} nthCall - Get the nth call record (0-indexed)
380
+ * @property {Function} reset - Clear recorded calls
381
+ * @property {Function} mockReturnValue - Set fixed return value
382
+ * @property {Function} mockReturnValueOnce - Queue a one-time return value
383
+ *
384
+ * @example
385
+ * const onClick = spy();
386
+ * button.addEventListener('click', onClick);
387
+ * fireEvent.click(button);
388
+ * assert.strictEqual(onClick.callCount, 1);
389
+ */
390
+ export function spy(impl) {
391
+ let _impl = impl || (() => undefined);
392
+ let _fixedReturn = undefined;
393
+ let _hasFixedReturn = false;
394
+ const _onceQueue = [];
395
+
396
+ function spyFn(...args) {
397
+ let returnValue;
398
+
399
+ if (_onceQueue.length > 0) {
400
+ returnValue = _onceQueue.shift();
401
+ } else if (_hasFixedReturn) {
402
+ returnValue = _fixedReturn;
403
+ } else {
404
+ returnValue = _impl.apply(this, args);
405
+ }
406
+
407
+ spyFn.calls.push({
408
+ args,
409
+ thisArg: this,
410
+ returnValue,
411
+ timestamp: Date.now()
412
+ });
413
+
414
+ return returnValue;
415
+ }
416
+
417
+ spyFn.calls = [];
418
+
419
+ Object.defineProperty(spyFn, 'callCount', {
420
+ get() { return spyFn.calls.length; },
421
+ enumerable: true
422
+ });
423
+
424
+ spyFn.lastCall = function () {
425
+ return spyFn.calls.length > 0 ? spyFn.calls[spyFn.calls.length - 1] : undefined;
426
+ };
427
+
428
+ spyFn.nthCall = function (n) {
429
+ return spyFn.calls[n];
430
+ };
431
+
432
+ spyFn.calledWith = function (...expectedArgs) {
433
+ return spyFn.calls.some(call =>
434
+ call.args.length === expectedArgs.length &&
435
+ call.args.every((arg, i) => Object.is(arg, expectedArgs[i]))
436
+ );
437
+ };
438
+
439
+ spyFn.reset = function () {
440
+ spyFn.calls = [];
441
+ _onceQueue.length = 0;
442
+ _hasFixedReturn = false;
443
+ _fixedReturn = undefined;
444
+ };
445
+
446
+ spyFn.mockReturnValue = function (val) {
447
+ _fixedReturn = val;
448
+ _hasFixedReturn = true;
449
+ return spyFn;
450
+ };
451
+
452
+ spyFn.mockReturnValueOnce = function (val) {
453
+ _onceQueue.push(val);
454
+ return spyFn;
455
+ };
456
+
457
+ return spyFn;
458
+ }
459
+
460
+ // =============================================================================
461
+ // 6. sleep
462
+ // =============================================================================
463
+
464
+ /**
465
+ * Return a promise that resolves after the given number of milliseconds.
466
+ *
467
+ * @param {number} ms - Milliseconds to wait
468
+ * @returns {Promise<void>}
469
+ */
470
+ export function sleep(ms) {
471
+ return new Promise(resolve => setTimeout(resolve, ms));
472
+ }
473
+
474
+ // =============================================================================
475
+ // 7. waitFor
476
+ // =============================================================================
477
+
478
+ /**
479
+ * Poll until a condition becomes true. Supports sync and async predicates.
480
+ *
481
+ * @param {Function} conditionFn - Sync or async predicate
482
+ * @param {object} [options]
483
+ * @param {number} [options.timeout=2000] - Max wait in ms
484
+ * @param {number} [options.interval=10] - Poll interval in ms
485
+ * @param {string} [options.message] - Custom timeout error message
486
+ * @returns {Promise<void>}
487
+ * @throws {Error} If timeout exceeded
488
+ *
489
+ * @example
490
+ * const ready = pulse(false);
491
+ * setTimeout(() => ready.set(true), 50);
492
+ * await waitFor(() => ready.peek() === true);
493
+ */
494
+ export function waitFor(conditionFn, options = {}) {
495
+ const { timeout = 2000, interval = 10, message } = options;
496
+
497
+ return new Promise((resolve, reject) => {
498
+ const timeoutId = setTimeout(() => {
499
+ clearInterval(intervalId);
500
+ reject(new Error(message || `waitFor: Condition not met within ${timeout}ms`));
501
+ }, timeout);
502
+
503
+ const check = async () => {
504
+ try {
505
+ const result = await conditionFn();
506
+ if (result) {
507
+ clearTimeout(timeoutId);
508
+ clearInterval(intervalId);
509
+ resolve();
510
+ }
511
+ } catch {
512
+ // Condition threw — keep polling
513
+ }
514
+ };
515
+
516
+ // Check immediately
517
+ check();
518
+ const intervalId = setInterval(check, interval);
519
+ });
520
+ }
521
+
522
+ // =============================================================================
523
+ // 8. mockStorage
524
+ // =============================================================================
525
+
526
+ /**
527
+ * Create a mock Web Storage implementation (localStorage/sessionStorage).
528
+ *
529
+ * @param {object} [options]
530
+ * @param {Record<string, string>} [options.initial] - Pre-populate entries
531
+ * @returns {MockStorageResult}
532
+ *
533
+ * @typedef {object} MockStorageResult
534
+ * @property {object} storage - Storage-compatible mock
535
+ * @property {Function} install - Install as globalThis.localStorage (or custom target)
536
+ * @property {Function} uninstall - Restore original globals
537
+ * @property {Function} clear - Clear all entries
538
+ * @property {object} data - Direct access to underlying data
539
+ * @property {SpyFunction} getItemSpy - Spy on getItem calls
540
+ * @property {SpyFunction} setItemSpy - Spy on setItem calls
541
+ * @property {SpyFunction} removeItemSpy - Spy on removeItem calls
542
+ *
543
+ * @example
544
+ * const mock = mockStorage({ initial: { theme: 'dark' } });
545
+ * mock.install('localStorage');
546
+ * assert.strictEqual(globalThis.localStorage.getItem('theme'), 'dark');
547
+ * mock.uninstall();
548
+ */
549
+ export function mockStorage(options = {}) {
550
+ const data = {};
551
+ const savedGlobals = {};
552
+
553
+ // Pre-populate
554
+ if (options.initial) {
555
+ for (const [k, v] of Object.entries(options.initial)) {
556
+ data[k] = String(v);
557
+ }
558
+ }
559
+
560
+ const getItemSpy = spy((key) => {
561
+ return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : null;
562
+ });
563
+
564
+ const setItemSpy = spy((key, value) => {
565
+ data[key] = String(value);
566
+ });
567
+
568
+ const removeItemSpy = spy((key) => {
569
+ delete data[key];
570
+ });
571
+
572
+ const storage = {
573
+ getItem(key) { return getItemSpy(key); },
574
+ setItem(key, value) { setItemSpy(key, value); },
575
+ removeItem(key) { removeItemSpy(key); },
576
+ clear() {
577
+ for (const key of Object.keys(data)) {
578
+ delete data[key];
579
+ }
580
+ },
581
+ key(index) {
582
+ const keys = Object.keys(data);
583
+ return index < keys.length ? keys[index] : null;
584
+ },
585
+ get length() {
586
+ return Object.keys(data).length;
587
+ }
588
+ };
589
+
590
+ function install(target = 'localStorage') {
591
+ savedGlobals[target] = globalThis[target];
592
+ globalThis[target] = storage;
593
+ }
594
+
595
+ function uninstall() {
596
+ for (const [target, original] of Object.entries(savedGlobals)) {
597
+ if (original !== undefined) {
598
+ globalThis[target] = original;
599
+ } else {
600
+ delete globalThis[target];
601
+ }
602
+ }
603
+ }
604
+
605
+ function clear() {
606
+ storage.clear();
607
+ getItemSpy.reset();
608
+ setItemSpy.reset();
609
+ removeItemSpy.reset();
610
+ }
611
+
612
+ return {
613
+ storage,
614
+ data,
615
+ install,
616
+ uninstall,
617
+ clear,
618
+ getItemSpy,
619
+ setItemSpy,
620
+ removeItemSpy
621
+ };
622
+ }
623
+
624
+ // =============================================================================
625
+ // 9. createTestContext
626
+ // =============================================================================
627
+
628
+ /**
629
+ * Create an isolated reactive context for a test.
630
+ * Unlike resetContext() which mutates the global context, this creates
631
+ * a private context that does not affect other tests.
632
+ *
633
+ * @param {string} [name] - Debug label
634
+ * @returns {IsolatedTestContext}
635
+ *
636
+ * @typedef {object} IsolatedTestContext
637
+ * @property {ReactiveContext} ctx - Underlying ReactiveContext
638
+ * @property {Function} run - Execute code in this context
639
+ * @property {Function} reset - Clear all effects and pending work
640
+ * @property {Function} dispose - Alias for reset
641
+ * @property {Function} pulse - Create a pulse scoped to this context
642
+ * @property {Function} effect - Create an effect scoped to this context
643
+ * @property {Function} computed - Create a computed scoped to this context
644
+ *
645
+ * @example
646
+ * const ctx = createTestContext('counter-test');
647
+ * const count = ctx.pulse(0);
648
+ * const tracker = ctx.run(() => trackEffect(() => count.get()));
649
+ * count.set(5);
650
+ * ctx.dispose();
651
+ */
652
+ export function createTestContext(name) {
653
+ const ctx = new ReactiveContext({ name: name || 'test-context' });
654
+
655
+ return {
656
+ ctx,
657
+ run(fn) {
658
+ return ctx.run(fn);
659
+ },
660
+ reset() {
661
+ ctx.reset();
662
+ },
663
+ dispose() {
664
+ ctx.reset();
665
+ },
666
+ pulse(value, options) {
667
+ return ctx.run(() => createPulse(value, options));
668
+ },
669
+ effect(fn, options) {
670
+ return ctx.run(() => createEffect(fn, options));
671
+ },
672
+ computed(fn, options) {
673
+ return ctx.run(() => createComputed(fn, options));
674
+ }
675
+ };
676
+ }
677
+
678
+ // =============================================================================
679
+ // 10. flushEffects
680
+ // =============================================================================
681
+
682
+ /**
683
+ * Synchronously flush all pending batched effects.
684
+ * Useful when testing code that uses batch() or deferred updates.
685
+ */
686
+ export function flushEffects() {
687
+ batch(() => {});
688
+ }
689
+
690
+ // =============================================================================
691
+ // 11. fireEvent
692
+ // =============================================================================
693
+
694
+ /**
695
+ * Dispatch a DOM event on a mock element.
696
+ *
697
+ * @param {MockElement} element - Target element
698
+ * @param {string} eventType - Event type ('click', 'input', 'change', etc.)
699
+ * @param {object} [init] - Additional event properties
700
+ * @returns {boolean} Whether the event was dispatched
701
+ *
702
+ * @example
703
+ * const btn = adapter.createElement('button');
704
+ * const handler = spy();
705
+ * adapter.addEventListener(btn, 'click', handler);
706
+ * fireEvent(btn, 'click');
707
+ * assert.strictEqual(handler.callCount, 1);
708
+ */
709
+ export function fireEvent(element, eventType, init = {}) {
710
+ const event = {
711
+ type: eventType,
712
+ bubbles: init.bubbles ?? true,
713
+ cancelable: init.cancelable ?? true,
714
+ defaultPrevented: false,
715
+ ...init,
716
+ preventDefault() { this.defaultPrevented = true; },
717
+ stopPropagation() {},
718
+ stopImmediatePropagation() {}
719
+ };
720
+
721
+ // Use Object.defineProperty for target since native Event has read-only target
722
+ Object.defineProperty(event, 'target', {
723
+ value: element,
724
+ writable: false,
725
+ configurable: true
726
+ });
727
+ Object.defineProperty(event, 'currentTarget', {
728
+ value: element,
729
+ writable: false,
730
+ configurable: true
731
+ });
732
+
733
+ if (typeof element.dispatchEvent === 'function') {
734
+ element.dispatchEvent(event);
735
+ } else {
736
+ // Fallback: directly invoke listeners on MockElement
737
+ const listeners = element._eventListeners;
738
+ if (listeners) {
739
+ const handlers = listeners.get(eventType);
740
+ if (handlers) {
741
+ for (const handler of [...handlers]) {
742
+ handler(event);
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ return !event.defaultPrevented;
749
+ }
750
+
751
+ // Shortcuts
752
+ fireEvent.click = (element, init) => fireEvent(element, 'click', init);
753
+ fireEvent.input = (element, init) => fireEvent(element, 'input', init);
754
+ fireEvent.change = (element, init) => fireEvent(element, 'change', init);
755
+ fireEvent.submit = (element, init) => fireEvent(element, 'submit', init);
756
+ fireEvent.focus = (element, init) => fireEvent(element, 'focus', { bubbles: false, ...init });
757
+ fireEvent.blur = (element, init) => fireEvent(element, 'blur', { bubbles: false, ...init });
758
+ fireEvent.keydown = (element, init) => fireEvent(element, 'keydown', init);
759
+ fireEvent.keyup = (element, init) => fireEvent(element, 'keyup', init);