@use "/shell" as *; @use "quark:list" as list; #release-notice { /* opens once: sheet re-runs restart a delay, so gate it on the fact it writes */ &:not([data-did-open]) { @delay 7000 { is-open: ""; data-did-open: ""; } } /* prevent clicks from bubbling to the sheet */ @on mouseup, click (stop-propagation); } provider-fetch[api-url*="package-metas/index.json"][is-success] { $package-indices: prop("provision").body; /* `excom.navGroup` moves a package out of its type's list, into that group */ $ungrouped: list.reject($package-indices.packages, "navGroup"); .package-links { [bind-elements] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "kit-element")); } [bind-element-bases] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "element-base")); } [bind-tools] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "tool")); } /* the standalone libraries: same rows, rendered flat (shell.css) */ [bind-libraries] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "library")); } [bind-library-group] ul:not(ul ul) { content: iterate(list.filter($package-indices.packages, "navGroup", "libraries")); } ul:not(ul ul) > li { $pkg: item.shortName; > spa-a { route-href: "/packages/#{item.shortName}"; } } /* one `content` rule per link: a second one would rewrite the first's text on every pass */ ul:not(ul ul, [bind-libraries] ul) > li > spa-a { content: item.shortName; } [bind-libraries] ul:not(ul ul) > li > spa-a { content: displayName(item.shortName); } /* packages whose docs span several pages: one collapsible group per section */ [bind-sections] { content: iterate(item.docSections); summary { content: item.title; } details > ul { content: iterate(item.docs); spa-a { route-href: "/packages/#{$pkg}/#{item.name}"; content: item.title; } } details:not(:has(spa-a[is-active])) { open: none; } } } /* page titles: the page, then the site. The docs home keeps the title in the document head; the other routes carry theirs in markup */ spa-route[is-active] { /* a site guide */ &[route-href$=":name"] { $title-guide: list.find($package-indices.docs, "name", $route.params.name); document-title: "#{$title-guide.title or $route.params.name} · Nucleus · docs"; } /* a package README */ &[route-href$=":packageName"] { document-title: "#{displayName($route.params.packageName)} · Nucleus · docs"; } /* one page of a package's docs */ &[route-href$=":docName"] { $title-package: list.find($package-indices.packages, "shortName", $route.params.packageName); document-title: "#{docTitle($title-package, $route.params.docName)} · #{displayName($route.params.packageName)} · Nucleus · docs"; } } /* the page's markdown file, for the footer link: the docs home and each guide the index lists, and a package's page whose index entry says `markdown` (build-docs-index sets it; the dev server's index has none, so no link there). siteDocHref and the path check say which URL is the page's own: the 404 and a package's doc pages have none */ > spa-manager[active-url] { $page-path: attr("active-url").split("#").at(0).split("?").at(0); $page-name: if($page-path == SITE_HOME: SITE_HOME_DOC; else: $page-path.split("/").at(-1)); $page-guide: list.find($package-indices.docs, "name", $page-name); $page-package: list.find($package-indices.packages, "shortName", $page-name); $page-markdown: if($page-guide and siteDocHref($page-guide.name) == $page-path: "/docs/#{$page-guide.name}.md"; $page-package.markdown and "#{SITE_BASE}/packages/#{$page-package.shortName}" == $page-path: "/#{$page-package.shortName}.md"); footer [bind-page-markdown] { href: $page-markdown; content: ternary($page-markdown, "Markdown version of this page"); } } } spa-route { $route: prop("provision"); /* paramless routes name their guide in markup (the docs home, `/`); /docs/:name gets it from params */ $route-doc-name: attr("data-doc-name"); } /* the desktop aside and the mobile sheet stamp the same nav template */ [data-site-nav] { details:has(spa-a[is-active]) { /* not kosher */ open: ""; } spa-a[is-active] { aria-current: ""; } spa-a:not([is-active]) { aria-current: none; } } /* mobile sheet: Escape / back gesture while open; a tapped link closes it */ #site-menu { &[is-open] dismiss-watcher { is-active: ""; } &:not([is-open]) dismiss-watcher { is-active: none; } @on click (target: "spa-a") { is-open: none; } } #site-menu-gesture { &:has(> #site-menu[is-open]) { progress-offset: 1; } &:not(:has(> #site-menu[is-open])) { progress-offset: 0; } @on gesture-handler-start { #site-menu { is-scrubbing: ""; } } @on gesture-handler-end { #site-menu { is-open: event.detail.snap == 1; is-scrubbing: none; } } } main { @on copy-source (handle: copySource); } #search-dialog[open] { > include-content { is-active: ""; /* for some reason this is necessary on first render */ @on include-content-did-render (handle: focusInput); } input[type="search"] { /* works on all subsequent opens */ autofocus: ""; } } #search-dialog:not([open]) input[type="search"] { autofocus: none; }

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 fetch responses
  • 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 NucleusKit. Or it can be used by itself:

CDN Package Manager
npm install @excom/nucleus-test
HTML Imports JS / CSS Imports
import { /* … */ } from "@excom/nucleus-test";
Peer dependencies (1)

Packages a consumer must install alongside this one. Workspace deps are bundled.

Package Version
vitest ^4
View Dist Files

All exports:

.
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./chrome.mjs
  • import: ./chrome.mjs
  • default: ./chrome.mjs
./index
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.min
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.min.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./setup
  • types: ./dist/setup.d.ts
  • import: ./dist/setup.js
  • default: ./dist/setup.js
./setup.js
  • types: ./dist/setup.d.ts
  • import: ./dist/setup.js
  • default: ./dist/setup.js
./setup.min
  • types: ./dist/setup.d.ts
  • import: ./dist/setup.min.js
  • default: ./dist/setup.min.js
./setup.min.js
  • types: ./dist/setup.d.ts
  • import: ./dist/setup.min.js
  • default: ./dist/setup.min.js

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"] },
});

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>`);
});

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 HTML

getEventListeners(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) mounts html in document.body and returns its first element
  • click(target, init?) dispatches a bubbling, cancelable, composed click; false when prevented
  • waitForEvent(target, type, trigger?, delay?) resolves delay ms after type fires, rejects after 1 s; trigger runs once it listens
  • wait(ms?) resolves after ms
  • readFileRelative(import.meta.url, relPath) reads relPath, relative to the test file, as text
  • readDemo(import.meta.url, name) reads support/demos/<name>.html from a test in support/tests
  • spyFetch(response, ms?) stubs fetch with a response (or a function returning one) after ms; status 200 and a JSON content-type by default; vi.restoreAllMocks() / restoreMocks: true restores fetch
  • serveStatic(root, { fallback?, api? }) is a fetch stand-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 ms

Console 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() });

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);

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();
  • serve({ root?, port?, intercept? }) serves a directory (the working directory by default) on a free port, with index.html for an extensionless path without a file. intercept(url) sees each request first and answers it with { status, body, type }, or with nothing to decline
  • open({ port, size? }) opens a page. goto(pathOrUrl) loads it and waits until the network is quiet, run(fn, ...args) calls fn in 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. size is "390x844" by default; under 768 wide the page is mobile, with touch
  • until(check, ms) resolves true once check() is truthy, false after ms; sleep(ms) resolves after ms
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, and CHROME overrides the path
  • Add a parentNode count to the complexity meter, after parentElement; snapshots written before gain one line per measurement

0.2.0

  • measureComplexity, expectComplexity and trackComplexity snapshot Quark and DOM work per test
View Source

Beta. The Nucleus Stack is in beta for a few weeks until features are stabilized and optimized.

Thanks — we'll email you when it ships.

Something went wrong. Please try again.