How the Ego-Browser Locator Facade API Supports Chained Calls: Immutable Selector Composition Explained
The ego-browser locator facade supports chained calls through immutable selector composition, where every method returns a new façade instance with an updated selector string rather than mutating the original object.
The ego-lite repository provides a lightweight browser automation framework with a Playwright-inspired API. Its locator façade enables expressive element selection through method chaining, making test scripts readable and maintainable. This article explains the architectural patterns that make this chaining possible, drawn directly from the citrolabs/ego-lite source code.
Immutable Façade Design Pattern
The core mechanism resides in src/helpers.ts, where createLocator (lines 20-31) implements a factory that returns plain objects with chainable methods. Each method follows the same contract: validate inputs, compose a new selector, return a new façade.
// From src/helpers.ts - simplified core pattern
function createLocator(selector) {
return {
// Every method returns a NEW createLocator call
nth(index) {
const newSelector = nthSelector(selector, index);
return createLocator(newSelector); // ← fresh instance
},
locator(subSelector) {
const newSelector = scopedSelector(selector, subSelector);
return createLocator(newSelector); // ← fresh instance
},
// ... additional chainable methods
};
}
This pure functional approach eliminates hidden state. The original selector string captured in closure remains untouched, while each chain step accumulates complexity in a new string passed to the next instance.
Selector Composition Through Internal Pseudo-Selectors
The façade encodes chain operations as internal pseudo-selectors prefixed to the selector string. Helper functions in src/helpers.ts handle this encoding:
| Helper Function | Lines | Purpose |
|---|---|---|
nthSelector |
13-15 | Wraps selector with internal:nth=N; prefix |
scopedSelector |
31-58 | Combines parent and child selectors with semicolon delimiter |
// Selector evolution through a chain
'#container' // initial locator
'internal:nth=2;#container' // after .nth(2)
'internal:nth=2;#container;button' // after .locator('button')
The semicolon-delimited format allows the query parser in src/locator-query.ts to split and interpret each segment, executing the operations in order against the DOM.
Chainable Methods in the Façade API
The ego-browser locator façade mirrors Playwright's locator API, exposing methods that fall into three categories:
Navigation methods (return new façades):
first()→ prefixes withinternal:last;(interpreted as first match)nth(index)→ validates withNumber.isIntegerand non-negative checklocator(selector)→ scopes to descendant elementsfilter(options)→ applies attribute or structural filtersgetByText(text),getByRole(role, options)→ semantic selectors
Action methods (terminal, execute via driver):
click(),fill(value),evaluate(fn),textContent(), etc.
Property methods (terminal, return values):
count(),isVisible(),boundingBox(), etc.
Early validation occurs before façade creation. For example, nth() rejects non-integers immediately rather than deferring errors to execution time.
Driver Resolution of Composed Selectors
The accumulated selector string ultimately reaches src/driver/locator.ts, which coordinates with src/locator-query.ts to execute queries. The flow:
- Parse the semicolon-delimited selector into operation segments
- Translate pseudo-selectors (
internal:*) to executable JavaScript - Execute against Chrome DevTools Protocol (CDP) for element resolution
- Apply the terminal action or return the requested property
Because the façade has already composed the full selector by chain's end, the driver treats the query as a single deterministic operation rather than multiple round-trip selections.
Practical Chaining Examples
// Example 1: Index-based access within a container
await page
.locator('#product-list')
.nth(2) // third item (zero-indexed)
.locator('.add-to-cart')
.click();
// Example 2: Filtering before interaction
await page
.locator('button')
.filter({ has: { attribute: 'data-testid', value: 'submit' } })
.first()
.click();
// Example 3: Deep semantic chaining
await page
.getByRole('navigation')
.getByRole('link', { name: 'Settings' })
.click();
// Example 4: Complex multi-step selection
await page
.locator('form#login')
.locator('input')
.filter({ has: { attribute: 'type', value: 'password' } })
.fill('securePassword123');
Each intermediate façade is discarded after its successor is created. The garbage collector reclaims these short-lived objects, while only the final façade participates in the driver call.
Comparison with Mutative Alternatives
| Approach | Pattern | Drawback Addressed by Ego-Browser |
|---|---|---|
| Mutative faсade | loc.first().nth(2) modifies internal state |
Race conditions in async code, unexpected shared state |
Builder with build() |
new Locator().first().nth(2).build() |
Verbose API, easy to forget terminal call |
| Ego-browser's immutable façade | Each step returns ready-to-use instance | Clean API, safe reassignment, predictable behavior |
The immutable approach enables reusable base locators:
// Define once
const formLocator = page.locator('#user-form');
// Branch to different fields without interference
await formLocator.locator('input[name="email"]').fill('test@example.com');
await formLocator.locator('input[name="phone"]').fill('555-0123');
// formLocator remains unmodified
Source File Reference Map
| File | Responsibility |
|---|---|
package/ego-browser/src/helpers.ts |
createLocator factory and selector helpers (lines 20-31) |
package/ego-browser/src/driver/locator.ts |
CDP driver for selector resolution |
package/ego-browser/src/locator-query.ts |
Query generation for CSS/XPath/Accessibility Tree |
package/ego-browser/src/index.ts |
Public API export (page.locator) |
package/ego-browser/src/helpers.test.mjs |
Test coverage for chaining behavior |
Summary
- The ego-browser locator facade API enables chained calls through immutable selector composition, where every method returns a new façade instance.
- The
createLocatorfactory insrc/helpers.tsimplements this pattern using closure-captured selector strings and fresh object returns. - Internal pseudo-selectors (
internal:nth=,internal:last;) encode chain operations in a parseable string format. - Early validation in methods like
nth()provides immediate feedback on incorrect arguments. - Terminal methods delegate to
src/driver/locator.tsandsrc/locator-query.tsfor CDP-based element resolution and action execution. - This design yields a pure, stateless API compatible with Playwright-style automation patterns.
Frequently Asked Questions
What makes the ego-browser locator façade immutable?
Each public method creates a new façade instance via createLocator() rather than modifying the original object's properties. The selector string is captured in closure and never reassigned; instead, helper functions generate updated strings for the next instance. This immutability prevents side effects when locators are reused or shared across async contexts.
How does the chaining syntax avoid mutating the original locator?
The createLocator factory returns a plain object whose methods immediately call createLocator again with modified arguments. For example, calling .nth(2) invokes nthSelector() to build a prefixed string, then createLocator(newSelector) produces a fresh object. The original object and its selector remain unchanged in memory, though no longer referenced in the chain.
What happens if I call .nth() with an invalid argument?
The façade validates numeric arguments before creating any new instance. In nth() and similar methods, Number.isInteger(index) and non-negative checks execute immediately; if validation fails, the method throws without invoking createLocator. This fail-fast behavior surfaces errors at call site rather than during later driver execution.
Can I reuse a base locator after chaining from it?
Yes—this is a key benefit of immutability. Since chained methods never modify the source façade, you can declare a base locator and branch multiple independent queries from it. Each branch receives its own selector string and façade instance, while the original remains available for additional chains or reuse in subsequent test steps.
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 →