From 093b7ab45564b5106ad78e7dcf13d505c651695c Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Fri, 23 Oct 2026 15:56:08 -0400 Subject: [PATCH] Stop Chromium from backgrounding pages in multi-context runs A test that opened and closed a second context could leave a page in the first one marked hidden: `document.visibilityState` read `hidden`, its timers were throttled, and code waiting for the page to be visible timed out a long way from the cause. Each context gets a window of its own, and Motus launched Chromium without the switches that stop it backgrounding a window it thinks is covered. Chrome for Testing 149 headless did this on every run; Chrome 156 headless did not. `--disable-backgrounding-occluded-windows`, `--disable-renderer-backgrounding` and `--disable-background-timer-throttling` are now among the default Chromium arguments, as they are in Playwright and Puppeteer, and `IgnoreDefaultArgs` still removes any of them. Every page Motus opens in a context it created is also given `Emulation.setFocusEmulationEnabled`, so `document.hasFocus()` is true in each context's pages at once. That is skipped on Firefox, which has no BiDi equivalent, and for contexts that were already open in an attached browser, and a browser that rejects the command still opens the page. The configuration guide now lists the default arguments and why each is there. Fixes #26 Signed-off-by: Torrey Betts --- docs/architecture/browser-lifecycle.md | 4 +- docs/guides/attaching-to-a-running-browser.md | 2 + docs/guides/configuration.md | 48 +++++- src/Motus/Browser/ChromiumArgs.cs | 18 ++- src/Motus/Context/BrowserContext.cs | 40 ++++- src/Motus/Protocol/CdpJsonContext.cs | 11 ++ .../Transport/CdpFakeResponse.cs | 5 +- .../Browser/BackgroundingIntegrationTests.cs | 148 ++++++++++++++++++ .../Browser/ChromiumBackgroundingArgsTests.cs | 94 +++++++++++ .../Context/FocusEmulationTests.cs | 95 +++++++++++ .../Motus.Tests/Transport/CdpFakeResponse.cs | 5 +- 11 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 tests/Motus.Tests/Browser/BackgroundingIntegrationTests.cs create mode 100644 tests/Motus.Tests/Browser/ChromiumBackgroundingArgsTests.cs create mode 100644 tests/Motus.Tests/Context/FocusEmulationTests.cs diff --git a/docs/architecture/browser-lifecycle.md b/docs/architecture/browser-lifecycle.md index 8612f9e..13088ca 100644 --- a/docs/architecture/browser-lifecycle.md +++ b/docs/architecture/browser-lifecycle.md @@ -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. @@ -224,7 +224,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. diff --git a/docs/guides/attaching-to-a-running-browser.md b/docs/guides/attaching-to-a-running-browser.md index e7ba352..4721bbc 100644 --- a/docs/guides/attaching-to-a-running-browser.md +++ b/docs/guides/attaching-to-a-running-browser.md @@ -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). --- diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 7c42c71..923d978 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -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?` | `null` | Default browser arguments to suppress. | +| `IgnoreDefaultArgs` | `IReadOnlyList?` | `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?` | `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. diff --git a/src/Motus/Browser/ChromiumArgs.cs b/src/Motus/Browser/ChromiumArgs.cs index 2d58e4a..5fc0a8d 100644 --- a/src/Motus/Browser/ChromiumArgs.cs +++ b/src/Motus/Browser/ChromiumArgs.cs @@ -7,15 +7,31 @@ namespace Motus; /// internal static class ChromiumArgs { - private static readonly string[] DefaultArgs = + /// + /// The switches every Chromium launch starts with, unless named in + /// . + /// + /// + /// 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 brings + /// backgrounding back for a test that is about it. + /// + 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", diff --git a/src/Motus/Context/BrowserContext.cs b/src/Motus/Context/BrowserContext.cs index 7b75710..5900196 100644 --- a/src/Motus/Context/BrowserContext.cs +++ b/src/Motus/Context/BrowserContext.cs @@ -292,9 +292,9 @@ await _registry.BrowserSession.SendAsync( } /// - /// 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. /// /// /// Shared by a page opened by a call and a page opened by the page, because a tab that appears @@ -304,6 +304,8 @@ await _registry.BrowserSession.SendAsync( /// private async Task ApplyContextStateToPageAsync(Page page) { + await EmulateFocusAsync(page).ConfigureAwait(false); + // Apply context options (viewport, locale, timezone, etc.) await ApplyContextOptionsToPageAsync(page).ConfigureAwait(false); @@ -892,6 +894,38 @@ public async Task StorageStateAsync(string? path = null) // --- Helpers --- + /// + /// Has the page behave as though its window had focus, so document.hasFocus() is true + /// and focus events fire in every page of a run at once, not only in whichever window the + /// browser last brought forward. + /// + /// + /// 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. + /// + 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) diff --git a/src/Motus/Protocol/CdpJsonContext.cs b/src/Motus/Protocol/CdpJsonContext.cs index 0847dd4..206b8a1 100644 --- a/src/Motus/Protocol/CdpJsonContext.cs +++ b/src/Motus/Protocol/CdpJsonContext.cs @@ -143,6 +143,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))] @@ -872,6 +875,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) // ============================================================================ diff --git a/tests/Motus.Recorder.Tests/Transport/CdpFakeResponse.cs b/tests/Motus.Recorder.Tests/Transport/CdpFakeResponse.cs index cd34050..28efdb7 100644 --- a/tests/Motus.Recorder.Tests/Transport/CdpFakeResponse.cs +++ b/tests/Motus.Recorder.Tests/Transport/CdpFakeResponse.cs @@ -10,8 +10,8 @@ internal static class CdpFakeResponse { /// /// 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. /// /// /// A fake answers these by method name before it looks at anything a fixture queued. That is @@ -24,6 +24,7 @@ internal static class CdpFakeResponse { "Target.setDiscoverTargets", "Page.getFrameTree", + "Emulation.setFocusEmulationEnabled", }; /// diff --git a/tests/Motus.Tests/Browser/BackgroundingIntegrationTests.cs b/tests/Motus.Tests/Browser/BackgroundingIntegrationTests.cs new file mode 100644 index 0000000..209fbf2 --- /dev/null +++ b/tests/Motus.Tests/Browser/BackgroundingIntegrationTests.cs @@ -0,0 +1,148 @@ +using Motus.Abstractions; + +namespace Motus.Tests.Browser; + +/// +/// Pins that a page in one context keeps running while other contexts come and go. +/// +/// +/// Each context gets a window of its own. Chromium marked a page hidden once a second context had +/// opened and closed, so its visibilitychange handlers saw hidden, its timers were +/// throttled, and code waiting for the page to be visible waited until the test timed out. Not +/// every build does it: Chrome for Testing 149 headless did on every run, and Chrome 156 headless +/// did not, so on some browsers these tests pass either way. +/// +[TestClass] +[TestCategory("Integration")] +public class BackgroundingIntegrationTests +{ + private const string TimerInterval = "50"; + + /// + /// Counts timer ticks, records every visibility change, and has nothing else in it, so the + /// numbers read back describe the page and not its content. + /// + private const string CountingDocument = + "data:text/html,

counting

"; + + private static readonly string[] BackgroundingSwitches = + [ + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling", + ]; + + private IBrowser? _browser; + + private async Task LaunchAsync(LaunchOptions? options = null) + { + try + { + _browser = await MotusLauncher.LaunchAsync(options ?? new LaunchOptions { Headless = true }); + } + catch (FileNotFoundException) + { + Assert.Inconclusive("No browser found; skipping integration tests."); + } + + if (_browser!.Version.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) + Assert.Inconclusive("Chromium's backgrounding does not apply to Firefox."); + + return _browser; + } + + [TestCleanup] + public async Task Cleanup() + { + if (_browser is not null) + await _browser.DisposeAsync(); + } + + [TestMethod] + public async Task ASecondContextOpeningAndClosing_LeavesThePageInTheFirstVisibleAndRunning() + { + var browser = await LaunchAsync(); + + var first = await browser.NewContextAsync(); + var page = await first.NewPageAsync(); + await page.GotoAsync(CountingDocument); + + var second = await browser.NewContextAsync(); + var other = await second.NewPageAsync(); + await other.GotoAsync("data:text/html,

second user

"); + await Task.Delay(500); + await second.CloseAsync(); + + // Measured over a window that starts after the second context is gone, which is when the + // first page used to be marked hidden. + await Task.Delay(300); + var ticksBefore = await page.EvaluateAsync("window.ticks"); + await Task.Delay(1500); + var ticksAfter = await page.EvaluateAsync("window.ticks"); + + var changes = await page.EvaluateAsync("window.visibilityChanges.join(',')"); + Assert.AreEqual("", changes, "the page never stopped being visible"); + Assert.AreEqual("visible", await page.EvaluateAsync("document.visibilityState")); + + // 1.5 seconds of a 50 ms interval is 30 ticks. A hidden page managed about 12. + var ticks = ticksAfter - ticksBefore; + Assert.IsTrue(ticks >= 20, $"the page's timers kept their cadence, but only {ticks} ticks ran in 1.5 seconds"); + } + + [TestMethod] + public async Task EveryContextsPage_HasFocusAtOnce() + { + var browser = await LaunchAsync(); + + var admin = await (await browser.NewContextAsync()).NewPageAsync(); + var visitor = await (await browser.NewContextAsync()).NewPageAsync(); + await admin.GotoAsync("data:text/html,

admin

"); + await visitor.GotoAsync("data:text/html,

visitor

"); + + Assert.IsTrue(await admin.EvaluateAsync("document.hasFocus()"), "the first context's page has focus"); + Assert.IsTrue(await visitor.EvaluateAsync("document.hasFocus()"), "the second context's page has focus"); + } + + [TestMethod] + public async Task LaunchedBrowser_RunsWithTheBackgroundingSwitches() + { + var commandLine = await ReadCommandLineAsync(await LaunchAsync()); + + foreach (var flag in BackgroundingSwitches) + StringAssert.Contains(commandLine, flag); + } + + [TestMethod] + public async Task IgnoreDefaultArgs_KeepsTheBackgroundingSwitchesOffTheCommandLine() + { + var commandLine = await ReadCommandLineAsync(await LaunchAsync(new LaunchOptions + { + Headless = true, + IgnoreDefaultArgs = BackgroundingSwitches, + })); + + foreach (var flag in BackgroundingSwitches) + Assert.IsFalse(commandLine.Contains(flag, StringComparison.Ordinal), $"{flag} was ignored but still passed"); + } + + /// + /// Reads the command line the browser reports it was started with, which is what it acted on, + /// rather than the list Motus built. + /// + private static async Task ReadCommandLineAsync(IBrowser browser) + { + var page = await browser.NewPageAsync(); + await page.GotoAsync("chrome://version"); + var commandLine = await page.EvaluateAsync( + "document.getElementById('command_line')?.textContent ?? ''"); + + if (string.IsNullOrEmpty(commandLine)) + Assert.Inconclusive("This browser's version page does not show its command line."); + + return commandLine; + } +} diff --git a/tests/Motus.Tests/Browser/ChromiumBackgroundingArgsTests.cs b/tests/Motus.Tests/Browser/ChromiumBackgroundingArgsTests.cs new file mode 100644 index 0000000..d29e298 --- /dev/null +++ b/tests/Motus.Tests/Browser/ChromiumBackgroundingArgsTests.cs @@ -0,0 +1,94 @@ +using Motus.Abstractions; + +namespace Motus.Tests.Browser; + +/// +/// Pins the switches that stop Chromium from backgrounding the pages Motus drives, and that each +/// of them can still be taken away. +/// +/// +/// Without them a page whose window the browser thinks is covered is marked hidden, and its timers, +/// animation frames and observers stop. Opening and closing a second context was enough to do that +/// to a page in the first one. +/// +[TestClass] +public class ChromiumBackgroundingArgsTests +{ + private static readonly string[] BackgroundingSwitches = + [ + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling", + ]; + + [TestMethod] + public void Build_IncludesTheBackgroundingSwitchesByDefault() + { + var args = ChromiumArgs.Build(new LaunchOptions(), "/tmp/profile"); + + foreach (var flag in BackgroundingSwitches) + CollectionAssert.Contains(args, flag); + } + + [TestMethod] + public void Build_IncludesTheBackgroundingSwitchesHeadedToo() + { + var args = ChromiumArgs.Build(new LaunchOptions { Headless = false }, "/tmp/profile"); + + foreach (var flag in BackgroundingSwitches) + CollectionAssert.Contains(args, flag); + } + + [TestMethod] + [DataRow("--disable-backgrounding-occluded-windows")] + [DataRow("--disable-renderer-backgrounding")] + [DataRow("--disable-background-timer-throttling")] + public void Build_IgnoreDefaultArgs_RemovesOneBackgroundingSwitchAndKeepsTheOthers(string ignored) + { + var args = ChromiumArgs.Build(new LaunchOptions { IgnoreDefaultArgs = [ignored] }, "/tmp/profile"); + + CollectionAssert.DoesNotContain(args, ignored); + foreach (var flag in BackgroundingSwitches.Where(f => f != ignored)) + CollectionAssert.Contains(args, flag); + } + + [TestMethod] + public void Build_IgnoringEveryDefault_LeavesNoneOfThem() + { + var options = new LaunchOptions { IgnoreDefaultArgs = ChromiumArgs.DefaultArgs }; + + var args = ChromiumArgs.Build(options, "/tmp/profile"); + + foreach (var flag in ChromiumArgs.DefaultArgs) + CollectionAssert.DoesNotContain(args, flag); + + CollectionAssert.Contains(args, "--remote-debugging-pipe", "the transport switch is not a default to ignore"); + CollectionAssert.Contains(args, "--user-data-dir=/tmp/profile", "the profile switch is not a default to ignore"); + } + + [TestMethod] + public void Build_NoSwitchIsGivenTwice() + { + var args = ChromiumArgs.Build(new LaunchOptions(), "/tmp/profile"); + + var names = args.Select(a => a.Split('=', 2)[0]).ToList(); + CollectionAssert.AllItemsAreUnique(names); + } + + /// + /// Chromium honors only the last of a repeated feature-list switch. A default + /// --disable-features or --enable-features would therefore be silently replaced + /// by one the caller passes, or replace it, so the defaults leave both lists to the caller. + /// + [TestMethod] + public void Build_LeavesFeatureListsToTheCaller() + { + var options = new LaunchOptions { Args = ["--disable-features=Translate"] }; + + var args = ChromiumArgs.Build(options, "/tmp/profile"); + + Assert.AreEqual(1, args.Count(a => a.StartsWith("--disable-features", StringComparison.Ordinal))); + Assert.AreEqual(0, args.Count(a => a.StartsWith("--enable-features", StringComparison.Ordinal))); + CollectionAssert.Contains(args, "--disable-features=Translate"); + } +} diff --git a/tests/Motus.Tests/Context/FocusEmulationTests.cs b/tests/Motus.Tests/Context/FocusEmulationTests.cs new file mode 100644 index 0000000..a4dd67c --- /dev/null +++ b/tests/Motus.Tests/Context/FocusEmulationTests.cs @@ -0,0 +1,95 @@ +using System.Text; +using System.Text.Json; +using Motus.Tests.Transport; + +namespace Motus.Tests.Context; + +/// +/// Pins that a page Motus opens is told to behave as though its window had focus, so +/// document.hasFocus() holds in every page of a run with several contexts, and that a +/// browser which cannot do that still opens the page. +/// +[TestClass] +public class FocusEmulationTests +{ + private const string FocusEmulation = "Emulation.setFocusEmulationEnabled"; + + private MethodAwareFakeCdpSocket _socket = null!; + private CdpTransport _transport = null!; + private CdpSessionRegistry _registry = null!; + private Motus.Browser _browser = null!; + + [TestInitialize] + public async Task Setup() + { + _socket = new MethodAwareFakeCdpSocket(); + _transport = new CdpTransport(_socket); + await _transport.ConnectAsync(new Uri("ws://localhost:1234"), CancellationToken.None); + _registry = new CdpSessionRegistry(_transport); + _browser = new Motus.Browser( + _transport, _registry, process: null, tempUserDataDir: null, + handleSigint: false, handleSigterm: false); + + _socket.Respond( + "Browser.getVersion", + """{"result": {"protocolVersion":"1.3","product":"Chrome/120","revision":"@x","userAgent":"UA","jsVersion":"12"}}"""); + + await _browser.InitializeAsync(CancellationToken.None); + + _socket.Respond("Target.createBrowserContext", """{"result": {"browserContextId": "ctx-1"}}"""); + _socket.Respond("Target.createTarget", """{"result": {"targetId": "target-1"}}"""); + _socket.Respond("Target.attachToTarget", """{"result": {"sessionId": "session-1"}}"""); + } + + [TestCleanup] + public async Task Cleanup() => await _transport.DisposeAsync(); + + [TestMethod] + public async Task NewPageAsync_EmulatesFocusOnThePagesOwnSession() + { + await _browser.NewPageAsync(); + + var sent = Sent().Where(m => m.Method == FocusEmulation).ToList(); + + Assert.AreEqual(1, sent.Count, "focus is emulated once per page"); + Assert.AreEqual("session-1", sent[0].SessionId, "focus is emulated on the page, not the browser"); + Assert.IsTrue(sent[0].Params.GetProperty("enabled").GetBoolean()); + } + + [TestMethod] + public async Task NewPageAsync_StillOpensThePage_WhenTheBrowserDoesNotKnowTheCommand() + { + _socket.Respond( + FocusEmulation, + $$"""{"error": {"code": -32601, "message": "'{{FocusEmulation}}' wasn't found"}, "sessionId": "session-1"}"""); + + var page = await _browser.NewPageAsync(); + + Assert.IsNotNull(page); + Assert.IsFalse(page.IsClosed); + Assert.AreEqual(1, _browser.Contexts[0].Pages.Count); + } + + [TestMethod] + public async Task NewPageAsync_OnAnAdoptedContext_LeavesFocusAlone() + { + // A context that was already open in the browser belongs to whoever is using it. + var adopted = new BrowserContext(_browser, _registry, "ctx-user", options: null, adopted: true); + + await adopted.NewPageAsync(); + + Assert.IsFalse(Sent().Any(m => m.Method == FocusEmulation)); + } + + private List<(string Method, string? SessionId, JsonElement Params)> Sent() + => _socket.SentMessages + .Select(bytes => + { + var root = JsonDocument.Parse(Encoding.UTF8.GetString(bytes)).RootElement; + return ( + root.GetProperty("method").GetString()!, + root.TryGetProperty("sessionId", out var s) ? s.GetString() : null, + root.TryGetProperty("params", out var p) ? p.Clone() : default); + }) + .ToList(); +} diff --git a/tests/Motus.Tests/Transport/CdpFakeResponse.cs b/tests/Motus.Tests/Transport/CdpFakeResponse.cs index 6f9f0b0..3153bcd 100644 --- a/tests/Motus.Tests/Transport/CdpFakeResponse.cs +++ b/tests/Motus.Tests/Transport/CdpFakeResponse.cs @@ -10,8 +10,8 @@ internal static class CdpFakeResponse { /// /// 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. /// /// /// A fake answers these by method name before it looks at anything a fixture queued. That is @@ -24,6 +24,7 @@ internal static class CdpFakeResponse { "Target.setDiscoverTargets", "Page.getFrameTree", + "Emulation.setFocusEmulationEnabled", }; ///