blob: d11f657e074957726f701449337b90e342a6f903 [file] [log] [blame]
// <copyright file="SeleniumManager.cs" company="Selenium Committers">
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// </copyright>
using System.Diagnostics;
using System.Globalization;
#if !NET8_0_OR_GREATER
using System.Runtime.InteropServices;
#endif
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
using OpenQA.Selenium.Internal.Logging;
namespace OpenQA.Selenium.Manager;
/// <summary>
/// Manages automatic discovery and configuration of browser drivers.
/// </summary>
/// <remarks>
/// <para>
/// <b>Warning: This is an experimental API that is subject to change in future versions.</b>
/// </para>
/// Selenium Manager automatically locates or downloads the appropriate browser driver
/// for the specified browser. It eliminates the need for manual driver management by:
/// <list type="bullet">
/// <item><description>Detecting the installed browser version</description></item>
/// <item><description>Downloading the matching driver binary if needed</description></item>
/// <item><description>Caching drivers for subsequent use</description></item>
/// <item><description>Providing paths to both driver and browser executables</description></item>
/// </list>
/// <para>
/// The Selenium Manager binary is automatically included with the Selenium package.
/// Set the SE_MANAGER_PATH environment variable to use a custom binary location.
/// </para>
/// </remarks>
public static partial class SeleniumManager
{
private static readonly ILogger _logger = Log.GetLogger(typeof(SeleniumManager));
// This logic to find the Selenium Manager binary is complex due to supporting multiple deployment scenarios.
// Once Selenium Manager becomes a true native library (dll, so, dylib),
// we will be able to reference it directly from the .NET bindings, and this logic will be removed.
private static readonly Lazy<string> _lazyBinaryFullPath = new(() =>
{
if (_logger.IsEnabled(LogEventLevel.Debug))
{
_logger.Debug("Locating Selenium Manager executable binary...");
}
string? binaryFullPath = Environment.GetEnvironmentVariable("SE_MANAGER_PATH");
if (binaryFullPath is not null)
{
if (!File.Exists(binaryFullPath))
{
throw new FileNotFoundException($"Unable to locate provided Selenium Manager binary at '{binaryFullPath}'.");
}
return binaryFullPath;
}
#if NET8_0_OR_GREATER
SupportedPlatform? platform = null;
if (OperatingSystem.IsWindows())
{
platform = SupportedPlatform.Windows;
}
else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
{
platform = SupportedPlatform.Linux;
}
else if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst())
{
platform = SupportedPlatform.MacOS;
}
#elif NETSTANDARD2_0
SupportedPlatform? platform = null;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
platform = SupportedPlatform.Windows;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
platform = SupportedPlatform.Linux;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
platform = SupportedPlatform.MacOS;
}
#elif NET462
var platform = SupportedPlatform.Windows;
#endif
var seleniumManagerFileName = platform switch
{
SupportedPlatform.Windows => "selenium-manager.exe",
SupportedPlatform.Linux => "selenium-manager",
SupportedPlatform.MacOS => "selenium-manager",
_ => throw new PlatformNotSupportedException(
$"Selenium Manager doesn't support your runtime platform: {Environment.OSVersion.Platform}"),
};
var baseDirectory = AppContext.BaseDirectory;
List<string> probingPaths = [];
if (baseDirectory is not null)
{
probingPaths.Add(Path.Combine(baseDirectory, seleniumManagerFileName));
switch (platform)
{
case SupportedPlatform.Windows:
probingPaths.Add(Path.Combine(baseDirectory, "runtimes", "win", "native", seleniumManagerFileName));
break;
case SupportedPlatform.Linux:
probingPaths.Add(Path.Combine(baseDirectory, "runtimes", "linux", "native", seleniumManagerFileName));
break;
case SupportedPlatform.MacOS:
probingPaths.Add(Path.Combine(baseDirectory, "runtimes", "osx", "native", seleniumManagerFileName));
break;
}
}
#if !NET462
// Supporting .NET5+ applications deployed as bundled applications (single file or AOT).
// In this case bootstrapper extracts the native libraries into a temporary directory.
// Most interesting build properties: "IncludeNativeLibrariesForSelfExtract" and "IncludeAllContentForSelfExtract".
var nativeDllSearchDirectories = AppContext.GetData("NATIVE_DLL_SEARCH_DIRECTORIES")?.ToString();
if (nativeDllSearchDirectories is not null)
{
probingPaths.AddRange(nativeDllSearchDirectories.Split(new char[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries).Select(path => Path.Combine(path, seleniumManagerFileName)));
}
#endif
// Covering the case when the application is hosted by another application, most likely
// we can find Selenium Manager in the assembly location, because "AppContext.BaseDirectory"
// might return the path of the host application.
var assemblyDirectory = Path.GetDirectoryName(typeof(SeleniumManager).Assembly.Location);
if (assemblyDirectory is not null)
{
probingPaths.Add(Path.Combine(assemblyDirectory, seleniumManagerFileName));
}
probingPaths = [.. probingPaths.Distinct()];
binaryFullPath = probingPaths.FirstOrDefault(File.Exists);
if (binaryFullPath is null)
{
var messageBuilder = new StringBuilder();
messageBuilder.AppendFormat("Selenium Manager binary '{0}' was not found in the following paths:", seleniumManagerFileName);
foreach (var probingPath in probingPaths)
{
messageBuilder.AppendLine();
messageBuilder.AppendFormat(" - {0}", probingPath);
}
throw new FileNotFoundException(messageBuilder.ToString());
}
return binaryFullPath;
});
/// <summary>
/// Discovers the browser and driver paths for the specified browser.
/// </summary>
/// <param name="name">The name of the browser (e.g., "chrome", "firefox", "edge").</param>
/// <param name="options">Optional discovery options to control browser and driver resolution.</param>
/// <param name="cancellationToken">A token to cancel the asynchronous operation. When cancellation is requested, the Selenium Manager process is terminated.</param>
/// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation, containing a <see cref="BrowserDiscoveryResult"/> with the paths to the driver and browser executables.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null, empty, or whitespace.</exception>
/// <exception cref="WebDriverException">Thrown when Selenium Manager fails to locate or download the required binaries.</exception>
/// <exception cref="OperationCanceledException">Thrown when the operation is canceled via <paramref name="cancellationToken"/>.</exception>
public static async Task<BrowserDiscoveryResult> DiscoverBrowserAsync(
string name,
BrowserDiscoveryOptions? options = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Browser name must be specified to find the driver using Selenium Manager.", nameof(name));
}
StringBuilder argsBuilder = new();
argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --browser \"{0}\"", name);
if (options is not null)
{
if (!string.IsNullOrEmpty(options.BrowserVersion))
{
argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --browser-version \"{0}\"", options.BrowserVersion);
}
if (!string.IsNullOrEmpty(options.BrowserPath))
{
argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --browser-path \"{0}\"", options.BrowserPath);
}
if (!string.IsNullOrEmpty(options.DriverVersion))
{
argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --driver-version \"{0}\"", options.DriverVersion);
}
if (!string.IsNullOrEmpty(options.Proxy))
{
argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --proxy \"{0}\"", options.Proxy);
}
}
argsBuilder.Append(" --language-binding csharp");
argsBuilder.Append(" --output mixed");
if (_logger.IsEnabled(LogEventLevel.Trace))
{
argsBuilder.Append(" --log-level trace");
}
else if (_logger.IsEnabled(LogEventLevel.Debug))
{
argsBuilder.Append(" --log-level debug");
}
return await RunCommandAsync(argsBuilder.ToString(), SeleniumManagerSerializerContext.Default.BrowserDiscoveryResult, cancellationToken).ConfigureAwait(false);
}
private static async Task<TResult> RunCommandAsync<TResult>(
string arguments,
JsonTypeInfo<TResult> jsonResultTypeInfo,
CancellationToken cancellationToken = default)
{
string smBinaryPath = _lazyBinaryFullPath.Value;
if (_logger.IsEnabled(LogEventLevel.Info))
{
_logger.Info($"Starting Selenium Manager process: {Path.GetFileName(smBinaryPath)} {arguments}");
}
using Process process = new();
process.StartInfo.FileName = smBinaryPath;
process.StartInfo.Arguments = arguments;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.StandardErrorEncoding = Encoding.UTF8;
process.StartInfo.StandardOutputEncoding = Encoding.UTF8;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
StringBuilder stdOutputBuilder = new();
StringBuilder errOutputBuilder = new();
try
{
#if !NET8_0_OR_GREATER
process.EnableRaisingEvents = true;
#endif
process.Start();
var stdOutputTask = ReadStandardOutputAsync();
var errOutputTask = ReadErrorOutputAsync();
#if NET8_0_OR_GREATER
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try
{
process.Kill();
}
catch
{
// Process may have already exited
}
// Await output tasks to prevent unobserved exceptions when process is killed
await AwaitAndSuppressExceptionsAsync(stdOutputTask, errOutputTask).ConfigureAwait(false);
throw;
}
#else
var tcs = new TaskCompletionSource<bool>();
EventHandler exitedHandler = (sender, args) => tcs.TrySetResult(true);
process.Exited += exitedHandler;
try
{
// Handle race condition where process exits before we subscribe
if (process.HasExited)
{
tcs.TrySetResult(true);
}
using (cancellationToken.Register(() =>
{
tcs.TrySetCanceled(cancellationToken);
try
{
process.Kill();
}
catch
{
// Process may have already exited
}
}))
{
try
{
await tcs.Task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try
{
process.Kill();
}
catch
{
// Process may have already exited
}
// Await output tasks to prevent unobserved exceptions when process is killed
await AwaitAndSuppressExceptionsAsync(stdOutputTask, errOutputTask).ConfigureAwait(false);
throw;
}
}
// Handle race condition where cancellation was requested after process exit
// but before we checked the TaskCompletionSource result.
cancellationToken.ThrowIfCancellationRequested();
}
finally
{
process.Exited -= exitedHandler;
}
#endif
// Ensure output streams are fully drained before parsing.
// ReadLineAsync processes lines as they arrive and completes when stream ends.
await Task.WhenAll(stdOutputTask, errOutputTask).ConfigureAwait(false);
if (process.ExitCode != 0)
{
var exceptionMessageBuilder = new StringBuilder($"Selenium Manager process exited abnormally with {process.ExitCode} code: {process.StartInfo.FileName} {arguments}");
if (!string.IsNullOrWhiteSpace(stdOutputBuilder.ToString()))
{
exceptionMessageBuilder.AppendLine();
exceptionMessageBuilder.AppendLine("--- Standard Output ---");
exceptionMessageBuilder.Append(stdOutputBuilder);
exceptionMessageBuilder.AppendLine("--- End Standard Output ---");
}
if (!string.IsNullOrWhiteSpace(errOutputBuilder.ToString()))
{
exceptionMessageBuilder.AppendLine();
exceptionMessageBuilder.AppendLine("--- Error Output ---");
exceptionMessageBuilder.Append(errOutputBuilder);
exceptionMessageBuilder.AppendLine("--- End Error Output ---");
}
throw new WebDriverException(exceptionMessageBuilder.ToString());
}
}
catch (Exception ex) when (ex is not OperationCanceledException and not WebDriverException)
{
throw new WebDriverException($"Error running Selenium Manager process: {process.StartInfo.FileName} {arguments}", ex);
}
string output = stdOutputBuilder.ToString();
TResult result;
try
{
result = JsonSerializer.Deserialize(output, jsonResultTypeInfo)
?? throw new JsonException($"Selenium Manager returned empty json output: {output}");
}
catch (Exception ex)
{
throw new WebDriverException($"Error deserializing Selenium Manager's response: {output}", ex);
}
return result;
// Local functions to read process output streams concurrently
async Task ReadStandardOutputAsync()
{
string? line;
#if NET8_0_OR_GREATER
while ((line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
#else
while ((line = await process.StandardOutput.ReadLineAsync().ConfigureAwait(false)) is not null)
#endif
{
stdOutputBuilder.AppendLine(line);
}
}
async Task ReadErrorOutputAsync()
{
string? line;
#if NET8_0_OR_GREATER
while ((line = await process.StandardError.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
#else
while ((line = await process.StandardError.ReadLineAsync().ConfigureAwait(false)) is not null)
#endif
{
var match = LogMessageRegex.Match(line);
if (match.Success)
{
if (!DateTimeOffset.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
{
if (_logger.IsEnabled(LogEventLevel.Warn))
{
_logger.Warn($"Unable to parse log message timestamp from Selenium Manager: '{match.Groups[1].Value}'. Defaulting to current time.");
}
dateTime = DateTimeOffset.UtcNow;
}
var logLevel = match.Groups[2].Value;
var message = match.Groups[3].Value;
switch (logLevel)
{
case "INFO":
_logger.LogMessage(dateTime, LogEventLevel.Info, message);
break;
case "WARN":
_logger.LogMessage(dateTime, LogEventLevel.Warn, message);
break;
case "ERROR":
_logger.LogMessage(dateTime, LogEventLevel.Error, message);
break;
case "DEBUG":
_logger.LogMessage(dateTime, LogEventLevel.Debug, message);
break;
case "TRACE":
_logger.LogMessage(dateTime, LogEventLevel.Trace, message);
break;
default:
if (_logger.IsEnabled(LogEventLevel.Warn))
{
_logger.Warn($"Unknown log level '{logLevel}' in Selenium Manager log message. Original message: {line}");
}
_logger.LogMessage(dateTime, LogEventLevel.Trace, message);
break;
}
}
else
{
// Collect non-structured error output for exception reporting
errOutputBuilder.AppendLine(line);
}
}
}
static async Task AwaitAndSuppressExceptionsAsync(params Task[] tasks)
{
try
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch
{
// Suppress exceptions
}
}
}
// Regex pattern to parse structured log messages from Selenium Manager.
// Example: "[2026-02-10T19:33:13.886Z ERROR] You need to specify a browser or driver"
// Groups: (1) timestamp, (2) log level, (3) message
// The optional tab (\t?) handles formatting variations between log levels.
const string LogMessageRegexPattern = @"^\[(.*) ([A-Z]+)\t?\] (.*)$";
#if NET8_0_OR_GREATER
[GeneratedRegex(LogMessageRegexPattern)]
private static partial Regex GeneratedLogMessageRegex();
private static Regex LogMessageRegex { get; } = GeneratedLogMessageRegex();
#else
private static Regex LogMessageRegex { get; } = new(LogMessageRegexPattern, RegexOptions.Compiled);
#endif
}
/// <summary>
/// Provides optional configuration for browser and driver discovery.
/// </summary>
public record BrowserDiscoveryOptions
{
/// <summary>
/// Gets or sets the specific browser version to target (e.g., "120.0.6099.109").
/// If not specified, the installed browser version is detected automatically.
/// </summary>
public string? BrowserVersion { get; set; }
/// <summary>
/// Gets or sets the path to the browser executable.
/// When specified, Selenium Manager uses this path instead of detecting the browser location.
/// </summary>
public string? BrowserPath { get; set; }
/// <summary>
/// Gets or sets the specific driver version to download (e.g., "120.0.6099.109").
/// If not specified, the driver version matching the browser version is selected automatically.
/// </summary>
public string? DriverVersion { get; set; }
/// <summary>
/// Gets or sets the proxy server URL for downloading browser drivers.
/// </summary>
public string? Proxy { get; set; }
}
/// <summary>
/// Contains the paths to the discovered browser driver and browser executable.
/// </summary>
/// <param name="DriverPath">The absolute path to the browser driver executable.</param>
/// <param name="BrowserPath">The absolute path to the browser executable.</param>
public record BrowserDiscoveryResult(
[property: JsonPropertyName("driver_path")] string DriverPath,
[property: JsonPropertyName("browser_path")] string BrowserPath);
[JsonSerializable(typeof(BrowserDiscoveryResult))]
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
internal sealed partial class SeleniumManagerSerializerContext : JsonSerializerContext;
internal enum SupportedPlatform
{
Windows,
Linux,
MacOS
}