diff --git a/Directory.Packages.props b/Directory.Packages.props index eadc39680..8c5d1dda9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + diff --git a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/Argument.cs b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/Argument.cs index 740a9ae22..a421e56c3 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/Argument.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/Argument.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using OpenQA.Selenium; namespace Microsoft.DotNet.XHarness.CLI.CommandArguments; @@ -284,32 +283,6 @@ public override void Action(string argumentValue) public override string ToString() => Value ? "true" : "false"; } -public abstract class EnumPageLoadStrategyArgument : Argument -{ - private readonly PageLoadStrategy _defaultValue; - - public EnumPageLoadStrategyArgument(string prototype, string description, PageLoadStrategy defaultValue) - : base(prototype, description, defaultValue) - { - _defaultValue = defaultValue; - } - - public override void Action(string argumentValue) - { - if (string.IsNullOrEmpty(argumentValue)) - { - Value = _defaultValue; - } - else - { - Value = argumentValue.Equals("none", StringComparison.OrdinalIgnoreCase) ? PageLoadStrategy.None : - argumentValue.Equals("eager", StringComparison.OrdinalIgnoreCase) ? PageLoadStrategy.Eager : - argumentValue.Equals("normal", StringComparison.OrdinalIgnoreCase) ? PageLoadStrategy.Normal : - _defaultValue; - } - } -} - public abstract class RepeatableArgument : Argument> { private readonly List _values = new(); diff --git a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/Arguments/PageLoadStrategyArgument.cs b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/Arguments/PageLoadStrategyArgument.cs deleted file mode 100644 index 8a7a29356..000000000 --- a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/Arguments/PageLoadStrategyArgument.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace Microsoft.DotNet.XHarness.CLI.CommandArguments.Wasm; - -using OpenQA.Selenium; -using System; - -internal class PageLoadStrategyArgument : EnumPageLoadStrategyArgument -{ - private const string HelpMessage = - $@"Decides how long WebDriver will hold off on completing a navigation method. - NORMAL (default): Does not block WebDriver at all. Ready state: complete. - EAGER: DOM access is ready, but other resources like images may still be loading. Ready state: interactive. - NONE: Does not block WebDriver at all. Ready state: any."; - - public PageLoadStrategyArgument(PageLoadStrategy defaultValue) - : base("pageLoadStrategy=", HelpMessage, defaultValue) - {} -} diff --git a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs index 1d980c118..bc3f4d92a 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs @@ -26,7 +26,6 @@ internal class WasmTestBrowserCommandArguments : XHarnessCommandArguments, IWebS public NoQuitArgument NoQuit { get; } = new(); public BackgroundThrottlingArgument BackgroundThrottling { get; } = new(); public LocaleArgument Locale { get; } = new("en-US"); - public PageLoadStrategyArgument PageLoadStrategy { get; } = new(OpenQA.Selenium.PageLoadStrategy.Normal); public SymbolMapFileArgument SymbolMapFileArgument { get; } = new(); public SymbolicatePatternsFileArgument SymbolicatePatternsFileArgument { get; } = new(); @@ -58,7 +57,6 @@ internal class WasmTestBrowserCommandArguments : XHarnessCommandArguments, IWebS NoQuit, BackgroundThrottling, Locale, - PageLoadStrategy, SymbolMapFileArgument, SymbolicatePatternsFileArgument, SymbolicatorArgument, @@ -75,16 +73,16 @@ public override void Validate() { base.Validate(); - if (!string.IsNullOrEmpty(BrowserLocation)) + if (!string.IsNullOrEmpty(BrowserLocation.Value)) { if (Browser == Wasm.Browser.Safari) { throw new ArgumentException("Safari driver doesn't support custom browser path"); } - if (!File.Exists(BrowserLocation)) + if (!File.Exists(BrowserLocation.Value)) { - throw new ArgumentException($"Could not find browser at {BrowserLocation}"); + throw new ArgumentException($"Could not find browser at {BrowserLocation.Value}"); } } diff --git a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestCommandArguments.cs b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestCommandArguments.cs index 20675e172..d25a027b5 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestCommandArguments.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestCommandArguments.cs @@ -18,7 +18,6 @@ internal class WasmTestCommandArguments : XHarnessCommandArguments, IWebServerAr public OutputDirectoryArgument OutputDirectory { get; } = new(); public TimeoutArgument Timeout { get; } = new(TimeSpan.FromMinutes(15)); public LocaleArgument Locale { get; } = new("en-US"); - public PageLoadStrategyArgument PageLoadStrategy { get; } = new(OpenQA.Selenium.PageLoadStrategy.Normal); public SymbolMapFileArgument SymbolMapFileArgument { get; } = new(); public SymbolicatePatternsFileArgument SymbolicatePatternsFileArgument { get; } = new(); @@ -44,7 +43,6 @@ internal class WasmTestCommandArguments : XHarnessCommandArguments, IWebServerAr Timeout, ExpectedExitCode, Locale, - PageLoadStrategy, SymbolMapFileArgument, SymbolicatePatternsFileArgument, SymbolicatorArgument, diff --git a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/PlaywrightBrowserWrapper.cs b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/PlaywrightBrowserWrapper.cs new file mode 100644 index 000000000..4b6134550 --- /dev/null +++ b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/PlaywrightBrowserWrapper.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace Microsoft.DotNet.XHarness.CLI.Commands.Wasm; + +/// +/// Wrapper around Playwright IPage to provide Selenium-like interface for easier migration +/// +internal class PlaywrightBrowserWrapper : IDisposable +{ + private readonly IPage _page; + private readonly IBrowser _browser; + private readonly IPlaywright _playwright; + + public PlaywrightBrowserWrapper(IPage page, IBrowser browser, IPlaywright playwright) + { + _page = page; + _browser = browser; + _playwright = playwright; + } + + public IPage Page => _page; + + public async Task NavigateToUrlAsync(string url) + { + await _page.GotoAsync(url); + } + + public async Task FindElementTextAsync(string selector) + { + var element = await _page.WaitForSelectorAsync(selector, new PageWaitForSelectorOptions + { + Timeout = 30000 + }); + return await element!.InnerTextAsync(); + } + + public void Dispose() + { + _page?.CloseAsync().Wait(); + _browser?.CloseAsync().Wait(); + _playwright?.Dispose(); + } +} + +/// +/// Service wrapper to mimic Selenium DriverService behavior +/// +internal class PlaywrightServiceWrapper : IDisposable +{ + private readonly IBrowser _browser; + public bool IsRunning => !_browser.IsConnected || _browser.IsConnected; + + public PlaywrightServiceWrapper(IBrowser browser) + { + _browser = browser; + } + + public void Dispose() + { + _browser?.CloseAsync().Wait(); + } +} diff --git a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmBrowserTestRunner.cs b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmBrowserTestRunner.cs index fce046a6b..5a6cddfbe 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmBrowserTestRunner.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmBrowserTestRunner.cs @@ -17,11 +17,7 @@ using Microsoft.DotNet.XHarness.CLI.CommandArguments.Wasm; using Microsoft.DotNet.XHarness.Common.CLI; using Microsoft.Extensions.Logging; - -using OpenQA.Selenium; -using OpenQA.Selenium.Support.UI; - -using SeleniumLogLevel = OpenQA.Selenium.LogLevel; +using Microsoft.Playwright; using LogLevel = Microsoft.Extensions.Logging.LogLevel; namespace Microsoft.DotNet.XHarness.CLI.Commands.Wasm; @@ -33,10 +29,6 @@ internal class WasmBrowserTestRunner private readonly IEnumerable _passThroughArguments; private readonly WasmTestMessagesProcessor _messagesProcessor; - // Messages from selenium prepend the url, and location where the message originated - // Eg. `foo` becomes `http://localhost:8000/xyz.js 0:12 "foo" - static readonly Regex s_consoleLogRegex = new(@"^\s*[a-z]*://[^\s]+\s+\d+:\d+\s+""(.*)""\s*$", RegexOptions.Compiled); - public WasmBrowserTestRunner(WasmTestBrowserCommandArguments arguments, IEnumerable passThroughArguments, WasmTestMessagesProcessor messagesProcessor, ILogger logger) { @@ -46,7 +38,7 @@ public WasmBrowserTestRunner(WasmTestBrowserCommandArguments arguments, IEnumera _messagesProcessor = messagesProcessor; } - public async Task RunTestsWithWebDriver(DriverService driverService, IWebDriver driver) + public async Task RunTestsWithPlaywrightAsync(PlaywrightServiceWrapper driverService, PlaywrightBrowserWrapper driver) { var htmlFilePath = Path.Combine(_arguments.AppPackagePath, _arguments.HTMLFile.Value); if (!File.Exists(htmlFilePath)) @@ -71,18 +63,18 @@ public async Task RunTestsWithWebDriver(DriverService driverService, I string testUrl = BuildUrl(serverURLs); - var seleniumLogMessageTask = Task.Run(() => RunSeleniumLogMessagePump(driver, cts.Token), cts.Token); + var playwrightLogMessageTask = Task.Run(() => RunPlaywrightLogMessagePump(driver.Page, cts.Token), cts.Token); cts.CancelAfter(_arguments.Timeout); _logger.LogDebug($"Opening in browser: {testUrl}"); - driver.Navigate().GoToUrl(testUrl); + await driver.NavigateToUrlAsync(testUrl); TaskCompletionSource wasmExitReceivedTcs = _messagesProcessor.WasmExitReceivedTcs; var tasks = new Task[] { wasmExitReceivedTcs.Task, consolePumpTcs.Task, - seleniumLogMessageTask, + playwrightLogMessageTask, logProcessorTask, Task.Delay(_arguments.Timeout) }; @@ -90,7 +82,7 @@ public async Task RunTestsWithWebDriver(DriverService driverService, I if (_arguments.BackgroundThrottling) { // throttling only happens when the page is not visible - driver.Manage().Window.Minimize(); + await driver.Page.EvaluateAsync("() => { Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: false }); }"); } var task = await Task.WhenAny(tasks).ConfigureAwait(false); @@ -103,14 +95,9 @@ public async Task RunTestsWithWebDriver(DriverService driverService, I { if (driverService.IsRunning) { - // Selenium isn't able to kill chrome in this case :/ - int pid = driverService.ProcessId; - var p = Process.GetProcessById(pid); - if (p != null) - { - _logger.LogError($"Tests timed out. Killing driver service pid {pid}"); - p.Kill(true); - } + // Playwright handles browser lifecycle more gracefully than Selenium + _logger.LogError($"Tests timed out. Closing browser gracefully"); + driver.Dispose(); // This will properly close the browser } // timed out @@ -122,10 +109,8 @@ public async Task RunTestsWithWebDriver(DriverService driverService, I if (task == wasmExitReceivedTcs.Task && wasmExitReceivedTcs.Task.IsCompletedSuccessfully) { _logger.LogTrace($"Looking for `tests_done` element, to get the exit code"); - var testsDoneElement = new WebDriverWait(driver, TimeSpan.FromSeconds(30)) - .Until(e => e.FindElement(By.Id("tests_done"))); - - if (int.TryParse(testsDoneElement.Text, out var code)) + var testsDoneElementText = await driver.FindElementTextAsync("#tests_done"); + if (int.TryParse(testsDoneElementText, out var code)) { var appExitCode = (ExitCode)Enum.ToObject(typeof(ExitCode), code); if (logProcessorExitCode != ExitCode.SUCCESS) @@ -204,50 +189,51 @@ private async Task RunConsoleMessagesPump(WebSocket socket, CancellationToken to } } - // This listens for any `console.log` messages. - // Since we pipe messages from managed code, and console.* to the websocket, + // This listens for any console messages from Playwright's native console event handling. + // Since we still pipe messages from managed code and console.* to the websocket, // this wouldn't normally get much. But listening on this to catch any messages // that we miss piping to the websocket. - private void RunSeleniumLogMessagePump(IWebDriver driver, CancellationToken token) + private void RunPlaywrightLogMessagePump(IPage page, CancellationToken token) { try { - ILogs logs = driver.Manage().Logs; - while (!token.IsCancellationRequested) + // Playwright provides structured console events, no need to poll like Selenium + page.Console += (_, consoleMessage) => { - foreach (var logType in logs.AvailableLogTypes) + if (token.IsCancellationRequested) return; + + if (consoleMessage.Type == "error") { - foreach (var logEntry in logs.GetLog(logType)) - { - if (logEntry.Level == SeleniumLogLevel.Severe) - { - // These are errors from the browser, some of which might be - // thrown as part of tests. So, we can't differentiate when - // it is an error that we can ignore, vs one that should stop - // the execution completely. - // - // Note: these could be received out-of-order as compared to - // console messages via the websocket. - // - // (see commit message for more info) - _logger.LogError($"[out of order message from the {logType}]: {logEntry.Message}"); - continue; - } - - var match = s_consoleLogRegex.Match(Regex.Unescape(logEntry.Message)); - string msg = match.Success ? match.Groups[1].Value : logEntry.Message; - _messagesProcessor.Invoke(msg); - } + // These are errors from the browser, some of which might be + // thrown as part of tests. So, we can't differentiate when + // it is an error that we can ignore, vs one that should stop + // the execution completely. + // + // Note: these could be received out-of-order as compared to + // console messages via the websocket. + // + // (see commit message for more info) + _logger.LogError($"[out of order message from the browser console]: {consoleMessage.Text}"); + return; } + + // Process the message through our existing message processor + _messagesProcessor.Invoke(consoleMessage.Text); + }; + + // Keep the task alive while not cancelled + while (!token.IsCancellationRequested) + { + Task.Delay(1000, token).Wait(token); } } - catch (WebDriverException wde) when (wde.Message.Contains("timed out after")) + catch (OperationCanceledException) { - _logger.LogDebug(wde.Message); + _logger.LogDebug($"RunPlaywrightLogMessagePump cancelled"); } catch (Exception ex) { - _logger.LogDebug($"Failed trying to read log messages via selenium: {ex}"); + _logger.LogDebug($"Failed trying to read log messages via playwright: {ex}"); throw; } } diff --git a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestBrowserCommand.cs b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestBrowserCommand.cs index e1db5fd5a..1b0ae4a9e 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestBrowserCommand.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestBrowserCommand.cs @@ -13,13 +13,7 @@ using Microsoft.DotNet.XHarness.Common.CLI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using OpenQA.Selenium; -using OpenQA.Selenium.Chrome; -using OpenQA.Selenium.Chromium; -using OpenQA.Selenium.Edge; -using OpenQA.Selenium.Firefox; -using OpenQA.Selenium.Safari; -using SeleniumLogLevel = OpenQA.Selenium.LogLevel; +using Microsoft.Playwright; namespace Microsoft.DotNet.XHarness.CLI.Commands.Wasm; @@ -65,12 +59,12 @@ protected override async Task InvokeInternal(ILogger logger) logger); diagnosticsData.Target = Arguments.Browser.Value.ToString(); - (DriverService driverService, IWebDriver driver) = Arguments.Browser.Value switch + (PlaywrightServiceWrapper driverService, PlaywrightBrowserWrapper driver) = Arguments.Browser.Value switch { - Browser.Chrome => GetChromeDriver(Arguments.Locale, logger), - Browser.Safari => GetSafariDriver(logger), - Browser.Firefox => GetFirefoxDriver(logger), - Browser.Edge => GetEdgeDriver(Arguments.Locale, logger), + Browser.Chrome => await GetChromeDriverAsync(Arguments.Locale, logger), + Browser.Safari => await GetSafariDriverAsync(logger), + Browser.Firefox => await GetFirefoxDriverAsync(logger), + Browser.Edge => await GetEdgeDriverAsync(Arguments.Locale, logger), // shouldn't reach here _ => throw new ArgumentException($"Unknown browser : {Arguments.Browser}") @@ -78,7 +72,7 @@ protected override async Task InvokeInternal(ILogger logger) try { - var exitCode = await runner.RunTestsWithWebDriver(driverService, driver); + var exitCode = await runner.RunTestsWithPlaywrightAsync(driverService, driver); if ((int)exitCode != Arguments.ExpectedExitCode) { logger.LogError($"Application has finished with exit code {exitCode} but {Arguments.ExpectedExitCode} was expected"); @@ -103,40 +97,16 @@ protected override async Task InvokeInternal(ILogger logger) token.WaitHandle.WaitOne(); } - // close all tabs before quit is a workaround for broken Selenium - GeckoDriver communication in Firefox - // https://github.com/dotnet/runtime/issues/101617 + // Playwright handles browser cleanup automatically, but we still do graceful shutdown var cts = new CancellationTokenSource(); cts.CancelAfter(10000); try { - logger.LogInformation($"Closing {driver.WindowHandles.Count} browser tabs before setting the main tab to config page and quitting."); - while (driver.WindowHandles.Count > 1 && driverService.IsRunning) - { - if (cts.IsCancellationRequested) - { - logger.LogInformation($"Timeout while trying to close tabs, {driver.WindowHandles.Count} is left open before quitting."); - break; - } - driver.Navigate().GoToUrl("about:config"); - driver.Navigate().GoToUrl("about:blank"); - driver.Close(); //Close Tab - - var lastWindowHandle = driver.WindowHandles.LastOrDefault(); - if (lastWindowHandle != null) - { - driver.SwitchTo().Window(lastWindowHandle); - } - } - await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); + logger.LogInformation($"Closing browser gracefully."); if (driverService.IsRunning) { - if (!cts.IsCancellationRequested && driver.WindowHandles.Count != 0) - { - driver.Navigate().GoToUrl("about:config"); - driver.Navigate().GoToUrl("about:blank"); - } - driver.Quit(); // Firefox driver hangs if Quit is not issued. - driver.Dispose(); + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); + driver.Dispose(); // This will close page, browser, and playwright driverService.Dispose(); } } @@ -147,104 +117,105 @@ protected override async Task InvokeInternal(ILogger logger) } } - private (DriverService, IWebDriver) GetSafariDriver(ILogger logger) + private async Task<(PlaywrightServiceWrapper, PlaywrightBrowserWrapper)> GetSafariDriverAsync(ILogger logger) { - var options = new SafariOptions(); - options.SetLoggingPreference(LogType.Browser, SeleniumLogLevel.All); - logger.LogInformation("Starting Safari"); - return CreateWebDriver( - () => SafariDriverService.CreateDefaultService(), - driverService => new SafariDriver(driverService, options, Arguments.Timeout)); + var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + var browser = await playwright.Webkit.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = !Arguments.NoHeadless, + Args = Arguments.BrowserArgs.Value.ToList(), + Timeout = (float)Arguments.Timeout.Value.TotalMilliseconds, + ExecutablePath = !string.IsNullOrEmpty(Arguments.BrowserLocation.Value) ? Arguments.BrowserLocation.Value : null + }); + + var page = await browser.NewPageAsync(); + + // Setup console logging to match Selenium behavior + page.Console += (_, e) => logger.LogDebug($"[Browser Console] {e.Text}"); + + return (new PlaywrightServiceWrapper(browser), new PlaywrightBrowserWrapper(page, browser, playwright)); } - private (DriverService, IWebDriver) GetFirefoxDriver(ILogger logger) + private async Task<(PlaywrightServiceWrapper, PlaywrightBrowserWrapper)> GetFirefoxDriverAsync(ILogger logger) { - var options = new FirefoxOptions(); - options.SetLoggingPreference(LogType.Browser, SeleniumLogLevel.All); - - if (!string.IsNullOrEmpty(Arguments.BrowserLocation)) + if (!string.IsNullOrEmpty(Arguments.BrowserLocation.Value)) { - options.BrowserExecutableLocation = Arguments.BrowserLocation; - logger.LogInformation($"Using Firefox from {Arguments.BrowserLocation}"); + logger.LogInformation($"Using Firefox from {Arguments.BrowserLocation.Value}"); } - options.AddArguments(Arguments.BrowserArgs.Value); + var args = Arguments.BrowserArgs.Value.ToList(); if (!Arguments.NoHeadless) - options.AddArguments("--headless"); + args.Add("--headless"); if (!Arguments.NoIncognito) - options.AddArguments("-private-window"); + args.Add("-private-window"); + + logger.LogInformation($"Starting Firefox with args: {string.Join(' ', args)}"); - options.PageLoadStrategy = Arguments.PageLoadStrategy.Value; + var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + var browser = await playwright.Firefox.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = !Arguments.NoHeadless, + Args = args, + Timeout = (float)Arguments.Timeout.Value.TotalMilliseconds, + ExecutablePath = !string.IsNullOrEmpty(Arguments.BrowserLocation.Value) ? Arguments.BrowserLocation.Value : null + }); - logger.LogInformation($"Starting Firefox with args: {string.Join(' ', options.ToCapabilities())} and load strategy: {Arguments.PageLoadStrategy.Value}"); + var page = await browser.NewPageAsync(); + + // Setup console logging + page.Console += (_, e) => logger.LogDebug($"[Browser Console] {e.Text}"); - return CreateWebDriver( - () => FirefoxDriverService.CreateDefaultService(), - (driverService) => new FirefoxDriver(driverService, options, Arguments.Timeout)); + return (new PlaywrightServiceWrapper(browser), new PlaywrightBrowserWrapper(page, browser, playwright)); } - private (DriverService, IWebDriver) GetChromeDriver(string sessionLanguage, ILogger logger) - => GetChromiumDriver( + private async Task<(PlaywrightServiceWrapper, PlaywrightBrowserWrapper)> GetChromeDriverAsync(string sessionLanguage, ILogger logger) + => await GetChromiumDriverAsync( "chromedriver", sessionLanguage, - options => ChromeDriverService.CreateDefaultService(), + (playwright) => playwright.Chromium, logger); - private (DriverService, IWebDriver) GetEdgeDriver(string sessionLanguage, ILogger logger) - => GetChromiumDriver( + private async Task<(PlaywrightServiceWrapper, PlaywrightBrowserWrapper)> GetEdgeDriverAsync(string sessionLanguage, ILogger logger) + => await GetChromiumDriverAsync( "edgedriver", sessionLanguage, - options => - { - options.UseChromium = true; - return EdgeDriverService.CreateDefaultServiceFromOptions(options); - }, logger); - - private (DriverService, IWebDriver) GetChromiumDriver( - string driverName, string sessionLanguage, Func getDriverService, ILogger logger) - where TDriver : ChromiumDriver - where TDriverOptions : ChromiumOptions - where TDriverService : ChromiumDriverService - { - var options = Activator.CreateInstance(); - options.SetLoggingPreference(LogType.Browser, SeleniumLogLevel.All); - - if (!string.IsNullOrEmpty(Arguments.BrowserLocation)) - { - options.BinaryLocation = Arguments.BrowserLocation; - logger.LogInformation($"Using Chrome from {Arguments.BrowserLocation}"); - } + (playwright) => playwright.Chromium, // Edge uses Chromium engine + logger); - options.AddArguments(Arguments.BrowserArgs.Value); + private async Task<(PlaywrightServiceWrapper, PlaywrightBrowserWrapper)> GetChromiumDriverAsync( + string driverName, string sessionLanguage, Func getBrowserType, ILogger logger) + where TDriverOptions : BrowserTypeLaunchOptions, new() + { + var args = Arguments.BrowserArgs.Value.ToList(); if (!Arguments.NoHeadless && !Arguments.BackgroundThrottling) - options.AddArguments("--headless"); + args.Add("--headless"); if (Arguments.DebuggerPort.Value != null) - options.AddArguments($"--remote-debugging-port={Arguments.DebuggerPort}"); + args.Add($"--remote-debugging-port={Arguments.DebuggerPort.Value}"); if (!Arguments.NoIncognito) - options.AddArguments("--incognito"); + args.Add("--incognito"); if (!Arguments.BackgroundThrottling) { - options.AddArguments(new[] + args.AddRange(new[] { - "--disable-background-timer-throttling", - "--disable-backgrounding-occluded-windows", - "--disable-renderer-backgrounding", - "--enable-features=NetworkService,NetworkServiceInProcess", - }); + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--enable-features=NetworkService,NetworkServiceInProcess", + }); } else { - options.AddArguments(@"--enable-features=IntensiveWakeUpThrottling:grace_period_seconds/1"); + args.Add(@"--enable-features=IntensiveWakeUpThrottling:grace_period_seconds/1"); } - options.AddArguments(new[] + args.AddRange(new[] { // added based on https://github.com/puppeteer/puppeteer/blob/main/src/node/Launcher.ts#L159-L181 "--allow-insecure-localhost", @@ -261,35 +232,18 @@ protected override async Task InvokeInternal(ILogger logger) if (File.Exists("/.dockerenv")) { // Use --no-sandbox for containers, and codespaces - options.AddArguments("--no-sandbox"); + args.Add("--no-sandbox"); } - if (Arguments.NoQuit) - options.LeaveBrowserRunning = true; - - if (options is ChromeOptions chromeOptions) - chromeOptions.PageLoadStrategy = Arguments.PageLoadStrategy.Value; - if (options is EdgeOptions edgeOptions) - edgeOptions.PageLoadStrategy = Arguments.PageLoadStrategy.Value; - - logger.LogInformation($"Starting {driverName} with args: {string.Join(' ', options.Arguments)} and load strategy: {Arguments.PageLoadStrategy.Value}"); - - // We want to explicitly specify a timeout here. This is for for the - // driver commands, like getLog. The default is 60s, which ends up - // timing out when getLog() is waiting, and doesn't receive anything - // for 60s. - // - // Since, we almost all the output gets written via the websocket now, - // getLog() might not see anything for long durations! - // - // So -> use a larger timeout! + logger.LogInformation($"Starting {driverName} with args: {string.Join(' ', args)}"); + // Retry logic preserved from original Selenium implementation string[] err_snippets = new[] { - "exited abnormally", - "Cannot start the driver service", - "failed to start" - }; + "exited abnormally", + "Cannot start the driver service", + "failed to start" + }; foreach (var file in Directory.EnumerateFiles(Arguments.OutputDirectory, $"{driverName}-*.log")) File.Delete(file); @@ -298,64 +252,60 @@ protected override async Task InvokeInternal(ILogger logger) int retry_num = 0; while (true) { - TDriverService? driverService = null; + IPlaywright? playwright = null; + IBrowser? browser = null; try { - driverService = getDriverService(options); - driverService.DriverProcessStarting += (object? sender, DriverProcessStartingEventArgs e) => + playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + var browserType = getBrowserType(playwright); + + // Set environment for browser launch + var env = new System.Collections.Generic.Dictionary(); + if (!string.IsNullOrEmpty(sessionLanguage)) { - // Browser respects LANGUAGE in the first place, only if empty it checks LANG - e.DriverServiceProcessStartInfo.EnvironmentVariables["LANGUAGE"] = sessionLanguage; - }; + env["LANGUAGE"] = sessionLanguage; + } - driverService.EnableAppendLog = false; - driverService.EnableVerboseLogging = true; - driverService.LogPath = Path.Combine(Arguments.OutputDirectory, $"{driverName}-{retry_num}.log"); + // Determine channel for branded browsers + string? channel = null; + if (driverName == "edgedriver") + channel = "msedge"; + else if (driverName == "chromedriver" && !string.IsNullOrEmpty(Arguments.BrowserLocation.Value)) + channel = "chrome"; - if (Activator.CreateInstance(typeof(TDriver), driverService, options, Arguments.Timeout.Value) is not TDriver driver) + browser = await browserType.LaunchAsync(new BrowserTypeLaunchOptions { - throw new ArgumentException($"Failed to create instance of {typeof(TDriver)}"); - } + Headless = !Arguments.NoHeadless, + Args = args, + Timeout = (float)Arguments.Timeout.Value.TotalMilliseconds, + ExecutablePath = !string.IsNullOrEmpty(Arguments.BrowserLocation.Value) ? Arguments.BrowserLocation.Value : null, + Channel = channel, + Env = env.Count > 0 ? env : null + }); - return (driverService, driver); + var page = await browser.NewPageAsync(); + + // Setup console logging + page.Console += (_, e) => logger.LogDebug($"[Browser Console] {e.Text}"); + + return (new PlaywrightServiceWrapper(browser), new PlaywrightBrowserWrapper(page, browser, playwright)); } - catch (TargetInvocationException tie) when - (tie.InnerException is WebDriverException wde - && err_snippets.Any(s => wde.ToString().Contains(s)) && retry_num < max_retries - 1) + catch (Exception ex) when (err_snippets.Any(s => ex.ToString().Contains(s)) && retry_num < max_retries - 1) { - // chrome can sometimes crash on startup when launching from chromedriver. - // As a *workaround*, let's retry that a few times - // Example error seen: - // [12:41:07] crit: OpenQA.Selenium.WebDriverException: unknown error: Chrome failed to start: exited abnormally. - // (chrome not reachable) - - // Log on max-1 tries, and rethrow on the last one - logger.LogWarning($"Failed to start the browser, attempt #{retry_num}: {wde}"); + // Preserve retry logic from Selenium implementation + logger.LogWarning($"Failed to start the browser, attempt #{retry_num}: {ex}"); - driverService?.Dispose(); + browser?.CloseAsync().Wait(); + playwright?.Dispose(); } catch { - driverService?.Dispose(); + browser?.CloseAsync().Wait(); + playwright?.Dispose(); throw; } retry_num++; } } - - private static (DriverService, IWebDriver) CreateWebDriver(Func getDriverService, Func getDriver) - where TDriverService : DriverService - { - var driverService = getDriverService(); - try - { - return (driverService, getDriver(driverService)); - } - catch - { - driverService?.Dispose(); - throw; - } - } } diff --git a/src/Microsoft.DotNet.XHarness.CLI/Microsoft.DotNet.XHarness.CLI.csproj b/src/Microsoft.DotNet.XHarness.CLI/Microsoft.DotNet.XHarness.CLI.csproj index aa5132fcd..f2674ab20 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/Microsoft.DotNet.XHarness.CLI.csproj +++ b/src/Microsoft.DotNet.XHarness.CLI/Microsoft.DotNet.XHarness.CLI.csproj @@ -29,7 +29,7 @@ - +