diff --git a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/TestCommandArguments.cs b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/TestCommandArguments.cs index 8e1d8bffa..1e19404f3 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/TestCommandArguments.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/TestCommandArguments.cs @@ -2,7 +2,9 @@ // 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.IO; using Mono.Options; namespace Microsoft.DotNet.XHarness.CLI.CommandArguments @@ -21,6 +23,9 @@ internal abstract class TestCommandArguments : AppRunCommandArguments /// Tests classes to be included in the run while all others are ignored. /// public IEnumerable ClassMethodFilters => _classMethodFilters; + public IList<(string path, string type)> WebServerMiddlewarePathsAndTypes { get; set; } = new List<(string, string)>(); + public IList SetWebServerEnvironmentVariablesHttp { get; set; } = new List(); + public IList SetWebServerEnvironmentVariablesHttps { get; set; } = new List(); protected override OptionSet GetCommandOptions() { @@ -40,6 +45,31 @@ protected override OptionSet GetCommandOptions() "ignored. Can be used more than once.", v => _classMethodFilters.Add(v) }, + { "web-server-middleware=", ", to assembly and type which contains Kestrel middleware for local test server. Could be used multiple times to load multiple middlewares.", + v => + { + var split = v.Split(','); + var file = split[0]; + var type = split.Length > 1 && !string.IsNullOrWhiteSpace(split[1]) + ? split[1] + : "GenericHandler"; + if (string.IsNullOrWhiteSpace(file)) + { + throw new ArgumentException($"Empty path to middleware assembly"); + } + if (!File.Exists(file)) + { + throw new ArgumentException($"Failed to find the middleware assembly at {file}"); + } + WebServerMiddlewarePathsAndTypes.Add((file,type)); + } + }, + { "set-web-server-http-env=", "Comma separated list of environment variable names, which should be set to HTTP host and port, for the unit test, which use xharness as test web server.", + v => SetWebServerEnvironmentVariablesHttp = v.Split(',') + }, + { "set-web-server-https-env=", "Comma separated list of environment variable names, which should be set to HTTPS host and port, for the unit test, which use xharness as test web server.", + v => SetWebServerEnvironmentVariablesHttps = v.Split(',') + }, }; foreach (var option in testOptions) diff --git a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs index 514569fae..d83f8c6fc 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/CommandArguments/WASM/WasmTestBrowserCommandArguments.cs @@ -48,7 +48,6 @@ internal class WasmTestBrowserCommandArguments : TestCommandArguments public bool Incognito { get; set; } = true; public bool Headless { get; set; } = true; public bool QuitAppAtEnd { get; set; } = true; - protected override OptionSet GetTestCommandOptions() => new() { { "browser=|b=", "Specifies the browser to be used. Default is Chrome", 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 fdebf1dd2..d80b2e3a3 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmBrowserTestRunner.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmBrowserTestRunner.cs @@ -7,25 +7,20 @@ using System.Diagnostics; using System.Net.WebSockets; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Web; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.DotNet.XHarness.CLI.CommandArguments.Wasm; using Microsoft.DotNet.XHarness.Common.CLI; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; using OpenQA.Selenium; using OpenQA.Selenium.Support.UI; using SeleniumLogLevel = OpenQA.Selenium.LogLevel; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; namespace Microsoft.DotNet.XHarness.CLI.Commands.Wasm { @@ -62,12 +57,12 @@ public async Task RunTestsWithWebDriver(DriverService driverService, I try { var consolePumpTcs = new TaskCompletionSource(); - string webServerAddr = await StartWebServer( - _arguments.AppPackagePath, + ServerURLs serverURLs = await WebServer.Start( + _arguments, _logger, socket => RunConsoleMessagesPump(socket, consolePumpTcs, cts.Token), cts.Token); - string testUrl = BuildUrl(webServerAddr); + string testUrl = BuildUrl(serverURLs); var seleniumLogMessageTask = Task.Run(() => RunSeleniumLogMessagePump(driver, cts.Token), cts.Token); cts.CancelAfter(_arguments.Timeout); @@ -232,14 +227,29 @@ private void RunSeleniumLogMessagePump(IWebDriver driver, CancellationToken toke } } - private string BuildUrl(string webServerAddr) + private string BuildUrl(ServerURLs serverURLs) { - var uriBuilder = new UriBuilder($"{webServerAddr}/{_arguments.HTMLFile}"); + var uriBuilder = new UriBuilder($"{serverURLs.Http}/{_arguments.HTMLFile}"); var sb = new StringBuilder(); if (_arguments.DebuggerPort != null) sb.Append($"arg=--debug"); + + foreach (var envVariable in _arguments.SetWebServerEnvironmentVariablesHttp) + { + if (sb.Length > 0) + sb.Append('&'); + sb.Append($"arg={HttpUtility.UrlEncode($"--setenv={envVariable}={serverURLs!.Http}")}"); + } + + foreach (var envVariable in _arguments.SetWebServerEnvironmentVariablesHttps) + { + if (sb.Length > 0) + sb.Append('&'); + sb.Append($"arg={HttpUtility.UrlEncode($"--setenv={envVariable}={serverURLs!.Https}")}"); + } + foreach (var arg in _passThroughArguments) { if (sb.Length > 0) @@ -251,37 +261,5 @@ private string BuildUrl(string webServerAddr) uriBuilder.Query = sb.ToString(); return uriBuilder.ToString(); } - - private static async Task StartWebServer(string contentRoot, Func onConsoleConnected, CancellationToken token) - { - var host = new WebHostBuilder() - .UseKestrel() - .UseContentRoot(contentRoot) - .UseStartup() - .ConfigureLogging(logging => - { - logging.AddConsole().AddFilter(null, LogLevel.Warning); - }) - .ConfigureServices((ctx, services) => - { - services.AddRouting(); - services.Configure(ctx.Configuration); - services.Configure(options => - { - options.OnConsoleConnected = onConsoleConnected; - }); - }) - .UseUrls("http://127.0.0.1:0") - .Build(); - - await host.StartAsync(token); - - var ipAddress = host.ServerFeatures - .Get()? - .Addresses - .FirstOrDefault(); - - return ipAddress ?? throw new InvalidOperationException("Failed to determine web server's IP address"); - } } } 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 31df5a3e5..e135fbe2c 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestBrowserCommand.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestBrowserCommand.cs @@ -175,6 +175,7 @@ protected override async Task InvokeInternal(ILogger logger) { // added based on https://github.com/puppeteer/puppeteer/blob/main/src/node/Launcher.ts#L159-L181 "--enable-features=NetworkService,NetworkServiceInProcess", + "--allow-insecure-localhost", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-breakpad", diff --git a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestWebServerStartup.cs b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestWebServerStartup.cs deleted file mode 100644 index 2871fad2b..000000000 --- a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/Browser/WasmTestWebServerStartup.cs +++ /dev/null @@ -1,72 +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. - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Options; - -using System; -using System.Threading.Tasks; -using System.Net.WebSockets; - -namespace Microsoft.DotNet.XHarness.CLI.Commands.Wasm -{ - public class WasmTestWebServerStartup - { - private readonly IWebHostEnvironment _hostingEnvironment; - - public WasmTestWebServerStartup(IWebHostEnvironment hostingEnvironment) - { - _hostingEnvironment = hostingEnvironment; - } - - public void Configure(IApplicationBuilder app, IOptionsMonitor optionsAccessor) - { - var provider = new FileExtensionContentTypeProvider(); - provider.Mappings[".wasm"] = "application/wasm"; - - foreach (var extn in new string[] { ".dll", ".pdb", ".dat", ".blat" }) - { - provider.Mappings[extn] = "application/octet-stream"; - } - - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = new PhysicalFileProvider(_hostingEnvironment.ContentRootPath), - ContentTypeProvider = provider, - ServeUnknownFileTypes = true - }); - - var options = optionsAccessor.CurrentValue; - if (options.OnConsoleConnected == null) - { - throw new ArgumentException("Bug: OnConsoleConnected callback not set"); - } - - app.UseWebSockets(); - app.UseRouter(router => - { - router.MapGet("/console", async context => - { - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } - - var socket = await context.WebSockets.AcceptWebSocketAsync(); - await options.OnConsoleConnected(socket); - }); - }); - } - } - - public class WasmTestWebServerOptions - { - public Func? OnConsoleConnected { get; set; } - } -} diff --git a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/JS/WasmTestCommand.cs b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/JS/WasmTestCommand.cs index c849789f5..d3f708c72 100644 --- a/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/JS/WasmTestCommand.cs +++ b/src/Microsoft.DotNet.XHarness.CLI/Commands/WASM/JS/WasmTestCommand.cs @@ -15,6 +15,7 @@ using Microsoft.DotNet.XHarness.Common.Execution; using Microsoft.DotNet.XHarness.Common.Logging; using Microsoft.Extensions.Logging; +using System.Threading; namespace Microsoft.DotNet.XHarness.CLI.Commands.Wasm { @@ -34,7 +35,7 @@ public WasmTestCommand() : base("test", true, CommandHelp) private static string FindEngineInPath(string engineBinary) { - if (File.Exists (engineBinary) || Path.IsPathRooted(engineBinary)) + if (File.Exists(engineBinary) || Path.IsPathRooted(engineBinary)) return engineBinary; var path = Environment.GetEnvironmentVariable("PATH"); @@ -67,33 +68,57 @@ protected override async Task InvokeInternal(ILogger logger) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) engineBinary = FindEngineInPath(engineBinary + ".cmd"); - var engineArgs = new List(); - - if (_arguments.Engine == JavaScriptEngine.V8) + var webServerCts = new CancellationTokenSource(); + try { - // v8 needs this flag to enable WASM support - engineArgs.Add("--expose_wasm"); - } + ServerURLs? serverURLs = null; + if (_arguments.WebServerMiddlewarePathsAndTypes.Count > 0) + { + serverURLs = await WebServer.Start( + _arguments, logger, + null, + webServerCts.Token); + webServerCts.CancelAfter(_arguments.Timeout); + } - engineArgs.AddRange(_arguments.EngineArgs); - engineArgs.Add(_arguments.JSFile); + var engineArgs = new List(); - if (_arguments.Engine == JavaScriptEngine.V8 || _arguments.Engine == JavaScriptEngine.JavaScriptCore) - { - // v8/jsc want arguments to the script separated by "--", others don't - engineArgs.Add("--"); - } + if (_arguments.Engine == JavaScriptEngine.V8) + { + // v8 needs this flag to enable WASM support + engineArgs.Add("--expose_wasm"); + } + + engineArgs.AddRange(_arguments.EngineArgs); + engineArgs.Add(_arguments.JSFile); + + if (_arguments.Engine == JavaScriptEngine.V8 || _arguments.Engine == JavaScriptEngine.JavaScriptCore) + { + // v8/jsc want arguments to the script separated by "--", others don't + engineArgs.Add("--"); + } - engineArgs.AddRange(PassThroughArguments); + if (_arguments.WebServerMiddlewarePathsAndTypes.Count > 0) + { + foreach (var envVariable in _arguments.SetWebServerEnvironmentVariablesHttp) + { + engineArgs.Add($"--setenv={envVariable}={serverURLs!.Http}"); + } + + foreach (var envVariable in _arguments.SetWebServerEnvironmentVariablesHttps) + { + engineArgs.Add($"--setenv={envVariable}={serverURLs!.Https}"); + } + } - var xmlResultsFilePath = Path.Combine(_arguments.OutputDirectory, "testResults.xml"); - File.Delete(xmlResultsFilePath); + engineArgs.AddRange(PassThroughArguments); - var stdoutFilePath = Path.Combine(_arguments.OutputDirectory, "wasm-console.log"); - File.Delete(stdoutFilePath); + var xmlResultsFilePath = Path.Combine(_arguments.OutputDirectory, "testResults.xml"); + File.Delete(xmlResultsFilePath); + + var stdoutFilePath = Path.Combine(_arguments.OutputDirectory, "wasm-console.log"); + File.Delete(stdoutFilePath); - try - { var logProcessor = new WasmTestMessagesProcessor(xmlResultsFilePath, stdoutFilePath, logger); var result = await processManager.ExecuteCommandAsync( engineBinary, @@ -102,11 +127,11 @@ protected override async Task InvokeInternal(ILogger logger) stdoutLog: new CallbackLog(logProcessor.Invoke), stderrLog: new CallbackLog(m => logger.LogError(m)), _arguments.Timeout); + if (result.ExitCode != _arguments.ExpectedExitCode) { logger.LogError($"Application has finished with exit code {result.ExitCode} but {_arguments.ExpectedExitCode} was expected"); - return ExitCode.GENERAL_FAILURE; - + return ExitCode.GENERAL_FAILURE; } else { @@ -119,6 +144,13 @@ protected override async Task InvokeInternal(ILogger logger) logger.LogCritical($"The engine binary `{engineBinary}` was not found"); return ExitCode.APP_LAUNCH_FAILURE; } + finally + { + if (!webServerCts.IsCancellationRequested) + { + webServerCts.Cancel(); + } + } } } } diff --git a/src/Microsoft.DotNet.XHarness.CLI/Commands/WebServer.cs b/src/Microsoft.DotNet.XHarness.CLI/Commands/WebServer.cs new file mode 100644 index 000000000..d6592bd07 --- /dev/null +++ b/src/Microsoft.DotNet.XHarness.CLI/Commands/WebServer.cs @@ -0,0 +1,156 @@ +// 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.Net.WebSockets; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; + +using LogLevel = Microsoft.Extensions.Logging.LogLevel; +using System.Runtime.Loader; +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using Microsoft.DotNet.XHarness.CLI.CommandArguments; + +namespace Microsoft.DotNet.XHarness.CLI.Commands +{ + public class WebServer + { + internal static async Task Start(TestCommandArguments arguments, ILogger logger, Func? onConsoleConnected, CancellationToken token) + { + var host = new WebHostBuilder() + .UseKestrel() + .UseContentRoot(arguments.AppPackagePath) + .UseStartup() + .ConfigureLogging(logging => + { + logging.AddConsole().AddFilter(null, LogLevel.Warning); + }) + .ConfigureServices((ctx, services) => + { + services.AddRouting(); + services.AddSingleton(logger); + services.Configure(ctx.Configuration); + services.Configure(options => + { + options.OnConsoleConnected = onConsoleConnected; + foreach (var (middlewarePath, middlewareTypeName) in arguments.WebServerMiddlewarePathsAndTypes) + { + var extensionAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(middlewarePath); + var middlewareType = extensionAssembly?.GetTypes().Where(type => type.Name == middlewareTypeName).FirstOrDefault(); + if (middlewareType == null) + { + var message = $"Can't find {middlewareTypeName} middleware in {middlewarePath}"; + logger.LogError(message); + throw new Exception(message); + } + options.EchoServerMiddlewares.Add(middlewareType); + } + }); + }) + .UseUrls("http://127.0.0.1:0", "https://127.0.0.1:0") + .Build(); + + await host.StartAsync(token); + + var ipAddress = host.ServerFeatures + .Get()? + .Addresses + .Where(a => a.StartsWith("http:")) + .Select(a => new Uri(a)) + .Select(uri => $"{uri.Host}:{uri.Port}") + .FirstOrDefault(); + + var ipAddressSecure = host.ServerFeatures + .Get()? + .Addresses + .Where(a => a.StartsWith("https:")) + .Select(a => new Uri(a)) + .Select(uri => $"{uri.Host}:{uri.Port}") + .FirstOrDefault(); + + if (ipAddress == null || ipAddressSecure == null) + { + throw new InvalidOperationException("Failed to determine web server's IP address or port"); + } + + return new ServerURLs(ipAddress, ipAddressSecure); + } + + class TestWebServerStartup + { + private readonly IWebHostEnvironment _hostingEnvironment; + private readonly ILogger _logger; + + public TestWebServerStartup(IWebHostEnvironment hostingEnvironment, ILogger logger) + { + _hostingEnvironment = hostingEnvironment; + _logger = logger; + } + + public void Configure(IApplicationBuilder app, IOptionsMonitor optionsAccessor) + { + var provider = new FileExtensionContentTypeProvider(); + provider.Mappings[".wasm"] = "application/wasm"; + + foreach (var extn in new string[] { ".dll", ".pdb", ".dat", ".blat" }) + { + provider.Mappings[extn] = "application/octet-stream"; + } + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(_hostingEnvironment.ContentRootPath), + ContentTypeProvider = provider, + ServeUnknownFileTypes = true + }); + + var options = optionsAccessor.CurrentValue; + + app.UseWebSockets(); + if (options.OnConsoleConnected != null) + { + app.UseRouter(router => + { + router.MapGet("/console", async context => + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + var socket = await context.WebSockets.AcceptWebSocketAsync(); + await options.OnConsoleConnected(socket); + }); + }); + } + + foreach (var middleware in options.EchoServerMiddlewares) + { + app.UseMiddleware(middleware); + _logger.LogInformation($"Loaded {middleware.FullName} middleware"); + } + } + } + + class TestWebServerOptions + { + public Func? OnConsoleConnected { get; set; } + public IList EchoServerMiddlewares { get; set; } = new List(); + } + } + + public record ServerURLs(string Http, string Https); +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.XHarness.TestRunners.Xunit/WasmApplicationEntryPoint.cs b/src/Microsoft.DotNet.XHarness.TestRunners.Xunit/WasmApplicationEntryPoint.cs index a93b50290..d7aff3882 100644 --- a/src/Microsoft.DotNet.XHarness.TestRunners.Xunit/WasmApplicationEntryPoint.cs +++ b/src/Microsoft.DotNet.XHarness.TestRunners.Xunit/WasmApplicationEntryPoint.cs @@ -40,7 +40,7 @@ private static void ParseEqualSeparatedArgument(Dictionary> var parts = argument.Split('='); if (parts.Length != 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) { - throw new ArgumentException("Invalid argument value '{argument}'.", nameof(argument)); + throw new ArgumentException($"Invalid argument value '{argument}'.", nameof(argument)); } var name = parts[0];