UCDL Accessibility Use Case Definition Language
Contents

Writing Accessibility Use Case Tests with @afixt/usecase-runner

Part 2: Understanding the Step DSL

Every step follows this pattern:

keyword: role_token "accessible_name" [modifiers]
  • keyword — The action to perform (locate, focus, enter, select, activate, verify, audit)
  • role_token — How to find the element (button, link, field, heading, etc.)
  • "accessible_name" — The element's accessible name (always in double quotes)
  • modifiers — Optional parameters specific to the keyword

1. locate — "Can the user find this?"

Asserts that an element exists and is visible in the accessibility tree.

- locate: button "Submit"
- locate: heading "Create Account" level 2
- locate: field "Email" within region "Sign Up"
- locate: text "Welcome back"
- locate: image "Company Logo"
- locate: navigation "Main Menu"

What it generates:

await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();

When to use it: Before interacting with any element. This is your first check — can an assistive technology user discover this element?

2. focus — "Can the user reach this with a keyboard?"

Moves keyboard focus to an element and asserts that focus was actually received.

- focus: field "Email"
- focus: button "Submit"
- focus: link "Sign In"

What it generates:

const el = page.getByLabel('Email');
await el.focus();
await expect(el).toBeFocused();

When to use it: Before entering data or activating controls. This catches elements that look interactive but can't be reached via keyboard.

3. enter — "Type text into a field"

Fills a text input or textarea with a value.

- enter: field "Email" value "[email protected]"
- enter: field "Search" value "accessibility" type_slowly true
- enter: field "Bio" value "{{ bio_text }}"

Modifiers:

Modifier Values Default Purpose
value "text" required The text to enter
type_slowly true/false false Simulates keystroke-by-keystroke typing. Use for autosuggest, input masking.
clear_first true/false true Whether to clear existing content first.

Boolean modifiers take a literal true or false. A typo is a parse error, not a silent false:

- enter: field "Search" value "x" type_slowly tru # error: type_slowly requires "true" or "false"

4. select — "Choose an option"

Works with checkboxes, radio buttons, and dropdown selects.

# Checkboxes
- select: checkbox "I agree to the Terms"
- select: checkbox "Subscribe to newsletter"

# Radio buttons
- select: radio "Express Shipping"
- select: radio "Pay by Credit Card"

# Dropdown selects
- select: select "Country" option "United States"
- select: select "State" option "{{ user_state }}"

The tool automatically uses .check() for checkboxes/radios and .selectOption() for dropdowns.

select does not flip anything. It means set to on, and it is idempotent — if the box is already checked, it returns and does nothing. So this does not test that the checkbox toggles:

# Wrong — the second select is a no-op, and the last line can never pass
- select: checkbox "I agree"
- verify: checkbox "I agree" attribute "aria-checked" is "true"
- select: checkbox "I agree"
- verify: checkbox "I agree" attribute "aria-checked" is "false"

Use toggle for that (below). deselect is the mirror image — set to off, also idempotent.

4a. toggle — "Does this control actually flip?"

Activates a control and asserts its state changed. This is the verb for checkboxes, switches, toggle buttons and disclosures:

- toggle: checkbox "Subscribe" # was false -> must now be true
- toggle: checkbox "Subscribe" # was true  -> must now be false

It catches a real and common component bug: a handler written setChecked(true) instead of setChecked(!checked), so the control only ever turns on. select agrees with that broken component and a working one alike, because it is idempotent by definition.

You can write the same test as activate plus two verifys, and that works. But the pairing is load-bearing and nothing enforces it — drop either verify and the case still passes, still reads as a toggle test, and no longer tests toggling. toggle cannot be written that way by accident.

The state attribute is inferred from the role:

Role Attribute
checkbox, switch, radio aria-checked, falling back to the native checked property
option, tab aria-selected
treeitem aria-expanded

button is ambiguous — aria-pressed on a toggle button, aria-expanded on a disclosure — so toggle uses whichever the element actually has. When it carries both, say which you mean:

- toggle: button "Mute" attribute "aria-pressed"
- toggle: button "Show details" attribute "aria-expanded"

Tri-state checkboxes work: toggle asserts the value changed, not that it strictly inverted, so the APG's mixed -> false -> true cycle passes at every step while a stuck control still fails.

5. activate — "Click or press this"

Triggers a button or link.

- activate: button "Submit"
- activate: link "Dashboard"
- activate: button "Submit" via keyboard
- activate: link "Report" new_tab true
- activate: button "Delete" force true

Modifiers:

Modifier Values Purpose
via keyboard Uses Enter/Space keypress instead of mouse click
new_tab true/false Expects the action to open a new tab
force true/false Clicks even when the element is disabled

via keyboard is important for accessibility testing. Some controls respond to click but not keyboard activation — that's a bug this modifier catches. keyboard is the only argument via takes, and a typo is a parse error rather than a silent fallback to clicking:

- activate: button "Submit" via keybord # error: via requires "keyboard"

force true is for negative cases. A control with aria-disabled="true" fails Playwright's enabled check, so a plain activate times out even on a page behaving correctly. force true drives the click anyway, so the case can assert that nothing happened — pair it with a verify of the state you expect to be unchanged. It can't be combined with via keyboard (keypresses are never blocked by disabled state, so there is nothing to force).

6. verify — "Assert a condition"

The most versatile keyword, with many sub-types:

Page-level checks:

- verify: url "/dashboard"
- verify: url "{{ dashboard_url }}"
- verify: title "Dashboard — My App"

Content checks:

- verify: text "Registration successful"
- verify: heading "Welcome" level 1

Element state checks:

- verify: visible button "Submit"
- verify: hidden dialog "Confirmation"
- verify: enabled button "Next"
- verify: disabled button "Submit"
- verify: checked checkbox "Remember Me"
- verify: focus field "Email"

Form validation checks (crucial for accessibility):

# Checks role="alert" contains this text
- verify: alert "Please correct the errors below"

# Checks aria-invalid="true" AND aria-describedby/aria-errormessage
- verify: field_error "Password"

# Checks the current value of a field
- verify: field "Email" has_value "[email protected]"

Attribute checks — eight predicate forms, not just equality:

# Literal equality
- verify: field "Email" attribute "aria-required" is "true"
- verify: button "Menu" attribute "aria-expanded" is "true"

# Attribute is present and non-empty (any value)
- verify: combobox "Country" attribute "aria-activedescendant" present

# Attribute is not on the element at all. Not the complement of `present`:
# an empty value (alt="") is neither — assert that one with is ""
- verify: button "Save" attribute "title" absent

# Attribute equals the value, OR is omitted entirely
- verify: field "Email" attribute "aria-invalid" is_or_absent "false"

# Value starts with a literal prefix
- verify: button "Open menu" attribute "aria-controls" starts_with "menu-"

# Value matches a regex
- verify:
    listbox "Items" attribute "aria-activedescendant" matches "option-\\d+"

# Every id token in the value resolves to an existing element on the page
- verify: dialog "Confirm" attribute "aria-describedby" references_existing_id

# Numeric value within range read from two other attribute names
- verify:
    slider "Volume" attribute "aria-valuenow" within_range_of "aria-valuemin"
    "aria-valuemax"

When to reach for is_or_absent. For a lot of ARIA state attributes, the spec default is a real value and leaving the attribute off is the conformant way to express it. A healthy field that has never been submitted simply has no aria-invalid. So the obvious assertion fires on exactly the markup it was written to bless:

# Fails on a perfectly conformant field: getAttribute returns null, not "false"
- verify: field "Email" attribute "aria-invalid" is "false"

# Passes on both encodings, and still fails on aria-invalid="true"
- verify: field "Email" attribute "aria-invalid" is_or_absent "false"

That applies to aria-invalid, aria-disabled, aria-required, aria-readonly and aria-busy.

It does not apply to aria-expanded, aria-pressed, aria-checked, aria-selected or aria-hidden. Those default to undefined, so a missing attribute is genuinely different from "false" and is usually a real bug — a disclosure button with no aria-expanded tells a screen reader nothing. is deliberately does not resolve ARIA defaults, so those assertions keep failing when the attribute is absent. Opt into the lenient reading one assertion at a time, only where the default is real.

absent is useful on its own too — asserting aria-hidden is not set on a focusable element, or that a title-tooltip crutch was removed.

The references_existing_id form is the right shape for ARIA relationship attributes — aria-labelledby, aria-describedby, aria-errormessage, aria-controls — because the meaningful check is that the reference resolves, not that it holds a specific id literal.

Count checks — the accessible name is optional:

- verify: count link "Remove" is 3
- verify: count main is 1 # exactly one main landmark
- verify: count role "tablist" is 1 # exactly one tablist on the page

Live region checks:

# Matches every ARIA live region on the page — an element with role="status",
# "alert", "log", "marquee" or "timer", or any aria-live other than "off"
- verify: live_region "3 results found"

# Restrict to one role when the case is about announcement behaviour
- verify: live_region role "alert" "Payment failed"

The unqualified form searches every live region, so a page with a save-status region and an error alert can be asserted against either. Add role "..." when the case specifically means the assertive one — role="status" and role="alert" differ in politeness, and a suite testing what a screen reader announces has a real reason to tell them apart.

One catch with the qualifier: a region that is a live region because it carries aria-live and nothing else has no role to name, so adding role "..." skips exactly those. Use the unqualified form when the page announces that way.

When no region carries the text, the failure lists every region it did find, so you can see whether the text is missing or simply somewhere else.

Download checks:

- verify: download "report.pdf"