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"
  • selector or xpath or role/name: the raw query string
  • nth: 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:

  1. Extract refId via parseRef
  2. Query the ref-map built from the latest snapshot
  3. If missing, throw ElementResolutionError with transient kind → triggers re-snapshot
  4. 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

  1. parseLocator returns {kind: "css", selector: "button.primary", ...}
  2. resolveLocatorCenter calls buildLocatorCenterJs
  3. This generates JavaScript using queryAllExpression from src/locator-query.ts
  4. The snippet runs document.querySelectorAll and selects by nth index (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

  • parseLocator in src/element-resolver.ts is the single entry point for all selector types
  • @N refs map to snapshot entries via ref-map.ts; stale refs trigger re-snapshot
  • loc=css: selectors compile to document.querySelectorAll via locator-query.ts
  • loc=role: selectors query the AX tree through findBackendNodeIdByRoleName and Accessibility.getFullAXTree
  • xpath= selectors execute via document.evaluate with 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →