Skip to main content
Info

In short: the browser turns a page’s HTML into a tree of objects, the DOM. A selector is a short text that describes where one element sits in that tree or what it looks like. Every click, every typed word and every assertion in a test starts with a selector, so a test is only as stable as its selectors.

The DOM: a page as a tree#

The browser reads HTML text and builds a tree of nodes. Most are elements (a div, a button), some are text, and the top one is the document.

The DOM of that HTML. Each box is an element with attributes; the button has a text child.

The DOM is alive. JavaScript adds, removes and changes nodes after the page has loaded, and the browser repaints. What you see is the DOM now, not the HTML file the server sent. That is why “View source” and the Elements panel of the developer tools can disagree, and why a test must wait for the element it wants to exist (see JavaScript, events and waiting).

An element has attributes (id, class, name, type, href, any data-*), text, and a position among its parent, children and siblings. A selector uses some of these.

The ways to point at an element#

The language the browser also uses to style pages. It is short and fast, and it is the default in seleniumbase-rs.

SelectorMeans
#userThe element whose id is user
.btnElements with the class btn
buttonEvery button
input[name="user"]An input whose name attribute is user
form#login .primaryA .primary anywhere inside form#login
form > buttonA button that is a direct child of a form
label + inputAn input right after a label
li:nth-child(2)The second item of its parent
[data-testid="save"]Any element with that attribute

CSS can go down the tree and across to a following sibling. It cannot go up to a parent and it cannot test an element’s text.

How seleniumbase-rs reads a selector#

You pass one string and the library works out which kind it is. The rules are in Selector::auto (src/utils/selectors.rs), checked in this order:

A partial-link-text prefix?

p_text=Sign is a link whose text contains “Sign”.

A link-text prefix?

link=Sign in (and text=Sign in) is a link with exactly that text. Everything after the prefix is the text, even if it looks like XPath. The prefix is matched exactly: LINK=Home is read as CSS.

Does it start like XPath?

A string that begins with /, ./, ../ or (, is .., or begins with an axis such as parent:: or ancestor::, is XPath.

Otherwise it is CSS

Nothing that is valid CSS can start any of the ways above, so CSS is the fallback. is_xpath_selector and is_link_text_selector expose the checks.

For more than a string, the pure CDP engine builds on SelectorBuf. It has constructors for each kind (SelectorBuf::css, xpath, link_text, partial_link_text) and for what people read (containing_text, text, has_text, label and role, which finds an element by its accessible role and name). xpath_to_css converts a simple XPath expression into CSS.

A Locator (page.locator("form#login")) holds a selector and does not look anything up until you act. You narrow it with locator() on the result, nth(i), first(), last(), visible() or parent(), count the matches with count(), and read them with text(), attribute(name), is_visible() or exists(). An action acts on the first match.

src/main.rs

Shadow DOM#

A web component can hide its inner structure in a shadow root: a separate small tree attached to a host element. A normal selector stops at the host, because the shadow tree is not part of the main one. Selectors do not cross the boundary on their own.

  • The WebDriver engine adds a piercing combinator, ::shadow. The selector my-app ::shadow .form ::shadow input is split at each ::shadow, and a script walks from the document into each host’s shadow root in turn. Only open shadow roots can be entered; a closed one hides its contents from every script.
  • The pure CDP engine has no shadow-piercing selector in this version. To reach into a shadow root there, run a script with Page::evaluate.
  • The companion extension records a click on a shadow element as a click on the shadow host, because that is what an outside selector can name.

See also the glossary entry Shadow DOM and the later section on web components.

Iframes#

An iframe is a whole other document inside the page. Its elements are not in the parent’s DOM: a selector run in the parent does not see them, and a selector run inside the frame does not see the parent. In WebDriver you switch into the frame, work there, and switch back out.

src/main.rs

BaseCase::switch_to_frame(css) switches into the frame that css selects, and switch_to_frame_of_element does the same for an element you already hold. A cross-site frame is also drawn by a separate browser process (see browser architecture), which is why the DevTools Protocol treats it as its own target.

Why selectors break#

Most broken tests are broken selectors. The page changed; the test did not.

SelectorWhy it breaksBetter
div.container > div:nth-child(3) > spanAny extra wrapper or reordered row moves itA selector that names the thing itself
.css-1x2y3z4 or .btn-a8f3Generated class names change on every buildA stable attribute
//*[@id="root"]/div/div[2]/buttonA path from the top breaks when the layout does//button[text()='Save']
buttonMatches the first of manyAdd a hook, or narrow with a parent
#next where the id is reusedThe page now has twoCheck the match count
link=Sign inA translation or a copy edit changes the textFine if the text is the contract; otherwise a data- attribute
Check

Writing stable selectors. In order of preference: a dedicated test hook (data-testid="save", data-test, data-qa); a stable id or name; an accessible role and name (button named “Save”), because that is also what a screen reader sees; the visible text of a link or button; and last a short CSS path anchored on something that does not move. Avoid positions (nth-child), generated class names and long paths. Always check that the selector matches one element before relying on it.

Waiting for the DOM#

A selector describes where to look, not when. Right after a click, the new element may not exist yet, and an element that is there may be covered by another. The pure CDP Locator looks the element up again each time and waits for it (see actionability), so it cannot hold a reference that has gone stale. In the WebDriver engine a found element is a reference that can go stale when the page redraws, which is why BaseCase methods take a selector and find it again (see element references).

The companion extension’s inspector shows, for a picked element, every selector it can think of in order of preference together with how many elements each one matches, and the recorder only writes selectors it has checked in the page. See Browser extension.

Where to go next#