Kanzo UI
The design of Kanzo UI

Testing a page built with Kanzo UI

Why @kanzo-tech/testing ships a harness per component in Angular CDK's shape and with its names, reads ARIA before anything else, reaches a canvas's coordinates only through a hook a test installs, and registers the one model double as a provider in the registry every model is resolved through; and why Ask's evaluation set lives beside Ask — and what would reverse each rule.

@kanzo-tech/testing is how a host tests a page built with the library, in Playwright, and how the library tests itself. This page is why it is shaped the way it is.

A harness per component, in Angular CDK's shape

Each component a host drives in a test has a harness: a class that knows how to find it and how to operate it, and nothing about the page around it. The reference is Angular CDK's Component Harnesses. The library that owns a component ships the code that drives it, one harness per component, written against an environment rather than a test runner. The same harness then runs in a unit test and in an end-to-end one.

import { playwright, GraphCanvasHarness, ChartHarness, FilterBarHarness } from "@kanzo-tech/testing";

const env = await playwright(page);                              // installs the test hook, then the page loads
await page.goto("/archive");
const lon = await env.getHarness(ChartHarness.with({ title: "Count by Airport.lon" }));
await lon.brush({ x: [-125, -100] });                            // data, not pixels
const graph = await env.getHarness(GraphCanvasHarness);
await graph.frame();                                             // waits for the drawn frame to change
await expect.poll(async () => (await env.getHarness(FilterBarHarness)).readout()).toBe("611 of 3,218 Airport");
HarnessOperates
GraphCanvasHarnessready, run, pause, lasso(points, { in: "data", with }), marquee, frame, screenOf, counts
ChartHarnessbrush({ x, y }), pick({ x }) by value, busy, settled
TimelineHarnessbrush([from, to]), range, play, pause
DashboardHarnesstile(title), a TileHarness; settled
FilterBarHarnesschips, remove(field), open(field), readout
RelationPickerHarnessroot, pick(type), add(hop) by sentence, step or label, path, remove(sentence)
AnswerHarnessask(question), answer, addToDashboard, openOnDashboard, filterTo
FindingsBadgeHarnesstally, groups, item(message): a FindingsItemHarness with severity, count, where, act and, for a group, sample
DockHarnessopen(panel), close, current

Testing is the reference.

Two environments: playwright(page) for a host's end-to-end suite and the docs' showcases, and dom(container) over Testing Library for the library's own tests. So the library's unit tests exercise the harness a host relies on.

It replaced each host writing its own: Keasy's demos found a chart by an <article> holding a heading, clicked at width * 0.35 of it, brushed from 0.231 to 0.292 of a histogram's width to mean 125°W to 100°W, and waited fixed milliseconds for what a click did. Every one of those knew the library's markup and broke when it changed.

The environment is made before the page loads, so playwright(page) is awaited: it installs the hook with addInitScript, and a component registers while it mounts. A page already loaded mounted without it.

What would reverse it: a host whose tests drive the components through their public props rather than the screen, such as component tests that mount a Dashboard with a spec. Then the harnesses are a convenience for end-to-end alone, and the dom environment goes.

Held by packages/ui/src/harnesses.test.tsx, "reads the readout once it has counted, and a clause as a chip it removes by its field", and packages/testing/e2e/workspace.e2e.test.ts, "picks a bar by its value, and removing the beast filter from the bar retracts it" — the same FilterBarHarness, once in jsdom and once in Chromium.

ARIA first, data-* last

A harness finds and waits on what assistive technology reads, in Testing Library's order: role, accessible name, text, and a data-* attribute only where nothing accessible says it. What the library already exposes stays the contract: GraphStatus and GraphCounts are aria-busy with live text, the layout's transport button is aria-pressed, FilterBar is the region Filters, a dock switcher is a radiogroup whose open panel is checked, and a tile is an article holding its title. What is added is accessible first. ChartRoot is a figure named by its title and aria-busy until it has drawn and while its query runs; GraphCanvas is aria-busy until its first drawn frame; a Dashboard is aria-busy while it reads its fields, and FilterBar's readout is a status, aria-busy while it counts. An answer card is aria-busy while the model writes and the engine reads, then holds its tile — an article — or a Problem; a harness waits for one of the two, because the card reads its fields over a skeleton after it stops being busy. A timeline's range will be a group whose aria-valuetext reads 1950 – 1960.

One attribute is added with no accessible equivalent: GraphCanvas's data-frame, a count of frames drawn, which is how a harness waits for the camera to have moved and how it finds the canvas. Two harnesses find their host by an existing data-slot, because nothing accessible tells it apart: a Dashboard, a column of cards that is not a landmark, and a findings badge, which is one popover trigger among others. An answer card's frame is found by its data-slot too: the tile inside it is an article, and the frame holding the tile and its actions has no role. A finding's severity is its row's data-variant, because the words a reader sees, Errors or 2 warnings, are the host's.

A harness never waits for a fixed time. It waits for a state, with Playwright's assertions that retry on their own.

What would reverse it: a state a reader can perceive that no ARIA role or property describes, measured in a component. Then that component gets a data-* of its own, named in this section.

Held by packages/testing/src/environment.test.ts, "fails a wait with what it waited for, never after a fixed time with nothing to say"; packages/ui/src/harnesses.test.tsx, "settles once the fields are read and no tile is loading, then finds a tile by its title"; and packages/graph/src/parts/graph-canvas-harness.test.tsx, "ready() rejects a canvas that finished loading without drawing a frame, as one with no WebGL does".

A canvas's coordinates come through a hook only a test installs

What a WebGL canvas or a chart's scale knows — where a vertex is on screen, which pixel is 125°W — reaches a harness through window.__KANZO_TESTING__, which the harness environment defines before the page loads and which production never has. Each root registers itself when it finds the hook, and does nothing otherwise:

interface KanzoTestingHook {
  graphs: Map<string, { screenOf(vertex: number): [number, number]; screenAt(point: { x: number; y: number }): [number, number] }>;
  charts: Map<string, { scale(channel: "x" | "y" | "color"): { apply(value: unknown): number; invert?(pixel: number): unknown } }>;
  models: Map<string, { script: ModelScript; prompts: string[] }>; // what a scripted model was asked, below
}

The reference is tldraw and Excalidraw, whose end-to-end suites reach the editor a test exposes, and React DevTools' __REACT_DEVTOOLS_GLOBAL_HOOK__, a global the tool defines and the library registers with. A chart's scale is Observable Plot's own figure.scale(channel).

What would reverse it: a canvas whose content is in the accessibility tree, such as a graph rendered with an accessible table of its vertices beside the canvas. Then a harness reads that, and the graph's half of the hook goes.

The type is declared once, in @kanzo-tech/testing, because that package installs the hook. ui and graph import it as a type from a devDependency, so it never reaches what they publish, and the testing package imports nothing of theirs: no cycle, and a harness runs against whatever version of the components a page loaded. llm reads nothing of the hook: a model is not registered here (below). A chart registers under its aria-label; a chart with no title is not one a harness can name, and is not registered.

Held by packages/graph/src/parts/graph-canvas-harness.test.tsx, "registers with the test hook under its id when one is installed, and lets go of it when it unmounts" and "registers nothing when no test installed the hook"; packages/ui/src/harnesses.test.tsx, "registers nothing when no test installed the hook, nor a chart with no title"; and, in Chromium over the workspace showcase in CI's harnesses job, packages/testing/e2e/workspace.e2e.test.ts, "draws the archive, and a lasso around a vertex gives the canvas a clause" and "brushes a range in data, through the chart's own scale".

Held by, for the parts other changes delivered, packages/ui/src/harnesses.test.tsx, "opens a panel by its label, and collapses the dock when the open one is pressed again", over ShellDockSwitcher; packages/ai/src/data/answer-harness.test.tsx, "waits for the card to answer, reads its tile, and filters the page to it", "adds the answer to the dashboard, through the host's pending write, and waits for the card to say it is there", "rejects an add the host refused with the words of the card's Problem" and "rejects an answer that failed with the words of its Problem", over AnswerCard; and packages/testing/e2e/workspace.e2e.test.ts, "asks a question the recording knows, reads the answer's chart, and filters the page to it".

Held by, for TimelineHarness over ChartTimeline, packages/graph/src/parts/timeline.test.tsx, "is driven by TimelineHarness: the window the group reads, and play and pause through the one button" — in jsdom, with the brush moved as a drag would move it, because jsdom has no layout for a pointer to cross. Its brush is ChartHarness's, which the end-to-end suite holds in Chromium; a timeline on the workspace showcase is what would hold the timeline's own there.

The names are CDK's

Where Angular CDK's harness API has an operation, ours has its name; where it has none, or its namesake does something else, ours keeps its own and says why here. This page names CDK as the reference, and the package first used other words for the same things with no reason written down — which is a divergence nobody decided. Decided 13 October 2026.

OursCDK's
env.getHarness(query), env.getAllHarnesses(query), env.rootElementthe samewere harness, harnesses, root
locatorFor(query, what?)locatorFor(query)was locate(query) and one(by, what), now one: CDK's locator takes a harness query or an element's query alike and returns the function that finds it. The function takes another element to search instead of the host, and what is the words a wait that runs out fails with. locatorForOptional and locatorForAll are not built: getAllHarnesses and a TestElement's find answer what they would, and no harness has asked
ComponentHarnessConstructorthe samewas HarnessType
TestElementthe samewas Handle
getAttribute(name)the samewas attribute
getDimensions(), an ElementDimensions of top, left, width, heightthe samewas rect(), a Rect of x, y, width, height
sendKeys(key)the samewas press. One key, by its KeyboardEvent.key, as every caller sends
text, clickthe samealready CDK's
static byhostSelectorkept: a By is Testing Library's query — role, name, text — not a selector
HarnessQueryHarnessQuerykept; CDK's is a constructor or a predicate, ours a constructor and its By
fill(value)—kept: CDK's setInputValue sets the property and dispatches nothing; ours types as a person does, as Playwright's fill
until, reveal, drag, evaluate, ariaBusy, find—kept: CDK waits on Angular's zone, and has no drag or scroll; ours waits on a state

And one duplicate went: testing's Severity was @kanzo-tech/ui's FindingSeverity under another name. FindingsItemHarness.severity() answers the same union, unnamed — the package imports nothing of the components', so it cannot import the name, and naming it a second time is the duplicate — and a test in ui holds the two equal.

What would reverse it: a CDK name that misleads a reader of our harnesses into expecting CDK's behaviour — then that row is kept as ours, with the reason, as fill is.

Held by packages/testing/src/environment.test.ts, "locates a part's harness or an element inside the host, and says what it waited for when neither comes"; packages/ui/src/harnesses.test.tsx, "reads a severity as FindingSeverity, the one name it has".

One model double

@kanzo-tech/llm/testing ships one scripted model — the AI SDK's MockLanguageModelV4 answering from a script of turns — and an end-to-end test registers the same script as a model id's provider. A unit test passes scriptedModel(script) where a model goes; an end-to-end test registers a script for a model id through playwright(page, { models }), before the page loads, and the product's code never knows (below). Decided 13 October 2026.

There were five: @kanzo-tech/ai's test double, the docs' mock model with the same shape as it, keasy's hand-written model injected on window.__keasyModelDouble — the hook this page already installs, reinvented — keasy's branch in its model module that serves it with a guard test that it never ships, and keasy's fixture over MockLanguageModelV4. A model the page cannot serve is llm/unavailable, localModel's own answer, and no double is needed to say it.

The script is data — ModelScript, declared in @kanzo-tech/testing — because a function does not cross into the page. Its shape is keasy's double's, the one with the most scenarios behind it: each call answered by the first turn whose when its prompt contains, once to answer one call and give way, and a reply that is text with its thinking, an object, a tool call, a failure — a stream's after its first words — or a call that hangs until it is stopped; a pace for a recording, and a load that fails. In a unit test the turns may be a function of the call instead, for an answer that is computed, as the docs' recordings recognise a question they were not given word for word; that never crosses into a page.

The answering is one function, scripted(script), in @kanzo-tech/testing, and it closes over nothing but promptOf. A page cannot import the mock: the page's bundle is the one that ships, and ai/test is not in it. What crosses into a page is source text — Playwright's addInitScript — so the answering is written to be sent as text: promptOf, scripted and the registration, declared, then called with the scripts as JSON. In this process scriptedModel is the SDK's mock over the same scripted, for the calls the SDK records on it. It lives beside the script rather than in llm because testing imports nothing of llm's and llm/testing already depends on testing.

What would reverse it: a test that needs a model whose behaviour a script cannot state — a tool call's input streamed as it is written, say. Then the script grows that, and it is still the one double. And scripted written in a module the page's bundle can import without shipping it — a bundler's test build that is the build that ships — would let the page take the mock itself.

Held by packages/llm/src/testing.test.ts, "answers each call with the first turn whose words its prompt holds, and a once turn only once"; packages/testing/src/environment.test.ts, "registers each model script it is given on the hook, under its model id, with no prompt yet" and "registers a provider per scripted id in the model registry, from a script that closes over nothing".

A model is resolved through one registry

localModel(id) has one path: it asks one provider registry for id, and production code names no test. The registry is on globalThis under Symbol.for("@kanzo-tech/llm/local"):

interface LocalModelRegistry {
  languageModels: Map<string, LocalEngine>; // by id: { model, load }, registered first
  fallbackProvider?: LocalProvider;         // the host's: setLocalProvider(localProvider({ baseURL, worker }))
}

A model registered for id answers first; else the host's provider; else WebLLM's own prebuilt list. Without an id, localModel() asks the first registered, else the first the provider lists. A host sets its provider once, at its composition root, with where its models are served from: localProvider({ baseURL: "/models" }) serves exactly what /models/app-config.json lists, and that file is WebLLM's own AppConfig, untranslated — the consumer is WebLLM, so its config is the format. The host's code names no model and no library version; whatever downloads the models writes the file beside them. A test's environment registers a provider per scripted id in languageModels, before the page's own scripts run, through the addInitScript path that installs the hook, and records each prompt on the hook.

It replaced two branches and a second mechanism. /local read __KANZO_TESTING__ itself and, when a script was there for its id, imported the double lazily — a test branch in the code that ships, and a chunk of ai/test in every production bundle that reached /local. And where a model's files came from was a setting of its own, from: { weights, library }, passed beside the engine choice on every call, so keasy kept a ModelProvider of its own to carry a URL and a second double-detecting branch around it. A provider configured with where it loads from is both at once.

The references:

  • The AI SDK's provider registry — createProviderRegistry and customProvider({ languageModels, fallbackProvider }), a model resolved by id from registered providers, and createOpenAI({ baseURL }), a provider configured with where it is served. languageModels and fallbackProvider are its names. registry.languageModel(id) names one model when it must; localModel() asks the first served for the same reason.
  • OpenTelemetry's global provider API — trace.setGlobalTracerProvider(provider): the library reads one global, a default until something registers, under a Symbol.for key so that every copy of the code finds the same one. setLocalProvider is its shape.
  • WebLLM's AppConfig — { model_list: [{ model, model_id, model_lib, … }] }, the type @mlc-ai/web-llm declares and its prebuilt list is; served as a file it is what localProvider reads. Transformers.js's env.localModelPath is the same move in the other in-browser runtime: one base on the host's origin that every model is read under.

Not the AI SDK's own global, globalThis.AI_SDK_DEFAULT_PROVIDER: it resolves every string model id a host passes anywhere, a gateway's among them, and a page that ran a model in it and called a server with another would have one global answering for both.

The threat, stated plainly. A script that runs before the app can register a provider, and the app will answer with it. It could equally replace Worker, WebAssembly, fetch or navigator.gpu before the app reads them, and WebLLM would run on whatever it found. No design can exclude a script that runs first and still let a test drive the build that ships — that is what testing the build that ships means — so the registry adds no capability such a script lacked. What it removes is the test branch: the code that ships no longer looks for a test, so it cannot be fooled into thinking it is in one. A page's Content Security Policy is what decides which scripts run first, as it always was.

What would reverse it: a second engine in the page whose models are not WebLLM's — a Transformers.js model beside a WebLLM one. Then app-config.json is no longer the one listing, and the provider takes a listing per engine, or the registry a provider per engine, as the AI SDK's takes a provider per prefix. And a page that must refuse any provider it did not set itself — a host that cannot trust its first scripts — would need the registry closed after boot, which also closes it to its tests; nothing here has asked.

Held by packages/llm/src/local.test.ts, "answers from the script registered for its id, records the prompt, and never reaches WebLLM", "asks the first scripted id when no id is asked, before the host's provider lists anything", "answers with WebLLM when nothing registered the id", "serves every model its app-config.json lists, in order, the first when no id is asked, with WebLLM's config handed on as written" and "is unserved when the listing is missing, lists nothing, or does not list the id asked".

Ask's evaluation set lives beside Ask

The evaluation set that scores Ask is packages/ai/eval, beside the Task it measures and outside the package's published files. It measures @kanzo-tech/ai/data's Ask and nothing of a product's: a case is a page's relation and filter, the questions asked in turn, and the rows the last answer must and must not have been read from — execution accuracy, as Tasks defines the set. It came from keasy's e2e/eval on 14 October 2026 and runs as it ran there; what changed is what it is driven through. In keasy it ran through keasy's pages, polled on a two-second loop and reached the card by data-slot; here it drives the library's own Ask through the harnesses a host uses, and waits on states. Decided 13 October 2026.

Casespackages/ai/eval/cases.ts, data: 25 on Nobel for nearly every shape of question, 11 on LDBC SNB for self-joins and a large graph. Each says the failure it tests. The two that were ties asked back are answers now, by the path the model writes, the other on the chip (Tasks)
Corporawritten by fossil's executor in Node through docs/scripts/write-corpus.mjs, as the docs' archive is: pnpm --filter @kanzo-tech/ai eval:corpus runs each corpora/<name>/fetch.sh (the data, pinned by digest) and then its program.fossil into docs/public/corpus/<name>. Data and corpus are .gitignored. The set carries its own copy of the two programs, shapes and fetch scripts, read from the files beside them rather than through keasy's connections; keasy keeps its own as seed data — each repository owns what it runs
Page/fixtures/ask-eval, a page nobody reads, as /fixtures/chat-frame is: the corpus attached by name, a Dashboard and its FilterBar over the case's relation, and Chat over useAskChat with AnswerCard, under the page's one selection. The model is localModel in a worker, Qwen3 4B unless EVAL_MODEL names another; no offers and no chips, which a case does not score and a model would be slower for
Runnerask.eval.ts, a Vitest project (vitest.eval.config.ts) beside @kanzo-tech/testing's end-to-end one: playwright-core's Chromium with WebGPU, DashboardHarness, FilterBarHarness, ChartHarness.pick for the filter, AnswerHarness for the card and its rows, and one harness of its own for a turn that ends in words, which draws no card to wait on. expect.poll and until, no fixed wait
Modelthe real one, on the machine's GPU through WebGPU — live, never replayed, never the double: what is measured is the model. The browser profile is kept in eval/.cache/, so the weights are downloaded once
Resultsone line per case in packages/ai/eval/results/<model>/ask.jsonl, .gitignored; a case that fails is a result, and the run fails only when it cannot ask
Whenby hand, pnpm --filter @kanzo-tech/ai eval with the docs running, before a change to Ask or to the model; never in pnpm verify and never on CI, which has no GPU. EVAL_DOUBLE=1 is the smoke run: five cases on the one model double, answering with the specs a right answer is, to show the runner, the page and the scoring work on a machine with no GPU. It scores nothing

The set is data apart from its runner, so the second runner Tasks names — the same weights on native llama.cpp in CI — reads the same cases when it is built.

What was not moved. keasy's cases.ts also held four cases for its assistant — the files a person picks and what the data is about, per dev example. They are the assistant's, not Ask's, and no runner read them; they stay keasy's, with its assistant.

No baseline yet. The machine that moved the set has no GPU: WebLLM downloads the weights and then fails to start its engine, and every case is recorded as the model cannot run in this browser. The only number there is predates Ask as a Task — keasy's reference pass of 12 October 2026, 2 of 10 on Qwen3 4B — so the first run on a GPU is the baseline the join and answer model is measured against.

The docs' fossil is 0.3.0-alpha.31, the version keasy's examples were last run on. The SNB program names a column the datagen wrote twice, KnowsRow."Person.id_1", and alpha.28 does not parse a quoted name.

What would reverse it: a second product's evaluation set over Ask with cases of its own — then the runner is the shared part, and each product keeps its cases.

On this page