diff --git a/dotnet/src/webdriver/DriverFinder.cs b/dotnet/src/webdriver/DriverFinder.cs index 57da194eb7def..34a77efc9725c 100644 --- a/dotnet/src/webdriver/DriverFinder.cs +++ b/dotnet/src/webdriver/DriverFinder.cs @@ -32,6 +32,9 @@ namespace OpenQA.Selenium; /// public class DriverFinder { + internal const string DriverPathKey = "driver_path"; + internal const string BrowserPathKey = "browser_path"; + private readonly DriverOptions options; private readonly Dictionary paths = new Dictionary(); @@ -52,7 +55,7 @@ public DriverFinder(DriverOptions options) /// public string GetBrowserPath() { - return BinaryPaths()[SeleniumManager.BrowserPathKey]; + return BinaryPaths()[BrowserPathKey]; } /// @@ -63,7 +66,7 @@ public string GetBrowserPath() /// public string GetDriverPath() { - return BinaryPaths()[SeleniumManager.DriverPathKey]; + return BinaryPaths()[DriverPathKey]; } /// @@ -102,18 +105,29 @@ public bool TryGetBrowserPath([NotNullWhen(true)] out string? browserPath) /// If one of the paths does not exist. private Dictionary BinaryPaths() { - if (paths.ContainsKey(SeleniumManager.DriverPathKey) && !string.IsNullOrWhiteSpace(paths[SeleniumManager.DriverPathKey])) + if (paths.TryGetValue(DriverPathKey, out string? cachedDriverPath) && !string.IsNullOrWhiteSpace(cachedDriverPath)) { return paths; } - Dictionary binaryPaths = SeleniumManager.BinaryPaths(CreateArguments()); - string driverPath = binaryPaths[SeleniumManager.DriverPathKey]; - string browserPath = binaryPaths[SeleniumManager.BrowserPathKey]; + if (options.BrowserName is null) + { + throw new NoSuchDriverException("Browser name must be specified to find the driver using Selenium Manager."); + } + + BrowserDiscoveryResult smResult = SeleniumManager.DiscoverBrowser(options.BrowserName, new BrowserDiscoveryOptions + { + BrowserVersion = options.BrowserVersion, + BrowserPath = options.BinaryLocation, + Proxy = options.Proxy?.SslProxy ?? options.Proxy?.HttpProxy + }); + + string driverPath = smResult.DriverPath; + string browserPath = smResult.BrowserPath; if (File.Exists(driverPath)) { - paths.Add(SeleniumManager.DriverPathKey, driverPath); + paths.Add(DriverPathKey, driverPath); } else { @@ -122,7 +136,7 @@ private Dictionary BinaryPaths() if (File.Exists(browserPath)) { - paths.Add(SeleniumManager.BrowserPathKey, browserPath); + paths.Add(BrowserPathKey, browserPath); } else { @@ -131,43 +145,4 @@ private Dictionary BinaryPaths() return paths; } - - /// - /// Create arguments to invoke Selenium Manager - /// - /// - /// A string with all arguments to invoke Selenium Manager - /// - /// - private string CreateArguments() - { - StringBuilder argsBuilder = new StringBuilder(); - argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --browser \"{0}\"", options.BrowserName); - - if (!string.IsNullOrEmpty(options.BrowserVersion)) - { - argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --browser-version {0}", options.BrowserVersion); - } - - string? browserBinary = options.BinaryLocation; - if (!string.IsNullOrEmpty(browserBinary)) - { - argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --browser-path \"{0}\"", browserBinary); - } - - if (options.Proxy != null) - { - if (options.Proxy.SslProxy != null) - { - argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --proxy \"{0}\"", options.Proxy.SslProxy); - } - else if (options.Proxy.HttpProxy != null) - { - argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --proxy \"{0}\"", options.Proxy.HttpProxy); - } - } - - return argsBuilder.ToString(); - } - } diff --git a/dotnet/src/webdriver/Internal/Logging/ILogContext.cs b/dotnet/src/webdriver/Internal/Logging/ILogContext.cs index 38b6cbb399773..270931109ae72 100644 --- a/dotnet/src/webdriver/Internal/Logging/ILogContext.cs +++ b/dotnet/src/webdriver/Internal/Logging/ILogContext.cs @@ -65,9 +65,10 @@ public interface ILogContext : IDisposable /// Emits a log message using the specified logger, log level, and message. /// /// The logger to emit the log message. + /// The timestamp of the log event. /// The log level of the message. /// The log message. - internal void EmitMessage(ILogger logger, LogEventLevel level, string message); + internal void EmitMessage(ILogger logger, DateTimeOffset timestamp, LogEventLevel level, string message); /// /// Sets the minimum log level for the current context. diff --git a/dotnet/src/webdriver/Internal/Logging/ILogger.cs b/dotnet/src/webdriver/Internal/Logging/ILogger.cs index 6a0b3de5a5a09..ef24857da0079 100644 --- a/dotnet/src/webdriver/Internal/Logging/ILogger.cs +++ b/dotnet/src/webdriver/Internal/Logging/ILogger.cs @@ -56,6 +56,14 @@ internal interface ILogger /// The log message. void Error(string message); + /// + /// Writes a log message with a specific timestamp and log level. + /// + /// The timestamp of the log event. + /// The severity level of the log message. + /// The log message. + void LogMessage(DateTimeOffset timestamp, LogEventLevel level, string message); + /// /// Gets or sets the log event level. /// diff --git a/dotnet/src/webdriver/Internal/Logging/LogContext.cs b/dotnet/src/webdriver/Internal/Logging/LogContext.cs index 5bb8ed5359e98..bed7d965804ca 100644 --- a/dotnet/src/webdriver/Internal/Logging/LogContext.cs +++ b/dotnet/src/webdriver/Internal/Logging/LogContext.cs @@ -95,12 +95,11 @@ public bool IsEnabled(ILogger logger, LogEventLevel level) return Handlers != null && level >= _level && (_loggers?.TryGetValue(logger.Issuer, out var loggerEntry) != true || level >= loggerEntry?.Level); } - public void EmitMessage(ILogger logger, LogEventLevel level, string message) + public void EmitMessage(ILogger logger, DateTimeOffset timestamp, LogEventLevel level, string message) { if (IsEnabled(logger, level)) { - var logEvent = new LogEvent(logger.Issuer, DateTimeOffset.Now, level, message); - + var logEvent = new LogEvent(logger.Issuer, timestamp, level, message); foreach (var handler in Handlers) { handler.Handle(logEvent); diff --git a/dotnet/src/webdriver/Internal/Logging/Logger.cs b/dotnet/src/webdriver/Internal/Logging/Logger.cs index 3910763fc330b..acc591c38d690 100644 --- a/dotnet/src/webdriver/Internal/Logging/Logger.cs +++ b/dotnet/src/webdriver/Internal/Logging/Logger.cs @@ -67,8 +67,13 @@ public bool IsEnabled(LogEventLevel level) return Log.CurrentContext.IsEnabled(this, level); } + public void LogMessage(DateTimeOffset timestamp, LogEventLevel level, string message) + { + Log.CurrentContext.EmitMessage(this, timestamp.ToLocalTime(), level, message); + } + private void LogMessage(LogEventLevel level, string message) { - Log.CurrentContext.EmitMessage(this, level, message); + LogMessage(DateTimeOffset.Now, level, message); } } diff --git a/dotnet/src/webdriver/SeleniumManager.cs b/dotnet/src/webdriver/SeleniumManager.cs index 901c415a78560..cdd1a5b72ba06 100644 --- a/dotnet/src/webdriver/SeleniumManager.cs +++ b/dotnet/src/webdriver/SeleniumManager.cs @@ -20,26 +20,40 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; +#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; -using static OpenQA.Selenium.SeleniumManagerResponse; namespace OpenQA.Selenium; /// -/// Wrapper for the Selenium Manager binary. -/// This implementation is still in beta, and may change. +/// Manages automatic discovery and configuration of browser drivers. /// -public static class SeleniumManager +/// +/// Selenium Manager automatically locates or downloads the appropriate browser driver +/// for the specified browser. It eliminates the need for manual driver management by: +/// +/// Detecting the installed browser version +/// Downloading the matching driver binary if needed +/// Caching drivers for subsequent use +/// Providing paths to both driver and browser executables +/// +/// +/// The Selenium Manager binary is automatically included with the Selenium package. +/// Set the SE_MANAGER_PATH environment variable to use a custom binary location. +/// +/// +public static partial class SeleniumManager { - internal const string DriverPathKey = "driver_path"; - internal const string BrowserPathKey = "browser_path"; - private static readonly ILogger _logger = Log.GetLogger(typeof(SeleniumManager)); // This logic to find Selenium Manager binary is complex and strange. @@ -47,6 +61,11 @@ public static class SeleniumManager // we will be able to use it directly from the .NET bindings, and this logic will be removed. private static readonly Lazy _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) @@ -168,49 +187,73 @@ public static class SeleniumManager }); /// - /// Determines the location of the browser and driver binaries. + /// Discovers the browser and driver paths for the specified browser. /// - /// List of arguments to use when invoking Selenium Manager. - /// - /// An array with two entries, one for the driver path, and another one for the browser path. - /// - public static Dictionary BinaryPaths(string arguments) + /// The name of the browser (e.g., "chrome", "firefox", "edge"). + /// Optional discovery options to control browser and driver resolution. + /// A containing the paths to the driver and browser executables. + /// Thrown when is null, empty, or whitespace. + /// Thrown when Selenium Manager fails to locate or download the required binaries. + public static BrowserDiscoveryResult DiscoverBrowser(string browserName, BrowserDiscoveryOptions? options = null) { - StringBuilder argsBuilder = new StringBuilder(arguments); - argsBuilder.Append(" --language-binding csharp"); - argsBuilder.Append(" --output json"); - if (_logger.IsEnabled(LogEventLevel.Debug)) + if (string.IsNullOrWhiteSpace(browserName)) { - argsBuilder.Append(" --debug"); + throw new ArgumentException("Browser name must be specified to find the driver using Selenium Manager.", nameof(browserName)); } - var smCommandResult = RunCommand(argsBuilder.ToString()); - Dictionary binaryPaths = new() + StringBuilder argsBuilder = new(); + + argsBuilder.AppendFormat(CultureInfo.InvariantCulture, " --browser \"{0}\"", browserName); + + if (options is not null) { - { BrowserPathKey, smCommandResult.BrowserPath }, - { DriverPathKey, smCommandResult.DriverPath } - }; + 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)) { - _logger.Trace($"Driver path: {binaryPaths[DriverPathKey]}"); - _logger.Trace($"Browser path: {binaryPaths[BrowserPathKey]}"); + argsBuilder.Append(" --log-level trace"); + } + else if (_logger.IsEnabled(LogEventLevel.Debug)) + { + argsBuilder.Append(" --log-level debug"); } - return binaryPaths; + return RunCommand(argsBuilder.ToString(), SeleniumManagerSerializerContext.Default.BrowserDiscoveryResult, options?.Timeout); } - /// - /// Executes a process with the given arguments. - /// - /// The switches to be used by Selenium Manager. - /// - /// the standard output of the execution. - /// - private static ResultResponse RunCommand(string arguments) + private static TResult RunCommand(string arguments, JsonTypeInfo jsonResultTypeInfo, TimeSpan? timeout = null) { - Process process = new Process(); - process.StartInfo.FileName = _lazyBinaryFullPath.Value; + 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; @@ -219,44 +262,44 @@ private static ResultResponse RunCommand(string arguments) process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; - StringBuilder outputBuilder = new StringBuilder(); - StringBuilder errorOutputBuilder = new StringBuilder(); + StringBuilder stdOutputBuilder = new(); + StringBuilder errOutputBuilder = new(); - DataReceivedEventHandler outputHandler = (sender, e) => outputBuilder.AppendLine(e.Data); - DataReceivedEventHandler errorOutputHandler = (sender, e) => errorOutputBuilder.AppendLine(e.Data); + process.OutputDataReceived += HandleStandardOutput; + process.ErrorDataReceived += HandleErrorOutput; try { - process.OutputDataReceived += outputHandler; - process.ErrorDataReceived += errorOutputHandler; - process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); - process.WaitForExit(); + if (!process.WaitForExit(timeout is null ? -1 : (int)timeout.Value.TotalMilliseconds)) + { + process.Kill(); + + throw new WebDriverException($"Selenium Manager process timed out after {(timeout ?? TimeSpan.FromMilliseconds(-1)).TotalMilliseconds} ms"); + } if (process.ExitCode != 0) { - // We do not log any warnings coming from Selenium Manager like the other bindings, as we don't have any logging in the .NET bindings - var exceptionMessageBuilder = new StringBuilder($"Selenium Manager process exited abnormally with {process.ExitCode} code: {process.StartInfo.FileName} {arguments}"); - if (!string.IsNullOrWhiteSpace(errorOutputBuilder.ToString())) + if (!string.IsNullOrWhiteSpace(stdOutputBuilder.ToString())) { exceptionMessageBuilder.AppendLine(); - exceptionMessageBuilder.AppendLine("Error Output >>"); - exceptionMessageBuilder.Append(errorOutputBuilder); - exceptionMessageBuilder.AppendLine("<<"); + exceptionMessageBuilder.AppendLine("--- Standard Output ---"); + exceptionMessageBuilder.Append(stdOutputBuilder); + exceptionMessageBuilder.AppendLine("--- End Standard Output ---"); } - if (!string.IsNullOrWhiteSpace(outputBuilder.ToString())) + if (!string.IsNullOrWhiteSpace(errOutputBuilder.ToString())) { exceptionMessageBuilder.AppendLine(); - exceptionMessageBuilder.AppendLine("Standard Output >>"); - exceptionMessageBuilder.Append(outputBuilder); - exceptionMessageBuilder.AppendLine("<<"); + exceptionMessageBuilder.AppendLine("--- Error Output ---"); + exceptionMessageBuilder.Append(errOutputBuilder); + exceptionMessageBuilder.AppendLine("--- End Error Output ---"); } throw new WebDriverException(exceptionMessageBuilder.ToString()); @@ -268,53 +311,127 @@ private static ResultResponse RunCommand(string arguments) } finally { - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorOutputHandler; + process.OutputDataReceived -= HandleStandardOutput; + process.ErrorDataReceived -= HandleErrorOutput; } - string output = outputBuilder.ToString().Trim(); + string output = stdOutputBuilder.ToString().Trim(); - SeleniumManagerResponse jsonResponse; + TResult result; try { - jsonResponse = JsonSerializer.Deserialize(output, SeleniumManagerSerializerContext.Default.SeleniumManagerResponse)!; + result = JsonSerializer.Deserialize(output, jsonResultTypeInfo)!; } catch (Exception ex) { throw new WebDriverException($"Error deserializing Selenium Manager's response: {output}", ex); } - if (jsonResponse.Logs is not null) + return result; + + void HandleStandardOutput(object sender, DataReceivedEventArgs e) { - // Treat SM's logs always as Trace to avoid SM writing at Info level - if (_logger.IsEnabled(LogEventLevel.Trace)) + stdOutputBuilder.AppendLine(e.Data); + } + + void HandleErrorOutput(object sender, DataReceivedEventArgs e) + { + if (e.Data is not null) { - foreach (var entry in jsonResponse.Logs) + var match = LogMessageRegex.Match(e.Data); + + if (match.Success) + { + var dateTime = DateTimeOffset.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + 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": + default: + _logger.LogMessage(dateTime, LogEventLevel.Trace, message); + break; + } + } + else { - _logger.Trace($"{entry.Level} {entry.Message}"); + errOutputBuilder.AppendLine(e.Data); } } } - - return jsonResponse.Result; } + + const string LogMessageRegexPattern = @"^\[(.*) (INFO|WARN|ERROR|DEBUG|TRACE)\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 } -internal sealed record SeleniumManagerResponse(IReadOnlyList Logs, ResultResponse Result) +/// +/// Provides optional configuration for browser and driver discovery. +/// +public record BrowserDiscoveryOptions { - public sealed record LogEntryResponse(string Level, string Message); - - public sealed record ResultResponse - ( - [property: JsonPropertyName(SeleniumManager.DriverPathKey)] - string DriverPath, - [property: JsonPropertyName(SeleniumManager.BrowserPathKey)] - string BrowserPath - ); + /// + /// 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. + /// + public string? BrowserVersion { get; set; } + + /// + /// Gets or sets the path to the browser executable. + /// When specified, Selenium Manager uses this path instead of detecting the browser location. + /// + public string? BrowserPath { get; set; } + + /// + /// 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. + /// + public string? DriverVersion { get; set; } + + /// + /// Gets or sets the proxy server URL for downloading browser drivers. + /// + public string? Proxy { get; set; } + + /// + /// Gets or sets the timeout for the Selenium Manager process execution. + /// If not specified, the process will run without a timeout. + /// + public TimeSpan? Timeout { get; set; } } -[JsonSerializable(typeof(SeleniumManagerResponse))] +/// +/// Contains the paths to the discovered browser driver and browser executable. +/// +/// The absolute path to the browser driver executable. +/// The absolute path to the browser executable. +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;