UCDL Accessibility Use Case Definition Language
Contents

Testing Without a Mouse: Interaction Profiles

Part 7 of "Testing the Way Users Actually Experience the Web" — a tutorial series on @afixt/usecase-runner.

Playwright can hover. It can hover over anything, at any time, with perfect precision. That's a problem.

A user navigating by keyboard, by switch access, or by voice control cannot hover. A mega-menu that opens on pointer-over, a tooltip that only appears on mouse-enter, a carousel that pauses on hover and otherwise auto-advances past the button you're trying to reach — these are all flows that Playwright completes happily and a large class of real users cannot complete at all.

A test that hovers is making a claim about the page that's only true for people with a pointing device. Interaction profiles make that claim explicit and let you refuse it.

The verbs that need a mouse

usecase-runner has exactly two pointer-only verbs:

- hover: button "Save"
- hover_out: button "Save"

They're mouse-only on purpose — there is no keyboard equivalent of hovering, and pretending otherwise (say, by mapping hover to focus) would hide the exact defect these verbs exist to surface. Use them when the thing under test genuinely gates on pointer hover: tooltip exposure, hover-to-pause patterns, WCAG 1.4.13 content-on-hover behaviour.

Everything else in the DSL — focus, activate ... via keyboard, keyboard:, type: — is keyboard-driven or keyboard-capable.

npx usecase-runner run ./usecases --profile no-pointer

Under no-pointer, any hover or hover_out step fails before it touches the page, with a distinct failure reason:

2  hover: button "Products"   FAIL   failure_reason: profile_forbidden
     Step uses "hover", which the "no-pointer" interaction profile forbids.
     The flow cannot be completed without a pointer.

profile_forbidden is separate from an ordinary assertion failure in the report, so you can tell "this flow requires a pointer" from "this assertion didn't hold." The enforced profile is also recorded on the result (UseCaseResult.profile), so a report always says which constraints actually ran — never the ones that were merely requested. That rule was borrowed from the screen-reader driver (sr_says records whether it used a virtual or real screen reader) and it applies here for the same reason: a result that doesn't say how it was produced can't be trusted.

The name is precise. no-pointer is not keyboard-only: it forbids pointer verbs, it doesn't drive the page by keyboard for you. Writing the keyboard path is still your job — which is the point.

<nav aria-label="Main">
  <button class="menu-trigger">Products</button>
  <ul class="mega-menu">
    <!-- shown via .menu-trigger:hover + .mega-menu -->
    <li><a href="/products/widgets">Widgets</a></li>
    <li><a href="/products/gadgets">Gadgets</a></li>
  </ul>
</nav>

A use case written by someone testing with a mouse:

id: nav-to-widgets
title: 'Reach the Widgets product page via the Products menu'
type: positive
start_location: 'https://app.example.com'
expected_result: 'The Widgets page loads'
preconditions: []

steps:
  - locate: button "Products" within navigation "Main"
  - hover: button "Products" within navigation "Main"
  - locate: link "Widgets" within navigation "Main"
  - activate: link "Widgets" within navigation "Main"
  - verify: url "/products/widgets"

Run unrestricted: five passes. Run with --profile no-pointer: step 2 fails with profile_forbidden, and the rest cascade. The report is now telling the truth — this flow cannot be completed without a mouse.

Writing the flow a keyboard user would take

Rewrite the case to describe what should work for everyone, and the page's defects show up as ordinary assertion failures:

steps:
  - locate: button "Products" within navigation "Main"
  - focus: button "Products" within navigation "Main"
  - verify: button "Products" attribute "aria-expanded" is "false"
  - activate: button "Products" within navigation "Main" via keyboard
  - verify: button "Products" attribute "aria-expanded" is "true"
  - locate: link "Widgets" within navigation "Main"
  - focus: link "Widgets" within navigation "Main"
  - activate: link "Widgets" within navigation "Main" via keyboard
  - verify: url "/products/widgets"

Against the hover-only markup, step 3 fails (no aria-expanded — absence is a defect, see Part 5), step 5 fails (activating the button does nothing), and step 7 fails (the links are display: none until hover, so they can't be focused). Three findings, each with a clear fix: add aria-expanded, open the menu on click/Enter, show it on :focus-within as well as :hover.

This case passes under both profiles once the page is fixed. That's the target state: a use case that describes a universally completable flow, and a profile that guarantees no one slipped a pointer dependency back in.

Keeping the hover test and the keyboard test

Sometimes the hover behaviour is legitimately the thing under test — a tooltip that must appear on hover per 1.4.13, say. Keep that case, and pair it with a focus-driven companion:

# tooltip-on-hover.uc.yaml — runs only in unrestricted mode
- hover: button "Help"
- verify: visible tooltip "Opens the help centre"
- hover_out: button "Help"
- verify: hidden tooltip "Opens the help centre"
# tooltip-on-focus.uc.yaml — must pass under no-pointer too
- focus: button "Help"
- verify: visible tooltip "Opens the help centre"
- keyboard: 'Escape'
- verify: hidden tooltip "Opens the help centre"

Run the whole suite under no-pointer in CI and you'll get one expected profile_forbidden on the hover case — which is the suite documenting, in a machine-checkable way, exactly which flows depend on a pointer and that there is a keyboard path for each.

--profile works on generate as well as run. The generated .spec.ts contains a step that raises the identical message, read from the same source file as the direct runner, so the two execution modes can never disagree about whether a flow is pointer-dependent. Mode equivalence is a normative requirement of the spec (§8.3), so a profile is only enforced if both sides honour it.

Next: One Flow, Many Outcomes: Extension Cases and Negative Testing