UCDL Accessibility Use Case Definition Language
Contents

Beyond the Step: Delegating to Audit Engines

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

Automated accessibility scanners and use-case tests answer different questions.

A scanner asks: at this moment, on this page, are there detectable WCAG violations? It's broad, fast, and shallow — it can find a missing alt, a contrast failure, a form control with no label, on every element at once. It has no idea whether a user can complete a task.

A use case asks: can a user accomplish this goal? It's narrow and deep — it follows one path through the application and checks every interaction on that path. It has no idea whether the footer's link colour passes contrast.

You want both, and you want them in the same file, because the interesting moments for a scanner are the ones a use case creates: after login, after opening a dialog, after filtering a list. That's what the delegating verbs do.

audit — run the engine at a point in the flow

- audit: page # whole page, WCAG AA (default)
- audit: page level "AAA"
- audit: region "Search results" # just this landmark and its descendants
- audit: dialog "Confirm delete"

audit serialises the page exactly as it stands — after every step that came before it, with the session, the dialog you just opened and the filter you just applied all in place — and hands that DOM to @afixt/afixt-engine along with the current viewport. Nothing is re-fetched, which is what makes authenticated and mid-flow views auditable at all: re-navigating to the URL would land on the login gate. It scores the result:

Engine result Step outcome
No issues pass
Low-priority issues only pass, with each issue recorded in accessibility_notes — the case scores Pass w/ Conditions
Any medium or high fail

For a scoped audit, the target is located through the accessibility tree first (getByRole, as always), so audit: dialog "Confirm delete" is also an assertion that such a dialog exists and is named. Then a selector is derived from the located element and passed to the engine as a context, so only violations inside it are reported.

The flip side of the snapshot: what the serialisation cannot carry is not audited. Shadow roots, <iframe> content and anything drawn into a <canvas> are outside it, so a clean audit is a claim about the serialised DOM rather than about everything the user sees.

The pattern that pays off most: audit every page of a multi-step flow, at the moment the flow reaches it.

id: checkout-audits
title: 'Every page of checkout passes WCAG AA'
type: positive
start_location: 'https://store.example.com/cart'
expected_result: 'No medium or high issues on any checkout page'
preconditions:
  - 'Cart contains at least one item'

steps:
  - verify: heading "Your cart" level 1
  - audit: page

  - activate: button "Checkout"
  - verify: heading "Shipping address" level 1
  - audit: page

  - enter: field "Street address" value "123 Main St"
  - enter: field "City" value "Springfield"
  - activate: button "Continue to payment"
  - verify: heading "Payment" level 1
  - audit: page
  - audit: region "Card details" level "AAA"

Scanners pointed at a URL list never see the payment page, because it only exists after the shipping form is filled in. Here the use case walks there and the audit runs on arrival.

contrast — targeted colour contrast

- contrast: text "Log in"
- contrast: button "Submit" level "AAA"
- contrast: region "Footer"

Delegates to @afixt/test-utils. Given a container, it walks every text-bearing descendant and reports tested / passed / failed / skipped counts. Useful when a full audit is noisy and you want one assertion about one component.

lang_check — does the declared language match the content?

- lang_check: page
- lang_check: region "Article"

Resolves the nearest lang attribute and compares it to the language detected (via franc) from the text. A French article under <html lang="en"> is read in an English voice by a screen reader, and it's a defect that no amount of markup inspection catches — you have to look at the words.

read_image — what does the image actually say?

- read_image: image "Sale banner" has_text "50% off"
- read_image: image "Logo" matches "^AFixt"

OCR via Tesseract. The classic use: a promotional banner whose alt says "Banner" while the pixels say "50% off this weekend only." locate: image "Sale banner" confirms it has an accessible name; read_image checks the name isn't hiding the content.

sr_says — what did the screen reader announce?

- activate: button "Search"
- sr_says: '"3 results found" after activate button "Search" within 2s'

The most direct assertion in the toolkit: run a screen reader (a virtual one in-process by default, or real VoiceOver/NVDA via srDriver), and assert that a phrase appeared in its spoken log. after <step> scopes the check to announcements made since that step; within <duration> polls up to a deadline, for live regions that announce asynchronously.

The driver that actually ran is recorded on every result. A virtual-SR pass is never reported as a real-SR pass.

scope.for_each — turn a use case into a sweep

Some checks are audit-shaped rather than flow-shaped: "every image has an accessible name", "every link in the breadcrumb resolves." The scope block iterates a case over a collection, with self standing for the current element:

id: breadcrumb-links-named
title:
  'Every breadcrumb link has an accessible name and a contrast-passing label'
type: positive
start_location: 'https://app.example.com/products/widgets/blue'
expected_result: 'No breadcrumb link is unnamed or low-contrast'
preconditions: []

scope:
  for_each: 'link within navigation "Breadcrumb"'

steps:
  - locate: self
  - verify: self attribute "href" present
  - contrast: self
  - verify: iteration_summary.failed is 0

The iteration_summary line is terminal — it runs once after all iterations.

If for_each matches nothing, the run is recorded as inapplicable (total: 0, inapplicable: 1) and scores pass — nothing failed, because nothing was evaluated. Know that before you rely on a sweep: delete every breadcrumb link and the case above goes green, failed is 0 and all. When the collection's existence is part of the claim, assert it — a locate: on the container ahead of the iteration, or a terminal verify: iteration_summary.total is N where the count is known.

The capstone shape of a serious use case, drawing on the whole series:

id: login-and-dashboard
title: 'Log in by keyboard and reach an accessible dashboard'
type: positive
start_location: 'https://app.example.com'
expected_result: 'The user is logged in and the dashboard passes audit'
preconditions:
  - 'User has valid credentials'

data:
  username: '[email protected]'
  password: 'SecurePass123!'

steps:
  - audit: page # the login page itself

  - locate: field "Email"
  - focus: field "Email"
  - enter: field "Email" value "{{ username }}"
  - locate: field "Password"
  - focus: field "Password"
  - enter: field "Password" value "{{ password }}"
  - locate: button "Sign in"
  - focus: button "Sign in"
  - activate: button "Sign in" via keyboard

  - verify: url "/dashboard"
  - verify: heading "Dashboard" level 1
  - sr_says: '"Dashboard" within 3s'
  - verify: count main is 1

  - audit: page # the page you can only reach by logging in
  - audit: navigation "Main"
  - contrast: region "Account summary"
  - lang_check: page

Run it under --profile no-pointer in CI, with generate feeding your Playwright suite, and negative extensions for the wrong-password and locked-account paths. Every line targets the accessibility tree. Every failure is a finding. Every pass is a claim about what a real user can do.

That's the philosophy, and it's the whole series: don't test the page you built; test the page your users get.


Series index: README