nucleus-test
Test Nucleus Stack apps and custom elements in Vitest with one setup line: a browser-like DOM, HTML-aware matchers and helpers for clicks, events and fetch.
Features
- One-line setup Vitest on happy-dom, patched for browser parity by
@excom/nucleus-dom - Semantic DOM diffs Compare markup, not whitespace / attribute order; snapshots as readable HTML
- Listener leak checks Count event listeners per element and assert them in one line
- Fixtures / mocks Mount HTML, click, await events, mock
fetchresponses - Complexity snapshots Snapshot the engine and DOM work a test costs; a changed count flags a regression
- Quiet logs
console.log(element)prints<div#id>, not thousands of lines
Installation
This package is available in the
npm install @excom/nucleus-testimport { /* … */ } from "@excom/nucleus-test";Peer dependencies (1)
Packages a consumer must install alongside this one. Workspace deps are bundled.
| Package | Version |
|---|---|
|
|
vitest |
^4 |
Usage
Install vitest 4 and happy-dom (the version @excom/nucleus-dom pins) next to this package, and add the setup file to your Vitest config.
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "happy-dom", setupFiles: ["@excom/nucleus-test/setup"] },
});// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "happy-dom", setupFiles: ["@excom/nucleus-test/setup"] },
});A test file imports everything from one place, Vitest's own API and @open-wc/semantic-dom-diff included.
import { afterEach, click, expect, fixture, it, waitForEvent } from "@excom/nucleus-test";
import "./x-drawer";
afterEach(() => {
document.body.innerHTML = "";
});
it("opens on click", async () => {
const drawer = fixture(`<x-drawer><button>Cart</button></x-drawer>`);
await waitForEvent(drawer, "x-drawer-open", () => click(drawer.querySelector("button")!));
expect(drawer).equalTag(`<x-drawer open></x-drawer>`);
});import { afterEach, click, expect, fixture, it, waitForEvent } from "@excom/nucleus-test";
import "./x-drawer";
afterEach(() => {
document.body.innerHTML = "";
});
it("opens on click", async () => {
const drawer = fixture(`<x-drawer><button>Cart</button></x-drawer>`);
await waitForEvent(drawer, "x-drawer-open", () => click(drawer.querySelector("button")!));
expect(drawer).equalTag(`<x-drawer open></x-drawer>`);
});Matchers
expect(list).dom.to.equal(`<ul><li>Tea</li></ul>`); // semantic HTML diff
expect(drawer).equalTag(`<x-drawer open></x-drawer>`); // own tag + attributes, children ignored
expect(drawer).toMatchListeners({ click: 1, keydown: 1 }); // exact listener counts per type
expect(window).toContainListeners({ resize: 0 }); // listed types only: none left after removal
expect(list).toMatchInlineSnapshot(); // elements snapshot as indented HTMLexpect(list).dom.to.equal(`<ul><li>Tea</li></ul>`); // semantic HTML diff
expect(drawer).equalTag(`<x-drawer open></x-drawer>`); // own tag + attributes, children ignored
expect(drawer).toMatchListeners({ click: 1, keydown: 1 }); // exact listener counts per type
expect(window).toContainListeners({ resize: 0 }); // listed types only: none left after removal
expect(list).toMatchInlineSnapshot(); // elements snapshot as indented HTMLgetEventListeners(target) returns the recorded listeners by type; clearEventListeners(target) forgets them. Both are globals too.
Types for the matchers and globals come with any import from @excom/nucleus-test; the setup file declares none.
Helpers
fixture(html)mountshtmlindocument.bodyand returns its first elementclick(target, init?)dispatches a bubbling, cancelable, composedclick;falsewhen preventedwaitForEvent(target, type, trigger?, delay?)resolvesdelayms aftertypefires, rejects after 1 s;triggerruns once it listenswait(ms?)resolves aftermsreadFileRelative(import.meta.url, relPath)readsrelPath, relative to the test file, as textreadDemo(import.meta.url, name)readssupport/demos/<name>.htmlfrom a test insupport/testsspyFetch(response, ms?)stubsfetchwith a response (or a function returning one) afterms; status 200 and a JSONcontent-typeby default;vi.restoreAllMocks()/restoreMocks: truerestoresfetchserveStatic(root, { fallback?, api? })is afetchstand-in serving a directory at the page's origin, like a static dev server, with an SPA fallback and/api/*sent to a handler:vi.spyOn(globalThis, "fetch").mockImplementation(serveStatic(root, { fallback: "index.html" }))
const fetchSpy = spyFetch({ body: JSON.stringify({ items: 3 }) });
// … the element under test fetches
expect(fetchSpy).toHaveBeenCalledWith("/api/cart");
spyFetch(() => ({ status: 404 }), 300); // a 404 after 300 msconst fetchSpy = spyFetch({ body: JSON.stringify({ items: 3 }) });
// … the element under test fetches
expect(fetchSpy).toHaveBeenCalledWith("/api/cart");
spyFetch(() => ({ status: 404 }), 300); // a 404 after 300 msConsole output goes through consoleSinks: spy on one to capture / silence it, e.g. vi.spyOn(consoleSinks, "warn").mockImplementation(() => {}).
Complexity snapshots
Count the work a test causes, in units (rule passes, binding reads / writes, DOM queries and writes), never time, and snapshot it. A changed count is a regression signal: review it, then update deliberately with -u. Counts are exact only when the test drives the page itself; a suite that polls a live app on timers and simulated latency gets different counts each run, so measure a single element or a controlled scenario there.
trackComplexity(engine, { settle? }) snapshots every test in the file (or describe) as <test> > complexity 1. engine is the engine's own counters, Quark.meter for Quark; settle lets the page finish before the count is taken. Call it after hooks that clear the DOM, since after hooks run last-registered first.
import { afterEach, trackComplexity } from "@excom/nucleus-test";
import { Quark } from "@excom/quark";
afterEach(() => {
document.body.innerHTML = "";
});
trackComplexity(Quark.meter, { settle: () => Quark.whenSettled() });import { afterEach, trackComplexity } from "@excom/nucleus-test";
import { Quark } from "@excom/quark";
afterEach(() => {
document.body.innerHTML = "";
});
trackComplexity(Quark.meter, { settle: () => Quark.whenSettled() });For one measurement inside a test, measureComplexity(engine) counts from that point: take() once the work has settled, stop() to restore the DOM, expectComplexity(budget) to match the test's complexity snapshot. Use it or trackComplexity in a file, not both: they share and reset the same counters.
const meter = measureComplexity(Quark.meter);
details.setAttribute("open", "");
await Quark.whenSettled();
const budget = meter.take();
meter.stop();
expectComplexity(budget);const meter = measureComplexity(Quark.meter);
details.setAttribute("open", "");
await Quark.whenSettled();
const budget = meter.take();
meter.stop();
expectComplexity(budget);A ComplexityBudget holds the engine counters (quarkRuns, ruleRuns, variableRuns, attributeRuns, listenerRuns, setVar, getVar, schedulePaint), the DOM calls (querySelectorAll, matches, closest, parentElement, parentNode, setAttribute, removeAttribute, textContent, importNode) and queryScopeCost, the elements scanned by the engine's rule queries, sampled at take(). parentElement and parentNode count the reads of each getter by the code under test, not the climbing the DOM emulation does to match a selector or build an event's path, which a browser does natively. Any engine with { counts, reset() } (EngineMeter) can be measured; without scopeSelectors(), queryScopeCost is 0. Quark.meter reads the selectors of the sheets registered at take(), so a sheet unregistered inside the measured window no longer counts there.
Headless Chrome
@excom/nucleus-test/chrome.mjs drives a page in headless Google Chrome from a Node script, for what happy-dom cannot show: layout, painted frames, a service worker. It needs Google Chrome on the machine, found in its usual place on macOS, Linux and Windows; CHROME overrides the path.
import { open, serve, until } from "@excom/nucleus-test/chrome.mjs";
const server = await serve({ root: "dist" });
const page = await open({ port: server.port });
await page.goto("/");
await until(() => page.run(() => !document.querySelector("[is-loading]")), 5000);
console.log(await page.run(() => document.title), page.issues());
await page.close();
server.close();import { open, serve, until } from "@excom/nucleus-test/chrome.mjs";
const server = await serve({ root: "dist" });
const page = await open({ port: server.port });
await page.goto("/");
await until(() => page.run(() => !document.querySelector("[is-loading]")), 5000);
console.log(await page.run(() => document.title), page.issues());
await page.close();
server.close();serve({ root?, port?, intercept? })serves a directory (the working directory by default) on a free port, withindex.htmlfor an extensionless path without a file.intercept(url)sees each request first and answers it with{ status, body, type }, or with nothing to declineopen({ port, size? })opens a page.goto(pathOrUrl)loads it and waits until the network is quiet,run(fn, ...args)callsfnin the page with JSON arguments,cdp(method, params)sends a DevTools Protocol command,shot(name)saves a screenshot,issues()returns the console errors and warnings, exceptions and failed requests so far,close()ends Chrome.sizeis"390x844"by default; under 768 wide the page is mobile, with touchuntil(check, ms)resolvestrueoncecheck()is truthy,falseafterms;sleep(ms)resolves afterms
Attributes
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
Provision
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
Events
Type
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
Recognized Elements
Child or descendant elements are recognized by and relevant to its functionality.Styles
@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
Release notes (2)
0.3.0
- Add
@excom/nucleus-test/chrome.mjs, a headless Chrome harness for browser checks (open(),serve(),until(),sleep()); it finds Chrome on macOS, Linux and Windows, andCHROMEoverrides the path - Add a
parentNodecount to the complexity meter, afterparentElement; snapshots written before gain one line per measurement
0.2.0
- measureComplexity, expectComplexity and trackComplexity snapshot Quark and DOM work per test
Release notes
e.preventDefault() is not
synchronously called on the event.
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.