Skip to main content
Info

In short: two ways of using a page differ from a mouse on a big screen. A phone is a small, touch-driven browser on a slow network, which sites adapt to with a viewport tag and CSS media queries; a desktop Chrome can pretend to be one by overriding its screen size, touch support and User-Agent, and seleniumbase-rs does that with its mobile mode. Assistive technology reads a second, simpler view of the page that the browser builds next to the picture, the accessibility tree, where each node has a role (“button”), a name (“Sign in”) and states. Pages written with the right HTML give that tree for free, and tests can use it too: finding an element by role and name is more stable than a CSS class, and it fails when the page is inaccessible.

What is different on a phone#

  • A small screen, held upright or sideways, that changes shape when the address bar slides away or the on-screen keyboard opens.
  • Touch, not a mouse. There is no pointer to hover with, fingers are imprecise, and gestures (swipe, pinch) exist.
  • Weaker hardware and networks. A phone CPU is several times slower than a laptop, and the connection comes and goes.
  • Different browsers. Chrome and Samsung Internet on Android use the Blink engine, Firefox on Android uses Gecko, and Safari on iPhone uses WebKit. On an iPhone, other browsers have traditionally been WebKit with a different skin. See browser engines.

The practical result: a page that works perfectly on a desktop Chrome can be unusable on a phone, and a test that passes on a desktop does not show it.

Making a page fit: responsive design#

The viewport meta tag#

Early phones were handed pages made for desktops, so they pretended to have a screen about 980 pixels wide and shrank the result to fit. A modern page tells the browser to stop doing that:

width=device-width means “lay the page out as wide as the real screen”. Without the tag, a phone draws the desktop layout tiny, and the CSS rules meant for small screens never apply. Two viewports are involved: the layout viewport, which CSS sizes things against, and the visual viewport, the part you currently see, which changes when you pinch-zoom or when the keyboard opens.

CSS pixels and the pixel ratio#

A phone screen that is 1170 physical pixels wide is called 390 wide by the browser. The unit in CSS (and in window.innerWidth) is the CSS pixel, and the device pixel ratio (DPR, window.devicePixelRatio) says how many physical pixels make one CSS pixel; here 3. A sharp image for that screen needs three times the pixels in each direction, which is what srcset is for (see compression and caching).

Media queries#

CSS changes itself according to the device:

The ones to know: width (with min- and max-), orientation (portrait or landscape), resolution, hover, pointer, prefers-color-scheme, prefers-reduced-motion. Layout tools such as flexbox, grid and container queries make a component adapt to the space it is given. Sites that serve different HTML to phones (a separate mobile site, or the same URL with other content) decide with the User-Agent and client hints; see detection.

Touch#

  • Touch generates touchstart and touchend, and, for compatibility, mouse events after them. Modern code uses pointer events (pointerdown, pointerup), which cover mouse, pen and finger.
  • Hover menus do not work, because there is nothing to hover with. A menu that opens on hover needs a tap alternative.
  • Controls must be big enough to hit. WCAG 2.2 asks for at least 24 by 24 CSS pixels as a minimum and recommends 44 by 44 (see accessibility).
  • A click still fires on a tap, so most pages work with a click in a test.

Device emulation#

Chrome’s DevTools has a device toolbar that turns a desktop tab into a phone. It is built from a few overrides, each a command of the DevTools Protocol:

What is changedCommandEffect on the page
Screen size, pixel ratio and the mobile flagEmulation.setDeviceMetricsOverrideinnerWidth, devicePixelRatio and media queries change; the meta viewport tag is honoured; scrollbars become overlays
Touch inputEmulation.setTouchEmulationEnabledontouchstart exists, maxTouchPoints is above zero, pointer: coarse matches
User-Agent and client hintsEmulation.setUserAgentOverrideThe User-Agent header and navigator.userAgent say “Android” and “Mobile”; Sec-CH-UA-Mobile becomes ?1

A typical Chrome user agent on an Android phone reads like this. The Mobile word is the signal most servers look for:

Other settings usually go with it: network and CPU throttling, a geolocation, an orientation.

Emulation or a real device?#

Emulation in desktop ChromeA real phone (or a device cloud)
Speed and costInstant, free, runs in CISlow to set up, costs money or hardware
Control from a testFull, through the protocolThrough WebDriver with Appium, or Chrome remote debugging over USB for Android
EngineAlways ChromiumThe real engine: WebKit on iPhone, Gecko on Firefox
Layout and CSSGood for breakpoints and responsive rulesExact, including safe areas and notches
Keyboard, address bar, gesturesNot reproducedReal, and a classic source of bugs
CPU, memory, GPUThe desktop’s, unless throttledThe real thing

Use emulation to catch the large share of problems cheaply (the layout breaks at 390 pixels, a hover menu, a missing viewport tag) and a few real devices for what only they can show. Do not assume that a pass in emulated Chrome means the page works in iPhone Safari.

Who the web has to work for#

People use the web with a keyboard only, a screen reader that speaks the page, a magnifier, voice control, a switch, or a high-contrast screen. Some of them always do, some for a while (a broken arm), some in a moment (a phone in bright sun, a noisy train). Accessibility (“a11y”: a, eleven letters, y) is making sure the page works for all of them. In many countries it is also a legal duty for shops, banks and public services.

Assistive technology does not look at pixels. It asks the browser what is on the page, and the browser answers from the accessibility tree.

The accessibility tree#

From markup to a screen reader. The browser does the middle steps.

The accessibility tree is a filtered copy of the DOM: it leaves out what has no meaning for a person (a purely decorative div, an element hidden with display: none) and adds what the DOM only implies. Take this markup:

The browser derives:

ElementRoleNameState
navnavigation”Main”
alink”Shop”
mainmain
h1heading, level 1”Welcome”
inputtextbox”Email”required
buttonbutton”Sign in”

You can see the tree in Chrome: DevTools, the Elements panel, the Accessibility tab.

Roles, names and states#

Every node answers three questions.

  • Role: what is it? A button, a link, a heading, a checkbox, a dialog, a landmark such as navigation or main. HTML elements carry a built-in role (an implicit role). The role attribute can set one explicitly.
  • Name: what is it called? The label a screen reader speaks. The browser works it out from the best source available: an aria-labelledby reference, then an aria-label, then the content or the element’s own text, a label element for a form control, the alt of an image, the title as the last resort.
  • State and properties: what condition is it in? Checked, expanded, disabled, selected, required, invalid, busy.

ARIA (Accessible Rich Internet Applications) is a set of attributes (role, aria-label, aria-expanded, aria-live and many more) for cases where HTML has no element for the job. Its first rule is to use a native element when one exists.

AccessibleNot accessible
A button<button>Save</button>: role, name, keyboard, focus, all built in<div class="btn" onclick="save()">Save</div>: no role, cannot be focused, Enter does nothing
Hiding an icon’s meaning<button aria-label="Close">x</button> gives the name “Close”<button>x</button> is announced as “x, button”
A togglearia-expanded="true" on the trigger, updated by scriptA class that only changes colour
Warning

ARIA changes only what is announced, never what the element does. Adding role="button" to a div does not make it focusable or respond to the keyboard. Bad ARIA is worse than none, because it promises something the page does not deliver.

Screen readers and the keyboard#

A screen reader speaks the page (or sends it to a braille display) and lets the user move through it without a mouse: NVDA and JAWS on Windows, VoiceOver on macOS and iOS, TalkBack on Android, Orca on Linux. People jump from heading to heading, list the links or the form fields, or move between landmarks. That is why headings need to be real headings in a sensible order, and why a link that says only “click here” is useless out of context.

The keyboard is the other half. Everything a mouse can do must work without one:

  • Tab moves focus to the next interactive element, Shift+Tab goes back. The order follows the DOM, so reordering with CSS can make focus jump around.
  • Enter follows a link or presses a button; Space presses a button or toggles a checkbox; Escape closes a dialog or menu; the arrow keys move inside one widget (tabs, menus, radio groups).
  • The focus must be visible, so the user can see where they are. Removing the outline with outline: none and giving nothing back is a common failure.
  • tabindex="0" puts an element in the tab order, tabindex="-1" allows focus from a script only. Avoid positive values.
  • A modal dialog keeps focus inside while open and returns it to the trigger when closed. A “skip to content” link at the top spares keyboard users from tabbing through the whole menu on every page.

WCAG in one table#

The Web Content Accessibility Guidelines (WCAG) are the standard behind most laws. Versions 2.1 and 2.2 are in use. Each requirement has a level: A (the basics), AA (the usual target and legal requirement), AAA (the highest, not expected everywhere). They fall under four principles, remembered as POUR.

PrincipleIdeaExamples
PerceivableInformation can be seen or heard by everyoneText alternatives for images; captions; text contrast of at least 4.5 to 1 (3 to 1 for large text); content readable when zoomed to 200%
OperableEverything can be used with different inputsAll functions by keyboard; no keyboard trap; visible focus; enough time; targets big enough to hit
UnderstandableThe page behaves predictably and helps with mistakesPage language set; labels on every field; clear error messages
RobustIt works with current and future assistive technologyValid markup; correct name, role and value for custom controls; status messages announced (aria-live)

Why accessible selectors are stable#

Compare three ways to find the same button:

SelectorBreaks when
div.container > div:nth-child(3) > buttonA wrapper is added or the order changes
.btn-primary.css-1x2y3zA designer renames a class or a build re-hashes it
role “button” with the name “Sign in”The button stops being a button, or its label changes: both are changes a user would notice

A role-and-name selector asks what a person would ask: “the Sign in button”. Developers can restyle, wrap and reorder freely without breaking the test. When such a selector cannot find an element that is obviously there, the page has a real defect: no label, a clickable div, a missing role. The test turns into a free accessibility check. Playwright’s getByRole and Testing Library made this style popular for exactly that reason (see selectors and the DOM for the other kinds of selector).

Its weak spots: the name is text, so copy changes and translations change it (use an exact match, or test with a fixed language), and two buttons with the same name need a parent to tell them apart.

Automated audits with axe-core#

axe-core is an open-source JavaScript engine from Deque that checks a page against WCAG rules. It runs inside the page and returns a list of violations, each with a rule id, an impact (minor, moderate, serious, critical), an explanation and the nodes at fault. Lighthouse’s accessibility score is built on it (see rendering and performance).

An automated tool finds a real share of problems, such as a missing alt, a form field without a label or low contrast, but not all: it cannot tell whether an alt text is meaningful, or whether the focus order makes sense. A real audit adds a keyboard pass and a screen reader pass.

How seleniumbase-rs uses it#

Mobile mode#

seleniumbase-rs has a mobile mode that follows Python SeleniumBase’s --mobile option.

  • BrowserConfig::with_mobile(true) turns Chrome’s mobile emulation on with the default screen, DeviceMetrics::default(): 390 by 715 CSS pixels at a pixel ratio of 3 (Python’s constants.Mobile), and an Android Chrome user agent (MOBILE_USER_AGENT).
  • BrowserConfig::with_device_metrics(metrics) chooses another screen, and turns mobile on as well. It is the counterpart of Python’s --metrics, also written --device-metrics, and of its d_width, d_height and d_p_r settings. Here setting metrics is enough, while Python applies them only together with --mobile.
  • DeviceMetrics::new(width, height, pixel_ratio) builds the screen, and a text "412,732,3" parses into one (FromStr), in the WIDTH,HEIGHT,RATIO form Python’s option takes. The three parts must be above zero, and the ratio may be fractional (2.625).
  • On the command line, the sbrs tool has a global --mobile switch (“Use a mobile device emulation profile”).
src/main.rs

What happens underneath#

The way the emulation reaches Chrome depends on the session.

  • A normal WebDriver session passes Chrome’s mobileEmulation experimental option (in goog:chromeOptions) with the device metrics and, when there is one, the user agent.
  • In undetected mode that capability would give the session away, so the session sends the DevTools commands of the table above instead: Emulation.setDeviceMetricsOverride with mobile: true, Emulation.setTouchEmulationEnabled with five touch points, and Emulation.setUserAgentOverride carrying client hints that say “Android” and “mobile”. See detection.
  • These overrides belong to a tab. The session sends them again for each window it opens or switches to, and apply_mobile_emulation on the browser session repeats them for a window you switched to some other way.
Note

The same three commands work on any DevTools session. In the pure CDP engine, send them with Page::execute, for example Emulation.setDeviceMetricsOverride with width, height, deviceScaleFactor and mobile: true.

Warning

Mobile mode changes what the page does. A site may redirect a phone User-Agent to another host, hide its desktop menu, or serve a smaller bundle. Hover-only controls cannot be reached. Write mobile tests against the mobile flow on purpose, and keep desktop tests separate.

Role selectors and audits#

seleniumbase-rs does not bundle axe-core. It has its own pieces, which cover different ground.

Role and label selectors#

SelectorBuf::role(role, name, exact) builds a selector for an ARIA role with an optional accessible name, the counterpart of Playwright’s getByRole. SelectorBuf::label(text, exact) finds a form control by its label, as getByLabel does.

Warning

These are approximations, written as XPath, because the engine does not ask the browser for the real accessibility tree. An element matches a role through its role attribute or as the native element for it (button, a with href, h1 to h6, input type="checkbox" and so on). The name matches the aria-label, text, value, title or alt, or the text of its label. Not followed: aria-labelledby, roles that depend on context, hidden elements, and states such as checked. A test can still pass against markup that the real tree would name differently.

src/main.rs

The audit_page tool#

The MCP servers that have the DevTools tools (cdp and sb) offer audit_page. It takes the page’s HTML as it is now, after its scripts ran, and runs HtmlInspector over it. It accepts severity (all or error) and limit (50 by default, 500 at most) and returns the numbers of errors and warnings, a count per rule, and the issues with the rule, message and a CSS selector for the element. Its own note says what it cannot judge: colour contrast, focus order and what a screen reader says. See MCP.

HtmlInspector in your own tests#

seleniumbase_rs::api::html_inspector::HtmlInspector::inspect(html) is a static checker with stable rule ids, so it can be diffed in CI. It does not run JavaScript, so give it the DOM as the browser has it now.

RuleSeverityReports
document-langerrorhtml has no lang
image-alterroran image has no alt attribute
interactive-nameerrora link or button has no accessible name
form-control-labelerroran input, select or textarea has no label
duplicate-iderroran id is used more than once
invalid-aria-referenceerroraria-labelledby or -describedby names a missing id
heading-orderwarninga heading level is skipped
main-landmarkwarningthere is no main landmark

The table shows the main rules; the module lists them all (for example skip-link, page-h1, table-headers, link-text).

src/main.rs

Running axe-core yourself#

Load the axe-core file into the page and run it. Both engines can evaluate JavaScript, and Page::evaluate on the pure CDP engine awaits the promise that axe.run() returns.

src/main.rs
Tip

Start with HtmlInspector for the cheap, repeatable checks in every run, add axe-core for contrast and ARIA rules on key pages, and keep manual keyboard and screen-reader passes for releases.

Where to go next#