diff --git a/Directory.Build.props b/Directory.Build.props index f02b3f855c..ec66f144ff 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -92,6 +92,14 @@ This is an early preview package, keep 1.0.0-alpha or similar suffix even in official builds. --> 1.0.0 + + alpha + + + 0.1.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index cab445f336..1fce5095ac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -87,6 +87,7 @@ + diff --git a/Microsoft.Testing.Platform.slnf b/Microsoft.Testing.Platform.slnf index 82a8c2dac3..a628ce93f2 100644 --- a/Microsoft.Testing.Platform.slnf +++ b/Microsoft.Testing.Platform.slnf @@ -25,6 +25,7 @@ "src\\Platform\\Microsoft.Testing.Extensions.VSTestBridge\\Microsoft.Testing.Extensions.VSTestBridge.csproj", "src\\Platform\\Microsoft.Testing.Extensions.VideoRecorder\\Microsoft.Testing.Extensions.VideoRecorder.csproj", "src\\Platform\\Microsoft.Testing.Platform.AI\\Microsoft.Testing.Platform.AI.csproj", + "src\\Platform\\Microsoft.Testing.Platform.Browser\\Microsoft.Testing.Platform.Browser.csproj", "src\\Platform\\Microsoft.Testing.Platform.MSBuild\\Microsoft.Testing.Platform.MSBuild.csproj", "src\\Platform\\Microsoft.Testing.Platform.ServerMode.Client.Sources\\Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj", "src\\Platform\\Microsoft.Testing.Platform\\Microsoft.Testing.Platform.csproj", diff --git a/MutationTesting.slnx b/MutationTesting.slnx index 285a4ae474..6ee71c0ead 100644 --- a/MutationTesting.slnx +++ b/MutationTesting.slnx @@ -19,6 +19,7 @@ + diff --git a/NonWindowsTests.slnf b/NonWindowsTests.slnf index dfff67fd32..9968f987b2 100644 --- a/NonWindowsTests.slnf +++ b/NonWindowsTests.slnf @@ -31,6 +31,7 @@ "src\\Platform\\Microsoft.Testing.Extensions.VSTestBridge\\Microsoft.Testing.Extensions.VSTestBridge.csproj", "src\\Platform\\Microsoft.Testing.Extensions.VideoRecorder\\Microsoft.Testing.Extensions.VideoRecorder.csproj", "src\\Platform\\Microsoft.Testing.Platform.AI\\Microsoft.Testing.Platform.AI.csproj", + "src\\Platform\\Microsoft.Testing.Platform.Browser\\Microsoft.Testing.Platform.Browser.csproj", "src\\Platform\\Microsoft.Testing.Platform.MSBuild\\Microsoft.Testing.Platform.MSBuild.csproj", "src\\Platform\\Microsoft.Testing.Platform.ServerMode.Client.Sources\\Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj", "src\\Platform\\Microsoft.Testing.Platform\\Microsoft.Testing.Platform.csproj", diff --git a/TestFx.slnx b/TestFx.slnx index 8f6802e4e6..c7b7c26ddc 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -58,6 +58,7 @@ + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BoundedResourceCleanup.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BoundedResourceCleanup.cs new file mode 100644 index 0000000000..e7421fa8ea --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BoundedResourceCleanup.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal static class BoundedResourceCleanup +{ + public static async Task ObserveCreationAsync( + Task creationTask, + TimeSpan timeout, + Action dispose, + Action report) + where T : class + { + try + { + return await creationTask.WaitAsync(timeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + report("Resource creation did not finish during bounded cleanup; disposal will continue in the background."); + _ = DisposeWhenCreatedAsync(creationTask, timeout, dispose, report); + return null; + } + catch (Exception ex) + { + report($"Resource creation failed during cleanup: {ex.Message}"); + return null; + } + } + + public static async Task DisposeAsync( + Action dispose, + TimeSpan timeout, + Action report) + { + try + { + await Task.Run(dispose).WaitAsync(timeout).ConfigureAwait(false); + } + catch (Exception ex) + { + report($"Resource disposal did not complete cleanly: {ex.Message}"); + } + } + + private static async Task DisposeWhenCreatedAsync( + Task creationTask, + TimeSpan timeout, + Action dispose, + Action report) + { + try + { + T resource = await creationTask.ConfigureAwait(false); + await DisposeAsync(() => dispose(resource), timeout, report).ConfigureAwait(false); + } + catch (Exception ex) + { + report($"Late resource creation could not be cleaned up: {ex.Message}"); + } + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs new file mode 100644 index 0000000000..20a85f3b91 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed record BrowserLauncherOptions( + string HostCommand, + string HostArguments, + string HostWorkingDirectory, + string BrowserExecutable, + TimeSpan StartupTimeout, + TimeSpan CompletionTimeout, + IReadOnlyList TestApplicationArguments, + DotnetTestHttpBootstrap Bootstrap) +{ + public static BrowserLauncherOptions Parse(string[] args) + { + int separatorIndex = Array.IndexOf(args, "--"); + if (separatorIndex < 0) + { + throw new BrowserLauncherException( + "The launcher command line must contain '--' before the Microsoft Testing Platform arguments."); + } + + var options = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < separatorIndex; i += 2) + { + if (i + 1 >= separatorIndex || !args[i].StartsWith("--", StringComparison.Ordinal)) + { + throw new BrowserLauncherException($"Invalid launcher option at position {i + 1}."); + } + + if (!options.TryAdd(args[i], args[i + 1])) + { + throw new BrowserLauncherException($"Launcher option '{args[i]}' was provided more than once."); + } + } + + string hostCommand = ReadEncodedOption(options, "--host-command-uri", required: true); + string hostArguments = ReadEncodedOption(options, "--host-arguments-uri", required: false); + string hostWorkingDirectory = ReadEncodedOption(options, "--host-working-directory-uri", required: true); + string browserExecutable = Path.GetFullPath( + ReadEncodedOption(options, "--browser-executable-uri", required: true)); + if (!File.Exists(browserExecutable)) + { + throw new BrowserLauncherException( + $"The configured browser executable does not exist: '{browserExecutable}'."); + } + + TimeSpan startupTimeout = ParseTimeout(options, "--startup-timeout-seconds"); + TimeSpan completionTimeout = ParseTimeout(options, "--completion-timeout-seconds"); + if (options.Count != 0) + { + throw new BrowserLauncherException( + $"Unknown launcher option '{options.Keys.First()}'."); + } + + string[] expandedArguments = SdkResponseFileExpander.Expand(args[(separatorIndex + 1)..]); + var bootstrap = DotnetTestHttpBootstrap.Parse(expandedArguments); + + return new BrowserLauncherOptions( + hostCommand, + hostArguments, + Path.GetFullPath(hostWorkingDirectory), + browserExecutable, + startupTimeout, + completionTimeout, + expandedArguments, + bootstrap); + } + + private static string ReadEncodedOption( + Dictionary options, + string name, + bool required) + { + if (!options.Remove(name, out string? encodedValue)) + { + return required + ? throw new BrowserLauncherException($"Required launcher option '{name}' is missing.") + : string.Empty; + } + + string value = Uri.UnescapeDataString(encodedValue); + return required && string.IsNullOrWhiteSpace(value) + ? throw new BrowserLauncherException($"Required launcher option '{name}' is empty.") + : value; + } + + private static TimeSpan ParseTimeout(Dictionary options, string name) + => options.Remove(name, out string? value) + && int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int seconds) + && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : throw new BrowserLauncherException( + $"Launcher option '{name}' must be a positive integer."); +} + +internal sealed record DotnetTestHttpBootstrap(Uri Endpoint, string Token) +{ + public static DotnetTestHttpBootstrap Parse(IReadOnlyList arguments) + { + string? server = null; + string? transport = null; + string? endpoint = null; + string? token = null; + + for (int i = 0; i < arguments.Count; i++) + { + string argument = arguments[i]; + if (argument is "--server" or "--dotnet-test-transport" or "--dotnet-test-http-endpoint" or "--dotnet-test-http-token") + { + if (++i >= arguments.Count) + { + throw new BrowserLauncherException( + $"Microsoft Testing Platform option '{argument}' has no value."); + } + + string value = arguments[i]; + switch (argument) + { + case "--server": + server = value; + break; + case "--dotnet-test-transport": + transport = value; + break; + case "--dotnet-test-http-endpoint": + endpoint = value; + break; + case "--dotnet-test-http-token": + token = value; + break; + } + } + } + + Uri endpointUri = Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? parsedEndpoint) + ? parsedEndpoint + : throw InvalidBootstrap(); + + return string.Equals(server, "dotnettestcli", StringComparison.OrdinalIgnoreCase) + && string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase) + && endpointUri.Scheme is "http" or "https" + && endpointUri.IsLoopback + && !string.IsNullOrWhiteSpace(token) + && !token.Any(char.IsWhiteSpace) + && !token.Any(char.IsControl) + ? new DotnetTestHttpBootstrap(endpointUri, token) + : throw InvalidBootstrap(); + + static BrowserLauncherException InvalidBootstrap() + => new( + "The SDK did not provide a valid loopback authenticated HTTP dotnettestcli bootstrap."); + } +} + +internal static class SdkResponseFileExpander +{ + public static string[] Expand(IReadOnlyList arguments) + { + if (arguments.Count == 0 + || !arguments[^1].StartsWith('@') + || arguments[^1].Length == 1) + { + throw new BrowserLauncherException( + "The browser experiment requires the SDK HTTP bootstrap response file as the final test application argument."); + } + + for (int i = 0; i < arguments.Count - 1; i++) + { + if (arguments[i].StartsWith('@')) + { + throw new BrowserLauncherException( + "User response files are not supported by the browser experiment. Pass those arguments directly."); + } + } + + var expanded = new List(arguments.Count + 8); + expanded.AddRange(arguments.Take(arguments.Count - 1)); + string path = Path.GetFullPath(arguments[^1][1..]); + try + { + ValidateResponseFilePermissions(path); + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.None); + using var reader = new StreamReader( + stream, + new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true)); + + while (reader.ReadLine() is { } line) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') + { + continue; + } + + int separator = trimmed.IndexOfAny([' ', '\t']); + if (separator < 0) + { + expanded.Add(trimmed); + continue; + } + + expanded.Add(trimmed[..separator]); + string value = trimmed[(separator + 1)..].TrimStart(); + if (value.Length != 0) + { + expanded.Add(value); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException) + { + throw new BrowserLauncherException( + "Unable to read the SDK response file.", + ex); + } + + return [.. expanded]; + } + + private static void ValidateResponseFilePermissions(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + UnixFileMode mode = File.GetUnixFileMode(path); + const UnixFileMode disallowed = + UnixFileMode.GroupRead + | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherWrite + | UnixFileMode.OtherExecute; + if ((mode & disallowed) != 0) + { + throw new BrowserLauncherException( + "The SDK response file is accessible by users other than its owner."); + } + } +} + +internal sealed class BrowserLauncherException : Exception +{ + public BrowserLauncherException(string message) + : base(message) + { + } + + public BrowserLauncherException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserRunMonitor.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserRunMonitor.cs new file mode 100644 index 0000000000..17f50c6341 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserRunMonitor.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal static class BrowserRunMonitor +{ + public static async Task WaitAsync( + Func> waitForBrowserCompletion, + Func waitForHostExit, + Func waitForBrowserExit, + CancellationToken cancellationToken) + { + using var completionCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + Task browserCompletion = waitForBrowserCompletion(completionCancellationTokenSource.Token); + Task hostExit = waitForHostExit(completionCancellationTokenSource.Token); + Task browserExit = waitForBrowserExit(completionCancellationTokenSource.Token); + + Task completed = await Task.WhenAny(browserCompletion, hostExit, browserExit).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (completed == browserCompletion) + { + int exitCode = await browserCompletion.ConfigureAwait(false); + await completionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + return exitCode; + } + + await completionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + throw new BrowserLauncherException( + completed == hostExit + ? "The browser host exited before the test application completed." + : "The browser exited before the test application completed."); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs new file mode 100644 index 0000000000..e58909ece8 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed record BrowserTerminalResult(int ExitCode, string? Error) +{ + public int GetExitCode(DiagnosticBuffer diagnostics) + { + if (Error is null) + { + return ExitCode; + } + + diagnostics.Add("browser", Error); + throw new BrowserLauncherException( + "The browser supervisor reported a fatal error."); + } +} + +internal static class BrowserBindingCallback +{ + public static T Invoke( + TaskCompletionSource completion, + Func callback) + { + try + { + return callback(); + } + catch (Exception ex) + { + completion.TrySetException( + new BrowserLauncherException( + "The browser supervisor binding request was rejected.", + ex)); + throw; + } + } + + public static void Invoke( + TaskCompletionSource completion, + Action callback) + => Invoke( + completion, + () => + { + callback(); + return true; + }); +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs new file mode 100644 index 0000000000..de06a8e1c4 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +using Microsoft.Playwright; + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed class ChromiumBrowser : IAsyncDisposable +{ + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); + + private readonly IPlaywright _playwright; + private readonly IBrowser _browser; + private readonly IBrowserContext _context; + private readonly IPage _page; + private readonly IAsyncDisposable _getArgumentsBinding; + private readonly IAsyncDisposable _terminalResultBinding; + private readonly TaskCompletionSource _completion; + private readonly DiagnosticBuffer _diagnostics; + private readonly TaskCompletionSource _disconnected = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + private ChromiumBrowser( + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + IPage page, + IAsyncDisposable getArgumentsBinding, + IAsyncDisposable terminalResultBinding, + TaskCompletionSource completion, + DiagnosticBuffer diagnostics) + { + _playwright = playwright; + _browser = browser; + _context = context; + _page = page; + _getArgumentsBinding = getArgumentsBinding; + _terminalResultBinding = terminalResultBinding; + _completion = completion; + _diagnostics = diagnostics; + _browser.Disconnected += (_, _) => _disconnected.TrySetResult(); + SubscribeToDiagnostics(); + } + + public Task WaitForExitAsync(CancellationToken cancellationToken) + => _disconnected.Task.WaitAsync(cancellationToken); + + public static async Task LaunchAsync( + BrowserLauncherOptions options, + Uri browserUri, + DiagnosticBuffer diagnostics, + CancellationToken cancellationToken) + { + using var startupTimeoutCancellationTokenSource = + new CancellationTokenSource(options.StartupTimeout); + using var startupCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + startupTimeoutCancellationTokenSource.Token); + CancellationToken startupCancellationToken = startupCancellationTokenSource.Token; + + IPlaywright? playwright = null; + IBrowser? browser = null; + IBrowserContext? context = null; + IAsyncDisposable? getArgumentsBinding = null; + IAsyncDisposable? terminalResultBinding = null; + Task? playwrightCreationTask = null; + Task? browserLaunchTask = null; + + try + { + // Playwright's DEBUG channel logs can contain binding payloads with the SDK bearer token. + Environment.SetEnvironmentVariable("DEBUG", null); + PlaywrightNodeExecutable.EnsureExecutable(); + playwrightCreationTask = Microsoft.Playwright.Playwright.CreateAsync(); + playwright = await playwrightCreationTask + .WaitAsync(startupCancellationToken).ConfigureAwait(false); + + browserLaunchTask = playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions + { + ExecutablePath = options.BrowserExecutable, + Headless = true, + Timeout = (float)options.StartupTimeout.TotalMilliseconds, + }); + browser = await browserLaunchTask.WaitAsync(startupCancellationToken).ConfigureAwait(false); + context = await browser.NewContextAsync( + new BrowserNewContextOptions + { + Locale = "en-US", + }).WaitAsync(startupCancellationToken).ConfigureAwait(false); + IPage page = await context.NewPageAsync() + .WaitAsync(startupCancellationToken).ConfigureAwait(false); + + string expectedOrigin = browserUri.GetLeftPart(UriPartial.Authority); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + getArgumentsBinding = await page.ExposeBindingAsync( + "__mtpBrowserGetArguments", + source => BrowserBindingCallback.Invoke( + completion, + () => + { + ValidateBindingSource(source, page, expectedOrigin); + return options.TestApplicationArguments.ToArray(); + })).WaitAsync(startupCancellationToken).ConfigureAwait(false); + terminalResultBinding = await page.ExposeBindingAsync( + "__mtpBrowserComplete", + (source, result) => BrowserBindingCallback.Invoke( + completion, + () => + { + ValidateBindingSource(source, page, expectedOrigin); + Complete(result, completion); + })).WaitAsync(startupCancellationToken).ConfigureAwait(false); + + var chromiumBrowser = new ChromiumBrowser( + playwright, + browser, + context, + page, + getArgumentsBinding, + terminalResultBinding, + completion, + diagnostics); + await chromiumBrowser.NavigateAsync(browserUri, startupCancellationToken) + .ConfigureAwait(false); + return chromiumBrowser; + } + catch (Exception ex) + { + if (playwright is null && playwrightCreationTask is not null) + { + playwright = await BoundedResourceCleanup.ObserveCreationAsync( + playwrightCreationTask, + CleanupTimeout, + static instance => instance.Dispose(), + message => diagnostics.Add("launcher cleanup", $"Playwright: {message}")) + .ConfigureAwait(false); + } + + if (browser is null && browserLaunchTask is not null) + { + browser = await TryObserveBrowserLaunchAsync(browserLaunchTask, diagnostics) + .ConfigureAwait(false); + } + + await DisposeBrowserAsync( + terminalResultBinding, + getArgumentsBinding, + context, + browser, + playwright, + diagnostics).ConfigureAwait(false); + + if (startupTimeoutCancellationTokenSource.IsCancellationRequested + && !cancellationToken.IsCancellationRequested) + { + throw new BrowserLauncherException( + $"The Chromium browser did not become ready within {options.StartupTimeout.TotalSeconds} seconds.", + ex); + } + + if (ex is OperationCanceledException or BrowserLauncherException) + { + throw; + } + + throw new BrowserLauncherException( + $"Unable to launch the Chromium browser '{options.BrowserExecutable}'.", + ex); + } + } + + public async Task WaitForCompletionAsync( + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(timeout); + using var linkedCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellationTokenSource.Token); + + try + { + BrowserTerminalResult result = await _completion.Task + .WaitAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false); + return result.GetExitCode(_diagnostics); + } + catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) + { + throw new BrowserLauncherException( + $"The browser test application did not complete within {timeout.TotalSeconds} seconds."); + } + } + + public async ValueTask DisposeAsync() + => await DisposeBrowserAsync( + _terminalResultBinding, + _getArgumentsBinding, + _context, + _browser, + _playwright, + _diagnostics).ConfigureAwait(false); + + private static void Complete( + JsonElement message, + TaskCompletionSource completion) + { + if (message.ValueKind != JsonValueKind.Object + || !message.TryGetProperty("exitCode", out JsonElement exitCode) + || !exitCode.TryGetInt32(out int exitCodeValue)) + { + throw new BrowserLauncherException( + "The browser supervisor returned an invalid terminal result."); + } + + string? error = null; + if (message.TryGetProperty("error", out JsonElement errorElement) + && errorElement.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined) + { + if (errorElement.ValueKind != JsonValueKind.String) + { + throw new BrowserLauncherException( + "The browser supervisor returned an invalid error value."); + } + + error = errorElement.GetString(); + } + + completion.TrySetResult(new BrowserTerminalResult(exitCodeValue, error)); + } + + private static void ValidateBindingSource( + BindingSource source, + IPage expectedPage, + string expectedOrigin) + { + if (!ReferenceEquals(source.Page, expectedPage) + || source.Frame.ParentFrame is not null + || !Uri.TryCreate(source.Frame.Url, UriKind.Absolute, out Uri? sourceUri) + || !string.Equals( + sourceUri.GetLeftPart(UriPartial.Authority), + expectedOrigin, + StringComparison.Ordinal)) + { + throw new BrowserLauncherException( + "The browser supervisor binding was called outside the expected top-level loopback origin."); + } + } + + private async Task NavigateAsync(Uri browserUri, CancellationToken cancellationToken) + { + await _page.GotoAsync( + browserUri.AbsoluteUri, + new PageGotoOptions + { + WaitUntil = WaitUntilState.DOMContentLoaded, + }).WaitAsync(cancellationToken).ConfigureAwait(false); + + if (!Uri.TryCreate(_page.Url, UriKind.Absolute, out Uri? finalUri) + || !string.Equals( + finalUri.GetLeftPart(UriPartial.Authority), + browserUri.GetLeftPart(UriPartial.Authority), + StringComparison.Ordinal)) + { + throw new BrowserLauncherException( + "The browser navigated outside the expected loopback origin before the test application started."); + } + } + + private void SubscribeToDiagnostics() + { + _page.Console += (_, message) + => _diagnostics.Add($"browser {message.Type}", message.Text); + _page.PageError += (_, error) + => _diagnostics.Add("browser exception", error); + _page.RequestFailed += (_, request) + => _diagnostics.Add( + "browser request", + $"{request.Method} {request.Url}: {request.Failure}"); + _page.Crash += (_, _) => + { + _diagnostics.Add("browser", "The browser page crashed."); + _completion.TrySetException( + new BrowserLauncherException("The browser page crashed before completing.")); + }; + } + + private static async Task DisposeBrowserAsync( + IAsyncDisposable? terminalResultBinding, + IAsyncDisposable? getArgumentsBinding, + IBrowserContext? context, + IBrowser? browser, + IPlaywright? playwright, + DiagnosticBuffer diagnostics) + { + await DisposeBindingAsync(terminalResultBinding, "terminal-result", diagnostics) + .ConfigureAwait(false); + await DisposeBindingAsync(getArgumentsBinding, "get-arguments", diagnostics) + .ConfigureAwait(false); + + if (context is not null) + { + try + { + await context.CloseAsync().WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + diagnostics.Add( + "launcher cleanup", + $"Unable to close the browser context: {ex.Message}"); + } + } + + if (browser is not null) + { + try + { + await browser.CloseAsync().WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + diagnostics.Add( + "launcher cleanup", + $"Unable to close the browser: {ex.Message}"); + } + } + + if (playwright is not null) + { + await BoundedResourceCleanup.DisposeAsync( + playwright.Dispose, + CleanupTimeout, + message => diagnostics.Add("launcher cleanup", $"Playwright: {message}")) + .ConfigureAwait(false); + } + } + + private static async Task DisposeBindingAsync( + IAsyncDisposable? binding, + string name, + DiagnosticBuffer diagnostics) + { + if (binding is null) + { + return; + } + + try + { + await binding.DisposeAsync().AsTask().WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + diagnostics.Add( + "launcher cleanup", + $"Unable to remove the browser {name} binding: {ex.Message}"); + } + } + + private static async Task TryObserveBrowserLaunchAsync( + Task browserLaunchTask, + DiagnosticBuffer diagnostics) + { + try + { + return await browserLaunchTask.WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + diagnostics.Add( + "launcher cleanup", + $"Unable to observe the interrupted browser launch: {ex.Message}"); + return null; + } + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs new file mode 100644 index 0000000000..0d54aa9bfb --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed class DiagnosticBuffer +{ + private const int MaximumEntries = 200; + private const int MaximumEntryLength = 4 * 1024; + + private readonly Queue _entries = new(); +#if NET9_0_OR_GREATER + private readonly Lock _sync = new(); +#else + private readonly object _sync = new(); +#endif + private readonly string[] _secrets; + + public DiagnosticBuffer(params string?[] secrets) + => _secrets = + [ + .. secrets + .Where(static value => !string.IsNullOrEmpty(value)) + .Cast() + .Distinct(StringComparer.Ordinal) + .OrderByDescending(static value => value.Length), + ]; + + public void Add(string source, string message) + { + string redacted = message; + foreach (string secret in _secrets) + { + redacted = redacted.Replace(secret, "[redacted]", StringComparison.Ordinal); + } + + if (redacted.Length > MaximumEntryLength) + { + redacted = redacted[..MaximumEntryLength] + "..."; + } + + lock (_sync) + { + if (_entries.Count == MaximumEntries) + { + _entries.Dequeue(); + } + + _entries.Enqueue($"[{source}] {redacted}"); + } + } + + public string Format() + { + lock (_sync) + { + return _entries.Count == 0 + ? "(no diagnostics captured)" + : string.Join(Environment.NewLine, _entries); + } + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs new file mode 100644 index 0000000000..a3fb350295 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed class HostProcess : IAsyncDisposable +{ + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); + private static readonly Regex AppUrlRegex = new( + @"^\s*App url:\s+(?http://\S+)\s*$", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private readonly Process _process; + private readonly DiagnosticBuffer _diagnostics; + private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + private readonly TaskCompletionSource _readiness = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly Task _stdoutTask; + private readonly Task _stderrTask; + + private HostProcess(Process process, DiagnosticBuffer diagnostics) + { + _process = process; + _diagnostics = diagnostics; + _stdoutTask = CaptureAsync( + process.StandardOutput, + "host stdout", + inspectReadiness: true, + _disposeCancellationTokenSource.Token); + _stderrTask = CaptureAsync( + process.StandardError, + "host stderr", + inspectReadiness: false, + _disposeCancellationTokenSource.Token); + } + + public Task WaitForExitAsync(CancellationToken cancellationToken) + => _process.WaitForExitAsync(cancellationToken); + + public static HostProcess Start( + BrowserLauncherOptions options, + DiagnosticBuffer diagnostics) + { + var startInfo = new ProcessStartInfo + { + FileName = options.HostCommand, + Arguments = options.HostArguments, + WorkingDirectory = options.HostWorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + try + { + Process process = Process.Start(startInfo) + ?? throw new BrowserLauncherException( + $"The browser host command '{options.HostCommand}' did not start."); + return new HostProcess(process, diagnostics); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + throw new BrowserLauncherException( + $"Unable to start the browser host command '{options.HostCommand}'.", + ex); + } + } + + public async Task WaitUntilReadyAsync( + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(timeout); + using var linkedCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellationTokenSource.Token, + _disposeCancellationTokenSource.Token); + + try + { + Task processExit = _process.WaitForExitAsync(linkedCancellationTokenSource.Token); + Task completed = await Task.WhenAny(_readiness.Task, processExit).ConfigureAwait(false); + if (completed == _readiness.Task) + { + return await _readiness.Task.ConfigureAwait(false); + } + + linkedCancellationTokenSource.Token.ThrowIfCancellationRequested(); + throw new BrowserLauncherException( + $"The browser host exited with code {_process.ExitCode} before reporting readiness."); + } + catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) + { + throw new BrowserLauncherException( + $"The browser host did not become ready within {timeout.TotalSeconds} seconds."); + } + } + + public async ValueTask DisposeAsync() + { + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch (Exception ex) when ( + ex is InvalidOperationException + or System.ComponentModel.Win32Exception + or NotSupportedException) + { + _diagnostics.Add( + "launcher cleanup", + $"Unable to terminate the browser host process: {ex.Message}"); + } + + try + { + await _process.WaitForExitAsync().WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or TimeoutException) + { + _diagnostics.Add( + "launcher cleanup", + $"Unable to confirm browser host process exit: {ex.Message}"); + } + + try + { + await Task.WhenAll(_stdoutTask, _stderrTask) + .WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) when ( + ex is IOException + or ObjectDisposedException + or TimeoutException) + { + _diagnostics.Add( + "launcher cleanup", + $"Unable to finish reading browser host diagnostics: {ex.Message}"); + } + + _process.Dispose(); + _disposeCancellationTokenSource.Dispose(); + } + + internal static Uri? TryParseAppUrl(string line) + => AppUrlRegex.Match(line) is not { Success: true } match + ? null + : Uri.TryCreate(match.Groups["url"].Value, UriKind.Absolute, out Uri? uri) + && uri.Scheme == Uri.UriSchemeHttp + && uri.IsLoopback + ? uri + : throw new BrowserLauncherException( + "The browser host reported a non-loopback application URL."); + + private async Task CaptureAsync( + StreamReader reader, + string source, + bool inspectReadiness, + CancellationToken cancellationToken) + { + try + { + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + _diagnostics.Add(source, line); + if (!inspectReadiness) + { + continue; + } + + try + { + if (TryParseAppUrl(line) is { } uri) + { + _readiness.TrySetResult(uri); + } + } + catch (BrowserLauncherException ex) + { + _readiness.TrySetException(ex); + return; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancellation is expected while shutting down the host. + return; + } + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj new file mode 100644 index 0000000000..ad88baf8f0 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj @@ -0,0 +1,61 @@ + + + + Exe + net8.0 + false + false + true + Major + linux-x64;linux-arm64;osx-x64;osx-arm64;win + + $(NoWarn);NU5111 + $(MicrosoftTestingPlatformBrowserVersionPrefix) + $(MicrosoftTestingPlatformBrowserPreReleaseVersionLabel) + true + + + + + + + + + $(TargetsForTfmSpecificContentInPackage);_AddBrowserLauncherPackageFiles + + + + + true + buildMultiTargeting + + + true + build + + + true + buildTransitive + + + + + + + + + + + + + + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md new file mode 100644 index 0000000000..099e4a2d30 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -0,0 +1,111 @@ +# Microsoft.Testing.Platform.Browser + +> **Experiment:** This package is a proof of concept for design validation. It is +> not a supported package or a shipping commitment. + +`Microsoft.Testing.Platform.Browser` is an optional launcher package for running +pure-managed `browser-wasm` Microsoft Testing Platform applications through +`dotnet test`. It keeps browser dependencies and browser release cadence out of +core Microsoft.Testing.Platform. + +## Usage + +```xml + + + net10.0 + Exe + true + PATH_TO_CHROMIUM + + + + + + + +``` + +The canonical application contains only managed test code. MTP generates +`Main(string[] args)`, and the package injects the arguments prepared by +`dotnet test` before that entry point runs. Managed `HttpClient` sends discovery, +progress, and results directly from MTP to the SDK authenticated HTTP gateway. +User and test code need no HTML, JavaScript, Blazor `IJSRuntime`, or `[JSImport]`. + +Browser WebAssembly still needs host JavaScript. The package supplies a minimal +page at `/_mtp/browser-host.html` and private boot supervisor that dynamically +imports the root `_framework/dotnet.js`, obtains the MTP arguments through a private Playwright +binding, invokes `runMain`, and reports only the terminal exit code or bootstrap +failure to the launcher. It does not discover tests, execute tests, parse +results, or relay the MTP HTTP protocol. + +## Experiment contract + +The package: + +- wraps the browser framework's existing `ComputeRunArguments` result only when + the caller sets the proof-of-concept marker `DotnetTestInvocation=true`; +- requires an explicit installed Chromium-family browser through + `TestingPlatformBrowserExecutable`; +- launches that browser through Playwright's private transport in an isolated + context, with no unauthenticated DevTools TCP endpoint; +- recognizes the WasmAppHost readiness line + `App url: http:///` and rejects non-loopback origins; +- makes argument and completion bindings available only to the expected + top-level page at the exact host origin; +- keeps the SDK HTTP bearer token out of URLs and redacts it from bounded host, + browser, and launcher diagnostics; +- owns bounded cleanup of the browser context, browser, and host process tree on + completion, failure, timeout, or cancellation. + +`TestingPlatformBrowserStartupTimeoutSeconds` defaults to 60 seconds and +`TestingPlatformBrowserCompletionTimeoutSeconds` defaults to 600 seconds. The +reserved `/_mtp/` path avoids colliding with a consumer-owned root page; no +public framework-page integration protocol is provided. + +Ordinary desktop targets and unmarked `ComputeRunArguments` calls remain +unchanged. + +This minimum proof of concept supports only the canonical +`Microsoft.NET.Sdk.WebAssembly` and WasmAppHost combination. The package keeps a +simple `AfterTargets="ComputeRunArguments"` hook; a project target that also +runs after `ComputeRunArguments` can subsequently replace the browser wrapper +and is unsupported by this experiment. A product preview must move launcher +selection into the .NET SDK rather than adding more package-level ordering +machinery. + +## Current limitations + +- Browser virtual-file-system artifacts are not exported. TRX, coverage, + diagnostics, and other file reports may remain only in the browser VFS. +- User-authored `@response-file` arguments are not supported by this experiment. + Only the final private SDK HTTP bootstrap response file is expanded. +- The SDK authenticated HTTP bootstrap and invocation marker are experimental + cross-repository contracts. The current proof-of-concept caller sets + `DotnetTestInvocation`; the shipping SDK does not yet own this selection. +- The package uses the framework-provided WasmAppHost and depends on its exact + HTTP readiness output; shared external-host readiness is unresolved. +- Managed MTP does not yet receive graceful cancellation before the launcher + starts bounded process cleanup. +- A permanently wedged Playwright driver can still leave its private process + orphaned after the bounded call-site cleanup expires. A stable implementation + needs explicit ownership of the transport process. +- The experimental package currently bundles Playwright's cross-platform Node + driver payload. Package size, source-build, signing, platform validation, and + servicing must be resolved before any preview or stable productization. +- The package is emitted to the repository's Shipping artifacts for acceptance + testing, but is not included in the product shipment layout. + +Microsoft.Testing.Platform is open source. You can find +`Microsoft.Testing.Platform.Browser` in the +[microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. + +## Documentation + +For comprehensive Microsoft Testing Platform documentation, see +. + +## Feedback & contributing + +Provide feedback or report issues in the +[microsoft/testfx](https://github.com/microsoft/testfx/issues) GitHub repository. diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PlaywrightNodeExecutable.cs b/src/Platform/Microsoft.Testing.Platform.Browser/PlaywrightNodeExecutable.cs new file mode 100644 index 0000000000..8cdf0e506e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PlaywrightNodeExecutable.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal static class PlaywrightNodeExecutable +{ + public static void EnsureExecutable() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + string nodePath = GetPath( + AppContext.BaseDirectory, + OperatingSystem.IsLinux() ? OSPlatform.Linux : OSPlatform.OSX, + RuntimeInformation.ProcessArchitecture); + if (!File.Exists(nodePath)) + { + throw new BrowserLauncherException( + $"The Playwright Node.js driver was not found at '{nodePath}'."); + } + + UnixFileMode mode = File.GetUnixFileMode(nodePath); + const UnixFileMode executeMode = + UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + if ((mode & executeMode) != executeMode) + { + File.SetUnixFileMode(nodePath, mode | executeMode); + } + } + + internal static string GetPath( + string baseDirectory, + OSPlatform operatingSystem, + Architecture architecture) + { + string platformDirectory = (operatingSystem, architecture) switch + { + ({ } os, Architecture.X64) when os == OSPlatform.Linux => "linux-x64", + ({ } os, Architecture.Arm64) when os == OSPlatform.Linux => "linux-arm64", + ({ } os, Architecture.X64) when os == OSPlatform.OSX => "darwin-x64", + ({ } os, Architecture.Arm64) when os == OSPlatform.OSX => "darwin-arm64", + _ => throw new BrowserLauncherException( + $"Microsoft.Testing.Platform.Browser does not support Playwright on {operatingSystem}/{architecture}."), + }; + + return Path.Combine(baseDirectory, ".playwright", "node", platformDirectory, "node"); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs new file mode 100644 index 0000000000..f55c2a355c --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Browser; + +internal static class Program +{ + private const string BrowserHostPath = "/_mtp/browser-host.html"; + + public static async Task Main(string[] args) + { + BrowserLauncherOptions? options = null; + DiagnosticBuffer? diagnostics = null; + + try + { + options = BrowserLauncherOptions.Parse(args); + diagnostics = new DiagnosticBuffer( + options.Bootstrap.Token, + options.Bootstrap.Endpoint.AbsoluteUri); + + using var runCancellationTokenSource = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + _ = runCancellationTokenSource.CancelAsync(); + }; + Console.CancelKeyPress += cancelHandler; + + var host = HostProcess.Start(options, diagnostics); + try + { + Uri hostUri = await host.WaitUntilReadyAsync( + options.StartupTimeout, + runCancellationTokenSource.Token).ConfigureAwait(false); + var browserUri = new Uri(hostUri, BrowserHostPath); + ChromiumBrowser browser = await ChromiumBrowser.LaunchAsync( + options, + browserUri, + diagnostics, + runCancellationTokenSource.Token).ConfigureAwait(false); + try + { + return await BrowserRunMonitor.WaitAsync( + token => browser.WaitForCompletionAsync(options.CompletionTimeout, token), + host.WaitForExitAsync, + browser.WaitForExitAsync, + runCancellationTokenSource.Token).ConfigureAwait(false); + } + finally + { + await browser.DisposeAsync().ConfigureAwait(false); + } + } + finally + { + await host.DisposeAsync().ConfigureAwait(false); + Console.CancelKeyPress -= cancelHandler; + } + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Microsoft.Testing.Platform.Browser: {ex.Message}").ConfigureAwait(false); + if (diagnostics is not null) + { + await Console.Error.WriteLineAsync(diagnostics.Format()).ConfigureAwait(false); + } + + return 1; + } + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.props b/src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.props new file mode 100644 index 0000000000..7741f0dac8 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.targets b/src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.targets new file mode 100644 index 0000000000..02d72e2a96 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.targets @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.props b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.props new file mode 100644 index 0000000000..e97bbd8eaf --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.props @@ -0,0 +1,6 @@ + + + 60 + 600 + + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.targets b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.targets new file mode 100644 index 0000000000..2004f4de2c --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.targets @@ -0,0 +1,74 @@ + + + true + <_TestingPlatformBrowserAssetsDirectory>$(MSBuildThisFileDirectory)assets\ + + + + + <_TestingPlatformBrowserHostStaticWebAssetsRoot>$(IntermediateOutputPath)testing-platform-browser\wwwroot\ + <_TestingPlatformBrowserHostStaticWebAssetsDirectory>$(_TestingPlatformBrowserHostStaticWebAssetsRoot)_mtp\ + + + + + + + + <_TestingPlatformBrowserHostStaticWebAsset Include="$(_TestingPlatformBrowserHostStaticWebAssetsDirectory)browser-host.html"> + _mtp/browser-host.html + + <_TestingPlatformBrowserHostStaticWebAsset Include="$(_TestingPlatformBrowserHostStaticWebAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js"> + _mtp/Microsoft.Testing.Platform.Browser.main.js + + + + + + + + + + + + + + + + <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll + <_TestingPlatformBrowserHostCommand>$(RunCommand) + <_TestingPlatformBrowserHostArguments>$(RunArguments) + <_TestingPlatformBrowserHostWorkingDirectory>$(RunWorkingDirectory) + <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(MSBuildProjectDirectory) + + $(DOTNET_HOST_PATH) + dotnet + exec "$(_TestingPlatformBrowserLauncher)" --host-command-uri "$([System.Uri]::EscapeDataString(`$(_TestingPlatformBrowserHostCommand)`))" --host-arguments-uri "$([System.Uri]::EscapeDataString(`$(_TestingPlatformBrowserHostArguments)`))" --host-working-directory-uri "$([System.Uri]::EscapeDataString(`$(_TestingPlatformBrowserHostWorkingDirectory)`))" --browser-executable-uri "$([System.Uri]::EscapeDataString(`$(TestingPlatformBrowserExecutable)`))" --startup-timeout-seconds $(TestingPlatformBrowserStartupTimeoutSeconds) --completion-timeout-seconds $(TestingPlatformBrowserCompletionTimeoutSeconds) -- + $(MSBuildProjectDirectory) + + + + + + + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js new file mode 100644 index 0000000000..20db52cb73 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +const status = document.querySelector('[role=status]'); + +globalThis.addEventListener('error', event => { + console.error(`Unhandled browser error: ${event.message}`); +}); + +globalThis.addEventListener('unhandledrejection', event => { + console.error(`Unhandled browser rejection: ${String(event.reason)}`); +}); + +try { + const launcherAvailable = typeof globalThis.__mtpBrowserGetArguments === 'function' + && typeof globalThis.__mtpBrowserComplete === 'function'; + const argumentsFromLauncher = launcherAvailable + ? await globalThis.__mtpBrowserGetArguments() + : []; + const { dotnet } = await import('../_framework/dotnet.js'); + const { runMain } = await dotnet + .withApplicationArguments(...argumentsFromLauncher) + .create(); + + const exitCode = await runMain(); + status.textContent = exitCode === 0 ? 'Passed' : `Failed (exit code ${exitCode})`; + if (launcherAvailable) { + await globalThis.__mtpBrowserComplete({ exitCode }); + } +} +catch (error) { + const message = error instanceof Error + ? `${error.name}: ${error.message}\n${error.stack ?? ''}` + : String(error); + console.error(message); + status.textContent = 'Failed (launcher error)'; + if (typeof globalThis.__mtpBrowserComplete === 'function') { + await globalThis.__mtpBrowserComplete({ exitCode: 1, error: message }); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/index.html b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/index.html new file mode 100644 index 0000000000..a030ef6084 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/index.html @@ -0,0 +1,14 @@ + + + + + + + + Microsoft Testing Platform browser host + + +

Microsoft Testing Platform is starting.

+ + + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.props b/src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.props new file mode 100644 index 0000000000..1a87a3eb91 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.targets b/src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.targets new file mode 100644 index 0000000000..e4e2c6b71e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.targets @@ -0,0 +1,3 @@ + + + diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs new file mode 100644 index 0000000000..c22cb4e7d1 --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -0,0 +1,570 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.IO.Compression; +using System.Text.Json; + +namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; + +/// +/// End-to-end coverage for the optional Microsoft.Testing.Platform.Browser package. +/// +[TestClass] +[DoNotParallelize] +public sealed class BrowserPackageExecutionTests : AcceptanceTestBase +{ + private static readonly string TargetFramework = TargetFrameworks.NetCurrent; + + private const string SourceCode = """ +#file BrowserPackageTestProject.csproj + + + + $TargetFramework$ + Exe + true + enable + false + false + $(NoWarn);NETSDK1201 + + $Browser$ + 60 + 120 + + + + + + + + + +#file BrowserPackageTests.cs +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class BrowserPackageTests +{ + [TestMethod] + public void RunsInsideBrowser() + { + Assert.IsTrue(OperatingSystem.IsBrowser()); + } + + [TestMethod] + [Ignore] + public void SkippedInsideBrowser() + { + } + + [TestMethod] + public void FailsInsideBrowser() + => Assert.Fail("Intentional browser experiment failure."); +} +"""; + + private const string ConsumerPageSourceCode = """ + +#file wwwroot/index.html + + +Consumer page +consumer-page-marker + +"""; + + private const string DesktopSourceCode = """ +#file BrowserPackageDesktopTestProject.csproj + + + + $TargetFramework$ + Exe + true + enable + + + + + + + + + +#file BrowserPackageDesktopTests.cs +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class BrowserPackageDesktopTests +{ + [TestMethod] + public void RunsOutsideBrowser() + { + Assert.IsFalse(OperatingSystem.IsBrowser()); + } +} +"""; + + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGateway() + { + string? browser = LocateBrowser(); + if (browser is null) + { + Assert.Inconclusive("Skipping Microsoft.Testing.Platform.Browser execution: no Chromium-family browser was found."); + return; + } + + string browserPackageVersion = GetBrowserPackageVersion(); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserPackageTestProject", + SourceCode + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) + .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); + Assert.IsFalse(Directory.Exists(Path.Combine(generator.TargetAssetPath, "wwwroot"))); + Assert.IsEmpty(Directory.EnumerateFiles(generator.TargetAssetPath, "*.html", SearchOption.TopDirectoryOnly)); + Assert.IsEmpty(Directory.EnumerateFiles(generator.TargetAssetPath, "*.js", SearchOption.TopDirectoryOnly)); + + DotnetMuxerResult run = await RunBrowserTestAsync( + generator, + "--filter FullyQualifiedName~RunsInsideBrowser", + environmentVariables: new Dictionary + { + ["DEBUG"] = "*", + }); + string runOutput = run.StandardOutput + run.StandardError; + Assert.AreEqual(0, run.ExitCode, run.ToString()); + Assert.Contains($"({TargetFramework}|wasm) passed [+1/x0/?0]", runOutput); + Assert.Contains("succeeded: 1", runOutput); + Assert.DoesNotContain("--dotnet-test-http-token", runOutput); + Assert.DoesNotContain("pw:channel", runOutput); + await AssertIsTestingPlatformApplicationAsync( + generator, + $"-property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}"); + + DotnetMuxerResult list = await RunBrowserTestAsync(generator, "--list-tests"); + string listOutput = list.StandardOutput + list.StandardError; + Assert.AreEqual(0, list.ExitCode, list.ToString()); + Assert.Contains("RunsInsideBrowser", listOutput); + Assert.Contains("SkippedInsideBrowser", listOutput); + Assert.Contains("FailsInsideBrowser", listOutput); + Assert.Contains("Discovered 3 tests", listOutput); + + DotnetMuxerResult skipped = await RunBrowserTestAsync( + generator, + "--filter FullyQualifiedName~SkippedInsideBrowser"); + string skippedOutput = skipped.StandardOutput + skipped.StandardError; + Assert.AreEqual((int)ExitCode.ZeroTests, skipped.ExitCode, skipped.ToString()); + Assert.Contains("skipped: 1", skippedOutput); + Assert.DoesNotContain("did not complete within", skippedOutput); + + DotnetMuxerResult failed = await RunBrowserTestAsync( + generator, + "--filter FullyQualifiedName~FailsInsideBrowser"); + string failedOutput = failed.StandardOutput + failed.StandardError; + Assert.AreNotEqual(0, failed.ExitCode, failed.ToString()); + Assert.Contains("failed: 1", failedOutput); + Assert.Contains("Intentional browser experiment failure.", failedOutput); + Assert.DoesNotContain("did not complete within", failedOutput); + } + + [TestMethod] + public async Task BrowserPackage_WrapsOnlyMarkedBrowserComputeRunArguments() + { + string browserPackageVersion = GetBrowserPackageVersion(); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserPackageComputeRunArgumentsProject", + SourceCode + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) + .PatchCodeWithReplace("$Browser$", "browser-placeholder")); + + DotnetMuxerResult restore = await DotnetCli.RunAsync( + $"restore {generator.TargetAssetPath}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, restore.ExitCode, restore.ToString()); + + string commonArguments = + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments" + + $" -property:TargetFramework={TargetFramework}" + + $" -property:RuntimeIdentifier={WasmRuntime.BrowserRid}" + + " -getProperty:RunCommand -getProperty:RunArguments"; + + DotnetMuxerResult ordinary = await DotnetCli.RunAsync( + commonArguments, + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, ordinary.ExitCode, ordinary.ToString()); + Assert.DoesNotContain("Microsoft.Testing.Platform.Browser.dll", ordinary.StandardOutput); + + DotnetMuxerResult marked = await DotnetCli.RunAsync( + commonArguments + " -property:DotnetTestInvocation=true", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, marked.ExitCode, marked.ToString()); + Assert.Contains("Microsoft.Testing.Platform.Browser.dll", marked.StandardOutput); + Assert.Contains("--host-command-uri", marked.StandardOutput); + Assert.Contains("--host-arguments-uri", marked.StandardOutput); + Assert.DoesNotContain(".launch", marked.StandardOutput); + } + + [TestMethod] + public async Task BrowserPackage_HostAssetsAreBuildOnlyConsumerAssets() + { + string? browser = LocateBrowser(); + if (browser is null) + { + Assert.Inconclusive("Skipping Microsoft.Testing.Platform.Browser execution: no Chromium-family browser was found."); + return; + } + + string browserPackageVersion = GetBrowserPackageVersion(); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserPackageStaticWebAssetsProject", + (SourceCode + ConsumerPageSourceCode) + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) + .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); + + DotnetMuxerResult build = await DotnetCli.RunAsync( + $"build {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, build.ExitCode, build.ToString()); + + using var buildManifest = JsonDocument.Parse(File.ReadAllText( + await GetEvaluatedPathAsync( + generator, + "StaticWebAssetBuildManifestPath"))); + Assert.IsTrue(ContainsBrowserHostAsset( + buildManifest, + "_mtp/browser-host.html", + "Build")); + Assert.IsTrue(ContainsBrowserHostAsset( + buildManifest, + "_mtp/Microsoft.Testing.Platform.Browser.main.js", + "Build")); + + DotnetMuxerResult run = await RunBrowserTestAsync( + generator, + "--filter FullyQualifiedName~RunsInsideBrowser"); + Assert.AreEqual(0, run.ExitCode, run.ToString()); + + string publishDirectory = Path.Combine(generator.TargetAssetPath, "publish"); + DotnetMuxerResult publish = await DotnetCli.RunAsync( + $"publish {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid} --output {publishDirectory}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, publish.ExitCode, publish.ToString()); + + using var publishManifest = JsonDocument.Parse(File.ReadAllText( + await GetEvaluatedPathAsync( + generator, + "StaticWebAssetPublishManifestPath"))); + Assert.IsFalse(ContainsBrowserHostAsset( + publishManifest, + "_mtp/browser-host.html")); + Assert.IsFalse(ContainsBrowserHostAsset( + publishManifest, + "_mtp/Microsoft.Testing.Platform.Browser.main.js")); + Assert.IsEmpty(Directory.EnumerateFiles( + publishDirectory, + "Microsoft.Testing.Platform.Browser.main.js", + SearchOption.AllDirectories)); + string consumerIndex = Directory.EnumerateFiles( + publishDirectory, + "index.html", + SearchOption.AllDirectories).Single(); + Assert.Contains("consumer-page-marker", File.ReadAllText(consumerIndex)); + } + + [TestMethod] + public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() + { + string browserPackageVersion = GetBrowserPackageVersion(); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserPackageDesktopTestProject", + DesktopSourceCode + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion)); + + DotnetMuxerResult run = await DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + cancellationToken: TestContext.CancellationToken); + + string output = run.StandardOutput + run.StandardError; + Assert.AreEqual(0, run.ExitCode, run.ToString()); + string architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + Assert.Contains($"({TargetFramework}|{architecture}) passed [+1/x0/?0]", output); + await AssertIsTestingPlatformApplicationAsync( + generator, + $"-property:TargetFramework={TargetFramework}"); + } + + [TestMethod] + public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() + { + string package = GetBrowserPackagePath(); + using ZipArchive archive = ZipFile.OpenRead(package); + string[] entries = [.. archive.Entries.Select(static entry => entry.FullName)]; + + Assert.Contains("tools/net8.0/any/Microsoft.Playwright.dll", entries); + Assert.Contains("tools/net8.0/any/Microsoft.Bcl.AsyncInterfaces.dll", entries); + Assert.Contains("tools/net8.0/any/.playwright/node/win32_x64/node.exe", entries); + Assert.Contains("tools/net8.0/any/.playwright/node/linux-x64/node", entries); + Assert.Contains("tools/net8.0/any/.playwright/node/linux-arm64/node", entries); + Assert.Contains("tools/net8.0/any/.playwright/node/darwin-x64/node", entries); + Assert.Contains("tools/net8.0/any/.playwright/node/darwin-arm64/node", entries); + Assert.Contains("tools/net8.0/any/.playwright/package/cli.js", entries); + Assert.DoesNotContain("buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets", entries); + + ZipArchiveEntry browserTargetsEntry = archive.GetEntry( + "buildMultiTargeting/Microsoft.Testing.Platform.Browser.targets") + ?? throw new AssertFailedException("The browser package targets were not packaged."); + using Stream browserTargetsStream = browserTargetsEntry.Open(); + using var browserTargetsReader = new StreamReader(browserTargetsStream); + string browserTargets = browserTargetsReader.ReadToEnd(); + Assert.Contains("SourceId=\"$(PackageId)\"", browserTargets); + Assert.Contains("AssetKind=\"Build\"", browserTargets); + Assert.Contains("AfterTargets=\"ComputeRunArguments\"", browserTargets); + Assert.DoesNotContain("CustomAfterDirectoryBuildTargets", browserTargets); + + ZipArchiveEntry browserMainEntry = archive.GetEntry( + "buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js") + ?? throw new AssertFailedException("The package-owned browser supervisor was not packaged."); + using Stream browserMainStream = browserMainEntry.Open(); + using var browserMainReader = new StreamReader(browserMainStream); + string browserMain = browserMainReader.ReadToEnd(); + Assert.Contains("await import('../_framework/dotnet.js')", browserMain); + Assert.Contains("__mtpBrowserGetArguments", browserMain); + Assert.Contains("__mtpBrowserComplete", browserMain); + Assert.Contains("launcherAvailable", browserMain); + Assert.DoesNotContain("testingPlatformBrowser", browserMain); + + ZipArchiveEntry runtimeConfigEntry = archive.GetEntry( + "tools/net8.0/any/Microsoft.Testing.Platform.Browser.runtimeconfig.json") + ?? throw new AssertFailedException("The browser launcher runtimeconfig was not packaged."); + using Stream runtimeConfigStream = runtimeConfigEntry.Open(); + using var runtimeConfig = System.Text.Json.JsonDocument.Parse(runtimeConfigStream); + Assert.AreEqual( + "Major", + runtimeConfig.RootElement.GetProperty("runtimeOptions").GetProperty("rollForward").GetString()); + + ZipArchiveEntry nuspecEntry = archive.Entries.Single( + static entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal)); + using Stream nuspecStream = nuspecEntry.Open(); + var nuspec = XDocument.Load(nuspecStream); + string[] packageDependencies = + [ + .. nuspec.Descendants() + .Where(static element => element.Name.LocalName == "dependency") + .Select(static element => element.Attribute("id")?.Value) + .OfType(), + ]; + Assert.DoesNotContain( + "Microsoft.Playwright", + packageDependencies, + "Microsoft.Playwright must remain a private build-time dependency; its runtime is bundled under tools."); + } + + [TestMethod] + public async Task BrowserPackage_PackNoBuildPreservesRuntimePayload() + { + string packageOutput = Path.Combine( + TestContext.TestRunResultsDirectory ?? Path.GetTempPath(), + $"browser-pack-no-build-{Guid.NewGuid():N}"); + Directory.CreateDirectory(packageOutput); + + DotnetMuxerResult pack = await DotnetCli.RunAsync( + $"pack {Path.Combine(RootFinder.Find(), "src", "Platform", "Microsoft.Testing.Platform.Browser", "Microsoft.Testing.Platform.Browser.csproj")} --configuration {Constants.BuildConfiguration} --no-build --no-restore -property:PackageOutputPath={packageOutput}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(0, pack.ExitCode, pack.ToString()); + string package = Directory.EnumerateFiles( + packageOutput, + "Microsoft.Testing.Platform.Browser.*.nupkg").Single(); + using ZipArchive archive = ZipFile.OpenRead(package); + Assert.IsNotNull(archive.GetEntry("tools/net8.0/any/Microsoft.Playwright.dll")); + Assert.IsNotNull(archive.GetEntry("tools/net8.0/any/.playwright/package/cli.js")); + } + + [TestMethod] + public void BrowserPackage_InvalidExplicitBrowserPathFails() + { + string? previousValue = Environment.GetEnvironmentVariable( + "TESTINGPLATFORM_BROWSER_EXECUTABLE"); + string invalidPath = Path.Combine( + Path.GetTempPath(), + $"missing-browser-{Guid.NewGuid():N}"); + + try + { + Environment.SetEnvironmentVariable( + "TESTINGPLATFORM_BROWSER_EXECUTABLE", + invalidPath); + + Assert.ThrowsExactly(() => LocateBrowser()); + } + finally + { + Environment.SetEnvironmentVariable( + "TESTINGPLATFORM_BROWSER_EXECUTABLE", + previousValue); + } + } + + private async Task RunBrowserTestAsync( + TestAsset generator, + string testArguments, + Dictionary? environmentVariables = null) + => await DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}" + + $" -property:DotnetTestInvocation=true {testArguments}", + environmentVariables: environmentVariables, + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + private async Task AssertIsTestingPlatformApplicationAsync( + TestAsset generator, + string properties) + { + DotnetMuxerResult evaluation = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -getProperty:IsTestingPlatformApplication {properties}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(0, evaluation.ExitCode, evaluation.ToString()); + Assert.AreEqual("true", evaluation.StandardOutput.Trim()); + } + + private static string GetBrowserPackageVersion() + { + string fileName = Path.GetFileName(GetBrowserPackagePath()); + const string packagePrefix = "Microsoft.Testing.Platform.Browser."; + return fileName[packagePrefix.Length..^".nupkg".Length]; + } + + private static string GetBrowserPackagePath() + { + const string packagePrefix = "Microsoft.Testing.Platform.Browser."; + return Directory + .EnumerateFiles(Constants.ArtifactsPackagesShipping, $"{packagePrefix}*.nupkg") + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault() + ?? throw new AssertFailedException( + $"Microsoft.Testing.Platform.Browser was not packed under '{Constants.ArtifactsPackagesShipping}'."); + } + + private static string? LocateBrowser() + { + string? requestedBrowser = Environment.GetEnvironmentVariable( + "TESTINGPLATFORM_BROWSER_EXECUTABLE"); + if (requestedBrowser is not null) + { + return File.Exists(requestedBrowser) + ? Path.GetFullPath(requestedBrowser) + : throw new AssertFailedException( + $"TESTINGPLATFORM_BROWSER_EXECUTABLE does not exist: '{requestedBrowser}'."); + } + + IEnumerable candidates = OperatingSystem.IsWindows() + ? new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft", "Edge", "Application", "msedge.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft", "Edge", "Application", "msedge.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Google", "Chrome", "Application", "chrome.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Google", "Chrome", "Application", "chrome.exe"), + } + : OperatingSystem.IsMacOS() + ? new[] + { + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + } + : FindBrowsersOnPath(); + + return candidates.FirstOrDefault(File.Exists); + } + + private static IEnumerable FindBrowsersOnPath() + { + string? path = Environment.GetEnvironmentVariable("PATH"); + if (path is null) + { + yield break; + } + + foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + foreach (string executable in new[] { "microsoft-edge", "google-chrome", "chromium", "chromium-browser" }) + { + yield return Path.Combine(directory, executable); + } + } + } + + private static string EscapeMsBuildValue(string value) + => value.Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); + + private static bool ContainsBrowserHostAsset( + JsonDocument manifest, + string relativePath, + string? assetKind = null) + => manifest.RootElement.GetProperty("Assets").EnumerateArray().Any( + asset => asset.GetProperty("SourceId").GetString() == "BrowserPackageTestProject" + && asset.GetProperty("RelativePath").GetString() == relativePath + && (assetKind is null + || asset.GetProperty("AssetKind").GetString() == assetKind)); + + private async Task GetEvaluatedPathAsync( + TestAsset generator, + string propertyName) + { + DotnetMuxerResult evaluation = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ResolveStaticWebAssetsConfiguration" + + $" -getProperty:{propertyName}" + + $" -property:Configuration=Release" + + $" -property:TargetFramework={TargetFramework}" + + $" -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, evaluation.ExitCode, evaluation.ToString()); + + string path = evaluation.StandardOutput.Trim(); + return Path.IsPathRooted(path) + ? path + : Path.GetFullPath(path, generator.TargetAssetPath); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs new file mode 100644 index 0000000000..d090894c1c --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -0,0 +1,451 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET +using Microsoft.Testing.Platform.Browser; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class BrowserLauncherOptionsTests +{ + private const string BootstrapToken = "secret-token"; + + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public void Parse_ReadsDirectLauncherOptionsAndSdkResponseFile() + { + string responseFile = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://127.0.0.1:1234/ + --dotnet-test-http-token secret-token + --filter FullyQualifiedName~Browser + """); + string browserExecutable = CreateEmptyFile(); + + try + { + var options = BrowserLauncherOptions.Parse( + [ + "--host-command-uri", Encode("dotnet"), + "--host-arguments-uri", Encode("""run --property "value with spaces" """), + "--host-working-directory-uri", Encode(Environment.CurrentDirectory), + "--browser-executable-uri", Encode(browserExecutable), + "--startup-timeout-seconds", "30", + "--completion-timeout-seconds", "90", + "--", + $"@{responseFile}", + ]); + + Assert.AreEqual("dotnet", options.HostCommand); + Assert.AreEqual("""run --property "value with spaces" """, options.HostArguments); + Assert.AreEqual(Path.GetFullPath(Environment.CurrentDirectory), options.HostWorkingDirectory); + Assert.AreEqual(Path.GetFullPath(browserExecutable), options.BrowserExecutable); + Assert.AreEqual(TimeSpan.FromSeconds(30), options.StartupTimeout); + Assert.AreEqual(TimeSpan.FromSeconds(90), options.CompletionTimeout); + Assert.Contains("--filter", options.TestApplicationArguments); + Assert.AreEqual(new Uri("http://127.0.0.1:1234/"), options.Bootstrap.Endpoint); + Assert.AreEqual(BootstrapToken, options.Bootstrap.Token); + } + finally + { + File.Delete(responseFile); + File.Delete(browserExecutable); + } + } + + [TestMethod] + public void Parse_PreservesEncodedNewlinesAndSpecialCharacters() + { + string responseFile = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://[::1]:1234/ + --dotnet-test-http-token secret-token + """); + string browserExecutable = CreateEmptyFile(); + const string hostArguments = "line1\r\nline2;%#'\""; + + try + { + var options = BrowserLauncherOptions.Parse( + [ + "--host-command-uri", Encode("host"), + "--host-arguments-uri", Encode(hostArguments), + "--host-working-directory-uri", Encode(Environment.CurrentDirectory), + "--browser-executable-uri", Encode(browserExecutable), + "--startup-timeout-seconds", "1", + "--completion-timeout-seconds", "2", + "--", + $"@{responseFile}", + ]); + + Assert.AreEqual(hostArguments, options.HostArguments); + } + finally + { + File.Delete(responseFile); + File.Delete(browserExecutable); + } + } + + [TestMethod] + public void Parse_PreservesEmptyHostArguments() + { + string responseFile = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://127.0.0.1:1234/ + --dotnet-test-http-token secret-token + """); + string browserExecutable = CreateEmptyFile(); + + try + { + var options = BrowserLauncherOptions.Parse( + [ + "--host-command-uri", Encode("host"), + "--host-arguments-uri", string.Empty, + "--host-working-directory-uri", Encode(Environment.CurrentDirectory), + "--browser-executable-uri", Encode(browserExecutable), + "--startup-timeout-seconds", "1", + "--completion-timeout-seconds", "2", + "--", + $"@{responseFile}", + ]); + + Assert.AreEqual(string.Empty, options.HostArguments); + } + finally + { + File.Delete(responseFile); + File.Delete(browserExecutable); + } + } + + [TestMethod] + [DataRow("--server vstest", "--dotnet-test-transport http", "--dotnet-test-http-endpoint http://127.0.0.1:1234/", "--dotnet-test-http-token secret")] + [DataRow("--server dotnettestcli", "--dotnet-test-transport pipe", "--dotnet-test-http-endpoint http://127.0.0.1:1234/", "--dotnet-test-http-token secret")] + [DataRow("--server dotnettestcli", "--dotnet-test-transport http", "--dotnet-test-http-endpoint http://example.com/", "--dotnet-test-http-token secret")] + [DataRow("--server dotnettestcli", "--dotnet-test-transport http", "--dotnet-test-http-endpoint http://127.0.0.1:1234/", "--dotnet-test-http-token secret token")] + public void DotnetTestHttpBootstrap_RejectsInvalidBootstrap( + string server, + string transport, + string endpoint, + string token) + => Assert.ThrowsExactly( + () => DotnetTestHttpBootstrap.Parse( + [ + .. SplitPair(server), + .. SplitPair(transport), + .. SplitPair(endpoint), + .. SplitPair(token), + ])); + + [TestMethod] + public void ResponseFileExpander_DoesNotLogSecretsInErrors() + { + string missingFile = Path.Combine( + Path.GetTempPath(), + $"missing-{BootstrapToken}-{Guid.NewGuid():N}.rsp"); + + BrowserLauncherException exception = Assert.ThrowsExactly( + () => SdkResponseFileExpander.Expand([$"@{missingFile}"])); + + Assert.DoesNotContain(BootstrapToken, exception.Message); + Assert.AreEqual("Unable to read the SDK response file.", exception.Message); + } + + [TestMethod] + public void ResponseFileExpander_RejectsUserResponseFileBeforeSdkBootstrap() + { + string bootstrap = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://127.0.0.1:1234/ + --dotnet-test-http-token secret-token + """); + + try + { + BrowserLauncherException exception = Assert.ThrowsExactly( + () => SdkResponseFileExpander.Expand( + [ + "--filter", + "FullyQualifiedName~Browser", + "@user.rsp", + $"@{bootstrap}", + ])); + + Assert.Contains("User response files are not supported", exception.Message); + } + finally + { + File.Delete(bootstrap); + } + } + + [TestMethod] + public void ResponseFileExpander_PreservesArgumentsAndExpandsFinalSdkBootstrap() + { + string bootstrap = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://127.0.0.1:1234/ + --dotnet-test-http-token secret-token + """); + + try + { + string[] expanded = SdkResponseFileExpander.Expand( + [ + "--filter", + "FullyQualifiedName~Browser test", + $"@{bootstrap}", + ]); + + Assert.AreSequenceEqual( + [ + "--filter", + "FullyQualifiedName~Browser test", + "--server", + "dotnettestcli", + "--dotnet-test-transport", + "http", + "--dotnet-test-http-endpoint", + "http://127.0.0.1:1234/", + "--dotnet-test-http-token", + "secret-token", + ], + expanded); + } + finally + { + File.Delete(bootstrap); + } + } + + [TestMethod] + public void ResponseFileExpander_RequiresFinalSdkBootstrapResponseFile() + { + BrowserLauncherException exception = Assert.ThrowsExactly( + () => SdkResponseFileExpander.Expand(["--list-tests"])); + + Assert.Contains("final test application argument", exception.Message); + } + + [TestMethod] + public void ResponseFileExpander_AppliesStrictPermissionsOnlyToSdkBootstrap() + { + string bootstrap = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://127.0.0.1:1234/ + --dotnet-test-http-token secret-token + """); + + try + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + bootstrap, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.GroupRead); + BrowserLauncherException permissionsException = + Assert.ThrowsExactly( + () => SdkResponseFileExpander.Expand([$"@{bootstrap}"])); + Assert.Contains( + "accessible by users other than its owner", + permissionsException.Message); + } + else + { + Assert.Contains( + "--server", + SdkResponseFileExpander.Expand([$"@{bootstrap}"])); + } + } + finally + { + File.Delete(bootstrap); + } + } + + [TestMethod] + public void HostProcess_ParsesOnlyExactHttpAppUrlReadiness() + { + Assert.AreEqual( + new Uri("http://127.0.0.1:1234/"), + HostProcess.TryParseAppUrl("App url: http://127.0.0.1:1234/")); + Assert.IsNull(HostProcess.TryParseAppUrl("App url: https://127.0.0.1:1234/")); + Assert.IsNull(HostProcess.TryParseAppUrl("Debug at url: http://127.0.0.1:1234/")); + Assert.IsNull(HostProcess.TryParseAppUrl("Now listening on: http://127.0.0.1:1234/")); + Assert.ThrowsExactly( + () => HostProcess.TryParseAppUrl("App url: http://example.com/")); + } + + [TestMethod] + public void DiagnosticBuffer_RedactsBootstrapValuesAndBoundsOutput() + { + var diagnostics = new DiagnosticBuffer( + BootstrapToken, + "abc", + "http://127.0.0.1:1234/abc", + "abc"); + + diagnostics.Add("one", $"token={BootstrapToken}"); + diagnostics.Add("two", "http://127.0.0.1:1234/abc"); + diagnostics.Add("three", "short=abc"); + diagnostics.Add("four", new string('x', 5_000)); + string output = diagnostics.Format(); + + Assert.DoesNotContain(BootstrapToken, output); + Assert.DoesNotContain("abc", output); + Assert.DoesNotContain("http://127.0.0.1:1234", output); + Assert.Contains("[redacted]", output); + Assert.IsLessThan(4_500, output.Length); + } + + [TestMethod] + public void BrowserTerminalResult_FatalErrorIsReportedThroughDiagnostics() + { + var diagnostics = new DiagnosticBuffer("abc"); + var result = new BrowserTerminalResult(1, "bootstrap abc failed"); + + BrowserLauncherException exception = Assert.ThrowsExactly( + () => result.GetExitCode(diagnostics)); + + Assert.AreEqual("The browser supervisor reported a fatal error.", exception.Message); + Assert.Contains("bootstrap [redacted] failed", diagnostics.Format()); + } + + [TestMethod] + public async Task BrowserBindingCallback_FaultsCompletionImmediately() + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + Assert.ThrowsExactly( + () => BrowserBindingCallback.Invoke( + completion, + () => throw new InvalidOperationException("hostile binding"))); + + BrowserLauncherException exception = + await Assert.ThrowsExactlyAsync( + async () => await completion.Task); + Assert.AreEqual( + "The browser supervisor binding request was rejected.", + exception.Message); + } + + [TestMethod] + public async Task BoundedResourceCleanup_DisposesLateCreationOffMainPath() + { + var creation = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var diagnostics = new List(); + + DisposableResource? observed = await BoundedResourceCleanup.ObserveCreationAsync( + creation.Task, + TimeSpan.FromMilliseconds(10), + static resource => resource.Dispose(), + diagnostics.Add); + Assert.IsNull(observed); + + var resource = new DisposableResource(); + creation.SetResult(resource); + await resource.Disposed.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.CancellationToken); + Assert.IsNotEmpty(diagnostics); + } + + [TestMethod] + public async Task BoundedResourceCleanup_BoundsSynchronousDisposal() + { + using var release = new ManualResetEventSlim(); + var diagnostics = new List(); + var stopwatch = Stopwatch.StartNew(); + + await BoundedResourceCleanup.DisposeAsync( + release.Wait, + TimeSpan.FromMilliseconds(10), + diagnostics.Add); + + Assert.IsLessThan(TimeSpan.FromSeconds(5), stopwatch.Elapsed); + Assert.IsNotEmpty(diagnostics); + release.Set(); + } + + [TestMethod] + public async Task BrowserRunMonitor_CancellationStopsCompletionWaitPromptly() + { + using var cancellationTokenSource = new CancellationTokenSource( + TimeSpan.FromMilliseconds(100)); + var stopwatch = Stopwatch.StartNew(); + + await Assert.ThrowsExactlyAsync( + () => BrowserRunMonitor.WaitAsync( + WaitForCompletionAsync, + WaitForExitAsync, + WaitForExitAsync, + cancellationTokenSource.Token)); + + Assert.IsLessThan(TimeSpan.FromSeconds(5), stopwatch.Elapsed); + + static async Task WaitForCompletionAsync(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + static Task WaitForExitAsync(CancellationToken cancellationToken) + => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + private static string[] SplitPair(string value) + { + int separator = value.IndexOf(' '); + return [value[..separator], value[(separator + 1)..]]; + } + + private static string Encode(string value) => Uri.EscapeDataString(value); + + private static string CreateResponseFile(string content) + { + string path = Path.Combine(Path.GetTempPath(), $"mtp-browser-{Guid.NewGuid():N}.rsp"); + File.WriteAllText(path, content); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + return path; + } + + private static string CreateEmptyFile() + { + string path = Path.Combine(Path.GetTempPath(), $"mtp-browser-{Guid.NewGuid():N}.exe"); + File.WriteAllText(path, string.Empty); + return path; + } + + private sealed class DisposableResource : IDisposable + { + public TaskCompletionSource Disposed { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public void Dispose() => Disposed.TrySetResult(); + } +} +#endif diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj index a361981a6d..1f8d613927 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj @@ -69,6 +69,17 @@ Condition="$([MSBuild]::GetTargetFrameworkIdentifier('$(TargetFramework)')) == '.NETCoreApp'" /> + + + + + + + + + + +