How Ego-Lite Locators Resolve @N Refs, loc=css, loc=role, and xpath Selectors
Ego-lite parses every selector through parseLocator in src/element-resolver.ts, then dispatches @N refs to a snapshot-based ref-map, loc=css: to document.querySelectorAll, loc=role: to the Accessibility AX tree, and xpath= to document.evaluate.
Ego-lite provides a unified selector syntax for browser automation through its ego-browser harness. Whether you're clicking buttons, filling forms, or waiting for elements, all targeting helpers accept a single string that automatically routes to the appropriate resolution strategy. This article breaks down exactly how four selector types—snapshot references, CSS, ARIA role, and XPath—are parsed and resolved according to the citrolabs/ego-lite source code.
The parseLocator Entry Point
All selector strings enter the resolution pipeline through parseLocator in src/element-resolver.ts lines 14-21. This function detects prefixes and returns a structured locator object containing:
kind:"css","role","xpath","ref", or"internal"selectororxpathorrole/name: the raw query stringnth: an optional index for disambiguating multiple matches
The parser also handles internal modifiers like internal:nth=2; or internal:last; that specify which match to return when multiple elements satisfy the selector.
@N Snapshot References
Snapshot references provide stable identifiers for elements captured during snapshotText() calls.
Parsing the Reference
The parseRef function in src/ref-map.ts (lines 1-10) extracts numeric IDs from strings starting with @ or ref=:
// Both resolve to ref ID 12
await click('@12');
await click('ref=12');
Resolution Flow
resolveElementCenter or resolveElementObjectId (lines 70-77 of element-resolver.ts) performs the lookup:
- Extract
refIdviaparseRef - Query the ref-map built from the latest snapshot
- If missing, throw
ElementResolutionErrorwith transient kind → triggers re-snapshot - If present, check for
backendNodeId:
const refId = parseRef(selectorOrRef);
if (refId) {
const entry = refMap.get(refId);
// Stale-ref handling with fallback to role-based lookup
}
When backendNodeId exists, the code tries DOM.getBoxModel (for coordinates) or DOM.resolveNode (for object ID). If the node became stale, it falls back to findBackendNodeIdByRoleName using cached role/name data.
loc=css: and Plain CSS Selectors
CSS locators are the default when no special prefix is detected.
Resolution Chain
parseLocatorreturns{kind: "css", selector: "button.primary", ...}resolveLocatorCentercallsbuildLocatorCenterJs- This generates JavaScript using
queryAllExpressionfromsrc/locator-query.ts - The snippet runs
document.querySelectorAlland selects bynthindex (defaults to 0)
function buildLocatorFindJs(locator) {
if (locator.kind === "css") {
const selector = `loc=css:${locator.selector}`;
return `(() => ${queryAllExpression(selector)}[${index}] || null)()`;
}
}
The loc= prefix is optional—button.primary and loc=css:button.primary resolve identically.
loc=role: ARIA Role Selectors
Role locators query the browser's Accessibility (AX) tree instead of the DOM, enabling reliable targeting by semantic role and accessible name.
Parsing Role Syntax
The parser recognizes role:button[name="Submit"] and returns:
{kind: "role", role: "button", name: "Submit", nth: 0}
AX Tree Resolution
resolveLocatorCenter invokes findBackendNodeIdByRoleName (lines 24-66):
async function findBackendNodeIdsByRoleName(cdp, sessionId, role, name, …) {
const result = await send(cdp, "Accessibility.getFullAXTree", params, effectiveSessionId);
// Filter nodes by role & optional name match
}
The full AX tree is fetched via CDP's Accessibility.getFullAXTree, then filtered client-side. The resulting backendNodeId converts to coordinates via DOM.getBoxModel or to an object handle via DOM.resolveNode.
xpath= Selectors
XPath expressions enable precise DOM navigation for complex queries.
XPath Resolution
When parseLocator detects the xpath= prefix, it yields {kind: "xpath", xpath: "//button[...]", ...}.
The generated JavaScript uses the native document.evaluate API:
if (locator.kind === "xpath") {
return `(() => {
const snapshot = document.evaluate(${JSON.stringify(locator.xpath)},
document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
return snapshot.snapshotItem(${index});
})()`;
}
The ORDERED_NODE_SNAPSHOT_TYPE result type ensures stable indexing when nth modifiers are applied.
Error Handling and Retry Semantics
Ego-lite classifies resolution failures to determine retry behavior:
| Error Kind | Trigger | Behavior |
|---|---|---|
| Transient | Stale ref, element not yet rendered | Retry after fresh snapshot |
| Permanent | Invalid selector, ambiguous match without nth |
Immediate failure with clear message |
The functions matchCountKind, selectorResolutionError, and ElementResolutionError (lines 46-61 of element-resolver.ts) implement this classification. Transient errors specifically on @N refs indicate the DOM has changed since the last snapshotText() call.
Complete Usage Examples
// @N reference from snapshot
await click('@23');
// CSS selectors (loc=css: optional)
await click('loc=css:button.primary');
await click('input[name="email"]');
// Role-based targeting
await click('loc=role:button[name="Submit"]');
await fillInput('loc=role:textbox[name="Search"]', 'query');
// XPath for complex navigation
await click('xpath=//nav//a[contains(@href,"/settings")]');
// nth modifier for multiple matches
await click('internal:nth=2;loc=css:li.item'); // Third item
await click('internal:last;xpath=//div[@role="list"]/div'); // Last item
All helpers ultimately route through resolveElementCenter or resolveElementObjectId in src/element-resolver.ts.
Key Source Files
| File | Purpose |
|---|---|
src/element-resolver.ts |
Core resolver: parsing, dispatch, @N lookup, CSS/role/XPath handling |
src/ref-map.ts |
parseRef and snapshot reference storage |
src/locator-query.ts |
queryAllExpression for CSS selector evaluation |
src/helpers.ts |
Public API: click, fillInput, waitForElement, etc. |
src/browser-runtime.ts |
CDP transport, DOM.getBoxModel, Accessibility.getFullAXTree |
Summary
parseLocatorinsrc/element-resolver.tsis the single entry point for all selector types@Nrefs map to snapshot entries viaref-map.ts; stale refs trigger re-snapshotloc=css:selectors compile todocument.querySelectorAllvialocator-query.tsloc=role:selectors query the AX tree throughfindBackendNodeIdByRoleNameandAccessibility.getFullAXTreexpath=selectors execute viadocument.evaluatewith ordered node snapshots- Transient errors (stale refs, timing issues) auto-retry; permanent errors fail fast
Frequently Asked Questions
How do I know when to use @N refs versus CSS selectors?
Use @N refs when you've called snapshotText() and want stable identifiers that survive DOM mutations better than selectors. Use CSS selectors for dynamic elements or when you haven't captured a snapshot. According to the ego-lite source, refs are short-lived—if resolution fails with a transient error, re-snapshot and retry.
What happens if a role selector matches multiple elements?
Without a modifier, the first match is returned. To target a specific match, prefix with internal:nth=N; where N is zero-based. For example: internal:nth=1;loc=role:button[name="Save"] returns the second Save button. The internal:last; modifier selects the final match.
Why would xpath= fail where loc=css: succeeds?
XPath expressions fail when the DOM structure changes structurally, whereas CSS selectors often tolerate class or attribute changes. Additionally, XPath resolution uses document.evaluate which may behave differently with namespaces or complex shadow DOM scenarios compared to querySelectorAll.
Can I combine multiple locator types in one selector string?
No—each selector string has a single kind determined by parseLocator. However, you can combine an internal modifier with any base locator using semicolon separation: internal:nth=2;loc=css:div.item or internal:last;xpath=//tr. The modifier is parsed separately and applied to the result set.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →