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.
- package/cli/analyze.js +21 -8
- package/cli/build.js +83 -56
- package/cli/dev.js +108 -94
- package/cli/docs-test.js +52 -33
- package/cli/index.js +81 -51
- package/cli/mobile.js +92 -40
- package/cli/release.js +64 -46
- package/cli/scaffold.js +14 -13
- package/compiler/lexer.js +55 -54
- package/compiler/parser/core.js +1 -0
- package/compiler/parser/state.js +6 -12
- package/compiler/parser/style.js +17 -20
- package/compiler/parser/view.js +1 -3
- package/compiler/preprocessor.js +124 -262
- package/compiler/sourcemap.js +10 -4
- package/compiler/transformer/expressions.js +122 -106
- package/compiler/transformer/index.js +2 -4
- package/compiler/transformer/style.js +74 -7
- package/compiler/transformer/view.js +86 -36
- package/loader/esbuild-plugin-server-components.js +209 -0
- package/loader/esbuild-plugin.js +41 -93
- package/loader/parcel-plugin.js +37 -97
- package/loader/rollup-plugin-server-components.js +30 -169
- package/loader/rollup-plugin.js +27 -78
- package/loader/shared.js +362 -0
- package/loader/swc-plugin.js +65 -82
- package/loader/vite-plugin-server-components.js +30 -171
- package/loader/vite-plugin.js +25 -10
- package/loader/webpack-loader-server-components.js +21 -134
- package/loader/webpack-loader.js +25 -80
- package/package.json +52 -12
- package/runtime/dom-selector.js +2 -1
- package/runtime/form.js +4 -3
- package/runtime/http.js +6 -1
- package/runtime/logger.js +44 -24
- package/runtime/router/utils.js +14 -7
- package/runtime/security.js +13 -1
- package/runtime/server-components/actions-server.js +23 -19
- package/runtime/server-components/error-sanitizer.js +18 -18
- package/runtime/server-components/security.js +41 -24
- package/runtime/ssr-preload.js +5 -3
- package/runtime/testing.js +759 -0
- package/runtime/utils.js +3 -2
- package/server/utils.js +15 -9
- package/sw/index.js +2 -0
- package/types/loaders.d.ts +1043 -0
- package/compiler/parser/_extract.js +0 -393
- 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);
|