diff --git a/README.md b/README.md index e83388c..bc37c3a 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/architecture/browser-lifecycle.md b/docs/architecture/browser-lifecycle.md index 8612f9e..cbd3e63 100644 --- a/docs/architecture/browser-lifecycle.md +++ b/docs/architecture/browser-lifecycle.md @@ -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 ` 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 ` 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. @@ -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 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 7c42c71..192613f 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8844695..d226c95 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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). --- diff --git a/src/Motus.Abstractions/Options/LaunchOptions.cs b/src/Motus.Abstractions/Options/LaunchOptions.cs index 49f119d..9e81d6c 100644 --- a/src/Motus.Abstractions/Options/LaunchOptions.cs +++ b/src/Motus.Abstractions/Options/LaunchOptions.cs @@ -9,9 +9,19 @@ public sealed record LaunchOptions public bool Headless { get; init; } = true; /// The browser distribution channel to use. + /// + /// When null, a Chromium build downloaded by motus install is used if there is one, and + /// otherwise an installed Chrome, Edge or Chromium, in that order. + /// and also prefer a motus install download for that + /// browser. and mean the + /// installed branded browser only. + /// public BrowserChannel? Channel { get; init; } - /// Path to a browser executable to use instead of the bundled one. + /// + /// Path to a browser executable to use instead of resolving one from , a + /// motus install download, or the browsers installed on the machine. + /// public string? ExecutablePath { get; init; } /// Additional command-line arguments to pass to the browser. diff --git a/src/Motus.Cli/Commands/BrowserPathHelper.cs b/src/Motus.Cli/Commands/BrowserPathHelper.cs index e62f118..2c8950f 100644 --- a/src/Motus.Cli/Commands/BrowserPathHelper.cs +++ b/src/Motus.Cli/Commands/BrowserPathHelper.cs @@ -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); } /// @@ -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)); } } diff --git a/src/Motus.Cli/Services/BrowserInstaller.cs b/src/Motus.Cli/Services/BrowserInstaller.cs index d788023..3dda9f9 100644 --- a/src/Motus.Cli/Services/BrowserInstaller.cs +++ b/src/Motus.Cli/Services/BrowserInstaller.cs @@ -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) { @@ -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 diff --git a/src/Motus/Browser/BrowserFinder.cs b/src/Motus/Browser/BrowserFinder.cs index a4fff83..c8f0984 100644 --- a/src/Motus/Browser/BrowserFinder.cs +++ b/src/Motus/Browser/BrowserFinder.cs @@ -9,15 +9,40 @@ namespace Motus; internal static class BrowserFinder { /// - /// 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 motus install 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. /// - internal static string? InstalledBinariesPath { get; set; } + internal static string InstallRoot { get; set; } = InstalledBrowsers.DefaultRoot; /// /// Resolves a browser executable path from channel preference or explicit path. /// - internal static string Resolve(BrowserChannel? channel, string? executablePath) + internal static string Resolve(BrowserChannel? channel, string? executablePath) => + Resolve(channel, executablePath, InstallRoot, CandidatesForChannel); + + /// + /// Resolves a browser executable path, with the download cache and the system locations + /// supplied by the caller. + /// + /// + /// An explicit path wins, then a browser motus install 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. + /// motus install 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. + /// + internal static string Resolve( + BrowserChannel? channel, + string? executablePath, + string installRoot, + Func> systemCandidates) { if (executablePath is not null) { @@ -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 CandidatesForChannel(BrowserChannel channel) { var candidates = new List(); - 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 diff --git a/src/Motus/Browser/InstalledBrowsers.cs b/src/Motus/Browser/InstalledBrowsers.cs new file mode 100644 index 0000000..1bc9230 --- /dev/null +++ b/src/Motus/Browser/InstalledBrowsers.cs @@ -0,0 +1,74 @@ +using Motus.Abstractions; + +namespace Motus; + +/// +/// The browser cache motus install downloads into, and the marker files that record what it +/// installed there. The installer writes the markers and the launcher reads them, so both go +/// through here rather than each keeping its own idea of where the cache is and what the markers +/// are called. +/// +/// +/// A download is not found by looking for an executable name under the cache. Each build unpacks +/// into a layout of its own, chromium-{version}/chrome-{platform}/chrome on Linux, +/// chrome.exe in the same place on Windows and an application bundle on macOS, and several +/// versions can sit side by side. The installer records the exact executable it installed in +/// .installed.{channel}, and that marker is the answer. +/// +internal static class InstalledBrowsers +{ + /// + /// The cache motus install uses when it is not given --path: + /// ~/.motus/browsers. + /// + internal static string DefaultRoot { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".motus", "browsers"); + + /// + /// The marker that records the executable installed for a channel, such as + /// .installed.chromium. + /// + internal static string MarkerPath(string root, string channel) => + Path.Combine(root, $".installed.{channel.ToLowerInvariant()}"); + + /// + /// The marker older installs wrote, which records the last browser installed without recording + /// which browser that was. + /// + internal static string LegacyMarkerPath(string root) => Path.Combine(root, ".installed"); + + /// + /// The executable motus install recorded for a channel under , or + /// null when there is no marker for it or the marker names a file that is no longer there. + /// + internal static string? Find(string root, BrowserChannel channel) => + ReadMarker(MarkerPath(root, channel.ToString())); + + /// + /// The executable a marker names, or null when the marker is missing, cannot be read, or names a + /// file that no longer exists. + /// + /// + /// A marker outlives the download it describes when the cache is cleaned by hand, so a stale + /// one is treated as no marker at all and the caller falls through to whatever else it would + /// have tried. A marker that cannot be read is treated the same way: it is a hint about where a + /// browser might be, and failing a launch over it would be worse than not having it. + /// + internal static string? ReadMarker(string markerPath) + { + string path; + try + { + if (!File.Exists(markerPath)) + return null; + + path = File.ReadAllText(markerPath).Trim(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + return path.Length > 0 && File.Exists(path) ? path : null; + } +} diff --git a/tests/Motus.Tests/Browser/BrowserFinderTests.cs b/tests/Motus.Tests/Browser/BrowserFinderTests.cs index 5c240fb..eaa6239 100644 --- a/tests/Motus.Tests/Browser/BrowserFinderTests.cs +++ b/tests/Motus.Tests/Browser/BrowserFinderTests.cs @@ -40,24 +40,6 @@ public void CandidatesForChannel_ReturnsDifferentPathsPerChannel() CollectionAssert.AreNotEqual(chrome.ToList(), edge.ToList()); } - [TestMethod] - public void CandidatesForChannel_IncludesInstalledBinariesPath_WhenSet() - { - var originalPath = BrowserFinder.InstalledBinariesPath; - try - { - BrowserFinder.InstalledBinariesPath = "/opt/motus/browsers"; - var candidates = BrowserFinder.CandidatesForChannel(BrowserChannel.Chrome); - - Assert.IsTrue(candidates.Any(c => c.StartsWith("/opt/motus/browsers")), - "Should include installed binaries path when set."); - } - finally - { - BrowserFinder.InstalledBinariesPath = originalPath; - } - } - [TestMethod] public void CandidatesForChannel_Firefox_ReturnsNonEmptyList() { @@ -66,26 +48,6 @@ public void CandidatesForChannel_Firefox_ReturnsNonEmptyList() Assert.IsTrue(candidates.Count > 0, "Should return at least one Firefox candidate path."); } - [TestMethod] - public void CandidatesForChannel_Firefox_IncludesInstalledBinariesPath_WhenSet() - { - var originalPath = BrowserFinder.InstalledBinariesPath; - try - { - BrowserFinder.InstalledBinariesPath = "/opt/motus/browsers"; - var candidates = BrowserFinder.CandidatesForChannel(BrowserChannel.Firefox); - - Assert.IsTrue(candidates.Any(c => c.StartsWith("/opt/motus/browsers")), - "Should include installed binaries path for Firefox when set."); - Assert.IsTrue(candidates.Any(c => c.Contains("firefox")), - "Firefox candidate should contain 'firefox' in the path."); - } - finally - { - BrowserFinder.InstalledBinariesPath = originalPath; - } - } - [TestMethod] public void CandidatesForChannel_Firefox_DoesNotOverlapChrome() { @@ -111,46 +73,6 @@ public void CandidatesForChannel_Chromium_ReturnsNonEmptyList() Assert.IsTrue(candidates.Count > 0, "Should return at least one Chromium candidate path."); } - [TestMethod] - public void CandidatesForChannel_Edge_IncludesInstalledBinariesPath_WhenSet() - { - var originalPath = BrowserFinder.InstalledBinariesPath; - try - { - BrowserFinder.InstalledBinariesPath = "/opt/motus/browsers"; - var candidates = BrowserFinder.CandidatesForChannel(BrowserChannel.Edge); - - Assert.IsTrue(candidates.Any(c => c.StartsWith("/opt/motus/browsers")), - "Should include installed binaries path for Edge when set."); - Assert.IsTrue(candidates.Any(c => c.Contains("msedge")), - "Edge candidate should contain 'msedge' in the path."); - } - finally - { - BrowserFinder.InstalledBinariesPath = originalPath; - } - } - - [TestMethod] - public void CandidatesForChannel_Chromium_IncludesInstalledBinariesPath_WhenSet() - { - var originalPath = BrowserFinder.InstalledBinariesPath; - try - { - BrowserFinder.InstalledBinariesPath = "/opt/motus/browsers"; - var candidates = BrowserFinder.CandidatesForChannel(BrowserChannel.Chromium); - - Assert.IsTrue(candidates.Any(c => c.StartsWith("/opt/motus/browsers")), - "Should include installed binaries path for Chromium when set."); - Assert.IsTrue(candidates.Any(c => c.Contains("chromium")), - "Chromium candidate should contain 'chromium' in the path."); - } - finally - { - BrowserFinder.InstalledBinariesPath = originalPath; - } - } - [TestMethod] public void CandidatesForChannel_AllChannels_DoNotOverlap() { @@ -159,33 +81,19 @@ public void CandidatesForChannel_AllChannels_DoNotOverlap() var chromium = BrowserFinder.CandidatesForChannel(BrowserChannel.Chromium); var firefox = BrowserFinder.CandidatesForChannel(BrowserChannel.Firefox); - // With InstalledBinariesPath cleared, platform-specific paths should be distinct - var originalPath = BrowserFinder.InstalledBinariesPath; - try - { - BrowserFinder.InstalledBinariesPath = null; - chrome = BrowserFinder.CandidatesForChannel(BrowserChannel.Chrome); - edge = BrowserFinder.CandidatesForChannel(BrowserChannel.Edge); - chromium = BrowserFinder.CandidatesForChannel(BrowserChannel.Chromium); - firefox = BrowserFinder.CandidatesForChannel(BrowserChannel.Firefox); - - var all = chrome.Concat(edge).Concat(chromium).Concat(firefox).ToList(); - var distinct = all.Distinct().ToList(); - Assert.AreEqual(distinct.Count, all.Count, "Candidate paths across channels should be unique."); - } - finally - { - BrowserFinder.InstalledBinariesPath = originalPath; - } + var all = chrome.Concat(edge).Concat(chromium).Concat(firefox).ToList(); + var distinct = all.Distinct().ToList(); + Assert.AreEqual(distinct.Count, all.Count, "Candidate paths across channels should be unique."); } [TestMethod] public void Resolve_WithChannel_ExercisesChannelPath() { - var originalPath = BrowserFinder.InstalledBinariesPath; + using var machine = new FakeMachine(); + var originalRoot = BrowserFinder.InstallRoot; try { - BrowserFinder.InstalledBinariesPath = "/nonexistent/motus/browsers"; + BrowserFinder.InstallRoot = machine.InstallRoot; try { var result = BrowserFinder.Resolve(channel: BrowserChannel.Firefox, executablePath: null); @@ -199,116 +107,307 @@ public void Resolve_WithChannel_ExercisesChannelPath() } finally { - BrowserFinder.InstalledBinariesPath = originalPath; + BrowserFinder.InstallRoot = originalRoot; } } [TestMethod] - public void Resolve_WithChannel_UsesExistingBinary() + public void Resolve_AutoDetect_ExercisesAutoDetectPath() { - var existingFile = typeof(BrowserFinderTests).Assembly.Location; - var originalPath = BrowserFinder.InstalledBinariesPath; + using var machine = new FakeMachine(); + var originalRoot = BrowserFinder.InstallRoot; try { - // Point InstalledBinariesPath to the directory containing the test assembly, - // and rename expectation to match the binary name pattern - var dir = Path.GetDirectoryName(existingFile)!; - - // Create a temp file named "chrome" (or "chrome.exe" on Windows) in a temp dir - var tempDir = Path.Combine(Path.GetTempPath(), $"motus-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); - var fakeBrowser = Path.Combine(tempDir, - OperatingSystem.IsWindows() ? "chrome.exe" : "chrome"); - File.WriteAllText(fakeBrowser, "fake"); - - BrowserFinder.InstalledBinariesPath = tempDir; - var result = BrowserFinder.Resolve(channel: BrowserChannel.Chrome, executablePath: null); - - Assert.AreEqual(fakeBrowser, result); - - // Cleanup - File.Delete(fakeBrowser); - Directory.Delete(tempDir); + BrowserFinder.InstallRoot = machine.InstallRoot; + try + { + var result = BrowserFinder.Resolve(channel: null, executablePath: null); + // If we reach here, a real browser is installed at a system path + Assert.IsTrue(File.Exists(result)); + } + catch (FileNotFoundException) + { + // Expected when no browser is installed at system paths + } } finally { - BrowserFinder.InstalledBinariesPath = originalPath; + BrowserFinder.InstallRoot = originalRoot; } } [TestMethod] - public void Resolve_AutoDetect_FindsFirstAvailableBrowser() + public void Resolve_WithChannel_UsesExistingSystemBinary() { - var originalPath = BrowserFinder.InstalledBinariesPath; - try - { - var tempDir = Path.Combine(Path.GetTempPath(), $"motus-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); - var fakeBrowser = Path.Combine(tempDir, - OperatingSystem.IsWindows() ? "chrome.exe" : "chrome"); - File.WriteAllText(fakeBrowser, "fake"); + using var machine = new FakeMachine(); + var chrome = machine.AddSystemBrowser(BrowserChannel.Chrome); - BrowserFinder.InstalledBinariesPath = tempDir; - // channel: null, executablePath: null triggers auto-detect - var result = BrowserFinder.Resolve(channel: null, executablePath: null); + var result = machine.Resolve(BrowserChannel.Chrome); - Assert.AreEqual(fakeBrowser, result, "Auto-detect should find Chrome first."); + Assert.AreEqual(chrome, result); + } - File.Delete(fakeBrowser); - Directory.Delete(tempDir); - } - finally - { - BrowserFinder.InstalledBinariesPath = originalPath; - } + [TestMethod] + public void Resolve_AutoDetect_TriesChromeThenEdgeThenChromium() + { + using var machine = new FakeMachine(); + var chromium = machine.AddSystemBrowser(BrowserChannel.Chromium); + + Assert.AreEqual(chromium, machine.Resolve(channel: null)); + + var edge = machine.AddSystemBrowser(BrowserChannel.Edge); + + Assert.AreEqual(edge, machine.Resolve(channel: null), "Edge comes before Chromium."); + + var chrome = machine.AddSystemBrowser(BrowserChannel.Chrome); + + Assert.AreEqual(chrome, machine.Resolve(channel: null), "Chrome comes before Edge."); } + // The case the default path exists for: a machine with nothing but what motus install put + // there, such as a fresh CI image, used to report that no browser could be found at all. [TestMethod] - public void Resolve_AutoDetect_ExercisesAutoDetectPath() + public void Resolve_AutoDetect_FindsTheDownload_WhenTheMachineHasNoBrowser() + { + using var machine = new FakeMachine(); + var downloaded = machine.Install(BrowserChannel.Chromium); + + Assert.AreEqual(downloaded, machine.Resolve(channel: null)); + } + + [TestMethod] + public void Resolve_AutoDetect_PrefersTheDownload_OverASystemBrowser() + { + using var machine = new FakeMachine(); + machine.AddSystemBrowser(BrowserChannel.Chrome); + machine.AddSystemBrowser(BrowserChannel.Edge); + machine.AddSystemBrowser(BrowserChannel.Chromium); + var downloaded = machine.Install(BrowserChannel.Chromium); + + Assert.AreEqual(downloaded, machine.Resolve(channel: null), + "The browser the user chose to install should win over whatever the machine happens to have."); + } + + [TestMethod] + public void Resolve_AutoDetect_FallsBackToTheSystem_WhenThereIsNoMarker() + { + using var machine = new FakeMachine(); + var chrome = machine.AddSystemBrowser(BrowserChannel.Chrome); + + // A download with no marker beside it is not something the installer leaves behind, and + // is not guessed at: the marker is what names the executable. + Directory.CreateDirectory(Path.Combine(machine.InstallRoot, "chromium-149.0.7827.54", "chrome-linux64")); + File.WriteAllText(Path.Combine(machine.InstallRoot, "chromium-149.0.7827.54", "chrome-linux64", "chrome"), ""); + + Assert.AreEqual(chrome, machine.Resolve(channel: null)); + } + + [TestMethod] + public void Resolve_AutoDetect_FallsBackToTheSystem_WhenTheMarkerIsStale() + { + using var machine = new FakeMachine(); + var chrome = machine.AddSystemBrowser(BrowserChannel.Chrome); + var downloaded = machine.Install(BrowserChannel.Chromium); + + // The cache cleaned by hand, leaving the marker naming a folder that is gone. + Directory.Delete(Path.GetDirectoryName(Path.GetDirectoryName(downloaded))!, recursive: true); + + Assert.AreEqual(chrome, machine.Resolve(channel: null)); + } + + [TestMethod] + public void Resolve_AutoDetect_Throws_WhenTheMarkerIsStaleAndTheMachineHasNoBrowser() + { + using var machine = new FakeMachine(); + var downloaded = machine.Install(BrowserChannel.Chromium); + File.Delete(downloaded); + + var ex = Assert.ThrowsException(() => machine.Resolve(channel: null)); + StringAssert.Contains(ex.Message, "motus install"); + } + + [TestMethod] + public void Resolve_AutoDetect_IgnoresAnEmptyMarker() + { + using var machine = new FakeMachine(); + var chrome = machine.AddSystemBrowser(BrowserChannel.Chrome); + Directory.CreateDirectory(machine.InstallRoot); + File.WriteAllText(InstalledBrowsers.MarkerPath(machine.InstallRoot, "chromium"), " \n"); + + Assert.AreEqual(chrome, machine.Resolve(channel: null)); + } + + [TestMethod] + public void Resolve_ReadsAMarkerWithATrailingNewline() + { + using var machine = new FakeMachine(); + var downloaded = machine.Install(BrowserChannel.Chromium); + File.WriteAllText(InstalledBrowsers.MarkerPath(machine.InstallRoot, "chromium"), downloaded + Environment.NewLine); + + Assert.AreEqual(downloaded, machine.Resolve(channel: null)); + } + + [TestMethod] + public void Resolve_ExplicitPath_WinsOverTheDownload() + { + using var machine = new FakeMachine(); + machine.Install(BrowserChannel.Chromium); + var pinned = machine.AddFile("pinned", "chrome"); + + Assert.AreEqual(pinned, machine.Resolve(channel: null, executablePath: pinned)); + Assert.AreEqual(pinned, machine.Resolve(BrowserChannel.Chromium, executablePath: pinned)); + } + + [TestMethod] + public void Resolve_ChromiumChannel_PrefersTheDownload_OverASystemChromium() + { + using var machine = new FakeMachine(); + machine.AddSystemBrowser(BrowserChannel.Chromium); + var downloaded = machine.Install(BrowserChannel.Chromium); + + Assert.AreEqual(downloaded, machine.Resolve(BrowserChannel.Chromium)); + } + + [TestMethod] + public void Resolve_ChromiumChannel_FallsBackToTheSystem_WhenTheMarkerIsStale() + { + using var machine = new FakeMachine(); + var chromium = machine.AddSystemBrowser(BrowserChannel.Chromium); + File.Delete(machine.Install(BrowserChannel.Chromium)); + + Assert.AreEqual(chromium, machine.Resolve(BrowserChannel.Chromium)); + } + + // Chrome and Edge name the branded browsers. The download is a Chromium build, so asking for + // Chrome by name must not quietly hand back something else, with or without Chrome present. + [TestMethod] + [DataRow(BrowserChannel.Chrome)] + [DataRow(BrowserChannel.Edge)] + public void Resolve_BrandedChannel_IgnoresTheChromiumDownload(BrowserChannel channel) + { + using var machine = new FakeMachine(); + machine.Install(BrowserChannel.Chromium); + + Assert.ThrowsException(() => machine.Resolve(channel)); + + var branded = machine.AddSystemBrowser(channel); + + Assert.AreEqual(branded, machine.Resolve(channel)); + } + + [TestMethod] + public void Resolve_FirefoxChannel_PrefersTheFirefoxDownload() { - var originalPath = BrowserFinder.InstalledBinariesPath; + using var machine = new FakeMachine(); + machine.AddSystemBrowser(BrowserChannel.Firefox); + machine.Install(BrowserChannel.Chromium); + var downloaded = machine.Install(BrowserChannel.Firefox); + + Assert.AreEqual(downloaded, machine.Resolve(BrowserChannel.Firefox)); + } + + [TestMethod] + public void Resolve_FirefoxChannel_DoesNotTakeTheChromiumDownload() + { + using var machine = new FakeMachine(); + machine.Install(BrowserChannel.Chromium); + + Assert.ThrowsException(() => machine.Resolve(BrowserChannel.Firefox)); + } + + // Auto-detect has never picked Firefox, which is driven over a different protocol, and a + // Firefox download does not change that. + [TestMethod] + public void Resolve_AutoDetect_DoesNotTakeAFirefoxDownload() + { + using var machine = new FakeMachine(); + machine.Install(BrowserChannel.Firefox); + + Assert.ThrowsException(() => machine.Resolve(channel: null)); + } + + [TestMethod] + public void Resolve_WithoutARoot_ReadsBrowserFinderInstallRoot() + { + using var machine = new FakeMachine(); + var downloaded = machine.Install(BrowserChannel.Chromium); + var originalRoot = BrowserFinder.InstallRoot; try { - var tempDir = Path.Combine(Path.GetTempPath(), $"motus-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); + BrowserFinder.InstallRoot = machine.InstallRoot; - BrowserFinder.InstalledBinariesPath = tempDir; - try - { - var result = BrowserFinder.Resolve(channel: null, executablePath: null); - // If we reach here, a real browser is installed at a system path - Assert.IsTrue(File.Exists(result)); - } - catch (FileNotFoundException) - { - // Expected when no browser is installed at system paths - } - finally - { - Directory.Delete(tempDir); - } + Assert.AreEqual(downloaded, BrowserFinder.Resolve(BrowserChannel.Chromium, executablePath: null)); } finally { - BrowserFinder.InstalledBinariesPath = originalPath; + BrowserFinder.InstallRoot = originalRoot; } } [TestMethod] - public void InstalledBinariesPath_Null_DoesNotPrependPath() + public void InstallRoot_DefaultsToTheInstallersCache() { - var originalPath = BrowserFinder.InstalledBinariesPath; - try + var expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".motus", "browsers"); + + Assert.AreEqual(expected, InstalledBrowsers.DefaultRoot); + Assert.AreEqual(Path.Combine(expected, ".installed.chromium"), InstalledBrowsers.MarkerPath(expected, "Chromium")); + } + + /// + /// A browser cache and a set of system locations in a temporary folder, so that what is + /// installed on the machine running the tests cannot change what they find. + /// + private sealed class FakeMachine : IDisposable + { + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"motus-finder-{Guid.NewGuid():N}"); + + public string InstallRoot => Path.Combine(_dir, "browsers"); + + /// + /// Lays a download out the way motus install does, a versioned folder holding the + /// platform's archive folder, and writes the marker that names its executable. + /// + public string Install(BrowserChannel channel) { - BrowserFinder.InstalledBinariesPath = null; - var candidates = BrowserFinder.CandidatesForChannel(BrowserChannel.Chrome); + var name = channel.ToString().ToLowerInvariant(); + var executable = channel == BrowserChannel.Firefox + ? AddFile(Path.Combine("browsers", $"{name}-150.0"), "firefox") + : AddFile(Path.Combine("browsers", $"{name}-149.0.7827.54", "chrome-linux64"), "chrome"); + File.WriteAllText(InstalledBrowsers.MarkerPath(InstallRoot, name), executable); + return executable; + } - // All candidates should be platform-specific system paths, not prepended - Assert.IsTrue(candidates.All(c => !string.IsNullOrWhiteSpace(c))); + public string AddSystemBrowser(BrowserChannel channel) => + AddFile(Path.Combine("system", channel.ToString()), "browser"); + + public string AddFile(string folder, string name) + { + var dir = Path.Combine(_dir, folder); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, name); + File.WriteAllText(path, ""); + return path; } - finally + + public string Resolve(BrowserChannel? channel, string? executablePath = null) => + BrowserFinder.Resolve(channel, executablePath, InstallRoot, SystemCandidates); + + // Each channel's system location is listed whether or not it exists yet, the way the + // real candidate lists name paths that may not be installed. + private IReadOnlyList SystemCandidates(BrowserChannel channel) => + [Path.Combine(_dir, "system", channel.ToString(), "browser")]; + + public void Dispose() { - BrowserFinder.InstalledBinariesPath = originalPath; + try + { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + catch (IOException) + { + } } } } diff --git a/tests/Motus.Tests/Browser/InstalledBrowserLaunchTests.cs b/tests/Motus.Tests/Browser/InstalledBrowserLaunchTests.cs new file mode 100644 index 0000000..836d618 --- /dev/null +++ b/tests/Motus.Tests/Browser/InstalledBrowserLaunchTests.cs @@ -0,0 +1,70 @@ +using Motus.Abstractions; + +namespace Motus.Tests.Browser; + +/// +/// Launching with default options starts the browser motus install downloaded, which is what +/// a test run under dotnet test does. The download here is a script standing in for a +/// browser that records that it was started, so the test can tell it apart from any browser the +/// machine has without depending on one. +/// +[TestClass] +[TestCategory("Integration")] +public class InstalledBrowserLaunchTests +{ + [TestMethod] + public async Task LaunchAsync_WithDefaultOptions_StartsTheDownloadedBrowser() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("The stand-in browser is a shell script."); + return; + } + + // An executable path from the environment or motus.json outranks the download, as it + // should, and the configuration is read once per process, so it cannot be cleared here. + if (MotusConfigLoader.Config.Launch?.ExecutablePath is { Length: > 0 } configured) + { + Assert.Inconclusive($"An executable path is configured ({configured}), and it outranks the download."); + return; + } + + var root = Path.Combine(Path.GetTempPath(), $"motus-install-{Guid.NewGuid():N}"); + var originalRoot = BrowserFinder.InstallRoot; + try + { + var folder = Path.Combine(root, "chromium-149.0.7827.54", "chrome-linux64"); + Directory.CreateDirectory(folder); + + var started = Path.Combine(root, "started"); + var executable = Path.Combine(folder, "chrome"); + File.WriteAllText(executable, $"#!/bin/sh\necho started > '{started}'\nexit 1\n"); + File.SetUnixFileMode(executable, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.WriteAllText(InstalledBrowsers.MarkerPath(root, "chromium"), executable); + + BrowserFinder.InstallRoot = root; + + // The stand-in exits rather than speaking CDP, so the launch fails. What matters is + // which executable it started. + try + { + await using var browser = await MotusLauncher.LaunchAsync( + new LaunchOptions { Headless = true, Timeout = 5_000 }); + Assert.Fail("The stand-in browser cannot be driven, so the launch should not have succeeded."); + } + catch (Exception ex) when (ex is not AssertFailedException) + { + } + + Assert.IsTrue(File.Exists(started), + "LaunchAsync with default options should start the browser motus install recorded."); + } + finally + { + BrowserFinder.InstallRoot = originalRoot; + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +}