Testing
Component harnesses for a page built with Kanzo UI — a class per component that finds it by its ARIA and drives it, in Playwright or in Testing Library, in Angular CDK's shape. A sibling of @kanzo-tech/ui that imports none of it.
@kanzo-tech/testing is how a host tests a page built with the library. Each component a test drives
has a harness: a class that knows how to find that component and how to operate it, and nothing
about the page around it. The same harness runs in a host's Playwright suite and in a unit test over
Testing Library, so the library's own tests exercise what a host relies on. Testing a page built with
Kanzo UI is why it is shaped this way.
pnpm add -D @kanzo-tech/testingIt imports none of the components. A harness knows one by its roles, its accessible names and its text, and what a canvas knows comes through a hook only a test installs, so it runs against whatever version of the components the page loaded.
In Playwright
import { ChartHarness, FilterBarHarness, GraphCanvasHarness, playwright } from "@kanzo-tech/testing";
const env = await playwright(page); // before the page loads: it installs the test hook
await page.goto("/archive");
const graph = await env.getHarness(GraphCanvasHarness);
await graph.ready();
const lon = await env.getHarness(ChartHarness.with({ title: "Count by Airport.lon" }));
await lon.brush({ x: [-125, -100] }); // in data, not in pixels
const bar = await env.getHarness(FilterBarHarness);
await expect.poll(() => bar.readout()).toBe("611 of 3,218 airports");playwright(page, options?) resolves with a HarnessEnvironment once it has installed the test
hook with addInitScript, so make it before the page navigates: a chart or a graph registers with
the hook while it mounts. options is an EnvironmentOptions: timeout, how long a harness waits
for a state before it fails — 10 000 ms by default — and models, a model double
per model id. It imports nothing of Playwright's at run time; playwright-core is an optional peer
for its types, and a suite on @playwright/test has it already.
In a unit test
import { FilterBarHarness } from "@kanzo-tech/testing";
import { dom } from "@kanzo-tech/testing/dom";
const env = dom(); // before render: the hook is installed at once
render(<Page />);
const bar = await env.getHarness(FilterBarHarness);
expect(await bar.readout()).toBe("10 sightings");dom(container?, options?), on its own subpath because it queries with @testing-library/dom, an
optional peer. container is where every search starts, document.body by default. jsdom lays
nothing out and has no WebGL, so what a harness reads there is the ARIA: a chart never measures a width
to draw at and stays busy, and a graph canvas has no renderer. Brushes, picks and lassos are for a
browser.
The environment
env.getHarness(query) resolves with the first component that matches, waiting for it to be in the
document; env.getAllHarnesses(query) returns every one there now. A query is a harness class, or a class
narrowed with its with: ChartHarness.with({ title }).
HarnessEnvironment—getHarness,getAllHarnesses,rootElement(theTestElementsearches start at) anduntil(probe, what), which resolves with the first truthy answer ofprobeand fails withwhat. A harness never waits for a fixed time, only for a state.ComponentHarness— the class every harness extends. A harness of your own declares a staticby, reaches its host asthis.host, and finds what is inside it withthis.locatorFor(query).By— how a host is found, in Testing Library's order:{ role, name?, has? }, then{ text }, and{ css }only where nothing accessible says it.TestElement— one element as the environment reaches it:find,text,getAttribute,getDimensions(anElementDimensions,{ top, left, width, height }),reveal,click({ at, with }),drag(points, { with }),fill,sendKeysandevaluate.atand the points are aPoint,[clientX, clientY];withlistsModifierkeys.Driver— what an environment is built from: a rootTestElementanduntil.ComponentHarnessConstructorandHarnessQueryare the class and the narrowed classenv.getHarnesstakes.
The names are Angular CDK's wherever CDK
has the operation: getHarness, getAllHarnesses, rootElement, locatorFor, TestElement,
getAttribute, getDimensions, sendKeys. by, fill, until, reveal, drag and evaluate
keep their own, because CDK has none or its namesake does something else
(why).
The harnesses
| Harness | Found by | Operates |
|---|---|---|
GraphCanvasHarness | data-frame, the frames the canvas has drawn | ready(), frames(), run(), pause(), lasso(points, { in, with }), marquee(from, to, { in, with }), frame(), screenOf(vertex), counts() |
ChartHarness | a figure named by its title | brush({ x, y }) in data, pick({ x }) or pick({ y }) by value, busy(), settled(), title() |
TimelineHarness | a figure named by its title | brush([from, to]), range(), play(), pause() |
DashboardHarness | its data-slot | tile(title), settled() |
TileHarness | an article holding its title | text(), chart(), settled() |
FilterBarHarness | the region Filters | chips(), remove(field), open(field), readout() |
RelationPickerHarness | the group Relation | root(), pick(type), add(hop), path(), remove(sentence), grain() — a hop by its sentence (Comment · located in this place), its step as relationKey writes it (<isLocatedIn<Comment), or its edge label where one item has it |
AnswerHarness | the composer, a textbox named Ask…; the answer cards by their data-slot | ask(question), answer(), rows(), addToDashboard(), openOnDashboard(), filterTo() |
FindingsBadgeHarness | its data-slot: the badge is named by the host's tally | tally(), open(), groups(), item(message) |
FindingsItemHarness | its data-slot, a listitem | severity() — @kanzo-tech/ui's FindingSeverity — count(), message(), where(), act(label), open(), sample() |
DockHarness | a radiogroup, named by the page | open(panel), close(), current() |
GraphCanvasHarness—ready()waits for the canvas's first drawn frame and fails on a canvas that finished loading without one.lassoandmarqueepress the toolbar's tool, draw, and wait for a frame drawn after the gesture; theirGestureOptionstakein: "data", the values bound toxandy(a layout that binds neither has only its own space), orin: "screen", the canvas's pixels, andwith, the keys held on release:MetaorControladds,Alttakes away.frame()presses Frame and waits for the camera to have drawn.screenOf(vertex)is where a vertex is drawn now, in the canvas's pixels.counts()readsGraphCounts' sentence wherever the page put it.ChartHarness—brushtakes anExtentper channel; a channel left out spans the plot, so{ x }is a horizontal brush.pickclicks the mark drawn at a value — a bar by its category. Both scroll the chart into view first and read its own scales through the hook.FilterBarHarness—chips()lists each clause by its label, region Saltmere, and each dashboard filter as column: value.remove(field)takes everything onfieldoff the bar: a clause chip retracts its clause, and a dashboard's filter is removed from its spec, which retracts its clause too.readout()is the status, once it has counted.AnswerHarness—answer()waits for the newestAnswerCardto stop beingaria-busy, which it is until its tile or itsProblemis drawn, and rejects with theProblem's words.rows()reads the rows the answer was read from — the engine's result under the page's filter when the answer ran — from the card's data table, by its caption, cell by cell: assert a figure there, not in the chart, which draws it, or in the model's reply, which only quotes it.filterTo()waits for Filter the page to these rows to be pressed, and rejects when the button is disabled because the answer keeps everything in view.addToDashboard()waits out Adding to the dashboard… while the host's write is pending, for Open on the dashboard (On the dashboard withoutonOpen), which the card reads from thedashboardsthe host hands back; a write the host refused rejects with the card'sProblem.openOnDashboard()presses Open on the dashboard, the host'sonOpen. The composer is named by its placeholder; a page with another composer starting Ask passesAnswerHarness.with({ name }).DockHarness—ShellDockSwitcheris a single-select toggle group, aradiogroupofradios, soopen(panel)waits for the item to be checked andclose()for none to be. The page names the group, soDockHarness.with({ name })picks one, and the bare harness takes the first radiogroup on the page. Any single-select toggle group has that shape:DockHarness.with({ name: "View" })drives a view switch.TimelineHarnessdrives aChartTimeline, found by its title:brush([from, to])isChartHarness's brush on the time axis, waiting for the window to move;range()reads the window as its group says it — 1950 – 1960 — ornull;play()andpause()press the one button and wait for itsaria-pressed.
Writing a harness is how to add one for a part of your own, and Organising an end-to-end suite is how to put them together.
The test hook
interface KanzoTestingHook {
graphs: Map<string, GraphProbe>; // by the canvas's id: screenOf(vertex), screenAt({ x, y })
charts: Map<string, ChartProbe>; // by the chart's title: scale(channel) → { apply, invert? }
models: Map<string, ModelProbe>; // by model id: { script, prompts }
}window.__KANZO_TESTING__ is defined by the environment and never by a page. GraphCanvas and
ChartRoot register themselves when they find it, and do nothing otherwise. A GraphProbe answers
with cosmos.gl's own spaceToScreenPosition; a ChartProbe is Observable Plot's own
figure.scale(channel). A chart is registered under its aria-label, which a dashboard tile sets to
its title; a chart with no title is not registered.
A model double
import { playwright } from "@kanzo-tech/testing";
const env = await playwright(page, {
models: {
"Qwen3-4B-q4f16_1-MLC": {
turns: [
{ when: "How many laureates", reply: { object: { spec: { of: "Laureate", show: { kind: "stat", measure: { op: "count" } } } } } },
{ when: "Offer", reply: { object: { elements: [] } } },
],
},
},
});
await page.goto("/ask");Each id gets a provider that answers from its script, registered in @kanzo-tech/llm/local's
provider registry before the page's own scripts run. localModel(id) resolves it as it resolves any
provider — and localModel(), which asks the first model served, resolves the first id scripted — on
every page this one loads, with no WebGPU and nothing downloaded or listed; the page's own code has no
branch for it (why). It answers with
scripted(script), the same answering @kanzo-tech/llm/testing's
scriptedModel wraps for a unit test. A ModelScript is data, because it crosses into the page:
turns— each call is answered by the firstModelTurnwhosewhenits prompt (every message, flattened) contains; one with nowhenanswers any call, and one that isonceanswers one and gives way. A call no turn answers fails, naming its prompt.reply, aModelReply—{ text, reasoning? };{ object }, a structured answer as its JSON;{ tool, input }, a call for an agent;{ fail, after? }, a failure, a stream's after writingafter;{ hang: true }, no answer until the call is stopped.pace—{ wait, perWord }in milliseconds, for a recording; at once without it.load—{ fail }: the model's load fails with these words, as WebLLM's does when a shard cannot be fetched.
What the double was asked is on the hook, in order:
const prompts = await page.evaluate(
(id) => (window as { __KANZO_TESTING__?: KanzoTestingHook }).__KANZO_TESTING__?.models.get(id)?.prompts ?? [],
"Qwen3-4B-q4f16_1-MLC",
);promptOf(call) is the same flattening for a unit test's call: a PromptedCall, any call whose prompt is the AI SDK's messages.
A page that cannot run the model at all needs no double: take navigator.gpu away before it loads,
and localModel fails as llm/unavailable, its own answer.
Next.js
Link and useRouter — next/link and next/navigation's router, wired to the blockers, and the lint rule that stops a raw import walking past them.
Writing a harness
A harness for a part of your own — extending ComponentHarness, finding its host and its insides by their ARIA, composing the library's harnesses inside it, and waiting on states.