# How Ego-Lite Locators Resolve @N Refs, loc=css, loc=role, and xpath Selectors

> Discover how ego-lite locators efficiently resolve @N refs, loc=css, loc=role, and xpath selectors by leveraging snapshot refs, AX tree, and document queries.

- Repository: [CitroLabs/ego-lite](https://github.com/citrolabs/ego-lite)
- Tags: how-to-guide
- Published: 2026-08-01

---

**Ego-lite parses every selector through `parseLocator` in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts) (lines 1-10) extracts numeric IDs from strings starting with `@` or `ref=`:

```ts
// 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`](https://github.com/citrolabs/ego-lite/blob/main/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`:

```ts
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`](https://github.com/citrolabs/ego-lite/blob/main/src/locator-query.ts)
4. The snippet runs `document.querySelectorAll` and selects by `nth` index (defaults to 0)

```ts
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:

```ts
{kind: "role", role: "button", name: "Submit", nth: 0}

```

### AX Tree Resolution

`resolveLocatorCenter` invokes **`findBackendNodeIdByRoleName`** (lines 24-66):

```ts
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:

```ts
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`](https://github.com/citrolabs/ego-lite/blob/main/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

```ts
// @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`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts).

---

## Key Source Files

| File | Purpose |
|------|---------|
| [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) | Core resolver: parsing, dispatch, @N lookup, CSS/role/XPath handling |
| [`src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts) | `parseRef` and snapshot reference storage |
| [`src/locator-query.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/locator-query.ts) | `queryAllExpression` for CSS selector evaluation |
| [`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts) | Public API: `click`, `fillInput`, `waitForElement`, etc. |
| [`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts) | CDP transport, `DOM.getBoxModel`, `Accessibility.getFullAXTree` |

---

## Summary

- **`parseLocator`** in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) is the single entry point for all selector types
- **`@N` refs** map to snapshot entries via [`ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/ref-map.ts); stale refs trigger re-snapshot
- **`loc=css:`** selectors compile to `document.querySelectorAll` via [`locator-query.ts`](https://github.com/citrolabs/ego-lite/blob/main/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.