spark-html-test-utils 1.0.0-rc.1
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 +83 -0
- package/package.json +37 -0
- package/src/index.js +167 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# spark-html-test-utils
|
|
2
|
+
|
|
3
|
+
Test helpers for [spark-html](https://www.npmjs.com/package/spark-html): mount a
|
|
4
|
+
component on [linkedom](https://github.com/WebReflection/linkedom), inspect its
|
|
5
|
+
reactive scope, and fire realistic DOM events — no browser, no build step.
|
|
6
|
+
|
|
7
|
+
This is the harness every spark-html debugging session hand-rolls, made
|
|
8
|
+
reusable. For component-level logic it's all you need. For hydration and
|
|
9
|
+
real-DOM-lifecycle behavior (detached hosts, event delegation timing), test in a
|
|
10
|
+
real browser.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i -D spark-html-test-utils # linkedom comes with it; spark-html is a peer
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## mount(fixture) → handle
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { mount, fireClick } from 'spark-html-test-utils';
|
|
20
|
+
|
|
21
|
+
const h = await mount({
|
|
22
|
+
root: '<div import="counter"></div>',
|
|
23
|
+
components: {
|
|
24
|
+
counter: '<button onclick={inc}>{n}</button><script>let n = 0; function inc(){ n++; }</script>',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
fireClick(h.query('button'));
|
|
29
|
+
await h.settle();
|
|
30
|
+
console.assert(h.query('button').textContent === '1');
|
|
31
|
+
h.cleanup();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`fixture` is a markup string (the `<body>`), or `{ root, components?, url? }`:
|
|
35
|
+
|
|
36
|
+
| field | meaning |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `root` | markup placed in `<body>` — usually a `<div import="…">` host |
|
|
39
|
+
| `components` | `{ name: source }` registered with `component()` before mount |
|
|
40
|
+
| `url` | the location the runtime sees (default `http://localhost/`) |
|
|
41
|
+
|
|
42
|
+
The handle:
|
|
43
|
+
|
|
44
|
+
| member | what it gives you |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `query(sel)` / `queryAll(sel)` | `document.querySelector[All]` over the mounted tree |
|
|
47
|
+
| `el` | the first booted component host (its `name` element) |
|
|
48
|
+
| `scope(el?)` | the reactive scope proxy — **read and write it** to drive/inspect state |
|
|
49
|
+
| `deps(node)` | the node's tracked dependency keys (`Set` or `null`) |
|
|
50
|
+
| `html()` | current `<body>` HTML — the serialized render |
|
|
51
|
+
| `settle()` | drain microtasks + rAF timers so reactive updates land before you assert |
|
|
52
|
+
| `cleanup()` | tear down components (drop store subscriptions) and restore globals |
|
|
53
|
+
|
|
54
|
+
`scope` and `deps` are the core `inspect` API (also re-exported), reading the
|
|
55
|
+
same `__spark*` internals as `spark-html-devtools` — a supported window.
|
|
56
|
+
|
|
57
|
+
## Event helpers
|
|
58
|
+
|
|
59
|
+
The runtime binds handlers with `addEventListener`, so a dispatched event with
|
|
60
|
+
the right type fires them; extra props ride on the event object the handler
|
|
61
|
+
receives, and events bubble (so `document`-delegated handlers fire too).
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { fire, fireClick, fireInput, fireChange, fireToggle, fireKey, fireSubmit, fireDrag } from 'spark-html-test-utils';
|
|
65
|
+
|
|
66
|
+
fireClick(el);
|
|
67
|
+
fireInput(input, 'typed'); // sets value, fires input (drives bind:value)
|
|
68
|
+
fireToggle(checkbox, true); // sets checked, fires change (drives bind:checked)
|
|
69
|
+
fireKey(input, 'Enter'); // keydown with event.key = 'Enter'
|
|
70
|
+
fireDrag(box, { from: { x: 0, y: 0 }, to: { x: 40, y: 8 } }); // pointerdown→move→up (+mouse), with clientX/Y
|
|
71
|
+
fire(el, 'focus', { detail: 1 });// anything else
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Recipe
|
|
75
|
+
|
|
76
|
+
- **Component logic** — linkedom mount here. Fast, deterministic, no browser.
|
|
77
|
+
- **SSR / hydration** — run the real server and drive a real browser (see the
|
|
78
|
+
spark-html repo's debugging workflow); linkedom can't model everything a
|
|
79
|
+
hydrating page does.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spark-html-test-utils",
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
|
+
"description": "Test helpers for spark-html: mount a component on linkedom, inspect its reactive scope, and fire realistic DOM events — no browser.",
|
|
5
|
+
"homepage": "https://wilkinnovo.github.io/spark-html",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"default": "./src/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node test/test-utils.js"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"spark-html": ">=1.0.0-rc.1 <2"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"linkedom": "^0.18.12"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/wilkinnovo/spark-html.git",
|
|
28
|
+
"directory": "packages/spark-html-test-utils"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"spark-html",
|
|
32
|
+
"testing",
|
|
33
|
+
"test-utils",
|
|
34
|
+
"linkedom"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-html-test-utils — mount a component on linkedom, inspect its reactive
|
|
3
|
+
* scope, and fire realistic DOM events, with no browser.
|
|
4
|
+
*
|
|
5
|
+
* This formalizes the harness every spark-html debugging session hand-rolls
|
|
6
|
+
* (see packages/spark-ssr/test/ssr.js `mountHydratedPage`). For component-level
|
|
7
|
+
* tests it's enough; hydration / real-DOM-lifecycle work still wants a real
|
|
8
|
+
* browser (the CDP recipe in the repo workflows).
|
|
9
|
+
*
|
|
10
|
+
* import { mount, fireClick, inspect } from 'spark-html-test-utils';
|
|
11
|
+
*
|
|
12
|
+
* const h = await mount({
|
|
13
|
+
* root: '<div import="counter"></div>',
|
|
14
|
+
* components: { counter: '<button onclick={inc}>{n}</button><script>let n = 0; function inc(){ n++; }</script>' },
|
|
15
|
+
* });
|
|
16
|
+
* fireClick(h.query('button'));
|
|
17
|
+
* await h.settle();
|
|
18
|
+
* assert.equal(h.query('button').textContent, '1');
|
|
19
|
+
* h.cleanup();
|
|
20
|
+
*
|
|
21
|
+
* `mount` re-exports the core `inspect` API (M1.3) so `h.scope()` / `h.deps()`
|
|
22
|
+
* read the same `__spark*` internals devtools does — a supported window, not a
|
|
23
|
+
* private hack.
|
|
24
|
+
*/
|
|
25
|
+
import { parseHTML } from 'linkedom';
|
|
26
|
+
import { mount as sparkMount, unmount as sparkUnmount, component, inspect } from 'spark-html';
|
|
27
|
+
|
|
28
|
+
export { inspect, component };
|
|
29
|
+
|
|
30
|
+
// The globals the runtime reaches for that linkedom's `window` doesn't put on
|
|
31
|
+
// `globalThis`. rAF → setTimeout(0) matches the core test shim; reactivity
|
|
32
|
+
// itself flushes on microtasks, so `settle()` just drains both.
|
|
33
|
+
function installGlobals(window, document, url) {
|
|
34
|
+
const u = new URL(url);
|
|
35
|
+
const globals = {
|
|
36
|
+
window, document, Node: window.Node,
|
|
37
|
+
location: { pathname: u.pathname, search: u.search, href: u.href, hash: u.hash, origin: u.origin },
|
|
38
|
+
requestAnimationFrame: (fn) => setTimeout(fn, 0),
|
|
39
|
+
cancelAnimationFrame: (id) => clearTimeout(id),
|
|
40
|
+
};
|
|
41
|
+
const prev = {};
|
|
42
|
+
for (const [k, v] of Object.entries(globals)) { prev[k] = globalThis[k]; globalThis[k] = v; }
|
|
43
|
+
return prev;
|
|
44
|
+
}
|
|
45
|
+
function restoreGlobals(prev) {
|
|
46
|
+
for (const [k, v] of Object.entries(prev)) {
|
|
47
|
+
if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* mount(fixture) → a handle.
|
|
53
|
+
*
|
|
54
|
+
* fixture is either a markup string (the document body) or
|
|
55
|
+
* `{ root, components?, url? }`:
|
|
56
|
+
* - root markup placed in <body> (usually a `<div import="…">` host)
|
|
57
|
+
* - components map of name → source, registered before mount (component())
|
|
58
|
+
* - url the location the runtime sees (default http://localhost/)
|
|
59
|
+
*/
|
|
60
|
+
export async function mount(fixture) {
|
|
61
|
+
const { root = '', components = {}, url = 'http://localhost/' } =
|
|
62
|
+
typeof fixture === 'string' ? { root: fixture } : (fixture || {});
|
|
63
|
+
|
|
64
|
+
for (const [name, src] of Object.entries(components)) component(name, src);
|
|
65
|
+
|
|
66
|
+
const { window, document } = parseHTML(`<!doctype html><html><body>${root}</body></html>`);
|
|
67
|
+
try { if (document.readyState === 'loading') document.readyState = 'complete'; } catch { /* fine */ }
|
|
68
|
+
|
|
69
|
+
const prev = installGlobals(window, document, url);
|
|
70
|
+
let cleaned = false;
|
|
71
|
+
|
|
72
|
+
const settle = async () => { for (let i = 0; i < 12; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await sparkMount(document.body);
|
|
76
|
+
await settle();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
restoreGlobals(prev);
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hosts = () => [...document.querySelectorAll('[name]')].filter((h) => h.__sparkScope);
|
|
83
|
+
const firstHost = () => hosts()[0] || document.body.firstElementChild;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
window,
|
|
87
|
+
document,
|
|
88
|
+
body: document.body,
|
|
89
|
+
/** The first booted component host (its `name` element). */
|
|
90
|
+
get el() { return firstHost(); },
|
|
91
|
+
query: (sel) => document.querySelector(sel),
|
|
92
|
+
queryAll: (sel) => [...document.querySelectorAll(sel)],
|
|
93
|
+
/** The reactive scope proxy of `el` (or the first host) — read AND write it. */
|
|
94
|
+
scope: (el) => inspect.scope(el || firstHost()),
|
|
95
|
+
/** The tracked dependency keys of a node (Set or null). */
|
|
96
|
+
deps: (node) => inspect.deps(node),
|
|
97
|
+
/** Current body HTML — the serialized render. */
|
|
98
|
+
html: () => document.body.innerHTML,
|
|
99
|
+
/** Drain microtasks + rAF timers so reactive updates land before you assert. */
|
|
100
|
+
settle,
|
|
101
|
+
/** Tear down mounted components (drop store subscriptions) and restore globals. */
|
|
102
|
+
cleanup() {
|
|
103
|
+
if (cleaned) return;
|
|
104
|
+
cleaned = true;
|
|
105
|
+
for (const h of hosts()) { try { sparkUnmount(h); } catch { /* already gone */ } }
|
|
106
|
+
restoreGlobals(prev);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── event helpers ───────────────────────────────────────────────────────────
|
|
112
|
+
// The runtime binds handlers with addEventListener, so a dispatched Event with
|
|
113
|
+
// the right type triggers them; extra props (clientX, key, …) ride on the event
|
|
114
|
+
// object the handler receives. linkedom's Event bubbles, so delegated handlers
|
|
115
|
+
// (document/window + closest()) fire too.
|
|
116
|
+
function makeEvent(el, type, props) {
|
|
117
|
+
const view = (el && el.ownerDocument && el.ownerDocument.defaultView) || globalThis.window;
|
|
118
|
+
const Ev = (view && view.Event) || globalThis.Event;
|
|
119
|
+
let ev;
|
|
120
|
+
try { ev = new Ev(type, { bubbles: true, cancelable: true }); }
|
|
121
|
+
catch { ev = { type, bubbles: true, cancelable: true, preventDefault() {}, stopPropagation() {} }; }
|
|
122
|
+
Object.assign(ev, props);
|
|
123
|
+
return ev;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Fire any event `type` on `el` with optional extra props; returns the event. */
|
|
127
|
+
export function fire(el, type, props = {}) {
|
|
128
|
+
const ev = makeEvent(el, type, props);
|
|
129
|
+
el.dispatchEvent(ev);
|
|
130
|
+
return ev;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const fireClick = (el, props = {}) => fire(el, 'click', { button: 0, ...props });
|
|
134
|
+
|
|
135
|
+
/** Set `el.value` (if given) then fire `input` — the event `bind:value` listens for. */
|
|
136
|
+
export function fireInput(el, value) {
|
|
137
|
+
if (value !== undefined) el.value = value;
|
|
138
|
+
return fire(el, 'input');
|
|
139
|
+
}
|
|
140
|
+
export function fireChange(el, value) {
|
|
141
|
+
if (value !== undefined) el.value = value;
|
|
142
|
+
return fire(el, 'change');
|
|
143
|
+
}
|
|
144
|
+
/** Set a checkbox/radio `checked` (if given) then fire `change`. */
|
|
145
|
+
export function fireToggle(el, checked) {
|
|
146
|
+
if (checked !== undefined) el.checked = checked;
|
|
147
|
+
return fire(el, 'change');
|
|
148
|
+
}
|
|
149
|
+
export const fireKey = (el, key, props = {}) => fire(el, 'keydown', { key, ...props });
|
|
150
|
+
export const fireSubmit = (el, props = {}) => fire(el, 'submit', props);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A realistic pointer drag: pointerdown → pointermove → pointerup, each paired
|
|
154
|
+
* with the mouse equivalent (libraries listen for one or the other), carrying
|
|
155
|
+
* clientX/clientY. `from`/`to` are `{x, y}`; `target` defaults to `el` but can
|
|
156
|
+
* be a drop target for the move/up phase.
|
|
157
|
+
*/
|
|
158
|
+
export function fireDrag(el, { from = { x: 0, y: 0 }, to = { x: 0, y: 0 }, target = el } = {}) {
|
|
159
|
+
const down = { clientX: from.x, clientY: from.y, button: 0, pointerId: 1, isPrimary: true };
|
|
160
|
+
const move = { clientX: to.x, clientY: to.y, button: 0, pointerId: 1, isPrimary: true };
|
|
161
|
+
fire(el, 'pointerdown', down);
|
|
162
|
+
fire(el, 'mousedown', down);
|
|
163
|
+
fire(target, 'pointermove', move);
|
|
164
|
+
fire(target, 'mousemove', move);
|
|
165
|
+
fire(target, 'pointerup', move);
|
|
166
|
+
fire(target, 'mouseup', move);
|
|
167
|
+
}
|