Test a primitive's interactions, accessibility attributes, server output, and reading direction.

@ui-sdk/core/testing provides helpers to simulate input, check focus and accessibility attributes, and inspect a primitive's state and server output.

Use them to test a primitive directly or check a rendered component. Interaction tests need a DOM, such as jsdom or a browser. Server output and part reads work without one.

Check a Pattern

If you've rendered a widget that follows a common accessibility pattern, checkPattern checks its roles, names, states, references between parts, and keyboard interactions:

import { checkPattern } from "@ui-sdk/core/testing"

await checkPattern(root, "tabs")

root is the widget's element, or an element around it. The check throws one error listing the problems it found. Here, removing the tab list's name gives it something to report:

import * as React from "react"
import { checkPattern } from "@ui-sdk/core/testing"
import { Tabs, TabsList, TabsPanel, TabsTab } from "./tabs"

function Example() {
  const root = React.useRef<HTMLDivElement>(null)
  const [named, setNamed] = React.useState(true)
  const [result, setResult] = React.useState<{
    ok: boolean
    text: string
  } | null>(null)

  async function check() {
    setResult(null)
    try {
      await checkPattern(root.current!, "tabs")
      setResult({
        ok: true,
        text: "Passes: roles, states, names, one tab stop, the arrows, Home and End.",
      })
    } catch (error) {
      setResult({ ok: false, text: (error as Error).message })
    }
  }

  return (
    <div data-slot="check">
      <div data-slot="controls">
        <button type="button" data-variant="solid" onClick={check}>
          Check the Tabs Pattern
        </button>
        <label data-slot="label">
          <input
            type="checkbox"
            checked={!named}
            onChange={(event) => {
              setNamed(!event.target.checked)
              setResult(null)
            }}
          />
          Leave the tab list unnamed
        </label>
      </div>
      <Tabs ref={root} defaultValue="overview">
        <TabsList aria-label={named ? "Project" : undefined}>
          <TabsTab value="overview">Overview</TabsTab>
          <TabsTab value="activity">Activity</TabsTab>
          <TabsTab value="files">Files</TabsTab>
        </TabsList>
        <TabsPanel value="overview">
          The project plan and upcoming milestones.
        </TabsPanel>
        <TabsPanel value="activity">
          Recent updates from the project team.
        </TabsPanel>
        <TabsPanel value="files">
          Briefs, mockups and other project files.
        </TabsPanel>
      </Tabs>
      {result && (
        <p
          role="status"
          data-slot="check-result"
          data-status={result.ok ? "pass" : "fail"}
        >
          {result.text}
        </p>
      )}
    </div>
  )
}

export { Example }
Press Check the Tabs Pattern. Then select Leave the tab list unnamed and run the check again to see the missing-name error.

The supported patterns are listbox, menu, menubar, tabs, radio-group, toolbar, tree, dialog, alertdialog, disclosure, accordion, combobox, slider, switch, and checkbox.

The check uses the widget's keys and restores focus and state where it can. Open a menu or dialog before checking it. A menu check activates an item, so use test actions that are safe to run.

It requires Home and End for listboxes, tabs, and toolbars, a name for every tab list, and aria-modal="true" for modal dialogs. Those are deliberately stricter than the optional behavior in parts of the WAI-ARIA Authoring Practices.

Mount a Primitive

Use mount when you want to test the primitive itself. Pass its definition, options, and a little markup. Tags that match slots become parts; their attributes supply the part props.

This test uses the confirm button. The first press arms it. The next confirms and disarms it:

import { expect, test } from "vitest"
import { mount } from "@ui-sdk/core/testing"
import { confirmButton } from "./confirm-button"

test("confirm with a click and Enter", async () => {
  const button = mount(confirmButton, {}, `<root>Delete</root>`)
  try {
    await button.click(button.root)
    expect(button.state.armed).toBe(true)
    expect(button.changes.at(-1)).toMatchObject({
      key: "armed",
      reason: "press",
      source: "mouse",
    })

    await button.keys("Enter")
    expect(button.state.armed).toBe(false)
  } finally {
    button.unmount()
  }
})
import * as React from "react"
import { mount } from "@ui-sdk/core/testing"
import { confirmButton } from "./confirm-button"

type Check = { label: string; ok: boolean }

async function test() {
  const checks: Check[] = []
  const expect = (label: string, ok: boolean) => checks.push({ label, ok })
  const button = mount(confirmButton, {}, `<root>Delete</root>`)
  try {
    await button.click(button.root)
    expect("A click arms the button", button.state.armed)
    const change = button.changes.at(-1)
    expect("The change says why: press", change?.reason === "press")
    expect("And how: mouse", change?.source === "mouse")
    await button.keys("Enter")
    expect("Enter confirms, and disarms it", !button.state.armed)
    expect("From the keyboard", button.changes.at(-1)?.source === "keyboard")
    expect(
      "The button is a button",
      button.part("root")!.getAttribute("type") === "button"
    )
  } finally {
    button.unmount()
  }
  return checks
}

function Example() {
  const [checks, setChecks] = React.useState<Check[] | null>(null)
  return (
    <div data-slot="stack">
      <button
        type="button"
        data-variant="solid"
        onClick={async () => setChecks(await test())}
      >
        Run the Test
      </button>
      {checks && (
        <ul aria-label="Results" data-slot="list">
          {checks.map((check) => (
            <li key={check.label} data-slot="test-result" data-ok={check.ok}>
              {check.ok ? "โœ“" : "โœ—"} {check.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

export { Example }
Press Run the Test to click the confirm button, press Enter, and inspect the resulting state, change reasons, and input sources.

root is the first element in the markup. Use part(slot, key) to find another part, such as view.part("item", "pear"). state reads the current state, api exposes the primitive's methods, and changes records each accepted change.

Always call unmount when you're done. It removes the markup, unbinds the parts, and destroys the instance.

Typing, Hovering and Announcements

Focus an input before calling type. Use keys for special keys and combinations, including Tab, Shift+Tab, and Space:

const search = mount(
  searchField,
  {},
  `<root><input aria-label="Search"></input></root>`
)
try {
  search.part("input")!.focus()
  await search.type("pear")
  expect(search.state.query).toBe("pear")

  await search.keys("Escape")
  expect(search.state.query).toBe("")
} finally {
  search.unmount()
}

For pointer interactions, hover(element) moves the pointer onto a part. drag(element, { to: target }) drags to another element; drag(element, { by: { x, y } }) moves by a distance.

import * as React from "react"
import { mount } from "@ui-sdk/core/testing"
import { searchField } from "./search-field"
import { seats } from "./seats"
import { subscribe } from "./subscribe-form"

type Check = { label: string; ok: boolean }

async function test() {
  const checks: Check[] = []
  const expect = (label: string, ok: boolean) => checks.push({ label, ok })

  const search = mount(
    searchField,
    {},
    `<root><input aria-label="Search"></input><clear>Clear</clear></root>`
  )
  try {
    search.part("input")!.focus()
    await search.type("pear")
    expect("Typing sets the query", search.state.query === "pear")
    await search.keys("Escape")
    expect("Escape clears it", search.state.query === "")
  } finally {
    search.unmount()
  }

  const map = mount(
    seats,
    {},
    `<root><seat value="A1"></seat><seat value="A2"></seat></root>`
  )
  try {
    await map.hover(map.part("seat", "A2")!)
    expect(
      "Hovering a seat marks it",
      map.part("seat", "A2")!.hasAttribute("data-hovered")
    )
  } finally {
    map.unmount()
  }

  const form = mount(
    subscribe,
    {},
    `<root as="form"><input as="input"></input><button type="submit">Subscribe</button><error></error></root>`
  )
  try {
    await form.click(form.root.querySelector("button")!)
    await form.settle()
    expect(
      "An empty email is announced",
      form.announced.includes("Enter an email address")
    )
  } finally {
    form.unmount()
  }
  return checks
}

function Example() {
  const [checks, setChecks] = React.useState<Check[] | null>(null)
  return (
    <div data-slot="stack">
      <button
        type="button"
        data-variant="solid"
        onClick={async () => setChecks(await test())}
      >
        Run the Tests
      </button>
      {checks && (
        <ul aria-label="Results" data-slot="list">
          {checks.map((check) => (
            <li key={check.label} data-slot="test-result" data-ok={check.ok}>
              {check.ok ? "โœ“" : "โœ—"} {check.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

export { Example }
Press Run the Tests to type and clear a search, hover a seat, and check the announcement from an empty sign-up form.

announced captures the text the primitive sends to its screen reader announcer. Wait for scheduled work with settle before checking a delayed announcement:

await form.click(form.root.querySelector("button")!)
await form.settle()
expect(form.announced).toContain("Enter an email address")

settle waits for microtasks, a timer turn, and two animation frames. If you need the grouped changes reported by the devtools, read settled; changes keeps every intermediate change.

The Server's HTML

checkServer renders the supplied parts without a DOM and returns the HTML. It checks that creating the primitive reads no browser globals, clock, or randomness, and that the parts' attributes, public state, and presence read none of those nor options:

import { checkServer } from "@ui-sdk/core/testing"

const html = checkServer(
  readMore,
  {},
  `<root><trigger>Read more</trigger><panel>More</panel></root>`
)

This uses the read-more primitive. The check throws if it finds a dependency that could make a part render differently on the server and client:

import * as React from "react"
import { definePrimitive } from "@ui-sdk/core"
import { checkServer } from "@ui-sdk/core/testing"
import { readMore } from "./read-more"

const clock = definePrimitive({
  name: "clock",
  state: () => ({}),
  api: () => ({}),
  slots: (slot) => ({
    root: slot({ attrs: () => ({ "data-rendered": String(Date.now()) }) }),
  }),
})

function run(broken: boolean) {
  try {
    const html = broken
      ? checkServer(clock, {}, `<root>Now</root>`)
      : checkServer(
          readMore,
          {},
          `<root><trigger>Read more</trigger><panel>More</panel></root>`
        )
    return { ok: true, text: html }
  } catch (error) {
    return { ok: false, text: (error as Error).message }
  }
}

function Example() {
  const [broken, setBroken] = React.useState(false)
  const [result, setResult] = React.useState<{
    ok: boolean
    text: string
  } | null>(null)
  return (
    <div data-slot="check">
      <div data-slot="row">
        <button
          type="button"
          data-variant="solid"
          onClick={() => setResult(run(broken))}
        >
          Check the Server HTML
        </button>
        <label data-slot="label">
          <input
            type="checkbox"
            checked={broken}
            onChange={(event) => {
              setBroken(event.target.checked)
              setResult(null)
            }}
          />
          Read the clock in attrs
        </label>
      </div>
      {result && (
        <pre
          role="status"
          data-slot="check-result"
          data-status={result.ok ? "pass" : "fail"}
        >
          {result.text}
        </pre>
      )}
    </div>
  )
}

export { Example }
Press Check the Server HTML to read the rendered parts. Then select Read the clock in attrs and run it again to see the dependency reported.

The markup follows the same rules as mount. Ordinary HTML tags stay as they are. Use as="form", for example, when a slot needs to render a specific tag.

Both Directions

For widgets with horizontal arrow navigation, checkDirection compares left-to-right and right-to-left behavior. It mounts a fresh instance for each check, Tabs into it, and compares focus and state after the arrows:

import { checkDirection } from "@ui-sdk/core/testing"

await checkDirection(pager, {}, `<root>Page</root>`)

Left in right-to-left text should match Right in left-to-right text, and vice versa. The check also fails when Right does nothing in left-to-right text, because it has no movement to compare.

import * as React from "react"
import { definePrimitive } from "@ui-sdk/core"
import { checkDirection } from "@ui-sdk/core/testing"
import { pager } from "./page-stepper"

const fixedPager = definePrimitive({
  name: "pager",
  state: () => ({ page: 1 }),
  api: (context) => ({
    keydown(event: KeyboardEvent) {
      const by = { ArrowRight: 1, ArrowLeft: -1 }[event.key]
      if (by) {
        context.store.set({
          page: Math.min(5, Math.max(1, context.store.get().page + by)),
        })
      }
    },
  }),
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ role: "spinbutton", tabindex: "0" }),
      on: { keydown: (event, { api }) => api.keydown(event) },
    }),
  }),
})

function Example() {
  const [broken, setBroken] = React.useState(false)
  const [result, setResult] = React.useState<{
    ok: boolean
    text: string
  } | null>(null)
  async function check() {
    try {
      await checkDirection(broken ? fixedPager : pager, {}, `<root>Page</root>`)
      setResult({
        ok: true,
        text: "Passes: the arrows swap in right-to-left text.",
      })
    } catch (error) {
      setResult({ ok: false, text: (error as Error).message })
    }
  }
  return (
    <div data-slot="check">
      <div data-slot="row">
        <button type="button" data-variant="solid" onClick={check}>
          Check Both Directions
        </button>
        <label data-slot="label">
          <input
            type="checkbox"
            checked={broken}
            onChange={(event) => {
              setBroken(event.target.checked)
              setResult(null)
            }}
          />
          Ignore the direction
        </label>
      </div>
      {result && (
        <p
          role="status"
          data-slot="check-result"
          data-status={result.ok ? "pass" : "fail"}
        >
          {result.text}
        </p>
      )}
    </div>
  )
}

export { Example }
Press Check Both Directions. Then select Ignore the direction to try a pager whose horizontal arrows never swap.

The pager example shows how it reads direction from the page.

Reading Parts

For a focused rendering assertion, create an instance and inspect a part directly. These helpers need no DOM:

import { partAttrs, partPresent, partState } from "@ui-sdk/core/testing"

const instance = readMore({ id: "more" })
try {
  instance.api.toggle()
  const trigger = instance.part("trigger", {})
  expect(partAttrs(trigger)).toMatchObject({ "aria-expanded": "true" })
  expect(partState(trigger)).toEqual({ open: true })
  expect(partPresent(instance.part("panel", {}))).toBe(true)
} finally {
  instance.destroy()
}

partAttrs reads HTML attributes, partState reads the public state passed to render functions, and partPresent tells you whether the part should render.

import * as React from "react"
import { partAttrs, partPresent, partState } from "@ui-sdk/core/testing"
import { readMore } from "./read-more"

function read(open: boolean) {
  const instance = readMore({ id: "more" })
  try {
    if (open) {
      instance.api.toggle()
    }
    const trigger = instance.part("trigger", {})
    const panel = instance.part("panel", {})
    return {
      trigger: JSON.stringify(partAttrs(trigger)),
      state: JSON.stringify(partState(trigger)),
      panel: String(partPresent(panel)),
    }
  } finally {
    instance.destroy()
  }
}

function Example() {
  const [open, setOpen] = React.useState(false)
  const parts = read(open)
  return (
    <div data-slot="stack">
      <label data-slot="label">
        <input
          type="checkbox"
          checked={open}
          onChange={(event) => setOpen(event.target.checked)}
        />
        Open it
      </label>
      <dl data-slot="read-parts-list">
        <dt>partAttrs(trigger)</dt>
        <dd data-attrs>{parts.trigger}</dd>
        <dt>partState(trigger)</dt>
        <dd data-state>{parts.state}</dd>
        <dt>partPresent(panel)</dt>
        <dd data-present>{parts.panel}</dd>
      </dl>
    </div>
  )
}

export { Example }
Select Open it and compare the trigger's attributes and public state with the panel's presence.

Picking the Platform

Shortcut labels use Command on Apple platforms and Control elsewhere. Set the platform explicitly when testing them, so the machine running the test doesn't change the result:

import { formatShortcut } from "@ui-sdk/core"
import { setPlatform } from "@ui-sdk/core/testing"

setPlatform("apple")
expect(formatShortcut("mod+b")).toContain("โŒ˜B")

setPlatform("other")
expect(formatShortcut("mod+b")).toContain("Ctrl+B")

The override stays in effect for later tests. Set it in your test setup to give each test the platform it expects.

Testing in a Framework

Render through the framework's testing tools when you need to check your component's markup, props, or integration with surrounding UI. The same approach applies: interact, then read the result. You can also pass its rendered element to checkPattern.

Unlike mount, framework renderers don't fill gaps in a DOM test environment. For tests that only need a popup to render, a jsdom setup can provide these small stubs:

HTMLElement.prototype.showPopover ??= function () {}
HTMLElement.prototype.hidePopover ??= function () {}
Element.prototype.getAnimations ??= () => []
document.head.append(
  Object.assign(document.createElement("style"), {
    textContent: "[popover] { display: block !important }",
  })
)

These stubs don't exercise native popup or animation behavior. Use a browser for those checks, and for checking the finished component with a keyboard and screen reader.