Kanzo UI
Testing

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/testing

It 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 (the TestElement searches start at) and until(probe, what), which resolves with the first truthy answer of probe and fails with what. 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 static by, reaches its host as this.host, and finds what is inside it with this.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 (an ElementDimensions, { top, left, width, height }), reveal, click({ at, with }), drag(points, { with }), fill, sendKeys and evaluate. at and the points are a Point, [clientX, clientY]; with lists Modifier keys.
  • Driver — what an environment is built from: a root TestElement and until. ComponentHarnessConstructor and HarnessQuery are the class and the narrowed class env.getHarness takes.

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

HarnessFound byOperates
GraphCanvasHarnessdata-frame, the frames the canvas has drawnready(), frames(), run(), pause(), lasso(points, { in, with }), marquee(from, to, { in, with }), frame(), screenOf(vertex), counts()
ChartHarnessa figure named by its titlebrush({ x, y }) in data, pick({ x }) or pick({ y }) by value, busy(), settled(), title()
TimelineHarnessa figure named by its titlebrush([from, to]), range(), play(), pause()
DashboardHarnessits data-slottile(title), settled()
TileHarnessan article holding its titletext(), chart(), settled()
FilterBarHarnessthe region Filterschips(), remove(field), open(field), readout()
RelationPickerHarnessthe group Relationroot(), 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
AnswerHarnessthe composer, a textbox named Ask…; the answer cards by their data-slotask(question), answer(), rows(), addToDashboard(), openOnDashboard(), filterTo()
FindingsBadgeHarnessits data-slot: the badge is named by the host's tallytally(), open(), groups(), item(message)
FindingsItemHarnessits data-slot, a listitemseverity() — @kanzo-tech/ui's FindingSeverity — count(), message(), where(), act(label), open(), sample()
DockHarnessa radiogroup, named by the pageopen(panel), close(), current()
  • GraphCanvasHarness — ready() waits for the canvas's first drawn frame and fails on a canvas that finished loading without one. lasso and marquee press the toolbar's tool, draw, and wait for a frame drawn after the gesture; their GestureOptions take in: "data", the values bound to x and y (a layout that binds neither has only its own space), or in: "screen", the canvas's pixels, and with, the keys held on release: Meta or Control adds, Alt takes 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() reads GraphCounts' sentence wherever the page put it.
  • ChartHarness — brush takes an Extent per channel; a channel left out spans the plot, so { x } is a horizontal brush. pick clicks 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 on field off 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 newest AnswerCard to stop being aria-busy, which it is until its tile or its Problem is drawn, and rejects with the Problem'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 without onOpen), which the card reads from the dashboards the host hands back; a write the host refused rejects with the card's Problem. openOnDashboard() presses Open on the dashboard, the host's onOpen. The composer is named by its placeholder; a page with another composer starting Ask passes AnswerHarness.with({ name }).
  • DockHarness — ShellDockSwitcher is a single-select toggle group, a radiogroup of radios, so open(panel) waits for the item to be checked and close() for none to be. The page names the group, so DockHarness.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.
  • TimelineHarness drives a ChartTimeline, found by its title: brush([from, to]) is ChartHarness's brush on the time axis, waiting for the window to move; range() reads the window as its group says it — 1950 – 1960 — or null; play() and pause() press the one button and wait for its aria-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 first ModelTurn whose when its prompt (every message, flattened) contains; one with no when answers any call, and one that is once answers one and gives way. A call no turn answers fails, naming its prompt.
  • reply, a ModelReply — { text, reasoning? }; { object }, a structured answer as its JSON; { tool, input }, a call for an agent; { fail, after? }, a failure, a stream's after writing after; { 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.

On this page