Browser Automation
Locate Espalier controls by their accessible roles and names, then send input through the browser. The examples below use Playwright.
Repository fixtures verify this contract with pinned Playwright and real Chrome. Other drivers are expected to work if they compute DOM accessibility and send trusted input, but they are not covered by the enforced fixture matrix.
The Supported Contract
Locate controls by role and accessible name, exactly as assistive technology resolves them. When one of these controls sits inside an esp-form-item, the item's label becomes the control's accessible name automatically — including when the control's module loads lazily after the form item rendered: esp-input, esp-pick-one, esp-pick-some, esp-textarea, esp-date-picker, and esp-font-picker (which forwards the name to its inner combobox). esp-radio-button-group and esp-checkbox-group take the item's label as the name of their radiogroup and group element, so getByRole("radiogroup", { name }) and getByRole("group", { name }) resolve the group while each option keeps its own label. Controls that carry their own visible label or naming API — esp-checkbox, esp-switch, esp-radio-button, and esp-slider — keep their own naming contract; the form-item label does not replace it.
While the form item shows an error, the control's named element also reports aria-invalid="true" and an accessible description of the hint followed by the error, so toHaveAttribute("aria-invalid", "true") and toHaveAccessibleDescription(...) work against the same role-located element.
// Sign-in form: <esp-form-item label="Email address"><esp-input></esp-input></esp-form-item>
await page.getByRole("textbox", { name: "Email address" }).fill("reader@example.test");
// or equivalently
await page.getByLabel("Email address").fill("reader@example.test");
// <esp-button label="Sign in"></esp-button>
await page.getByRole("button", { name: "Sign in" }).click();
// <esp-form-item label="Favorite fruit"><esp-pick-one>…</esp-pick-one></esp-form-item>
await page.getByRole("combobox", { name: "Favorite fruit" }).click();
await page.getByRole("option", { name: "Cherry" }).click();
What each interaction produces, per control:
| Control | Locator | Interaction | Observable result |
|---|---|---|---|
esp-input |
getByRole("textbox", { name }) / getByLabel |
fill(value) |
Host value updates; exactly one esp-value-changed with the entered value |
esp-input |
same | pressSequentially(text) |
One esp-value-changed per keystroke, ending on the full value — identical to real typing |
esp-button |
getByRole("button", { name }) |
click() |
Exactly one esp-clicked event |
esp-button |
same | press("Enter") |
Exactly one esp-clicked event |
esp-button[disabled] |
same | any click or keyboard reach | Nothing — the control is inert and out of the tab order, but stays reachable by role and name |
esp-header-button |
getByRole("button", { name }) |
click() or press("Enter")/press(" ") |
Exactly one esp-clicked event. The host carries no role: aria-label, aria-controls and aria-expanded set on it are forwarded to the one native button inside, so the name resolves to a single element. A forwarded aria-controls is readable as an attribute but resolves to a relationship only when its target shares the button's shadow root |
esp-header's menu toggle |
getByRole("button", { name: "Open navigation" | "Close navigation" }) |
click() or press("Enter")/press(" ") |
The drawer opens or closes; the same button is renamed for its new state, aria-expanded follows it, and focus returns to it on close |
esp-pick-one / esp-pick-some |
getByRole("combobox", { name }) |
click() or press("ArrowDown") |
Menu opens; aria-expanded reflects the state; the open menu is a named listbox whose aria-multiselectable reflects the mode |
esp-pick-one options |
getByRole("option", { name }) |
click(), or ArrowDown/Enter on the combobox |
Host value updates; exactly one esp-value-changed (detail: the picked item) per pick; the menu closes; the option shows aria-selected="true" on reopen |
esp-radio-button-group / esp-checkbox-group in an esp-form-item |
getByRole("radiogroup", { name }) / getByRole("group", { name }) |
— | The group resolves by the form item's label; while the item shows an error it is aria-invalid="true" and described by the hint then the error |
esp-pick-some options |
getByRole("option", { name }) |
click() per option |
Host values accumulates; one esp-value-changed (detail: the selected-item array) per pick; the menu stays open across picks; picked options show aria-selected="true" |
What the Transport Must Do
The contract assumes interactions travel through the browser's input pipeline — the same path a person's keyboard and pointer take. Playwright's fill, type/pressSequentially, click, and press all qualify: they focus the control and deliver trusted events, which is what Espalier's value syncing and activation listen to.
Two shortcuts some automation layers take are not supported, because the browser itself gives them no observable side effects:
- Setting
.valuedirectly on the native input. The text changes visually, but noinputevent fires, so no value syncing can happen — in any web component, or in a plain<input>with aninputlistener. If your tool "types" this way, the hostvaluestaying empty is a defect in the tool, not the component. - Calling
.click()on theesp-buttonhost element. A programmatic host click does not activate the native button inside the shadow root. Use a real click (any coordinates-based or role-located click), orpress("Enter")/press(" ")on the focused control.
If a flow works with a manual pointer and keyboard but not with your automation tool, compare what the tool actually dispatches against this section before filing an Espalier issue — and file the issue against the tool when it falls into one of the two categories above.
Notes for Test Authors
- Assert on the public contract: host properties (
value,values), component events (esp-value-changed,esp-clicked), and ARIA state (aria-expanded,aria-selected). None of these require reaching into shadow roots. - Prefer
exact: truewhen asserting a label-derived name. Playwright's name matching is substring by default, and a text input'splaceholderparticipates in accessible-name fallback — a substring match can keep passing on the placeholder when the label plumbing is what you meant to test. - Picker options render inside a top-layer popover. They are ordinary role/name targets while the menu is open; there is no need to wait for animations beyond your tool's normal actionability checks.
- Espalier exposes roles both through
ElementInternals(for assistive technology) and as content attributes (for automation tooling that computes ARIA from the DOM). ThegetByRoleguarantee is the pinned Playwright/Chrome matrix the fixtures run; other transports that compute ARIA from the DOM are expected to resolve the same roles, but are unverified.