Testing
Behavior is tested in real browsers, not in a simulated DOM. Logic that needs no DOM is tested in Node. Every component has conformance scenarios that every adapter must pass (TextInput, which has no element, through TextField’s), and they check what a screen reader says as well as what the DOM holds.
The rules are in the
root AGENTS.md
(Conformance, Testing) and in
packages/testing/AGENTS.md.
Tests sit next to the code they test, and the file name picks the Vitest
project that runs them (vitest.config.ts):
| File name | Project | Runs in |
|---|---|---|
*.test.ts(x) |
node |
Node, with no DOM: a DOM access fails with document is not defined. |
*.browser.test.ts(x) |
browser |
Real Chromium, Firefox and WebKit through Playwright, headless. |
*.screen-reader.test.ts(x) |
screen-readers |
Headed Chromium with VoiceOver or NVDA. The project exists only when UNIARIA_SCREEN_READERS is set. |
The node and browser projects pick up files under packages/*/src/ and
conformance/src/; the screen-readers project, files under
packages/*/src/. Import test functions from vitest explicitly; there are
no globals. Workspace packages resolve each other’s sources, so no build is
needed first.
- Node: the runtime, collections, behavior definitions driven through
createInstance(call actions and part handlers, readattrsandgetState()), server rendering withrenderToString, and thessr-safety.test.tsfiles of@uniaria/coreand the adapters, which import every entry without a DOM. - Browser: anything that renders, touches the DOM, moves focus or
listens to a screen reader. The interactions in
dom/have their own*.browser.test.tsfiles, and the adapters have theirs: render counts, StrictMode in React,<KeepAlive>,<Teleport>and compiled templates in Vue, hydration, owner lookup, and custom elements mounted inside React and Vue to prove they never fight over nodes.
pnpm test # node, then browser
pnpm test:node # only the DOM-free tests
pnpm test:browser # all three engines
pnpm vitest run --project "browser (chromium)" # one engine
pnpm test:watch # watch modeVitest’s own arguments narrow a run further: a file path, or -t with part
of a test’s name. Conformance tests are named after the suite and the
adapter, such as checkbox (react):
pnpm vitest run --project "browser (webkit)" packages/react/src/conformance.browser.test.ts -t "checkbox (react)"If one engine fails to launch on your machine, run the others by project
name (browser (chromium), browser (firefox), browser (webkit)) and rely
on CI for the rest.
Conformance scenarios in conformance/src/<name>/ describe a component’s
behavior once, and every adapter must pass them: the DOM, keyboard behavior,
focus movement, events, public state, render counts and spoken output.
An agent never edits a scenario to make an implementation pass. Scenarios
come from an approved spec, and every change to conformance/ needs the
owner’s approval. If a scenario looks wrong, stop and describe the problem to
the owner; don’t work around it.
Scenarios and implementation never change in the same commit or pull
request, so a failing test can’t be made to pass by editing the test.
scripts/check-conformance-separation.mjs fails any commit that touches both
conformance/ and non-test files under packages/*/src/; test files
(*.test.ts(x)) don’t count as implementation.
pnpm check:conformancechecks your unpushed commits (origin/main..HEAD). Run it before pushing.- CI checks every commit a push to
mainadds, and a pull request’s whole diff. Only the owner can override it, only on a pull request, with theconformance-approvedlabel.
Scenarios depend only on @uniaria/testing, and each adapter implements one
function. The source is packages/testing/src/conformance/.
- Suites are data.
defineConformance<Props, State>('button', ({ scenario, group }) => …)names the component by string and never imports a definition, so it exists before the implementation does.PropsandStatecome from the spec, in the suite’stypes.ts. - Templates describe the consumer’s markup without a framework:
h('button', { part: 'root', class: 'x' }, 'Save').partnames the UniAria part; every other key is an attribute the consumer owns.nested('checkbox', { value: 'email' }, …)puts another component inside, androws()renders a virtualized list’s rows from its state. - Adapters implement
mount(request). They render the template, apply props the way their consumers do, callrecord()for the component’s own events, and returnsetProps,state(whatexpose()returns),renderCountandunmount. Attribute changes must be visible as soon as the interaction that caused them resolves. - Scenarios get a context:
mount, realpointerandkeyboardinput through Playwright (they can hold a button or a key),touch,spoken()andlisten()for the virtual screen reader, andscreenReaderfor a real one.mounted.part(name)finds the root bydata-uni, a part outside the root bydata-uni-partand any other part bydata-elementinside the root (partSelector()), and fails loudly when the marker is missing. - Events are camelCase in scenarios (
pressStart); the DOM event is lowercase (pressstart) and the React prop isonPressStart.
Real input goes through Playwright commands (uniCommands, registered in the
browser project), never through a synthetic dispatchEvent, so scenarios
see the browser’s own event sequences. Touch goes through DevTools, which
only Chromium takes: scenarios that touch declare needs: ['touch'] and are
skipped elsewhere. Don’t turn on Playwright’s hasTouch, which would make
every test run as on a touch device.
The runner checks itself: packages/testing/src/conformance/run.browser.test.ts
runs it against a plain-DOM fixture adapter in every browser.
A scenario declares the roadmap phase whose features it needs
({ phase: 2 }; 1 by default). An adapter’s run declares the phase it has
reached, runConformance(adapter, suites, { phase: 5 }), and later scenarios
are reported as skipped, not failed. Raise the phase in the adapter’s
conformance.browser.test.ts as the roadmap advances.
packages/react/src/conformance.browser.test.ts,
packages/vue/src/conformance.browser.test.ts and
packages/elements/src/conformance.browser.test.ts run every suite:
- React renders the template inside StrictMode, so every scenario also
runs through a double mount, spreading
ui.parts.<name>onto the part elements. A nested component renders with its ownuseBehavior()inside its owner’sOwnerProvider. - Vue renders the template with
h()in a harness component, bindingui.parts.<name>onto the part elements asv-binddoes. A nested component renders with its ownuseBehavior(), which finds the component around it as its owner. - Custom elements render the template as plain DOM, wrap the parts in
the component’s host,
<uni-<name>>, and set props as properties. A nested component’s children are wrapped in its own host.
Some behavior has two paths, and scenarios cover both. Popover and Tooltip
scenarios force the fallback for browsers without the Popover API through
the development switch (Symbol.for('@uniaria/fallbacks/v1')), and
scenarios that place a part run in both positioning modes, 'script' and
'css'.
An internal interaction re-renders the owning React or Vue component zero
times: hover, press, focus, highlight, opening an overlay. Conformance
scenarios check it through renderCount(). The React and Vue adapters count
every render of the component that owns the instance; the elements adapter
returns 0. A
scenario reads the count, interacts, and expects the same count:
const renders = mounted.renderCount();
await keyboard.tab();
await keyboard.press('Enter');
expect(mounted.renderCount()).toBe(renders);The hook tests in packages/react/src/use-behavior.browser.test.tsx count
renders exactly, outside StrictMode where they need to: a selector
re-renders only when its selected value changes, and a prop change renders
once. packages/vue/src/use-behavior.browser.test.ts does the same for
Vue.
packages/react/src/compare/compare.browser.test.tsx compares Button with
React Aria’s: at every step of one real-input script it requires the same
state attributes and presses, and no UniAria render for interactions.
pnpm bench times both in a production build.
Spoken output is checked with
@guidepup/virtual-screen-reader,
a simulated screen reader that reads the accessibility tree the browser
exposes. The checks are part of the browser project, so CI runs them on
every push to main and every pull request.
spoken()reads the whole document and returns every phrase, fromdocumenttoend of document, in the library’s format:button, Save, disabled.listen()starts hearing live regions and returns a function that gives what they announced since, each phrase prefixed with its politeness:assertive: Saved.spoken()can’t run while listening.
The spec’s “Expected spoken output” section is where these checks come from.
packages/styles/src/screenshots.browser.test.ts compares each component
with the default styles, in every scheme and in forced colors, with
reference images in packages/styles/src/__references__/. Text renders
differently on every engine and OS, so the references are Chromium’s on
Linux, as CI runs it, and the test skips anywhere else. A change to how a
component looks fails it in CI until the references change too; the failed
run’s failed-screenshots-chromium artifact holds each actual image and its
diff. Remake them in Playwright’s Docker image, then review the changed
images and commit them with the change:
pnpm -C packages/styles screenshotsThe script needs Docker. It copies the working tree into the image, with your uncommitted changes, and rewrites only the references that no longer match. When you remove a gallery, delete its references by hand.
.github/workflows/screen-readers.yml runs the scenarios that need a real
screen reader: VoiceOver on macOS and NVDA on Windows, both with headed
Chromium, driven through @guidepup/guidepup. A scenario opts in with
needs: ['screenReader']; those scenarios run only in this workflow, and
only they run there. Each adapter has a conformance.screen-reader.test.ts
that runs them with { phase: 5, screenReader: true }.
- When. Nightly at 03:00 UTC, and on demand from the Actions tab or with
gh workflow run screen-readers.yml. A night with no new commit onmainin the last day skips the run, since macOS and Windows runners cost more. - How. Each job prepares the runner with
@guidepup/setup(permissions and settings on macOS, NVDA’s configuration on Windows), installs the screen reader, then runspnpm test:screen-readerswithUNIARIA_SCREEN_READERSset, with VoiceOver’s hints turned off. The project runs one file at a time, retries a failed test once, and gives each test two minutes. - What a scenario can do.
screenReader.startFrom(element)clicks an element before the component and forgets what was said;press(key)andtype(text)go through the operating system, as a user’s keys would, and wait after each key until the reader stops speaking;next()moves the reader’s own cursor;spoken()returns what it said since;readFocus()returns what it holds for the focused element, for checks where the speech on arrival is cut short. VoiceOver and NVDA word things differently (checkboxandcheck box), so scenarios match what they need to hear, not whole phrases. - Writing a new scenario. Run the workflow by hand with its
discoverinput turned on. It setsUNIARIA_SCREEN_READERS=discover, and the run logs what the screen reader holds for the focus after each key.
Never run it locally. Preparing a machine for Guidepup changes its privacy and screen-reader settings, and a run takes over the machine’s screen reader and keyboard. Check a change with a manual run of the workflow instead.