In Playwright, how does page.locator() decide whether an unprefixed selector string is CSS or XPath?
answer
- No prefix means Playwright must guess
- Only the leading characters are inspected
- Slashes and dots point one way
- Quotes end to end mean something else
- Everything left over falls to one engine
basics
~20 sPlaywright reads an unprefixed string as XPath when it starts with // or .. , as a text selector when the whole string is quoted, and as CSS otherwise. Write css= or xpath= to remove the guess.
solid answer
~40 s`page.locator()` takes a selector string that may name its engine explicitly -- `css=`, `xpath=`, `text=`, `id=`, `data-testid=`, `nth=` -- or leave Playwright to infer one. Inference is a short rule list applied to the leading characters only: a string wrapped end to end in quotes is a `text=` selector; a string starting with `//`, with `//` behind opening parentheses, or with `..` is XPath; everything else is CSS. So `page.locator('//button')` and `page.locator('xpath=//button')` are the same locator, and `page.locator('"Reopen"')` is `text="Reopen"`. Because only the leading characters are inspected, an XPath expression such as `count(//tr)` is handed to the CSS parser and throws. In a shared suite prefer the explicit prefix: four characters buy you an unambiguous parse and a self-documenting call site.
code
typescript · 10 lines// Issue tracker: these two locators are identical.
await page.locator('//button[@aria-label="Close issue"]').click();
await page.locator('xpath=//button[@aria-label="Close issue"]').click();
// The leading characters decide, so this XPath is parsed as CSS and throws.
// await page.locator('count(//tr[@class="issue-row"])').click();
// Explicit prefixes leave nothing to infer.
await page.locator('css=.issue-detail button[type="submit"]').click();
await page.locator('text="Reopen"').click();go deeper
Remember the three outcomes: quoted string means text, a leading // or .. means XPath, and everything else means CSS. Get in the habit of writing css= or xpath= so you never have to recall the rule under pressure.
Be able to explain that the parse is string-only and happens before the browser is touched, and that the engine name test rejects things like input[value, which is why an attribute selector still reaches the CSS engine.
In review, treat an unprefixed selector built by string interpolation as a defect waiting to happen: a changed leading character silently changes engines. Ask for explicit prefixes wherever a selector is assembled rather than written out.
Decide whether raw selector strings belong in your suite at all, and if they do, set one convention for prefixes and enforce it with a lint rule so the inference rules never become tribal knowledge new engineers have to acquire.
## What `page.locator()` actually receives `page.locator(selector)` takes a **selector string**, not a DOM query object. Before the browser is asked anything, Playwright parses that string into one or more *parts*, and each part names a **selector engine** plus a body for that engine. You can write the engine out with an `engine=body` prefix, or you can leave it off and let Playwright infer one. The parse is pure string analysis -- the page is never consulted, so a mis-inferred selector fails the same way on every site. The built-in engines you can name with a prefix include: - `css=` -- CSS, extended with Playwright's own pseudo-classes, and piercing open shadow roots. - `xpath=` -- evaluated the way `Document.evaluate` evaluates XPath; it does **not** pierce shadow roots. - `text=` -- text matching; unquoted is a trimmed case-insensitive substring, quoted is exact. - `id=`, `data-testid=`, `data-test-id=`, `data-test=` -- plain attribute equality, not CSS, so CSS-only syntax such as `:enabled` is not accepted in the body. - `nth=` -- a zero-based index, where `nth=0` is the first match and `nth=-1` the last. - any name you added yourself through `selectors.register`. ## The inference rules, in order A part is treated as prefixed when it contains `=` **and** the text before that `=` is made only of letters, digits, `_`, `-`, `+`, `:` or a leading `*`. If that test fails, Playwright falls through to inference and applies these checks to the trimmed part, first match winning: 1. The part is wrapped end to end in `"..."` or `'...'` -- the **`text`** engine. 2. The part begins with `//`, or with any run of `(` followed by `//`, or begins with `..` -- the **`xpath`** engine. 3. Anything else -- the **`css`** engine. | You write | Engine chosen | Explicit equivalent | |---|---|---| | `button.primary` | css | `css=button.primary` | | `//button` | xpath | `xpath=//button` | | `(//tr)[1]` | xpath | `xpath=(//tr)[1]` | | `..` | xpath | `xpath=..` | | `"Reopen"` | text | `text="Reopen"` | | `input[value=submit]` | css | `css=input[value=submit]` | That last row is the one that surprises people. The string does contain `=`, but the text in front of it is `input[value`, which is not a legal engine name, so the prefix test fails and the whole string goes to CSS -- which is what you wanted anyway. ## Where the guess bites Inference only reads the leading characters, so an XPath expression that does not *start* with `//` or `..` is silently handed to the CSS parser. `count(//tr[@class="issue-row"])` and `descendant::button` both look like XPath to a human and like broken CSS to Playwright, which throws an invalid-selector error rather than doing what you meant. The fix is four characters: `xpath=`. The mirror-image surprise is the quoted form. `page.locator('"Reopen"')` is a **text** selector, not a CSS attribute value, so it matches an element whose trimmed text is exactly `Reopen`. Teams that build selector strings by interpolating a label into a template hit this the first time the label arrives already quoted. ## Why the explicit prefix wins in a shared suite - It documents intent at the call site, so a reviewer does not have to run the inference rules in their head. - It removes the class of failures where a refactor changes the leading character of a generated selector and quietly changes engines with it. - It keeps `>>`-chained strings readable, because every part then announces what it is. ## In an issue tracker On a board page, `page.locator('//article[@data-issue-id="ISS-412"]//button[@aria-label="Close"]')` and `page.locator('xpath=//article[@data-issue-id="ISS-412"]//button[@aria-label="Close"]')` are the same locator -- the first simply relies on the leading `//`. Write the second. When the same target is better expressed in CSS, say `css=article[data-issue-id="ISS-412"] button[aria-label="Close"]`, the prefix makes the switch between the two engines a one-token edit rather than a rewrite.
- What happens if the selector string contains an equals sign that is not an engine prefix, such as input[value=submit]?Nothing surprising. Playwright only treats text before `=` as an engine name when it is made of letters, digits, `_`, `-`, `+`, `:` or a leading `*`. `input[value` fails that test, so the prefix rule does not fire and the whole string goes to the CSS engine, which is the intended reading.
- Why does page.locator('"Reopen"') behave differently from page.locator('Reopen')?A string wrapped end to end in single or double quotes is inferred as the `text` engine, so `'"Reopen"'` becomes `text="Reopen"` and matches an element whose trimmed text is exactly Reopen. Without the quotes, `Reopen` is not quoted, does not start with `//` or `..`, and is therefore parsed as a CSS type selector for a `<reopen>` element.
- Does the inference ever consult the page to break a tie?No. Parsing happens entirely in the selector string before any query is sent to the browser, so a selector that is inferred wrongly fails identically on every page and every browser. That is why an invalid-selector error surfaces immediately rather than as a timeout.
saying these in an interview costs you the question
- Claiming page.locator only accepts CSS selectors
- Thinking Playwright inspects the page to pick an engine
- Assuming any string containing an equals sign is a prefixed selector
- Believing an XPath expression is detected wherever // appears
- Treating a fully quoted string as a CSS attribute value