Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/architecture/selectors-and-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The text-based factories write their text as a JSON string literal followed by a

`GetByLabel` reads an element's labels in the same order of precedence a browser computes a name from: the elements named by `aria-labelledby` when it resolves to any, otherwise a non-empty `aria-label`, otherwise the `<label>` elements associated with a form control through `for` or by wrapping it.

`GetByRole` matches the accessible name exactly, as `Accessibility.queryAXTree` does. Elements the accessibility tree ignores, such as one inside `display: none` or `aria-hidden="true"`, never match.
`GetByRole` matches the accessible name exactly, as `Accessibility.queryAXTree` does. Elements the accessibility tree ignores, such as one inside `display: none` or `aria-hidden="true"`, never match. It needs a Chromium-based browser; see [On Firefox](#on-firefox).

### Resolution scope: the page, or one frame

Expand All @@ -65,6 +65,14 @@ Measurement follows the same root. `ILocator.BoundingBoxAsync` reports page coor

See [Frames and iframes](../guides/frames-and-iframes.md) for the caller-facing view.

### On Firefox

Every strategy that runs a query in the page (CSS, XPath, text, test id, and the `GetBy*` factories other than `GetByRole`) resolves on Firefox the same as on Chromium, and so do chained, parent and filtered locators, the locator actions and queries, and the assertions. Two actions are the exception, for reasons outside the locator: `ScreenshotAsync` captures through a page screenshot, which has no WebDriver BiDi translation yet, and `DblClickAsync` reaches the page as two single clicks. The element handles the strategies return are named by WebDriver BiDi shared ids; see [Transport and Protocol](transport-and-protocol.md) for how the CDP-shaped calls are translated.

Three things need the accessibility tree, which WebDriver BiDi does not expose: `GetByRole` and `role=` selectors, the `_node` selector behind `LocatorByBackendNodeId`, and the `ToHaveAccessibleNameAsync` and `ToHaveRoleAsync` assertions. On Firefox each throws a `NotSupportedException` straight away that says so, rather than waiting out its timeout.

A Firefox page does not report the frames inside it yet, so `page.Frames` holds only the main frame and there is no `IFrame` to build a locator on for an `<iframe>`.

### TestId Strategy

`TestIdSelectorStrategy` accepts a configurable attribute name in its constructor (default `"data-testid"`). The `StrategyName` property returns the configured attribute name, so registering the strategy with a different attribute (for example, `"data-cy"`) also changes the prefix used in selector strings.
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture/transport-and-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ BiDi requires an explicit `session.new` handshake after the WebSocket connects.

Event subscriptions in `BiDiSession` also pass through `BiDiEventMap.GetEventTranslation` to remap CDP event names to BiDi event names and translate event payloads. Before yielding the first event for a given BiDi event name, `BiDiSession.EnsureSubscribedAsync` sends a `session.subscribe` command with the event name and, where applicable, the context ID. This subscription is tracked per-session in a `HashSet<string>` so it is only sent once.

Script commands need more than a renamed method too, because CDP and BiDi name remote objects and carry values differently. The engine keeps a single opaque `objectId` string per remote object, while BiDi names a DOM node by its `sharedId` and any other object by a realm-bound `handle`. `BiDiScriptValues` hands a node out as its shared id with a `sharedId:` prefix and anything else as its handle, and turns the id back into `{ "sharedId": ... }` or `{ "handle": ... }` wherever it is sent as `this` or as an argument. A shared id is the same every time a node is serialized, so an element that two queries find is the same id both times, and it is the reference `input.setFiles` takes, which is how `DOM.setFileInputFiles` translates. Values travel as BiDi typed values both ways: a CDP argument becomes a local value, an object as a list of key and value pairs, and every argument is passed on, an empty one as `undefined`, so later ones keep their positions. A result comes back with its contents rebuilt as the JSON `JSON.stringify` would give, which is what a CDP call made by value returns, with a number JSON cannot hold reported as an `unserializableValue`.

BiDi has no command that lists an object's properties, so `Runtime.getProperties` becomes a `script.callFunction` that returns the object to itself, serialized one level deep, and each element or entry becomes a property. That is what the selector strategies read: they evaluate their query to an array of elements and then make an element handle from each entry, so every strategy that queries the page, and every chained query, works on Firefox through this one translation. Only nodes and primitives among the entries come back usable, since BiDi gives a handle only to the value a call returns.

Key events need more than a renamed method. CDP names a key by its DOM `key` value, such as `"Enter"` or `"Shift"`, but a WebDriver key action takes a single character, and every key that produces no character is named by the Private Use Area code point the WebDriver key table gives it: U+E006 for Enter, U+E004 for Tab, U+E008 for the left Shift and U+E050 for the right one. `BiDiKeyValues` does this mapping, and it reads the CDP `code` as well, so the right-hand modifiers, the numpad Enter and the numpad digits keep their location. A key that produces a character is sent as that character, and a key WebDriver has no value for, such as CapsLock, fails with a `NotSupportedException` before anything is sent. The CDP `modifiers` bitmask is not carried over, because WebDriver keeps the keys an input source holds between commands and derives the modifiers from them. A CDP `char` event becomes a pause, because a WebDriver key down already produces the key's text.

---
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Page.GetByPlaceholder("Search...")
Page.GetByTestId("login-form")
```

`GetByRole` matches implicit roles and computed accessible names, so a plain `<button>Submit</button>` is found without a `role` or `aria-label` attribute, and the name must match exactly. `GetByLabel` finds a control through its `<label for>`, a wrapping `<label>`, `aria-labelledby` or `aria-label`. `GetByText`, `GetByLabel`, `GetByPlaceholder`, `GetByTitle` and `GetByAltText` match a case-insensitive substring by default; pass `exact: true` to require the whole text, case-sensitively:
`GetByRole` matches implicit roles and computed accessible names, so a plain `<button>Submit</button>` is found without a `role` or `aria-label` attribute, and the name must match exactly. `GetByLabel` finds a control through its `<label for>`, a wrapping `<label>`, `aria-labelledby` or `aria-label`. `GetByText`, `GetByLabel`, `GetByPlaceholder`, `GetByTitle` and `GetByAltText` match a case-insensitive substring by default; pass `exact: true` to require the whole text, case-sensitively. `GetByRole` reads the browser's accessibility tree, which only Chromium-based browsers expose, so on Firefox it throws a `NotSupportedException` and `GetByLabel`, `GetByText` or `GetByTestId` is the one to use; every other locator works on both:

```csharp
Page.GetByText("welcome") // matches "Welcome back, Ada"
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/frames-and-iframes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ A page is a tree of frames. The main frame is the document you navigated to; eve

Some of those frames run in a renderer process of their own. That changes a great deal underneath and nothing at all in the API: an out-of-process frame is discovered, traversed, evaluated, and clicked exactly like any other.

Frames are reported on Chromium-based browsers. A Firefox page does not report the frames inside it yet, so there `page.Frames` holds only the main frame.

---

## Traversing
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ An agent can also attach at any point with `browser_attach`, which is what to re
motus mcp --allow-attach
```

Attaching speaks the Chrome DevTools Protocol, so the browser at the other end is a Chromium-based one; Firefox is driven only by a session the server starts, with `--channel firefox`. Snapshot refs are unavailable in a Firefox session either way, because the accessibility tree they are built from is read over that same protocol: the `snapshot` tool says so rather than describing the page, and the coordinate tools take over from there with `--caps coordinates`.
Attaching speaks the Chrome DevTools Protocol, so the browser at the other end is a Chromium-based one; Firefox is driven only by a session the server starts, with `--channel firefox`. Snapshot refs are unavailable in a Firefox session either way, because the accessibility tree they are built from is read over that same protocol: the `snapshot` tool says so rather than describing the page. A selector still reaches an element anywhere a ref is accepted, except a `role=` selector, which reads that same tree, and the coordinate tools take over from there with `--caps coordinates`.

Three consequences are worth knowing:

Expand Down Expand Up @@ -429,7 +429,7 @@ navigate(url: "https://example.com/status", snapshot: true)
|---|---|---|
| `--connect` | _(none)_ | Drive a browser that is already running instead of starting one. Takes the debugging endpoint it was started with (`http://127.0.0.1:9222`) or its CDP WebSocket URL. The browser is never closed by the server, and the options that describe starting one no longer apply. |
| `--headless` | `true` | Run the browser without a visible window. Pass `--headless false` to watch the agent drive a real window. |
| `--channel` | `chromium` | Browser to drive: `chromium`, `chrome`, `edge`, or `firefox`. A channel named here has to be installed: the server stops with an error rather than starting a different browser in its place. Snapshot refs need a Chromium-based browser, because the accessibility tree they are built from is read over the Chrome DevTools Protocol; on Firefox the `snapshot` tool says so rather than describing the page, and the coordinate tools still work against a screenshot, which makes `--caps coordinates` worth adding for a Firefox session. |
| `--channel` | `chromium` | Browser to drive: `chromium`, `chrome`, `edge`, or `firefox`. A channel named here has to be installed: the server stops with an error rather than starting a different browser in its place. Snapshot refs need a Chromium-based browser, because the accessibility tree they are built from is read over the Chrome DevTools Protocol; on Firefox the `snapshot` tool says so rather than describing the page, selectors other than `role=` still work in place of refs, and the coordinate tools still work against a screenshot, which makes `--caps coordinates` worth adding for a Firefox session. |
| `--executable-path` | _(none)_ | Start this browser binary instead of resolving one from `--channel`. Pins a session to an exact build, and is the way past a channel the server cannot find. |
| `--browser-arg` | _(none)_ | An extra command-line argument for the browser. Attach the value with `=`, and repeat the flag for more than one: `--browser-arg=--no-sandbox --browser-arg=--disable-dev-shm-usage`. Chromium refuses to start as root, so a container usually needs `--no-sandbox`. |
| `--user-data-dir` | _(none)_ | Browser profile directory. Cookies, history, and signed-in sessions persist in it between runs instead of every session starting clean. |
Expand Down
17 changes: 14 additions & 3 deletions src/Motus/Locator/Locator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ private Locator(Page page, Frame? frameRoot, string selector, int? nthIndex, str
// The frame this locator resolves against, falling back to the page's main frame.
private IFrame ResolutionFrame => _frameRoot ?? _page.GetFrameForSelectors();

// Whether the locator is rooted at a frame inside the page, rather than at the page or its main
// frame. Only such a frame has a viewport of its own, so only its elements need measuring in
// the page's coordinate space. A main-frame root is measured the way a page-level locator is,
// which also keeps it off the Chromium-only box model command.
private bool IsRootedInChildFrame =>
_frameRoot is not null && !ReferenceEquals(_frameRoot, _page.GetFrameForSelectors());

// The session that owns this locator's frame. Always the page session while every frame
// renders in the page's process, but looked up so that stops being an assumption.
private IMotusSession Session => _page.SessionFor(_frameRoot);
Expand Down Expand Up @@ -119,6 +126,10 @@ internal async Task<bool> HasClassAsync(string className, CancellationToken ct)

private async Task<AccessibilityAXNodeSimple?> QuerySingleAXNodeAsync(CancellationToken ct)
{
// Checked before resolving, so an element that has not rendered yet does not hold the
// assertion polling until its timeout for an answer it could never give.
CapabilityGuard.RequireAccessibilityTree(Session, "ToHaveAccessibleNameAsync and ToHaveRoleAsync");

var objectId = await ResolveObjectIdCoreAsync(ct).ConfigureAwait(false);

if (!_accessibilityEnabled)
Expand Down Expand Up @@ -493,7 +504,7 @@ private async Task<BoundingBox> GetBoundingBoxOrThrowAsync(string objectId, Canc
// and touch input is dispatched in the page's coordinate space. Reading the rect through
// the element would aim input at the frame's offset rather than the element, so a
// frame-rooted locator measures with the box model, which is reported against the page.
if (_frameRoot is not null)
if (IsRootedInChildFrame)
{
var pageBox = await GetPageRelativeBoxAsync(objectId, ct).ConfigureAwait(false);
if (pageBox is not null)
Expand Down Expand Up @@ -701,7 +712,7 @@ internal static (int parentSteps, string remaining) ParseParentPrefix(string sel
private async Task<bool> ScrollIntoViewAsync(string objectId, CancellationToken ct)
{
var scrolled = await ActionabilityChecker.ScrollIntoViewIfNeededAsync(Session, objectId, ct).ConfigureAwait(false);
if (_frameRoot is null)
if (!IsRootedInChildFrame)
return true;

var box = await GetPageRelativeBoxAsync(objectId, ct).ConfigureAwait(false);
Expand Down Expand Up @@ -1182,7 +1193,7 @@ public async Task<string> InputValueAsync(double? timeout = null)
// Reported in the page's coordinate space, the same space input is dispatched in, so a box
// read here can be handed straight to the mouse. Measuring through the element instead
// would answer in the frame's own space and quietly disagree with where a click lands.
if (_frameRoot is not null)
if (IsRootedInChildFrame)
{
var pageBox = await GetPageRelativeBoxAsync(objectId, cts.Token).ConfigureAwait(false);
if (pageBox is not null)
Expand Down
4 changes: 2 additions & 2 deletions src/Motus/Selectors/RoleSelectorStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public async Task<IReadOnlyList<IElementHandle>> ResolveAsync(
{
var page = SelectorStrategyHelpers.GetPage(frame);
var session = page.SessionFor(frame);
CapabilityGuard.Require(session.Capabilities, MotusCapabilities.AccessibilityTree,
"Role selector (Accessibility.queryAXTree)", CapabilityGuard.GetTransportDescription(session));
CapabilityGuard.RequireAccessibilityTree(session, "GetByRole and role= selectors",
"Match by label, text, test id or CSS selector instead.");

var (role, name) = ParseRoleSelector(selector);

Expand Down
20 changes: 14 additions & 6 deletions src/Motus/Transport/BiDi/BiDiJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,19 @@ internal sealed record BiDiScriptEvaluateResult(
[property: JsonPropertyName("result")] BiDiScriptRemoteValue? Result = null,
[property: JsonPropertyName("exceptionDetails")] BiDiScriptExceptionDetails? ExceptionDetails = null);

internal sealed record BiDiScriptLocalValue(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("value")] JsonElement? Value = null);
internal sealed record BiDiScriptSerializationOptions(
[property: JsonPropertyName("maxObjectDepth")] int? MaxObjectDepth = null);

// Arguments and this are local values or remote references, whose shape depends on what they
// carry, so they are built as JSON by BiDiScriptValues rather than modelled here.
internal sealed record BiDiScriptCallFunctionParams(
[property: JsonPropertyName("functionDeclaration")] string FunctionDeclaration,
[property: JsonPropertyName("awaitPromise")] bool AwaitPromise,
[property: JsonPropertyName("target")] BiDiScriptTarget Target,
[property: JsonPropertyName("arguments")] BiDiScriptLocalValue[]? Arguments = null,
[property: JsonPropertyName("arguments")] JsonElement[]? Arguments = null,
[property: JsonPropertyName("resultOwnership")] string? ResultOwnership = null,
[property: JsonPropertyName("this")] BiDiScriptLocalValue? This = null);
[property: JsonPropertyName("this")] JsonElement? This = null,
[property: JsonPropertyName("serializationOptions")] BiDiScriptSerializationOptions? SerializationOptions = null);

internal sealed record BiDiScriptCallFunctionResult(
[property: JsonPropertyName("type")] string Type,
Expand All @@ -119,6 +121,11 @@ internal sealed record BiDiInputPerformActionsParams(

internal sealed record BiDiInputPerformActionsResult;

internal sealed record BiDiInputSetFilesParams(
[property: JsonPropertyName("context")] string Context,
[property: JsonPropertyName("element")] JsonElement Element,
[property: JsonPropertyName("files")] string[] Files);

internal sealed record BiDiInputActionSequence(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("id")] string Id,
Expand Down Expand Up @@ -213,12 +220,13 @@ internal sealed record BiDiNetworkHeader(
[JsonSerializable(typeof(BiDiScriptExceptionDetails))]
[JsonSerializable(typeof(BiDiScriptEvaluateParams))]
[JsonSerializable(typeof(BiDiScriptEvaluateResult))]
[JsonSerializable(typeof(BiDiScriptLocalValue))]
[JsonSerializable(typeof(BiDiScriptSerializationOptions))]
[JsonSerializable(typeof(BiDiScriptCallFunctionParams))]
[JsonSerializable(typeof(BiDiScriptCallFunctionResult))]
// Input domain
[JsonSerializable(typeof(BiDiInputPerformActionsParams))]
[JsonSerializable(typeof(BiDiInputPerformActionsResult))]
[JsonSerializable(typeof(BiDiInputSetFilesParams))]
[JsonSerializable(typeof(BiDiInputActionSequence))]
[JsonSerializable(typeof(BiDiInputSourceParameters))]
[JsonSerializable(typeof(BiDiInputAction))]
Expand Down
Loading
Loading