@wcstack/testing
v4.20.0
Published
Headless test helpers for wcstack pages: mount HTML under happy-dom, await bindings, read/write state, settle updates - the README recipe as one import.
Downloads
5,334
Maintainers
Readme
@wcstack/testing
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
What if testing a page were one import?
A <wcs-state> page is plain DOM, so it already tests headlessly with happy-dom — the recipe is in the state README and needs no test-only API. @wcstack/testing is that recipe packaged: mount the HTML, wait for every element and binding, read and write the state, settle, assert. It is a convenience, not a requirement; the bare recipe keeps working without it.
It is for <wcs-state> pages. A page built on @wcstack/signals has no state tree for state() to return: test its signals, components and bindNode wiring directly under vitest + happy-dom.
import { mount, settle, fire } from "@wcstack/testing";
const app = await mount(`
<wcs-state json='{"count": 1}'></wcs-state>
<p data-wcs="textContent: count"></p>
<button data-wcs="onclick: up">+1</button>
`);
expect(app.root.querySelector("p")!.textContent).toBe("1");
await app.state().write((s) => { s.count = 42; }); // what a handler does
await settle();
expect(app.root.querySelector("p")!.textContent).toBe("42");
fire(app.root.querySelector("button")!, "click"); // what a user does
await settle();
app.unmount();npm install -D @wcstack/testing @wcstack/state @wcstack/server vitest happy-dom@wcstack/state and @wcstack/server are peers: mount() registers the elements from state and waits with server's waitForReady — the same stabilization loop renderToString uses before serializing, so <wcs-router>'s first route, a $connectedCallback that inserts more elements, and <wcs-state>'s binding construction are all covered by one call. Under vitest, set environment: 'happy-dom' and nothing else is needed; in bare Node, see installDom().
API
mount(html, options?) → Promise<MountedApp>
Registers the elements, inserts html, and resolves once everything under the root is ready.
| Option | Description |
|---|---|
| root | "document" (default) — the HTML becomes document.body's content. "shadow" — it goes into an open ShadowRoot on a fresh host appended to document.body, so bindings scope to that root |
| bootstrap | Registration functions to run first. Default [bootstrapState] (imported lazily from @wcstack/state). Add what the page uses — bootstrapRouter, bootstrapFetch, … — as functions or async loaders: async () => (await import("@wcstack/router")).bootstrapRouter(). Every wcstack bootstrap is idempotent |
| stateTagName | The state tag when bootstrapState({ tagNames }) renamed it (default wcs-state) |
| maxIterations | Stabilization rounds for elements inserted while waiting (default 10) |
| resolveModule | Since 4.9. (url: URL) => string \| URL \| null — the local file a <wcs-state src> module is read from, the same option as @wcstack/server's renderToString: it gets the URL the page names (resolved against document.baseURI — an http: URL under happy-dom) and returns a path or a file: URL. Node's module loader reads file: and data: URLs only. Installed for the length of the mount; the module is imported anew for each mount, so tests do not share a state object. Without it, or when it returns nothing, the state fails to initialize with a message saying so — for the root <wcs-state>, mount() rejects with it. A .json src is fetched, as before. See A state in its own file |
Since 4.11, an option mount() does not have, or a value not of the option's type, rejects at once — before the document is touched — naming the option and listing the options, as every package's bootstrapXxx() does; a misspelt resolveModul used to be ignored, and the test failed later with Node's ERR_UNSUPPORTED_ESM_URL_SCHEME. An option whose value is undefined is skipped. fire()'s init and installDom()'s options are checked the same way.
The returned MountedApp:
| Member | Description |
|---|---|
| root | document or the ShadowRoot — query it |
| container | document.body or the shadow host |
| state() | Accessor for the root <wcs-state> — v2 has one state tree per root, so there is nothing to name. Volumes (mount=) are part of that tree: read them by path prefix. (mount() mounts DOM; mount= mounts state.) Throws if absent |
| state().read(fn) | Run fn against a readonly proxy and return its result |
| state().write(fn) | Run fn (sync or async) against a writable proxy — exactly what a handler does. Follow with await settle() |
| state().element | The element itself |
| unmount() | Remove the mounted HTML |
mount() also smooths two happy-dom edges so the page behaves as in a browser:
- it disables
URL.createObjectURLonce per process when a browser-style one is present — Node cannot importblob:URLs, and without this an inline<script type="module">state never finishes loading (the loader then takes itsdata:URL path, as SSR does); - it wraps happy-dom's
textContent/innerTextsetters to stringify like the DOM spec — happy-dom turns a numeric0into an empty string (andinnerTextthrows on numbers), so atextContent: countbinding would blank out at zero only under happy-dom.
A <wcs-state> with nothing to load — no state, src or json attribute and no inline <script type="module"> — waits for setInitialState(), which a test cannot call before mount() returns. Since 4.9, mount() rejects at once and says what to write instead (json=, an inline script, or src with resolveModule); it used to never resolve, and the test timed out without a word. Volumes (mount=) are checked too; bind-component elements and elements inside a <template> are not.
A state in its own file
Since 4.9, a page that keeps its state in a file — <wcs-state src="./state.js">, the form the documents prefer under a strict CSP — mounts with resolveModule, which maps the URL the page names to the file:
import { mount } from "@wcstack/testing";
// public/index.html has <wcs-state src="./state.js">; this test is in tests/
const resolveModule = (url: URL) => new URL("../public" + url.pathname, import.meta.url);
const app = await mount(`
<wcs-state src="./state.js"></wcs-state>
<p data-wcs="textContent: total"></p>
`, { resolveModule });(url) => join(publicDir, url.pathname) works as well. Write the URL as a concatenation, as above: Vite (and so vitest) rewrites new URL("…", import.meta.url) written with a string or template literal into an asset URL that is not the file. The state may import its helpers (./lib.js) — they resolve next to the file. An inline <script type="module"> state cannot import a relative module under Node: it is evaluated from a data: URL, which has no base for ./lib.js (the error then points at the CSP guide, which does not apply to a test). Put such a state in its own file and use resolveModule.
settle() → Promise<void>
Two microtask turns and one macrotask — enough for a write to reach the DOM. Use it after write(), fire(), or any DOM event.
fire(target, type, init?) → boolean
Dispatches an Event (or a CustomEvent when init.detail is given), bubbling by default. Returns false when a handler called preventDefault(). Since 4.11, an init key other than bubbles, cancelable, composed and detail throws: it never reached the event — { key: "Enter" } made no KeyboardEvent. For another event class, call target.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })) yourself.
installDom(options?) → Promise<() => Promise<void>>
For runners without a DOM environment: creates a happy-dom Window (options.url, default http://localhost/) — or takes options.window — and installs its globals with server's installGlobals. The returned function restores the globals and closes the window. Import @wcstack/state (and any other wcstack package) after calling it: element classes bind their base class at module evaluation. happy-dom is an optional peer, needed only here.
import { installDom, mount, settle } from "@wcstack/testing";
const restore = await installDom();
try {
const app = await mount(html); // @wcstack/state is imported lazily, after the DOM exists
// ...
} finally {
await restore();
}Blind spots
Two things happy-dom cannot reproduce; keep one browser e2e (Playwright) for them:
customElements.defineupgrades existing nodes by replacing them, so "a value reaches the same node after a late define" cannot be asserted headlessly.- Event timing differs from real browsers.
Example
examples/state-testing-todo/ — a todo page and its vitest suite driving it through mount / fire / settle.
License
MIT
