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
4 changes: 2 additions & 2 deletions docs/architecture/browser-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The launch sequence runs in the following order:
- For Chromium channels: if `options.UserDataDir` is `null`, a temporary directory is created at `{Path.GetTempPath()}/motus-profile-{8-char guid}`. The `ownsTempDir` flag is set to `true` so the directory is deleted on cleanup.
- For Firefox: `FirefoxProfileManager.CreateTempProfile` creates a minimal profile directory unconditionally when no `UserDataDir` is supplied.

6. **Process start.** A `ProcessStartInfo` is built with `UseShellExecute = false`, `RedirectStandardOutput = true`, and `RedirectStandardError = true`. Arguments are added via `ArgumentList` (not `Arguments`) so that paths with spaces are handled correctly without manual quoting. For Firefox, environment variables from `FirefoxArgs.Build` are also applied.
6. **Process start.** A `ProcessStartInfo` is built with `UseShellExecute = false`, `RedirectStandardOutput = true`, and `RedirectStandardError = true`. Arguments are added via `ArgumentList` (not `Arguments`) so that paths with spaces are handled correctly without manual quoting. A Chromium browser starts with the defaults listed in [Default browser arguments](../guides/configuration.md#default-browser-arguments), less any named in `IgnoreDefaultArgs`, and `Args` come last. For Firefox, environment variables from `FirefoxArgs.Build` are also applied.

A browser on the pipe transport is started through `/bin/sh -c 'exec "$0" "$@" 3<&0 4>&1 1>/dev/null'`, because the process API offers no way to give a child an arbitrary file descriptor. The shell copies this process's end of the redirected standard input onto descriptor 3 and of the redirected standard output onto descriptor 4, points standard output at nothing so the browser's own logging cannot be mistaken for protocol traffic, and then replaces itself with the browser, so the process handle still refers to the browser. Standard output then carries CDP rather than diagnostics, and only standard error is drained.

Expand Down Expand Up @@ -249,7 +249,7 @@ The sequence:

i. `Page.getFrameTree` is read and recorded. The browser never announces a new tab's initial `about:blank` as a navigation, so this read is what gives the page its main frame before anything navigates it: `Url` reads `about:blank`, `MainFrame` and `Frames` are populated, and `EvaluateAsync` and `SetContentAsync` work straight away. On Firefox the same read is answered by `browsingContext.getTree`.

4. Context options are applied to the new page: viewport (`Emulation.setDeviceMetricsOverride`), locale, timezone, color scheme, user agent, HTTPS error handling, and geolocation are each sent if the corresponding option is set.
4. `Emulation.setFocusEmulationEnabled` is sent so the page behaves as though its window had focus, which keeps `document.hasFocus()` true in every context's pages at once. It is skipped for a context that was already open in an attached browser and on Firefox, and a browser that rejects it does not stop the page from opening. Context options are then applied to the new page: viewport (`Emulation.setDeviceMetricsOverride`), locale, timezone, color scheme, user agent, HTTPS error handling, and geolocation are each sent if the corresponding option is set.

5. Context-level extra HTTP headers and offline state are propagated to the page.

Expand Down
2 changes: 2 additions & 0 deletions docs/guides/attaching-to-a-running-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ A remote debugging endpoint grants complete control of the browser and of every
curl http://127.0.0.1:9222/json/version
```

**An attached browser runs with whatever switches it was started with.** A browser Motus launches gets switches that stop Chromium from backgrounding its pages; see [Default browser arguments](configuration.md#default-browser-arguments). One Motus attached to has only what its own command line gave it, so a page whose window is covered or minimized can be marked hidden, with its timers throttled and its animation frames and observers stopped. Start it with `--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-background-timer-throttling` when that matters. Focus is handled the same way it is on a launched browser for the contexts Motus creates, whose pages report `document.hasFocus()` as true, and left alone for contexts that were already open, whose windows have focus or not as the person using them decides.

**When it is unclear whether Motus or the browser is at fault, read the protocol log.** An attached browser is often one with extensions, a signed-in profile or another client on the same endpoint, any of which can change how it answers. Setting `MOTUS_PROTOCOL_LOG` to a file path, or `ConnectOptions.ProtocolLog` in code, records every command Motus sent and every reply and event the browser returned, including events from targets Motus never asked about. See [Troubleshooting with the protocol log](configuration.md#troubleshooting-with-the-protocol-log).

---
Expand Down
48 changes: 47 additions & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,13 +416,59 @@ The merge checks are per-property:
| `UserDataDir` | `string?` | `null` | Path to a browser user-data directory. Enables persistent sessions. |
| `HandleSIGINT` | `bool` | `true` | Close the browser when the process receives SIGINT, or Ctrl+C on Windows. |
| `HandleSIGTERM` | `bool` | `true` | Close the browser when the process receives SIGTERM or SIGHUP. Unix only. |
| `IgnoreDefaultArgs` | `IReadOnlyList<string>?` | `null` | Default browser arguments to suppress. |
| `IgnoreDefaultArgs` | `IReadOnlyList<string>?` | `null` | Default browser arguments to suppress, matched exactly. See [Default browser arguments](#default-browser-arguments). |
| `DownloadsPath` | `string?` | `null` | Directory for browser-initiated file downloads. |
| `Plugins` | `IReadOnlyList<IPlugin>?` | `null` | Plugin instances loaded into every browser context created from this launch. |
| `Accessibility` | `AccessibilityOptions?` | `null` | Accessibility audit hook configuration. Disabled when `null`. |
| `Performance` | `PerformanceOptions?` | `null` | Performance metrics collector configuration. Disabled when `null`. |
| `ProtocolLog` | `ProtocolLogOptions?` | `null` | Protocol log configuration. Disabled when `null`, unless `MOTUS_PROTOCOL_LOG` names a destination. Also on `ConnectOptions`. |

### Default browser arguments

A Chromium browser Motus launches starts with these switches, before any in `Args`:

| Switch | Why |
|---|---|
| `--disable-background-networking` | Stops update checks and other traffic a test did not ask for. |
| `--disable-background-timer-throttling` | Keeps a page's timers at full speed when the browser thinks it is in the background. |
| `--disable-backgrounding-occluded-windows` | Keeps a page visible when its window is covered by another. |
| `--disable-client-side-phishing-detection` | Stops phishing checks from loading models and making requests. |
| `--disable-default-apps` | Skips installing the default apps into a new profile. |
| `--disable-extensions` | Starts without extensions. |
| `--disable-hang-monitor` | Stops the "page unresponsive" dialog from appearing over a paused page. |
| `--disable-popup-blocking` | Lets a page open popups, so tests can drive them. |
| `--disable-prompt-on-repost` | Reloads a page reached by POST without asking. |
| `--disable-renderer-backgrounding` | Keeps a page's renderer process at normal priority. |
| `--disable-sync` | Turns off account sync. |
| `--disable-translate` | Turns off the translation prompt. |
| `--metrics-recording-only` | Records usage metrics without uploading them. |
| `--no-first-run` | Skips the first-run experience. |
| `--safebrowsing-disable-auto-update` | Stops Safe Browsing list downloads. |

The transport (`--remote-debugging-pipe`, or `--remote-debugging-port` on Windows), `--user-data-dir`, and `--headless=new` or the headed-mode switches are added as well. They are not defaults in the sense above, and `IgnoreDefaultArgs` does not remove them.

The three backgrounding switches matter most in a test that uses more than one context, such as an admin and a visitor. Each context gets a window of its own, and without them opening and closing a second context could leave a page in the first one marked hidden: `document.visibilityState` reads `hidden`, `visibilitychange` handlers run, timers are throttled, and `requestAnimationFrame`, `ResizeObserver` and `IntersectionObserver` callbacks stop. The symptom is a timeout in code waiting for any of those, a long way from the cause.

To test backgrounding itself, name the switches in `IgnoreDefaultArgs`. Each entry is matched against a default exactly, so one switch can go and the rest stay:

```csharp
await using var browser = await MotusLauncher.LaunchAsync(new LaunchOptions
{
IgnoreDefaultArgs =
[
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-background-timer-throttling",
],
});
```

Motus passes no `--disable-features` or `--enable-features` switch of its own. Chromium honors only the last of a repeated feature-list switch, so one passed in `Args` is the only one the browser sees.

Every page Motus opens on Chromium also has focus emulated through CDP, so `document.hasFocus()` is true and focus events fire in each page of a run at once, rather than only in whichever window the browser last brought forward. This is not a launch switch and `IgnoreDefaultArgs` does not affect it. It does not apply to Firefox, which has no equivalent in WebDriver BiDi, or to pages in a context that was already open in a browser Motus attached to. See [Attaching to a Running Browser](attaching-to-a-running-browser.md#known-traps).

A Firefox browser Motus launches gets its own, much shorter, list of defaults, which `IgnoreDefaultArgs` filters the same way.

### AccessibilityOptions

Nested under `LaunchOptions.Accessibility`. A `null` value disables the hook entirely.
Expand Down
18 changes: 17 additions & 1 deletion src/Motus/Browser/ChromiumArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,31 @@ namespace Motus;
/// </summary>
internal static class ChromiumArgs
{
private static readonly string[] DefaultArgs =
/// <summary>
/// The switches every Chromium launch starts with, unless named in
/// <see cref="LaunchOptions.IgnoreDefaultArgs"/>.
/// </summary>
/// <remarks>
/// The three backgrounding switches keep every page Motus drives running at full speed. The
/// browser treats a page whose window it thinks is covered as hidden, and a hidden page has
/// its timers throttled, its renderer deprioritized, and its animation frames and observers
/// stopped. Each context gets a window of its own, so opening and closing a second context is
/// enough to leave a page in the first one hidden, and a test then waits on something that
/// never runs. Removing them through <see cref="LaunchOptions.IgnoreDefaultArgs"/> brings
/// backgrounding back for a test that is about it.
/// </remarks>
internal static readonly string[] DefaultArgs =
[
"--disable-background-networking",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-client-side-phishing-detection",
"--disable-default-apps",
"--disable-extensions",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-renderer-backgrounding",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
Expand Down
40 changes: 37 additions & 3 deletions src/Motus/Context/BrowserContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,9 @@ await _registry.BrowserSession.SendAsync(
}

/// <summary>
/// Gives a page everything the context carries: its emulation options, its extra headers, its
/// offline state, the storage state it was configured with, and a video recording when one was
/// asked for.
/// Gives a page everything the context carries: focus, its emulation options, its extra
/// headers, its offline state, the storage state it was configured with, and a video recording
/// when one was asked for.
/// </summary>
/// <remarks>
/// Shared by a page opened by a call and a page opened by the page, because a tab that appears
Expand All @@ -304,6 +304,8 @@ await _registry.BrowserSession.SendAsync(
/// </remarks>
private async Task ApplyContextStateToPageAsync(Page page)
{
await EmulateFocusAsync(page).ConfigureAwait(false);

// Apply context options (viewport, locale, timezone, etc.)
await ApplyContextOptionsToPageAsync(page).ConfigureAwait(false);

Expand Down Expand Up @@ -892,6 +894,38 @@ public async Task<StorageState> StorageStateAsync(string? path = null)

// --- Helpers ---

/// <summary>
/// Has the page behave as though its window had focus, so <c>document.hasFocus()</c> is true
/// and focus events fire in every page of a run at once, not only in whichever window the
/// browser last brought forward.
/// </summary>
/// <remarks>
/// A context that was already open in the browser is left alone, for the same reason its
/// viewport is: whether its windows have focus belongs to whoever is using them. WebDriver
/// BiDi has no equivalent command, so a Firefox page is left alone too. A Chromium build that
/// does not know the command answers with an error, and the page goes on without it rather
/// than failing to open. The emulation lasts as long as Motus's session on the page.
/// </remarks>
private async Task EmulateFocusAsync(Page page)
{
if (_adopted || (page.Session.Capabilities & MotusCapabilities.EmulationOverrides) == 0)
return;

try
{
await page.Session.SendAsync(
"Emulation.setFocusEmulationEnabled",
new EmulationSetFocusEmulationEnabledParams(Enabled: true),
CdpJsonContext.Default.EmulationSetFocusEmulationEnabledParams,
CdpJsonContext.Default.EmulationSetFocusEmulationEnabledResult,
CancellationToken.None).ConfigureAwait(false);
}
catch (MotusProtocolException)
{
// Not supported by this build: the page keeps the browser's own idea of focus.
}
}

private async Task ApplyContextOptionsToPageAsync(Page page)
{
if (_options is null)
Expand Down
11 changes: 11 additions & 0 deletions src/Motus/Protocol/CdpJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ namespace Motus;
[JsonSerializable(typeof(EmulationSetEmulatedMediaParams))]
[JsonSerializable(typeof(EmulationMediaFeature))]
[JsonSerializable(typeof(EmulationSetEmulatedMediaResult))]
// --- Emulation domain (focus) ---
[JsonSerializable(typeof(EmulationSetFocusEmulationEnabledParams))]
[JsonSerializable(typeof(EmulationSetFocusEmulationEnabledResult))]
// --- Input domain ---
[JsonSerializable(typeof(InputDispatchKeyEventParams))]
[JsonSerializable(typeof(InputDispatchKeyEventResult))]
Expand Down Expand Up @@ -885,6 +888,14 @@ internal sealed record EmulationMediaFeature(string Name, string Value);

internal sealed record EmulationSetEmulatedMediaResult();

// ============================================================================
// Emulation domain (focus)
// ============================================================================

internal sealed record EmulationSetFocusEmulationEnabledParams(bool Enabled);

internal sealed record EmulationSetFocusEmulationEnabledResult();

// ============================================================================
// Emulation domain (locale, timezone, user agent)
// ============================================================================
Expand Down
5 changes: 3 additions & 2 deletions tests/Motus.Recorder.Tests/Transport/CdpFakeResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ internal static class CdpFakeResponse
{
/// <summary>
/// Commands a fixture does not answer itself: ones that only switch a connection-wide feature
/// on, and ones that ask the browser for state an empty answer reports as nothing to record.
/// They carry nothing a fixture asserts against.
/// on, ones that put every new page in the same mode, and ones that ask the browser for state
/// an empty answer reports as nothing to record. They carry nothing a fixture asserts against.
/// </summary>
/// <remarks>
/// A fake answers these by method name before it looks at anything a fixture queued. That is
Expand All @@ -24,6 +24,7 @@ internal static class CdpFakeResponse
{
"Target.setDiscoverTargets",
"Page.getFrameTree",
"Emulation.setFocusEmulationEnabled",
};

/// <summary>
Expand Down
Loading
Loading