@ozsarman/clarityjs 0.6.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,622 @@
1
+ /**
2
+ * Clarity.js Test Utilities
3
+ *
4
+ * Lightweight helpers for unit-testing Clarity components with jsdom.
5
+ *
6
+ * Peer dependency: jsdom (npm install --save-dev jsdom)
7
+ *
8
+ * Usage (Node + jsdom):
9
+ * import { setupDOM, render, query, queryAll, fireEvent, act, cleanup } from '@ozsarman/clarityjs/test'
10
+ *
11
+ * // Bootstrap once per test file (or in a beforeAll hook):
12
+ * setupDOM()
13
+ *
14
+ * test('Counter increments', async () => {
15
+ * const { container } = render(Counter)
16
+ * const btn = query(container, 'button')
17
+ * await act(() => fireEvent(btn, 'click'))
18
+ * assert.equal(query(container, 'h1').textContent, '1')
19
+ * })
20
+ *
21
+ * afterEach(cleanup) // unmount all rendered components
22
+ *
23
+ * Usage (browser / Vitest with happy-dom):
24
+ * No setup needed — document / window already exist.
25
+ * Just import and call render(), query(), etc.
26
+ *
27
+ * Author: Claude (Anthropic)
28
+ */
29
+
30
+ // ─── Environment bootstrap ────────────────────────────────────────────────────
31
+
32
+ let _dom = null; // jsdom JSDOM instance (Node only)
33
+
34
+ /**
35
+ * Install a jsdom window/document into the global scope.
36
+ * Call once at the top of a test file when running in Node.js.
37
+ *
38
+ * @param {string} html Optional initial HTML (default: <body></body>)
39
+ * @param {object} opts Additional JSDOM options (url, resources, …)
40
+ */
41
+ export async function setupDOM(html = '<!DOCTYPE html><html><body></body></html>', opts = {}) {
42
+ // No-op in browser environments
43
+ if (typeof window !== 'undefined' && typeof window.document !== 'undefined') return;
44
+
45
+ try {
46
+ const { JSDOM } = await import('jsdom');
47
+ _dom = new JSDOM(html, {
48
+ url: opts.url ?? 'http://localhost/',
49
+ pretendToBeVisual: opts.pretendToBeVisual ?? true,
50
+ ...opts,
51
+ });
52
+ // Inject into global scope so clarity-runtime.js works
53
+ const { window: win } = _dom;
54
+ global.window = win;
55
+ global.document = win.document;
56
+ global.navigator = win.navigator;
57
+ global.Node = win.Node;
58
+ global.Element = win.Element;
59
+ global.HTMLElement = win.HTMLElement;
60
+ global.DocumentFragment = win.DocumentFragment;
61
+ global.MutationObserver = win.MutationObserver;
62
+ global.requestAnimationFrame = (fn) => setTimeout(fn, 16);
63
+ global.queueMicrotask = global.queueMicrotask ?? ((fn) => Promise.resolve().then(fn));
64
+ } catch (err) {
65
+ throw new Error(
66
+ '[Clarity Test] jsdom not found.\n' +
67
+ 'Run: npm install --save-dev jsdom\n' +
68
+ 'Original error: ' + err.message
69
+ );
70
+ }
71
+ }
72
+
73
+ // ─── Render registry (for cleanup) ───────────────────────────────────────────
74
+ const _rendered = [];
75
+
76
+ /**
77
+ * Render a Clarity component into a detached container element.
78
+ *
79
+ * @param {function} ComponentFn The component function (exported from .clarity)
80
+ * @param {object} props Props to pass (default: {})
81
+ * @param {object} options { container } — use an existing element
82
+ * @returns {{ container, element, unmount }}
83
+ */
84
+ export function render(ComponentFn, props = {}, options = {}) {
85
+ const container = options.container ?? document.createElement('div');
86
+ // Append to body so effects that query the document work
87
+ document.body.appendChild(container);
88
+
89
+ const el = ComponentFn(props);
90
+
91
+ if (!(el instanceof Node)) {
92
+ throw new Error(
93
+ `[Clarity Test] render: ${ComponentFn.name || 'component'} did not return a DOM Node.\n` +
94
+ `Got: ${typeof el}`
95
+ );
96
+ }
97
+
98
+ container.appendChild(el);
99
+
100
+ // Call onMount lifecycle hook
101
+ if (typeof el.__clarity_mount__ === 'function') {
102
+ el.__clarity_mount__();
103
+ }
104
+
105
+ const unmount = () => {
106
+ if (typeof el.__clarity_cleanup__ === 'function') {
107
+ el.__clarity_cleanup__();
108
+ }
109
+ container.innerHTML = '';
110
+ container.parentNode?.removeChild(container);
111
+ };
112
+
113
+ _rendered.push(unmount);
114
+ return { container, element: el, unmount };
115
+ }
116
+
117
+ /**
118
+ * Unmount all components rendered since the last cleanup().
119
+ * Call in afterEach to prevent test pollution.
120
+ */
121
+ export function cleanup() {
122
+ _rendered.splice(0).forEach(fn => {
123
+ try { fn(); } catch (_) { /* ignore cleanup errors */ }
124
+ });
125
+ }
126
+
127
+ // ─── DOM helpers ──────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Query a single element inside a container.
131
+ *
132
+ * @param {Element} container
133
+ * @param {string} selector CSS selector
134
+ * @returns {Element|null}
135
+ */
136
+ export function query(container, selector) {
137
+ return container.querySelector(selector);
138
+ }
139
+
140
+ /**
141
+ * Query all elements matching a selector inside a container.
142
+ *
143
+ * @param {Element} container
144
+ * @param {string} selector
145
+ * @returns {Element[]}
146
+ */
147
+ export function queryAll(container, selector) {
148
+ return [...container.querySelectorAll(selector)];
149
+ }
150
+
151
+ /**
152
+ * Find an element by its text content (exact or partial match).
153
+ *
154
+ * @param {Element} container
155
+ * @param {string} text
156
+ * @param {string} selector Optional tag/selector filter (default: '*')
157
+ * @returns {Element|null}
158
+ */
159
+ export function queryByText(container, text, selector = '*') {
160
+ const els = [...container.querySelectorAll(selector)];
161
+ return els.find(el => el.textContent.includes(text)) ?? null;
162
+ }
163
+
164
+ // ─── Event helpers ────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Fire a DOM event on an element.
168
+ *
169
+ * @param {Element} element
170
+ * @param {string} type Event type ('click', 'input', 'submit', …)
171
+ * @param {object} init EventInit options (bubbles, cancelable, detail, …)
172
+ */
173
+ export function fireEvent(element, type, init = {}) {
174
+ const event = new Event(type, { bubbles: true, cancelable: true, ...init });
175
+ element.dispatchEvent(event);
176
+ }
177
+
178
+ /**
179
+ * Convenience: fire a click on an element.
180
+ */
181
+ export function click(element) {
182
+ fireEvent(element, 'click');
183
+ }
184
+
185
+ /**
186
+ * Convenience: fire an input event and update the element's value.
187
+ * Simulates typing into <input> or <textarea>.
188
+ *
189
+ * @param {HTMLInputElement} element
190
+ * @param {string} value
191
+ */
192
+ export function type(element, value) {
193
+ element.value = value;
194
+ fireEvent(element, 'input');
195
+ fireEvent(element, 'change');
196
+ }
197
+
198
+ /**
199
+ * Fire a keyboard event on an element.
200
+ *
201
+ * @param {Element} element
202
+ * @param {string} type 'keydown' | 'keyup' | 'keypress'
203
+ * @param {string} key Key name ('Enter', 'Escape', 'a', …)
204
+ * @param {object} init Additional KeyboardEventInit options
205
+ */
206
+ export function keyEvent(element, type, key, init = {}) {
207
+ const event = new KeyboardEvent(type, { key, bubbles: true, cancelable: true, ...init });
208
+ element.dispatchEvent(event);
209
+ }
210
+
211
+ // ─── Act (flush async updates) ────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Run a function and flush all pending microtasks / queueMicrotask callbacks.
215
+ *
216
+ * Clarity uses queueMicrotask() for <when>, <list>, and <Route> effects.
217
+ * After a state change + fireEvent, call await act() so those deferred
218
+ * updates run before your assertions.
219
+ *
220
+ * @param {function} fn Optional synchronous action to run first
221
+ * @returns {Promise<void>}
222
+ */
223
+ export async function act(fn) {
224
+ if (typeof fn === 'function') fn();
225
+ // Flush microtasks
226
+ await new Promise(resolve => setTimeout(resolve, 0));
227
+ }
228
+
229
+ /**
230
+ * Wait until a condition becomes true, polling every `interval` ms.
231
+ *
232
+ * Useful for lazy() components that load asynchronously.
233
+ *
234
+ * @param {function} condition () => boolean
235
+ * @param {number} timeout Max wait in ms (default: 2000)
236
+ * @param {number} interval Poll interval in ms (default: 50)
237
+ * @returns {Promise<void>}
238
+ */
239
+ export function waitFor(condition, timeout = 2000, interval = 50) {
240
+ return new Promise((resolve, reject) => {
241
+ const start = Date.now();
242
+ const poll = () => {
243
+ if (condition()) {
244
+ resolve();
245
+ } else if (Date.now() - start >= timeout) {
246
+ reject(new Error(`[Clarity Test] waitFor timed out after ${timeout}ms`));
247
+ } else {
248
+ setTimeout(poll, interval);
249
+ }
250
+ };
251
+ poll();
252
+ });
253
+ }
254
+
255
+ // ─── Snapshot helpers ─────────────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Serialize an element's inner HTML with normalized whitespace.
259
+ * Useful for snapshot-style assertions.
260
+ *
261
+ * @param {Element} element
262
+ * @returns {string}
263
+ */
264
+ export function toHTML(element) {
265
+ return element.innerHTML.replace(/\s+/g, ' ').trim();
266
+ }
267
+
268
+ /**
269
+ * Get an element's text content with normalized whitespace.
270
+ *
271
+ * @param {Element} element
272
+ * @returns {string}
273
+ */
274
+ export function getText(element) {
275
+ return (element.textContent ?? '').replace(/\s+/g, ' ').trim();
276
+ }
277
+
278
+ // ─── Signal testing helpers ───────────────────────────────────────────────────
279
+
280
+ /**
281
+ * Spy on a signal: returns an array that records every .set() call.
282
+ *
283
+ * @param {{ get(): any, set(v: any): void }} sig A Clarity signal
284
+ * @returns {{ values: any[], restore: function }}
285
+ */
286
+ export function spySignal(sig) {
287
+ const values = [];
288
+ const origSet = sig.set.bind(sig);
289
+ sig.set = (v) => {
290
+ values.push(v);
291
+ origSet(v);
292
+ };
293
+ return {
294
+ values,
295
+ restore() { sig.set = origSet; },
296
+ };
297
+ }
298
+
299
+ // ─── Snapshot system ──────────────────────────────────────────────────────────
300
+ //
301
+ // Snapshots are stored in .clarity-snapshots/<basename>.snap files next to
302
+ // the test file. Each snapshot is a named HTML string.
303
+ //
304
+ // Usage:
305
+ // const { container } = render(MyComponent);
306
+ // toMatchSnapshot(container, 'MyComponent default state');
307
+ //
308
+ // To update snapshots, set the environment variable:
309
+ // CLARITY_UPDATE_SNAPSHOTS=1 (or pass { update: true } as option)
310
+
311
+ const _UPDATE_SNAPSHOTS = typeof process !== 'undefined'
312
+ ? (process.env.CLARITY_UPDATE_SNAPSHOTS === '1' || process.argv.includes('--update-snapshots'))
313
+ : false;
314
+
315
+ /** In-memory snapshot store: Map<snapshotFile, Map<name, html>> */
316
+ const _snapStore = new Map();
317
+ /** Tracks which snapshots were used in this run */
318
+ const _snapUsed = new Set();
319
+ /** Tracks new/updated snapshots to write back */
320
+ const _snapDirty = new Set();
321
+
322
+ function _snapFilePath(testFile) {
323
+ if (!testFile) return null;
324
+ const { dirname: dirn, basename: basen } = _nodePath();
325
+ const dir = dirn(testFile);
326
+ const base = basen(testFile).replace(/\.(test|spec)\.(js|ts|mjs|cjs)$/, '') || basen(testFile);
327
+ return _nodeJoin(dir, '.clarity-snapshots', `${base}.snap`);
328
+ }
329
+
330
+ function _nodePath() {
331
+ // Lazy import — not available in browser
332
+ try { return require('node:path'); } catch { return { dirname: p => p.split('/').slice(0,-1).join('/'), basename: p => p.split('/').pop() }; }
333
+ }
334
+ function _nodeJoin(...parts) {
335
+ try { return require('node:path').join(...parts); } catch { return parts.join('/'); }
336
+ }
337
+
338
+ function _loadSnapFile(snapFile) {
339
+ if (!snapFile || _snapStore.has(snapFile)) return;
340
+ const map = new Map();
341
+ _snapStore.set(snapFile, map);
342
+ try {
343
+ const { readFileSync } = require('node:fs');
344
+ const raw = readFileSync(snapFile, 'utf8');
345
+ // Format: each snapshot is "// Snapshot: <name>\n<html>\n---\n"
346
+ const blocks = raw.split(/^---$/m);
347
+ for (const block of blocks) {
348
+ const m = block.match(/^\/\/ Snapshot: (.+)\n([\s\S]*)$/m);
349
+ if (m) map.set(m[1].trim(), m[2].trim());
350
+ }
351
+ } catch { /* file doesn't exist yet — will be created */ }
352
+ }
353
+
354
+ function _saveSnapFile(snapFile) {
355
+ if (!snapFile) return;
356
+ const map = _snapStore.get(snapFile);
357
+ if (!map) return;
358
+ let out = '';
359
+ for (const [name, html] of map) {
360
+ out += `// Snapshot: ${name}\n${html}\n---\n`;
361
+ }
362
+ try {
363
+ const { mkdirSync, writeFileSync } = require('node:fs');
364
+ const { dirname: dirn } = require('node:path');
365
+ mkdirSync(dirn(snapFile), { recursive: true });
366
+ writeFileSync(snapFile, out, 'utf8');
367
+ } catch (e) {
368
+ console.warn(`[Clarity Test] Could not write snapshot file: ${snapFile}\n${e.message}`);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Assert that an element's outer/inner HTML matches a stored snapshot.
374
+ * On first run (no snapshot exists), creates the snapshot.
375
+ * On mismatch, throws a descriptive error.
376
+ *
377
+ * @param {Element} element - DOM element to snapshot
378
+ * @param {string} name - Unique snapshot name within this test file
379
+ * @param {object} [opts]
380
+ * @param {boolean} [opts.update=false] - Force update this snapshot
381
+ * @param {string} [opts.testFile] - Absolute path to the test file (auto-detected when possible)
382
+ * @param {boolean} [opts.outer=false] - Use outerHTML instead of innerHTML
383
+ */
384
+ export function toMatchSnapshot(element, name, opts = {}) {
385
+ const update = opts.update ?? _UPDATE_SNAPSHOTS;
386
+ const outer = opts.outer ?? false;
387
+ const testFile = opts.testFile ?? _detectTestFile();
388
+ const snapFile = _snapFilePath(testFile);
389
+
390
+ _loadSnapFile(snapFile);
391
+
392
+ const html = _normalizeHTML(outer ? element.outerHTML : element.innerHTML);
393
+ const key = `${snapFile ?? 'anonymous'}::${name}`;
394
+ _snapUsed.add(key);
395
+
396
+ const map = snapFile ? _snapStore.get(snapFile) : null;
397
+ const existing = map?.get(name);
398
+
399
+ if (existing === undefined || update) {
400
+ // First run or update — store the snapshot
401
+ if (map) {
402
+ map.set(name, html);
403
+ _snapDirty.add(snapFile);
404
+ _saveSnapFile(snapFile);
405
+ }
406
+ if (update && existing !== undefined && existing !== html) {
407
+ console.log(`[Clarity Snapshot] Updated: "${name}"`);
408
+ } else if (existing === undefined) {
409
+ console.log(`[Clarity Snapshot] Created: "${name}"`);
410
+ }
411
+ return;
412
+ }
413
+
414
+ // Compare
415
+ if (html !== existing) {
416
+ throw new SnapshotMismatchError(name, existing, html);
417
+ }
418
+ }
419
+
420
+ class SnapshotMismatchError extends Error {
421
+ constructor(name, expected, received) {
422
+ const diff = _simpleDiff(expected, received);
423
+ super(
424
+ `[Clarity Snapshot] Mismatch: "${name}"\n\n` +
425
+ `Expected:\n${_indent(expected)}\n\n` +
426
+ `Received:\n${_indent(received)}\n\n` +
427
+ `Diff:\n${diff}\n\n` +
428
+ `Run with CLARITY_UPDATE_SNAPSHOTS=1 to accept the new snapshot.`
429
+ );
430
+ this.name = 'SnapshotMismatchError';
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Force-update a named snapshot. Call before `toMatchSnapshot` to refresh it.
436
+ * Useful in a test: `updateSnapshot('my-snap'); toMatchSnapshot(el, 'my-snap');`
437
+ */
438
+ export function updateSnapshot(name, testFile) {
439
+ const snapFile = _snapFilePath(testFile ?? _detectTestFile());
440
+ _loadSnapFile(snapFile);
441
+ const map = snapFile ? _snapStore.get(snapFile) : null;
442
+ if (map) map.delete(name);
443
+ }
444
+
445
+ /**
446
+ * Delete snapshots that were not used in this run (obsolete snapshots).
447
+ * Call in afterAll() to keep snapshot files clean.
448
+ */
449
+ export function pruneSnapshots(testFile) {
450
+ const snapFile = _snapFilePath(testFile ?? _detectTestFile());
451
+ _loadSnapFile(snapFile);
452
+ const map = snapFile ? _snapStore.get(snapFile) : null;
453
+ if (!map) return;
454
+
455
+ let pruned = 0;
456
+ for (const name of map.keys()) {
457
+ const key = `${snapFile}::${name}`;
458
+ if (!_snapUsed.has(key)) {
459
+ map.delete(name);
460
+ pruned++;
461
+ }
462
+ }
463
+ if (pruned > 0) {
464
+ _saveSnapFile(snapFile);
465
+ console.log(`[Clarity Snapshot] Pruned ${pruned} obsolete snapshot(s)`);
466
+ }
467
+ }
468
+
469
+ function _normalizeHTML(html) {
470
+ return (html ?? '')
471
+ .replace(/\s+/g, ' ')
472
+ .replace(/> </g, '>\n<')
473
+ .trim();
474
+ }
475
+
476
+ function _indent(str) {
477
+ return str.split('\n').map(l => ' ' + l).join('\n');
478
+ }
479
+
480
+ function _simpleDiff(a, b) {
481
+ const aLines = a.split('\n');
482
+ const bLines = b.split('\n');
483
+ const lines = [];
484
+ const max = Math.max(aLines.length, bLines.length);
485
+ for (let i = 0; i < max; i++) {
486
+ if (aLines[i] === bLines[i]) {
487
+ lines.push(` ${aLines[i] ?? ''}`);
488
+ } else {
489
+ if (aLines[i] !== undefined) lines.push(`- ${aLines[i]}`);
490
+ if (bLines[i] !== undefined) lines.push(`+ ${bLines[i]}`);
491
+ }
492
+ }
493
+ return lines.slice(0, 30).join('\n') + (lines.length > 30 ? '\n …' : '');
494
+ }
495
+
496
+ function _detectTestFile() {
497
+ // Try to read the test file path from Node.js stack trace
498
+ try {
499
+ const e = new Error();
500
+ const stack = e.stack?.split('\n') ?? [];
501
+ for (const line of stack) {
502
+ const m = line.match(/\((.+\.(?:test|spec)\.[mc]?[jt]s):\d+:\d+\)/);
503
+ if (m) return m[1];
504
+ }
505
+ } catch { /* ignore */ }
506
+ return null;
507
+ }
508
+
509
+ // ─── Component mocks ──────────────────────────────────────────────────────────
510
+
511
+ const _componentMocks = new Map();
512
+
513
+ /**
514
+ * Replace a component function with a mock implementation for testing.
515
+ *
516
+ * Usage:
517
+ * const { restore } = mockComponent(Header, ({ title }) => {
518
+ * const el = document.createElement('div');
519
+ * el.textContent = `[MockHeader: ${title}]`;
520
+ * return el;
521
+ * });
522
+ * // ...test...
523
+ * restore();
524
+ *
525
+ * @param {function} ComponentFn The real component function
526
+ * @param {function} [mockImpl] Mock implementation (default: renders a placeholder div)
527
+ * @returns {{ mock: function, calls: Array, restore: function }}
528
+ */
529
+ export function mockComponent(ComponentFn, mockImpl) {
530
+ const calls = [];
531
+ const originalName = ComponentFn.name ?? 'Component';
532
+
533
+ const mock = function MockedComponent(props) {
534
+ calls.push({ props });
535
+ if (mockImpl) return mockImpl(props);
536
+ // Default: render a labelled placeholder
537
+ const el = document.createElement('div');
538
+ el.setAttribute('data-clarity-mock', originalName);
539
+ el.textContent = `[Mock: ${originalName}]`;
540
+ return el;
541
+ };
542
+ Object.defineProperty(mock, 'name', { value: originalName });
543
+
544
+ // Store the mock for potential lookup by import path
545
+ _componentMocks.set(ComponentFn, { original: ComponentFn, mock, calls });
546
+
547
+ function restore() {
548
+ _componentMocks.delete(ComponentFn);
549
+ }
550
+
551
+ return { mock, calls, restore };
552
+ }
553
+
554
+ /**
555
+ * Check if a component has been mocked.
556
+ * Used internally by render() to swap implementations.
557
+ */
558
+ export function getMockedComponent(ComponentFn) {
559
+ return _componentMocks.get(ComponentFn)?.mock ?? ComponentFn;
560
+ }
561
+
562
+ /**
563
+ * Clear all component mocks.
564
+ */
565
+ export function clearComponentMocks() {
566
+ _componentMocks.clear();
567
+ }
568
+
569
+ // ─── Signal mocks ─────────────────────────────────────────────────────────────
570
+
571
+ /**
572
+ * Create a mock signal that records all .set() calls.
573
+ *
574
+ * Mirrors the { get, set, peek, update, subscribe } interface of real signals
575
+ * but is entirely self-contained — no reactivity runtime required.
576
+ *
577
+ * Usage:
578
+ * const [nameSig, nameHistory] = mockSignal('Alice');
579
+ * nameSig.set('Bob');
580
+ * assert.deepEqual(nameHistory, ['Alice', 'Bob']); // initial + set
581
+ *
582
+ * @param {*} initialValue
583
+ * @returns {[signal, valuesArray]}
584
+ */
585
+ export function mockSignal(initialValue) {
586
+ let _val = initialValue;
587
+ const _subs = new Set();
588
+ const history = [initialValue];
589
+
590
+ const sig = {
591
+ get() { return _val; },
592
+ peek() { return _val; },
593
+ set(v) {
594
+ _val = v;
595
+ history.push(v);
596
+ for (const fn of _subs) { try { fn(v); } catch {} }
597
+ },
598
+ update(fn) { sig.set(fn(_val)); },
599
+ subscribe(fn) {
600
+ _subs.add(fn);
601
+ fn(_val); // call immediately like real signals
602
+ return () => _subs.delete(fn);
603
+ },
604
+ __isMockSignal: true,
605
+ };
606
+
607
+ return [sig, history];
608
+ }
609
+
610
+ // ─── Re-exports for convenience ───────────────────────────────────────────────
611
+ // Users can write: import { render, act, click, query } from '@ozsarman/clarityjs/test'
612
+ export default {
613
+ setupDOM, render, cleanup,
614
+ query, queryAll, queryByText,
615
+ fireEvent, click, type, keyEvent,
616
+ act, waitFor,
617
+ toHTML, getText,
618
+ spySignal,
619
+ toMatchSnapshot, updateSnapshot, pruneSnapshots,
620
+ mockComponent, getMockedComponent, clearComponentMocks,
621
+ mockSignal,
622
+ };