@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.
- package/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
|
@@ -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
|
+
};
|