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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,13 @@ Under `dotnet test`, `[MotusTestClass]` does the same for an MSTest suite: it re

A suite is only reproducible if it can say which browser it ran against. A CI image update can change Chrome underneath a build that was green yesterday, and the failure that follows is indistinguishable from a regression in your own code.

`motus install --revision` downloads an exact Chrome for Testing build, and `MOTUS_EXECUTABLE_PATH` points a test run at it. Code that sets `LaunchOptions.ExecutablePath` itself still wins.
`motus install --revision` downloads an exact Chrome for Testing build, and a test run that names no browser uses it, ahead of any Chrome installed on the machine. `MOTUS_EXECUTABLE_PATH` names the binary explicitly, and code that sets `LaunchOptions.ExecutablePath` itself still wins over both.

```bash
motus install --channel chromium --revision 149.0.7827.156
dotnet test

# Or name the binary explicitly
export MOTUS_EXECUTABLE_PATH="$(cat ~/.motus/browsers/.installed.chromium)"
dotnet test
```
Expand Down
38 changes: 32 additions & 6 deletions docs/architecture/browser-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,44 @@ For an attached browser, a dropped connection says nothing about whether the bro

`BrowserFinder.Resolve` determines the executable path using the following precedence:

1. **Explicit path.** If `options.ExecutablePath` is set, it is validated with `File.Exists` and returned directly. A `FileNotFoundException` is thrown if the file does not exist.
1. **Explicit path.** If `options.ExecutablePath` is set, it is validated with `File.Exists` and returned directly. A `FileNotFoundException` is thrown if the file does not exist. `launch.executablePath` in `motus.json` and `MOTUS_EXECUTABLE_PATH` both arrive here, through the config merge.

2. **Channel-specific candidates.** If `options.Channel` is set, `CandidatesForChannel` builds an ordered list of paths for the current platform and returns the first one that exists.
2. **Channel set.** If `options.Channel` is set, a browser `motus install` downloaded for that same channel is used first, then `CandidatesForChannel` builds an ordered list of system paths for the current platform and the first one that exists is returned.

3. **Auto-detect.** If neither option is set, `Resolve` iterates over `Chrome`, `Edge`, and `Chromium` in that order, returning the first match found.
3. **Auto-detect.** If neither option is set, the Chromium download is used first, then the system `Chrome`, `Edge`, and `Chromium` are tried in that order, returning the first match found.

### `InstalledBinariesPath`
### Browsers downloaded by `motus install`

When the Motus install system downloads a browser, it sets `BrowserFinder.InstalledBinariesPath`. This path is prepended to every channel's candidate list, ahead of system-installed locations.
`motus install` unpacks each build into a versioned folder under `~/.motus/browsers`, laid out as the archive ships it:

| Platform | Executable |
|---|---|
| Linux | `chromium-{version}/chrome-linux64/chrome` |
| Windows | `chromium-{version}/chrome-win64/chrome.exe` (`chrome-win32` on 32-bit Windows) |
| macOS | `chromium-{version}/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing` (`chrome-mac-x64` on Intel) |
| Windows, Firefox | `firefox-{version}/firefox.exe` |

Several versions can sit side by side, so the folder is not searched. The installer records the executable it just installed in a marker file, `.installed.chromium` or `.installed.firefox`, and `BrowserFinder` reads that marker. A marker that names a file that no longer exists, because the cache was cleaned by hand, is ignored and resolution carries on to the system locations. The CLI commands and the MCP server read the same markers, so a test run and the `motus` tool agree on which browser was installed.

The download comes before a system browser because it is the one the user chose to install, usually pinned to a version. On a machine that also has Chrome installed, a launch with no channel and no path runs the download rather than that Chrome.

A named channel is binding. Only a download recorded for that same channel answers it:

| `Channel` | Resolves to |
|---|---|
| not set | the Chromium download, then system Chrome, Edge, Chromium |
| `Chromium` | the Chromium download, then a system Chromium |
| `Chrome` | a system Chrome only |
| `Edge` | a system Edge only |
| `Firefox` | the Firefox download, then a system Firefox |

`motus install` downloads Chromium (a Chrome for Testing build) and, on Windows, Firefox. Chrome and Edge are branded browsers it leaves to their own installers, so asking for either by name still means the branded browser, and a missing one is reported as missing rather than replaced by the download. Auto-detect never picks Firefox, downloaded or not.

Only the default cache is read. A browser installed with `motus install --path` is not found this way; point `launch.executablePath` or `MOTUS_EXECUTABLE_PATH` at it instead.

### Pinning a specific binary

Steps 2 and 3 above resolve to whichever build is installed, which is why a CI run can change behavior without a commit: the channel moved. Step 1 is the escape from that. `motus install --revision <n>` downloads an exact Chromium revision, constructing the download URL for that revision rather than resolving through the stable listing, and `launch.executablePath` or `MOTUS_EXECUTABLE_PATH` then points `options.ExecutablePath` at it. A path set in code still wins over both, so the precedence above is unchanged.
A system browser resolves to whichever build the machine has, which is why a CI run can change behavior without a commit: the channel moved. `motus install --revision <n>` downloads an exact Chromium revision, constructing the download URL for that revision rather than resolving through the stable listing, and the marker then points at it, so a launch with no channel picks it up. The marker records the most recent install, so the browser used is the one installed last. Setting `launch.executablePath` or `MOTUS_EXECUTABLE_PATH` to the path it reports makes the choice explicit, which is still the strongest pin, and a path set in code wins over both.

On Windows, a browser unzipped into a user directory does not necessarily carry the file-system permissions its sandbox requires. The installer grants them during install and when repairing an existing install. A failure there is reported rather than fatal, since the browser can still run where those permissions are not needed.

Expand Down Expand Up @@ -386,6 +411,7 @@ The returned `IBrowserLease` holds the browser and a return callback. Disposing

- `src/Motus/Motus.cs` - `MotusLauncher` source
- `src/Motus/Browser/BrowserFinder.cs` - platform executable discovery
- `src/Motus/Browser/InstalledBrowsers.cs` - the `motus install` cache and its marker files
- `src/Motus/Context/BrowserContext.cs` - context and page creation
- `src/Motus/Page/Page.cs` - page initialization and CDP domain enablement
- `src/Motus/Pool/MotusBrowserPool.cs` and `BrowserPool.cs` - pool implementation
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ motus install --revision 1421000
motus run bin/Release/net8.0/MyTests.dll
```

`motus install` reports the path it installed to. Supply that path as `launch.executablePath`, or as `MOTUS_EXECUTABLE_PATH` in the environment, which is usually easier in CI because the install step can export it directly. When a revision is given the download URL is constructed for that revision rather than resolved through the stable listing, so the same revision is fetched every time.
A launch with no `channel` and no `executablePath`, or with `channel` set to `Chromium`, uses the browser `motus install` downloaded last, ahead of any Chrome, Edge or Chromium installed on the machine. `Chrome` and `Edge` still mean the branded browsers. `motus install` also reports the path it installed to. Supplying that path as `launch.executablePath`, or as `MOTUS_EXECUTABLE_PATH` in the environment, makes the choice explicit, and is needed when the install used `--path` or a suite wants a download other than the one installed last. When a revision is given the download URL is constructed for that revision rather than resolved through the stable listing, so the same revision is fetched every time.

On Windows, a browser unzipped into a user directory is granted the file-system permissions its sandbox needs as part of installing or repairing an install. A failure to do so is reported but does not abort the install, since the browser may still run in configurations that do not need those permissions.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ motus install --revision 1421000
| `--revision` | latest | Install one exact revision instead of resolving the current one. |
| `--path` | default cache | Override the browser cache directory. |

Pinning a revision is what makes a CI run reproducible when the stable channel moves. The command prints the path it installed to, which is the value to hand to `launch.executablePath` or `MOTUS_EXECUTABLE_PATH`. See [Browser Lifecycle](../architecture/browser-lifecycle.md).
Pinning a revision is what makes a CI run reproducible when the stable channel moves. A launch that names no browser, or names `chromium`, uses the browser installed into the default cache, ahead of one installed on the machine. The command also prints the path it installed to, which is the value to hand to `launch.executablePath` or `MOTUS_EXECUTABLE_PATH` to name it explicitly, and which a download made with `--path` needs. See [Browser Lifecycle](../architecture/browser-lifecycle.md).

---

Expand Down
12 changes: 11 additions & 1 deletion src/Motus.Abstractions/Options/LaunchOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@ public sealed record LaunchOptions
public bool Headless { get; init; } = true;

/// <summary>The browser distribution channel to use.</summary>
/// <remarks>
/// When null, a Chromium build downloaded by <c>motus install</c> is used if there is one, and
/// otherwise an installed Chrome, Edge or Chromium, in that order. <see cref="BrowserChannel.Chromium"/>
/// and <see cref="BrowserChannel.Firefox"/> also prefer a <c>motus install</c> download for that
/// browser. <see cref="BrowserChannel.Chrome"/> and <see cref="BrowserChannel.Edge"/> mean the
/// installed branded browser only.
/// </remarks>
public BrowserChannel? Channel { get; init; }

/// <summary>Path to a browser executable to use instead of the bundled one.</summary>
/// <summary>
/// Path to a browser executable to use instead of resolving one from <see cref="Channel"/>, a
/// <c>motus install</c> download, or the browsers installed on the machine.
/// </summary>
public string? ExecutablePath { get; init; }

/// <summary>Additional command-line arguments to pass to the browser.</summary>
Expand Down
24 changes: 7 additions & 17 deletions src/Motus.Cli/Commands/BrowserPathHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,24 @@ namespace Motus.Cli.Commands;

public static class BrowserPathHelper
{
private static readonly string BrowserCacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".motus", "browsers");
// The cache and its marker names are shared with the launcher, which reads the same markers
// when no executable path is given, so the CLI and a test run agree on what was installed.
private static readonly string BrowserCacheDir = InstalledBrowsers.DefaultRoot;

private static readonly string LegacyMarkerPath = Path.Combine(BrowserCacheDir, ".installed");
private static readonly string LegacyMarkerPath = InstalledBrowsers.LegacyMarkerPath(BrowserCacheDir);

public static string? Resolve(string? channel = null)
{
// Try channel-specific marker first
if (channel is not null)
{
var channelMarker = Path.Combine(BrowserCacheDir, $".installed.{channel}");
var channelPath = ReadMarker(channelMarker);
var channelPath = InstalledBrowsers.ReadMarker(InstalledBrowsers.MarkerPath(BrowserCacheDir, channel));
if (channelPath is not null)
return channelPath;
}

// Fall back to legacy single marker for backward compatibility
return ReadMarker(LegacyMarkerPath);
return InstalledBrowsers.ReadMarker(LegacyMarkerPath);
}

/// <summary>
Expand All @@ -31,15 +30,6 @@ public static class BrowserPathHelper
public static string? ResolveChannel(string channel)
{
ArgumentException.ThrowIfNullOrEmpty(channel);
return ReadMarker(Path.Combine(BrowserCacheDir, $".installed.{channel.ToLowerInvariant()}"));
}

private static string? ReadMarker(string markerPath)
{
if (!File.Exists(markerPath))
return null;

var path = File.ReadAllText(markerPath).Trim();
return File.Exists(path) ? path : null;
return InstalledBrowsers.ReadMarker(InstalledBrowsers.MarkerPath(BrowserCacheDir, channel));
}
}
9 changes: 3 additions & 6 deletions src/Motus.Cli/Services/BrowserInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,7 @@ internal static string GetPlatformKey()
return "linux64";
}

internal static string DefaultCachePath() =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".motus", "browsers");
internal static string DefaultCachePath() => InstalledBrowsers.DefaultRoot;

private static string FindChromiumExecutable(string destDir, string platformKey)
{
Expand Down Expand Up @@ -251,12 +250,10 @@ private static string FindFirefoxExecutable(string destDir)
private static void WriteMarker(string cachePath, string channel, string executablePath)
{
// Write channel-specific marker
var markerPath = Path.Combine(cachePath, $".installed.{channel}");
File.WriteAllText(markerPath, executablePath);
File.WriteAllText(InstalledBrowsers.MarkerPath(cachePath, channel), executablePath);

// Also write legacy marker for backward compatibility
var legacyMarkerPath = Path.Combine(cachePath, ".installed");
File.WriteAllText(legacyMarkerPath, executablePath);
File.WriteAllText(InstalledBrowsers.LegacyMarkerPath(cachePath), executablePath);
}

// ALL RESTRICTED APPLICATION PACKAGES, the identity Chromium's most restricted child processes
Expand Down
61 changes: 37 additions & 24 deletions src/Motus/Browser/BrowserFinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,40 @@ namespace Motus;
internal static class BrowserFinder
{
/// <summary>
/// When set, this path is prepended to candidate lists for all channels.
/// Used by the install system to register downloaded binaries.
/// The browser cache searched for a <c>motus install</c> download. It is the installer's default
/// cache, and tests point it at a folder of their own so that what is installed on the machine
/// running them cannot change their outcome.
/// </summary>
internal static string? InstalledBinariesPath { get; set; }
internal static string InstallRoot { get; set; } = InstalledBrowsers.DefaultRoot;

/// <summary>
/// Resolves a browser executable path from channel preference or explicit path.
/// </summary>
internal static string Resolve(BrowserChannel? channel, string? executablePath)
internal static string Resolve(BrowserChannel? channel, string? executablePath) =>
Resolve(channel, executablePath, InstallRoot, CandidatesForChannel);

/// <summary>
/// Resolves a browser executable path, with the download cache and the system locations
/// supplied by the caller.
/// </summary>
/// <remarks>
/// An explicit path wins, then a browser <c>motus install</c> downloaded, then one the machine
/// has installed. The download comes before the system browser because it is the one the user
/// chose to install, usually pinned to a version, and a suite that found a system Chrome first
/// would quietly test whatever Chrome the machine happened to have.
///
/// A named channel is binding, and only a download recorded for that same channel answers it.
/// <c>motus install</c> downloads Chromium and Firefox; Chrome and Edge are branded browsers it
/// leaves to their own installers, so those channels still resolve to the installed browser.
/// With no channel, the Chromium download is tried before the system Chrome, Edge and Chromium,
/// in that order. A Firefox download is never picked without the Firefox channel, just as a
/// system Firefox is not.
/// </remarks>
internal static string Resolve(
BrowserChannel? channel,
string? executablePath,
string installRoot,
Func<BrowserChannel, IReadOnlyList<string>> systemCandidates)
{
if (executablePath is not null)
{
Expand All @@ -28,42 +53,30 @@ internal static string Resolve(BrowserChannel? channel, string? executablePath)

if (channel is not null)
{
return FindFirstExisting(CandidatesForChannel(channel.Value))
return InstalledBrowsers.Find(installRoot, channel.Value)
?? FindFirstExisting(systemCandidates(channel.Value))
?? throw new FileNotFoundException($"No {channel} installation found.");
}

if (InstalledBrowsers.Find(installRoot, BrowserChannel.Chromium) is { } downloaded)
return downloaded;

// Auto-detect: try Chrome, Edge, Chromium in order
foreach (var ch in new[] { BrowserChannel.Chrome, BrowserChannel.Edge, BrowserChannel.Chromium })
{
var path = FindFirstExisting(CandidatesForChannel(ch));
var path = FindFirstExisting(systemCandidates(ch));
if (path is not null)
return path;
}

throw new FileNotFoundException("No supported browser found. Install Chrome, Edge, or Chromium.");
throw new FileNotFoundException(
"No supported browser found. Install Chrome, Edge, or Chromium, or run 'motus install' to download Chromium.");
}

internal static IReadOnlyList<string> CandidatesForChannel(BrowserChannel channel)
{
var candidates = new List<string>();

if (InstalledBinariesPath is not null)
{
var name = channel switch
{
BrowserChannel.Chrome => "chrome",
BrowserChannel.Edge => "msedge",
BrowserChannel.Chromium => "chromium",
BrowserChannel.Firefox => "firefox",
_ => "chrome"
};

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
candidates.Add(Path.Combine(InstalledBinariesPath, $"{name}.exe"));
else
candidates.Add(Path.Combine(InstalledBinariesPath, name));
}

if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
candidates.AddRange(channel switch
Expand Down
Loading
Loading