/**
* Agent-drivability as a red/green property, not a hope.
*
* A stated goal of this library is that **Playwright MCP and AI
* agents can drive the fleet's apps**, and that reduces to one
* mechanical requirement: `getByRole(role, { name })` finds exactly
* one thing. The fleet fails it comprehensively today —
* plex-channels' icon buttons are bare glyphs (`↶`, `↷`, `▶`),
* mux-magic's status pills are `
`s with no role, lounge-tv has
* zero `aria-*` attributes — and every one of those failures is
* invisible in a browser and invisible to axe.
*
* So every component's story calls this, and the assertion is the
* same one Playwright will make.
*
* Deliberately dependency-free — no `expect`, no jest-dom, no
* testing-library import. This ships in `@charcuterie/ui/testing`
* for consumers to use on *their* components, and a published
* package should not drag a test framework into an app's dependency
* graph to do it. Plain `Error`s with actionable messages work in a
* story `play`, in Vitest, in Playwright, and in a scratch script.
*/
/**
* The subset of a testing-library query object this needs —
* structural, so Storybook's `canvas`, a `within(element)` result,
* and `screen` all satisfy it without an import.
*/
export type AgentQueries = {
queryAllByRole: (
role: string,
options?: { name?: RegExp | string },
) => HTMLElement[]
}
export type AgentTarget = {
name?: RegExp | string
role: string
}
const describeTarget = ({ name, role }: AgentTarget) =>
name === undefined
? `role "${role}"`
: `role "${role}" named ${
typeof name === "string"
? `"${name}"`
: String(name)
}`
/**
* The composite widgets whose members are *supposed* to carry
* `tabindex="-1"`.
*
* Found by M4. `Tabs` is the first component here to use the
* roving-tabindex pattern, and this helper rejected it outright:
* "has a negative tabindex, so it can be clicked but never reached
* with Tab". That rule is right for a standalone button and wrong
* for a tab — a roving group's whole design is that exactly one
* member is in the tab order and the arrow keys reach the rest, so
* a tab list where every tab had `tabindex="0"` would be the bug.
*
* So the check is not skipped for these; it is **replaced by the
* roving rule itself**, which is a stronger assertion than the one
* it stands in for.
*/
const COMPOSITE_ROLES = new Set([
"grid",
"listbox",
"menu",
"menubar",
"radiogroup",
"tablist",
"toolbar",
"tree",
"treegrid",
])
const COMPOSITE_MEMBER_ROLES = new Set([
"gridcell",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"radio",
"tab",
"treeitem",
])
/**
* A toolbar's members are ordinary controls.
*
* Every other composite here names its members by role — a `tab`, an
* `option`, a `menuitem`. A `role="toolbar"` names none of them: the
* APG's toolbar holds plain buttons, toggle buttons, a radio group,
* a menu button, and it is the *container* that declares the
* pattern. So `toolbar` sat in `COMPOSITE_ROLES` from M4 and was
* unreachable, because nothing inside one ever carried a member
* role — a roving toolbar was rejected outright with "has a negative
* tabindex", which is the assertion this helper makes for a
* *standalone* button and precisely the wrong one here.
*/
const TOOLBAR_MEMBER_SELECTOR =
"a[href], button, input, select, textarea, [tabindex]"
const getGroupMembers = (group: Element) =>
group.getAttribute("role") === "toolbar"
? Array.from(
group.querySelectorAll(
TOOLBAR_MEMBER_SELECTOR,
),
)
: Array.from(
group.querySelectorAll("[role]"),
).filter((member) =>
COMPOSITE_MEMBER_ROLES.has(
member.getAttribute("role") ?? "",
),
)
const getCompositeGroup = (element: Element) => {
const isMemberByRole = COMPOSITE_MEMBER_ROLES.has(
element.getAttribute("role") ?? "",
)
let current = element.parentElement
while (current) {
const groupRole = current.getAttribute("role") ?? ""
if (COMPOSITE_ROLES.has(groupRole)) {
return groupRole === "toolbar" || isMemberByRole
? current
: null
}
current = current.parentElement
}
return null
}
const getIsHiddenFromAgents = (element: Element) => {
let current: Element | null = element
while (current) {
if (
current.getAttribute("aria-hidden") === "true" ||
current.hasAttribute("inert")
) {
return true
}
current = current.parentElement
}
return false
}
/**
* Asserts the control an agent would reach for is reachable, unique,
* and keyboard-operable. Returns it, so a caller can go on to assert
* something specific about it.
*/
export const expectAgentDrivable = (
canvas: AgentQueries,
target: AgentTarget,
): HTMLElement => {
const matches = canvas.queryAllByRole(
target.role,
target.name === undefined
? undefined
: { name: target.name },
)
if (matches.length === 0) {
throw new Error(
`Not agent-drivable: nothing matches ${describeTarget(
target,
)}. An agent cannot click what it cannot name — give the control an accessible name (visible text, \`aria-label\`, or a \`VisuallyHidden\` label) rather than a \`data-testid\`.`,
)
}
// Ambiguity is a failure, not a warning. Two "Start" buttons on a
// page mean an agent picks one at random, which is worse than not
// finding either — and the fix is a `Card` heading turning each
// one into its own `region`.
if (matches.length > 1) {
throw new Error(
`Not agent-drivable: ${
matches.length
} elements match ${describeTarget(
target,
)}. Scope them — a labelled region per group — or make the names distinct.`,
)
}
const [element] = matches
if (!element) {
throw new Error(
`Not agent-drivable: ${describeTarget(
target,
)} matched an empty element.`,
)
}
if (getIsHiddenFromAgents(element)) {
throw new Error(
`Not agent-drivable: ${describeTarget(
target,
)} is inside an \`aria-hidden\` or \`inert\` subtree, so a real screen reader and a real agent both skip it.`,
)
}
if (element.querySelector("[data-testid]")) {
throw new Error(
`\`data-testid\` found inside ${describeTarget(
target,
)}. This library has none by rule: a testid is a name only the test suite can see, which is exactly the gap that leaves an app untestable by an agent.`,
)
}
const isNativelyInteractive = [
"a",
"button",
"input",
"select",
"textarea",
].includes(element.tagName.toLowerCase())
const isDisabled =
element.hasAttribute("disabled") ||
element.getAttribute("aria-disabled") === "true"
if (
isNativelyInteractive &&
!isDisabled &&
element.tabIndex < 0
) {
const group = getCompositeGroup(element)
if (!group) {
throw new Error(
`Not agent-drivable: ${describeTarget(
target,
)} has a negative tabindex, so it can be clicked but never reached with Tab.`,
)
}
// A roving group, so the question changes: not "is this
// element in the tab order" — it must not be — but "is the
// *group* reachable, exactly once". Zero tab stops strands the
// whole widget; several put every member back in the tab
// order, which is the pattern not being implemented at all.
const tabStops = getGroupMembers(group).filter(
(member) =>
!member.hasAttribute("disabled") &&
member.getAttribute("aria-disabled") !== "true" &&
// floating-ui plants `aria-hidden` focus guards
// (``) either side of an overlay's
// reference element, and a toolbar holding an overflow
// trigger therefore contains two. They are not members of
// anything — a screen reader and an agent both skip them —
// and counting them reported a correct roving group as
// having three tab stops.
!getIsHiddenFromAgents(member) &&
member.tabIndex >= 0,
)
if (tabStops.length !== 1) {
throw new Error(
`Not agent-drivable: ${describeTarget(
target,
)} is inside a \`${group.getAttribute(
"role",
)}\` with ${
tabStops.length
} tab stops. A roving-tabindex group needs exactly one — Tab enters and leaves it once, and the arrow keys move within it.`,
)
}
}
return element
}
/**
* The other half of the contract, for decoration.
*
* A `Skeleton` that assistive technology can see is a screen reader
* reading three empty bars, and an agent finding a "loading" element
* it cannot act on. Asserting the *absence* of a role is how that
* stays true — axe will not tell you, because there is nothing
* invalid about it.
*/
export const expectHiddenFromAgents = (
element: Element,
): void => {
if (!getIsHiddenFromAgents(element)) {
throw new Error(
'Expected this element to be hidden from assistive technology (`aria-hidden="true"` or `inert`). Decoration that is announced is noise: the load has to be announced by the region that owns it, not by its placeholder.',
)
}
}