From 3a9eae4afc3e0e19293e701559b930aa0aef4be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sun, 13 Sep 2026 22:24:53 +0200 Subject: [PATCH 01/16] Add browser-wasm dotnet test launcher package Introduce an optional Microsoft.Testing.Platform.Browser package with Chromium/CDP launch supervision, secure SDK HTTP bootstrap injection, build-transitive browser assets, and focused unit and acceptance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 8 + Microsoft.Testing.Platform.slnf | 1 + MutationTesting.slnx | 1 + NonWindowsTests.slnf | 1 + TestFx.slnx | 1 + .../BrowserExecutableLocator.cs | 82 ++++ .../BrowserLauncherOptions.cs | 376 ++++++++++++++++ .../ChromiumBrowser.cs | 411 ++++++++++++++++++ .../DevToolsConnection.cs | 162 +++++++ .../DiagnosticBuffer.cs | 56 +++ .../HostProcess.cs | 235 ++++++++++ .../Microsoft.Testing.Platform.Browser.csproj | 39 ++ .../PACKAGE.md | 90 ++++ .../Program.cs | 87 ++++ .../Microsoft.Testing.Platform.Browser.props | 3 + ...Microsoft.Testing.Platform.Browser.targets | 3 + .../Microsoft.Testing.Platform.Browser.props | 10 + ...Microsoft.Testing.Platform.Browser.targets | 68 +++ ...Microsoft.Testing.Platform.Browser.main.js | 38 ++ .../buildMultiTargeting/assets/index.html | 14 + .../Microsoft.Testing.Platform.Browser.props | 3 + ...Microsoft.Testing.Platform.Browser.targets | 3 + .../BrowserPackageExecutionTests.cs | 271 ++++++++++++ .../BrowserLauncherOptionsTests.cs | 221 ++++++++++ ...rosoft.Testing.Extensions.UnitTests.csproj | 2 + 25 files changed, 2186 insertions(+) create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/BrowserExecutableLocator.cs create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/Program.cs create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.props create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/build/Microsoft.Testing.Platform.Browser.targets create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.props create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.targets create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/index.html create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.props create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildTransitive/Microsoft.Testing.Platform.Browser.targets create mode 100644 test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs 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/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/BrowserExecutableLocator.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserExecutableLocator.cs new file mode 100644 index 0000000000..b4df7b8380 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserExecutableLocator.cs @@ -0,0 +1,82 @@ +// 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 BrowserExecutableLocator +{ + public static string Locate(string? configuredPath) + { + string? environmentPath = Environment.GetEnvironmentVariable("MTP_BROWSER_EXECUTABLE"); + foreach (string? candidate in EnumerateCandidates(configuredPath, environmentPath)) + { + if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate)) + { + return Path.GetFullPath(candidate); + } + } + + foreach (string executableName in GetExecutableNames()) + { + if (FindOnPath(executableName) is { } path) + { + return path; + } + } + + throw new BrowserLauncherException( + "No Chromium-family browser was found. Set TestingPlatformBrowserExecutable or MTP_BROWSER_EXECUTABLE."); + } + + internal static IEnumerable EnumerateCandidates(string? configuredPath, string? environmentPath) + { + yield return configuredPath; + yield return environmentPath; + + if (OperatingSystem.IsWindows()) + { + foreach (string root in new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + }) + { + yield return Path.Combine(root, "Microsoft", "Edge", "Application", "msedge.exe"); + yield return Path.Combine(root, "Google", "Chrome", "Application", "chrome.exe"); + yield return Path.Combine(root, "Chromium", "Application", "chrome.exe"); + } + } + else if (OperatingSystem.IsMacOS()) + { + yield return "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; + yield return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + yield return "/Applications/Chromium.app/Contents/MacOS/Chromium"; + } + } + + private static IEnumerable GetExecutableNames() + => OperatingSystem.IsWindows() + ? ["msedge.exe", "chrome.exe", "chromium.exe"] + : ["microsoft-edge", "microsoft-edge-stable", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]; + + private static string? FindOnPath(string executableName) + { + string? path = Environment.GetEnvironmentVariable("PATH"); + if (path is null) + { + return null; + } + + foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string candidate = Path.Combine(directory, executableName); + if (File.Exists(candidate)) + { + return Path.GetFullPath(candidate); + } + } + + return null; + } +} 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..fa889e1f7d --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -0,0 +1,376 @@ +// 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, + IReadOnlyList HostArguments, + string HostWorkingDirectory, + string UrlPath, + string? BrowserExecutable, + IReadOnlyList BrowserArguments, + 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."); + } + + string hostCommand; + string hostArguments; + string hostWorkingDirectory; + string urlPath; + string? browserExecutable; + string browserArguments; + TimeSpan startupTimeout; + TimeSpan completionTimeout; + + if (separatorIndex == 2 && args[0] == "--config") + { + string[] configuration; + try + { + configuration = File.ReadAllLines(args[1]); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new BrowserLauncherException("Unable to read the browser launcher configuration file.", ex); + } + + if (configuration.Length != 8) + { + throw new BrowserLauncherException("The browser launcher configuration file is invalid."); + } + + hostCommand = ReadConfigurationValue(configuration, 0, "host-command"); + hostArguments = ReadConfigurationValue(configuration, 1, "host-arguments"); + hostWorkingDirectory = ReadConfigurationValue(configuration, 2, "host-working-directory"); + urlPath = ReadConfigurationValue(configuration, 3, "url-path"); + browserExecutable = ReadConfigurationValue(configuration, 4, "browser-executable"); + browserArguments = ReadConfigurationValue(configuration, 5, "browser-arguments"); + startupTimeout = ParseTimeout( + ReadConfigurationValue(configuration, 6, "startup-timeout-seconds"), + "startup timeout"); + completionTimeout = ParseTimeout( + ReadConfigurationValue(configuration, 7, "completion-timeout-seconds"), + "completion timeout"); + } + else + { + 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}."); + } + + options.Add(args[i], args[i + 1]); + } + + hostCommand = DecodeRequired(options, "--host-command-base64"); + hostArguments = DecodeRequired(options, "--host-arguments-base64"); + hostWorkingDirectory = DecodeRequired(options, "--host-working-directory-base64"); + urlPath = DecodeRequired(options, "--url-path-base64"); + browserExecutable = DecodeOptional(options, "--browser-executable-base64"); + browserArguments = DecodeOptional(options, "--browser-arguments-base64") ?? string.Empty; + + startupTimeout = ParseTimeout(options, "--startup-timeout-seconds"); + completionTimeout = ParseTimeout(options, "--completion-timeout-seconds"); + } + + string[] expandedArguments = ResponseFileArgumentExpander.Expand(args[(separatorIndex + 1)..]); + var bootstrap = DotnetTestHttpBootstrap.Parse(expandedArguments); + + return new BrowserLauncherOptions( + hostCommand, + CommandLineTokenizer.Split(hostArguments), + Path.GetFullPath(hostWorkingDirectory), + NormalizeUrlPath(urlPath), + browserExecutable, + CommandLineTokenizer.Split(browserArguments), + startupTimeout, + completionTimeout, + expandedArguments, + bootstrap); + } + + private static string DecodeRequired(Dictionary options, string name) + => DecodeOptional(options, name) is { Length: > 0 } value + ? value + : throw new BrowserLauncherException($"Required launcher option '{name}' is missing or empty."); + + private static string? DecodeOptional(Dictionary options, string name) + { + if (!options.Remove(name, out string? encodedValue)) + { + return null; + } + + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(encodedValue)); + } + catch (FormatException ex) + { + throw new BrowserLauncherException($"Launcher option '{name}' is not valid Base64.", ex); + } + } + + private static TimeSpan ParseTimeout(Dictionary options, string name) + => options.Remove(name, out string? value) + ? ParseTimeout(value, name) + : throw new BrowserLauncherException($"Launcher option '{name}' must be a positive integer."); + + private static TimeSpan ParseTimeout(string value, string name) + => 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."); + + private static string NormalizeUrlPath(string path) + => path.StartsWith("/", StringComparison.Ordinal) ? path : "/" + path; + + private static string ReadConfigurationValue(string[] configuration, int index, string name) + { + string prefix = name + "="; + return configuration[index].StartsWith(prefix, StringComparison.Ordinal) + ? configuration[index][prefix.Length..] + : throw new BrowserLauncherException("The browser launcher configuration file is invalid."); + } +} + +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; + } + } + } + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? endpointUri)) + { + throw new BrowserLauncherException( + "The SDK did not provide a valid loopback authenticated HTTP dotnettestcli bootstrap."); + } + + bool isValid = 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); + + return isValid + ? new DotnetTestHttpBootstrap(endpointUri, token!) + : throw new BrowserLauncherException( + "The SDK did not provide a valid loopback authenticated HTTP dotnettestcli bootstrap."); + } +} + +internal static class ResponseFileArgumentExpander +{ + public static string[] Expand(IReadOnlyList arguments) + { + var expanded = new List(); + var activeFiles = new HashSet( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + + foreach (string argument in arguments) + { + ExpandArgument(argument, expanded, activeFiles); + } + + return [.. expanded]; + } + + private static void ExpandArgument(string argument, List expanded, HashSet activeFiles) + { + if (!argument.StartsWith('@')) + { + expanded.Add(argument); + return; + } + + string path = Path.GetFullPath(argument[1..]); + if (!activeFiles.Add(path)) + { + throw new BrowserLauncherException("Recursive response files are not supported."); + } + + 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; + } + + foreach (string nestedArgument in CommandLineTokenizer.Split(trimmed)) + { + ExpandArgument(nestedArgument, expanded, activeFiles); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException) + { + throw new BrowserLauncherException($"Unable to read the SDK response file '{Path.GetFileName(path)}'.", ex); + } + finally + { + activeFiles.Remove(path); + } + } + + 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 '{Path.GetFileName(path)}' is accessible by users other than its owner."); + } + } +} + +internal static class CommandLineTokenizer +{ + public static string[] Split(string commandLine) + { + var arguments = new List(); + var current = new StringBuilder(); + bool inQuotes = false; + int backslashCount = 0; + + void FlushBackslashes() + { + if (backslashCount > 0) + { + current.Append('\\', backslashCount); + backslashCount = 0; + } + } + + for (int i = 0; i < commandLine.Length; i++) + { + char character = commandLine[i]; + if (character == '\\') + { + backslashCount++; + continue; + } + + if (character == '"') + { + current.Append('\\', backslashCount / 2); + if (backslashCount % 2 == 0) + { + inQuotes = !inQuotes; + } + else + { + current.Append('"'); + } + + backslashCount = 0; + continue; + } + + FlushBackslashes(); + if (char.IsWhiteSpace(character) && !inQuotes) + { + if (current.Length > 0) + { + arguments.Add(current.ToString()); + current.Clear(); + } + + continue; + } + + current.Append(character); + } + + FlushBackslashes(); + if (inQuotes) + { + throw new BrowserLauncherException("A launcher command line contains an unclosed quote."); + } + + if (current.Length > 0) + { + arguments.Add(current.ToString()); + } + + return [.. arguments]; + } +} + +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/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs new file mode 100644 index 0000000000..db105551d7 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -0,0 +1,411 @@ +// 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.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed class ChromiumBrowser : IAsyncDisposable +{ + private readonly Process _process; + private readonly string _profileDirectory; + private readonly DiagnosticBuffer _diagnostics; + private readonly DevToolsConnection _devToolsConnection; + private readonly CancellationTokenSource _captureCancellationTokenSource; + private readonly Task _stdoutTask; + private readonly Task _stderrTask; + + private ChromiumBrowser( + Process process, + string profileDirectory, + DiagnosticBuffer diagnostics, + DevToolsConnection devToolsConnection, + CancellationTokenSource captureCancellationTokenSource, + Task stdoutTask, + Task stderrTask) + { + _process = process; + _profileDirectory = profileDirectory; + _diagnostics = diagnostics; + _devToolsConnection = devToolsConnection; + _captureCancellationTokenSource = captureCancellationTokenSource; + _stdoutTask = stdoutTask; + _stderrTask = stderrTask; + } + + public Task WaitForExitAsync(CancellationToken cancellationToken) + => _process.WaitForExitAsync(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; + + string executable = BrowserExecutableLocator.Locate(options.BrowserExecutable); + string profileDirectory = Path.Combine(Path.GetTempPath(), $"mtp-browser-profile-{Guid.NewGuid():N}"); + Directory.CreateDirectory(profileDirectory); + + var startInfo = new ProcessStartInfo + { + FileName = executable, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (string argument in new[] + { + "--headless=new", + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-dev-shm-usage", + "--disable-extensions", + "--disable-features=Translate", + "--disable-sync", + "--metrics-recording-only", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + $"--user-data-dir={profileDirectory}", + "about:blank", + }) + { + startInfo.ArgumentList.Add(argument); + } + + if (!OperatingSystem.IsWindows() && IsRunningInContainer()) + { + startInfo.ArgumentList.Add("--no-sandbox"); + } + + foreach (string argument in options.BrowserArguments) + { + startInfo.ArgumentList.Add(argument); + } + + Process process; + try + { + process = Process.Start(startInfo) + ?? throw new BrowserLauncherException($"The browser executable '{executable}' did not start."); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + throw new BrowserLauncherException($"Unable to start the browser executable '{executable}'.", ex); + } + + var captureCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task stdoutTask = CaptureAsync( + process.StandardOutput, + "browser stdout", + diagnostics, + captureCancellationTokenSource.Token); + Task stderrTask = CaptureAsync( + process.StandardError, + "browser stderr", + diagnostics, + captureCancellationTokenSource.Token); + + try + { + Uri devToolsEndpoint = await WaitForPageEndpointAsync( + process, + profileDirectory, + options.StartupTimeout, + startupCancellationToken).ConfigureAwait(false); + + var connection = new DevToolsConnection(); + await connection.ConnectAsync(devToolsEndpoint, startupCancellationToken).ConfigureAwait(false); + var browser = new ChromiumBrowser( + process, + profileDirectory, + diagnostics, + connection, + captureCancellationTokenSource, + stdoutTask, + stderrTask); + browser.SubscribeToDiagnostics(); + await browser.InitializePageAsync( + options.TestApplicationArguments, + browserUri, + startupCancellationToken).ConfigureAwait(false); + return browser; + } + catch + { + await captureCancellationTokenSource.CancelAsync().ConfigureAwait(false); + KillProcess(process); + await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); + captureCancellationTokenSource.Dispose(); + process.Dispose(); + TryDeleteDirectory(profileDirectory, diagnostics); + throw; + } + } + + public async Task WaitForCompletionAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(timeout); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellationTokenSource.Token); + + try + { + while (true) + { + if (_process.HasExited) + { + throw new BrowserLauncherException( + $"The browser exited with code {_process.ExitCode} before the test application completed."); + } + + JsonElement result = await _devToolsConnection.SendCommandAsync( + "Runtime.evaluate", + new + { + expression = "globalThis.__mtpBrowserResult ?? null", + returnByValue = true, + awaitPromise = true, + }, + linkedCancellationTokenSource.Token).ConfigureAwait(false); + + JsonElement remoteObject = result.GetProperty("result"); + if (remoteObject.TryGetProperty("value", out JsonElement value) + && value.ValueKind == JsonValueKind.Object + && value.TryGetProperty("completed", out JsonElement completed) + && completed.GetBoolean()) + { + if (value.TryGetProperty("error", out JsonElement error)) + { + _diagnostics.Add("browser", error.GetString() ?? error.GetRawText()); + } + + return value.GetProperty("exitCode").GetInt32(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) + { + throw new BrowserLauncherException($"The browser test application did not complete within {timeout.TotalSeconds} seconds."); + } + } + + public async ValueTask DisposeAsync() + { + await _devToolsConnection.DisposeAsync().ConfigureAwait(false); + KillProcess(_process); + try + { + await _process.WaitForExitAsync().ConfigureAwait(false); + } + catch (InvalidOperationException) + { + } + + await _captureCancellationTokenSource.CancelAsync().ConfigureAwait(false); + await Task.WhenAll(_stdoutTask, _stderrTask).ConfigureAwait(false); + _captureCancellationTokenSource.Dispose(); + _process.Dispose(); + TryDeleteDirectory(_profileDirectory, _diagnostics); + } + + private static async Task CaptureAsync( + StreamReader reader, + string source, + DiagnosticBuffer diagnostics, + CancellationToken cancellationToken) + { + try + { + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + diagnostics.Add(source, line); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private static async Task WaitForPageEndpointAsync( + Process process, + string profileDirectory, + TimeSpan timeout, + CancellationToken cancellationToken) + { + string activePortPath = Path.Combine(profileDirectory, "DevToolsActivePort"); + using var timeoutCancellationTokenSource = new CancellationTokenSource(timeout); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellationTokenSource.Token); + + try + { + while (true) + { + if (process.HasExited) + { + throw new BrowserLauncherException( + $"The browser exited with code {process.ExitCode} before DevTools became available."); + } + + if (File.Exists(activePortPath)) + { + string[] lines; + try + { + lines = await File.ReadAllLinesAsync(activePortPath, linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (IOException) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), linkedCancellationTokenSource.Token).ConfigureAwait(false); + continue; + } + + if (lines.Length > 0 + && int.TryParse(lines[0], NumberStyles.None, CultureInfo.InvariantCulture, out int port)) + { + using var httpClient = new HttpClient(); + BrowserTarget[]? targets; + try + { + targets = await httpClient.GetFromJsonAsync( + $"http://127.0.0.1:{port}/json/list", + linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException) + { + await Task.Delay( + TimeSpan.FromMilliseconds(50), + linkedCancellationTokenSource.Token).ConfigureAwait(false); + continue; + } + + string? webSocketDebuggerUrl = targets? + .FirstOrDefault(static target => target.Type == "page") + ?.WebSocketDebuggerUrl; + if (Uri.TryCreate(webSocketDebuggerUrl, UriKind.Absolute, out Uri? endpoint)) + { + return endpoint; + } + } + } + + await Task.Delay(TimeSpan.FromMilliseconds(50), linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) + { + throw new BrowserLauncherException($"Chromium DevTools did not become ready within {timeout.TotalSeconds} seconds."); + } + } + + private async Task InitializePageAsync( + IReadOnlyList testApplicationArguments, + Uri browserUri, + CancellationToken cancellationToken) + { + await _devToolsConnection.SendCommandAsync("Runtime.enable", parameters: null, cancellationToken).ConfigureAwait(false); + await _devToolsConnection.SendCommandAsync("Page.enable", parameters: null, cancellationToken).ConfigureAwait(false); + await _devToolsConnection.SendCommandAsync("Log.enable", parameters: null, cancellationToken).ConfigureAwait(false); + + string serializedArguments = JsonSerializer.Serialize(testApplicationArguments); + await _devToolsConnection.SendCommandAsync( + "Page.addScriptToEvaluateOnNewDocument", + new + { + source = $"Object.defineProperty(globalThis, '__mtpBrowserArguments', {{ value: Object.freeze({serializedArguments}), configurable: false, enumerable: false, writable: false }});", + }, + cancellationToken).ConfigureAwait(false); + await _devToolsConnection.SendCommandAsync( + "Page.navigate", + new { url = browserUri.AbsoluteUri }, + cancellationToken).ConfigureAwait(false); + } + + private void SubscribeToDiagnostics() + => _devToolsConnection.EventReceived += (method, parameters) => + { + switch (method) + { + case "Runtime.consoleAPICalled": + string type = parameters.TryGetProperty("type", out JsonElement typeElement) + ? typeElement.GetString() ?? "console" + : "console"; + string message = parameters.TryGetProperty("args", out JsonElement arguments) + ? string.Join( + " ", + arguments.EnumerateArray().Select(static argument => + argument.TryGetProperty("value", out JsonElement value) + ? value.ToString() + : argument.TryGetProperty("description", out JsonElement description) + ? description.GetString() + : argument.GetRawText())) + : parameters.GetRawText(); + _diagnostics.Add($"browser {type}", message); + break; + + case "Runtime.exceptionThrown": + _diagnostics.Add("browser exception", parameters.GetRawText()); + break; + + case "Log.entryAdded": + _diagnostics.Add("browser log", parameters.GetRawText()); + break; + } + }; + + private static void KillProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + } + + private static bool IsRunningInContainer() + => string.Equals( + Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), + "true", + StringComparison.OrdinalIgnoreCase) + || File.Exists("/.dockerenv"); + + private static void TryDeleteDirectory(string path, DiagnosticBuffer diagnostics) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + diagnostics.Add("launcher", $"Unable to delete the isolated browser profile: {ex.Message}"); + } + } + + private sealed record BrowserTarget( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("webSocketDebuggerUrl")] string WebSocketDebuggerUrl); +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs b/src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs new file mode 100644 index 0000000000..6fb30964bb --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs @@ -0,0 +1,162 @@ +// 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.Net.WebSockets; +using System.Text.Json; + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed class DevToolsConnection : IAsyncDisposable +{ + private readonly ClientWebSocket _webSocket = new(); + private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly ConcurrentDictionary> _pendingCommands = new(); + private Task? _receiveTask; + private int _nextCommandId; + + public event Action? EventReceived; + + public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken) + { + await _webSocket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false); + _receiveTask = ReceiveLoopAsync(_disposeCancellationTokenSource.Token); + } + + public async Task SendCommandAsync(string method, object? parameters, CancellationToken cancellationToken) + { + int id = Interlocked.Increment(ref _nextCommandId); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_pendingCommands.TryAdd(id, completion)) + { + throw new BrowserLauncherException("Unable to register a Chromium DevTools command."); + } + + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new { id, method, @params = parameters }); + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + + using CancellationTokenRegistration registration = cancellationToken.Register(() => + { + if (_pendingCommands.TryRemove(id, out TaskCompletionSource? pendingCommand)) + { + pendingCommand.TrySetCanceled(cancellationToken); + } + }); + return await completion.Task.ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + using var closeCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + if (_webSocket.State == WebSocketState.Open) + { + try + { + await _webSocket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "Microsoft Testing Platform browser run completed.", + closeCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is WebSocketException or OperationCanceledException or InvalidOperationException) + { + } + } + + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + if (_receiveTask is not null) + { + try + { + await _receiveTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + CompletePendingCommands(new OperationCanceledException("The Chromium DevTools connection was disposed.")); + _sendLock.Dispose(); + _webSocket.Dispose(); + _disposeCancellationTokenSource.Dispose(); + } + + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + byte[] buffer = new byte[16 * 1024]; + try + { + while (!cancellationToken.IsCancellationRequested && _webSocket.State == WebSocketState.Open) + { + using var message = new MemoryStream(); + while (true) + { + WebSocketReceiveResult result = await _webSocket.ReceiveAsync( + new ArraySegment(buffer), + cancellationToken).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + return; + } + + await message.WriteAsync(buffer.AsMemory(0, result.Count), cancellationToken).ConfigureAwait(false); + if (result.EndOfMessage) + { + break; + } + } + + using var document = JsonDocument.Parse(message.ToArray()); + JsonElement root = document.RootElement; + if (root.TryGetProperty("id", out JsonElement idElement) + && _pendingCommands.TryRemove(idElement.GetInt32(), out TaskCompletionSource? completion)) + { + if (root.TryGetProperty("error", out JsonElement error)) + { + completion.TrySetException( + new BrowserLauncherException($"Chromium DevTools command failed: {error.GetRawText()}")); + } + else + { + completion.TrySetResult(root.GetProperty("result").Clone()); + } + } + else if (root.TryGetProperty("method", out JsonElement methodElement)) + { + EventReceived?.Invoke( + methodElement.GetString() ?? string.Empty, + root.TryGetProperty("params", out JsonElement parameters) ? parameters.Clone() : default); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + CompletePendingCommands(ex); + } + finally + { + CompletePendingCommands(new BrowserLauncherException("The Chromium DevTools connection closed.")); + } + } + + private void CompletePendingCommands(Exception exception) + { + foreach (KeyValuePair> pendingCommand in _pendingCommands) + { + if (_pendingCommands.TryRemove(pendingCommand.Key, out TaskCompletionSource? completion)) + { + completion.TrySetException(exception); + } + } + } +} 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..f2f9532e4e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs @@ -0,0 +1,56 @@ +// 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(); + private readonly object _sync = new(); + private readonly string[] _secrets; + + public DiagnosticBuffer(params string?[] secrets) + => _secrets = + [ + .. secrets + .Where(static value => value is { Length: >= 8 }) + .Cast(), + ]; + + 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..ef7072a7dc --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs @@ -0,0 +1,235 @@ +// 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.Net; +using System.Text.Json; + +namespace Microsoft.Testing.Platform.Browser; + +internal sealed class HostProcess : IAsyncDisposable +{ + private static readonly Regex ListeningUrlRegex = new( + @"Now listening on:\s+(?https?://\S+)", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private readonly Process _process; + private readonly DiagnosticBuffer _diagnostics; + private readonly string _launchInfoPath; + private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + private readonly TaskCompletionSource _stdoutReadiness = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task _stdoutTask; + private readonly Task _stderrTask; + + private HostProcess(Process process, DiagnosticBuffer diagnostics, string launchInfoPath) + { + _process = process; + _diagnostics = diagnostics; + _launchInfoPath = launchInfoPath; + _stdoutTask = CaptureAsync(process.StandardOutput, "host stdout", inspectReadiness: true, _disposeCancellationTokenSource.Token); + _stderrTask = CaptureAsync(process.StandardError, "host stderr", inspectReadiness: true, _disposeCancellationTokenSource.Token); + } + + public Task WaitForExitAsync(CancellationToken cancellationToken) + => _process.WaitForExitAsync(cancellationToken); + + public static HostProcess Start(BrowserLauncherOptions options, DiagnosticBuffer diagnostics) + { + string launchInfoPath = Path.Combine(Path.GetTempPath(), $"mtp-browser-host-{Guid.NewGuid():N}.json"); + var startInfo = new ProcessStartInfo + { + FileName = options.HostCommand, + WorkingDirectory = options.HostWorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (string argument in options.HostArguments) + { + startInfo.ArgumentList.Add(argument); + } + + startInfo.Environment["TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE"] = launchInfoPath; + + Process process; + try + { + process = Process.Start(startInfo) + ?? throw new BrowserLauncherException($"The browser host command '{options.HostCommand}' did not start."); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + throw new BrowserLauncherException($"Unable to start the browser host command '{options.HostCommand}'.", ex); + } + + return new HostProcess(process, diagnostics, launchInfoPath); + } + + 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 + { + while (true) + { + if (_process.HasExited) + { + throw new BrowserLauncherException( + $"The browser host exited with code {_process.ExitCode} before reporting readiness."); + } + + if (TryReadLaunchInfo() is { } launchInfoUri) + { + return launchInfoUri; + } + + var delay = Task.Delay(TimeSpan.FromMilliseconds(100), linkedCancellationTokenSource.Token); + Task completed = await Task.WhenAny(_stdoutReadiness.Task, delay).ConfigureAwait(false); + if (completed == _stdoutReadiness.Task) + { + return await _stdoutReadiness.Task.ConfigureAwait(false); + } + + linkedCancellationTokenSource.Token.ThrowIfCancellationRequested(); + } + } + 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 (InvalidOperationException) + { + } + + try + { + await _process.WaitForExitAsync().ConfigureAwait(false); + } + catch (InvalidOperationException) + { + } + + await Task.WhenAll(_stdoutTask, _stderrTask).ConfigureAwait(false); + _process.Dispose(); + _disposeCancellationTokenSource.Dispose(); + + try + { + File.Delete(_launchInfoPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _diagnostics.Add("launcher", $"Unable to delete the browser host launch-info file: {ex.Message}"); + } + } + + 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 + && ListeningUrlRegex.Match(line) is { Success: true } match + && Uri.TryCreate(match.Groups["url"].Value, UriKind.Absolute, out Uri? uri)) + { + if (IsLoopbackHttpUri(uri)) + { + _stdoutReadiness.TrySetResult(uri); + } + else + { + _stdoutReadiness.TrySetException( + new BrowserLauncherException("The browser host reported a non-loopback URL.")); + } + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private Uri? TryReadLaunchInfo() + { + if (!File.Exists(_launchInfoPath)) + { + return null; + } + + try + { + ValidateLaunchInfoPermissions(); + using FileStream stream = File.OpenRead(_launchInfoPath); + BrowserHostLaunchInfo? launchInfo = JsonSerializer.Deserialize( + stream, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return launchInfo is { Version: 1 } + && Uri.TryCreate(launchInfo.Url, UriKind.Absolute, out Uri? uri) + && IsLoopbackHttpUri(uri) + ? uri + : throw new BrowserLauncherException("The browser host launch-info file is invalid."); + } + catch (IOException) + { + return null; + } + catch (JsonException ex) + { + throw new BrowserLauncherException("The browser host launch-info file is invalid.", ex); + } + } + + private static bool IsLoopbackHttpUri(Uri uri) + => (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) + || string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)) + && (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) + || (IPAddress.TryParse(uri.Host, out IPAddress? address) && IPAddress.IsLoopback(address))); + + private void ValidateLaunchInfoPermissions() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + UnixFileMode mode = File.GetUnixFileMode(_launchInfoPath); + const UnixFileMode disallowed = + UnixFileMode.GroupRead + | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherWrite + | UnixFileMode.OtherExecute; + if ((mode & disallowed) != 0) + { + throw new BrowserLauncherException("The browser host launch-info file is accessible by users other than its owner."); + } + } + + private sealed record BrowserHostLaunchInfo(int Version, string Url); +} 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..51257f2f1a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj @@ -0,0 +1,39 @@ + + + + Exe + net8.0 + false + false + true + $(MicrosoftTestingPlatformBrowserVersionPrefix) + $(MicrosoftTestingPlatformBrowserPreReleaseVersionLabel) + true + + + + + + + + + + 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..2843e91352 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -0,0 +1,90 @@ +# Microsoft.Testing.Platform.Browser + +`Microsoft.Testing.Platform.Browser` is an optional launcher package for running +`browser-wasm` Microsoft Testing Platform applications through `dotnet test`. + +The package keeps browser dependencies and browser release cadence out of core +Microsoft.Testing.Platform. It contains: + +- build-transitive assets that provide a browser boot page and JavaScript supervisor; +- an MSBuild `ComputeRunArguments` hook that selects the browser launcher only for + `browser-*` runtime identifiers; +- a dependency-free .NET launcher that starts a configured WebAssembly host, launches an + installed Chromium-family browser in an isolated profile, injects the Microsoft Testing + Platform arguments before the runtime starts, captures bounded diagnostics, and cleans up + the browser and host process trees. + +Test discovery and results are not parsed or relayed by this package. The browser test +application connects directly to the authenticated HTTP gateway created by the .NET SDK. + +## Prerequisites + +- A .NET SDK that supplies the authenticated `dotnettestcli` HTTP bootstrap for + `browser-wasm`. +- Microsoft Edge, Google Chrome, Chromium, or a compatible executable selected with + `TestingPlatformBrowserExecutable`. +- A browser-WASM host. The proof of concept defaults to `dotnet run --no-build` and + recognizes the current `Now listening on: ` output. A shared host can instead write + the versioned launch-info file named by the + `TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE` environment variable: + + ```json + { "version": 1, "url": "http://127.0.0.1:12345/" } + ``` + + The host must create the file atomically and with owner-only permissions. The launcher + prefers this contract over console parsing. + +## Usage + +```xml + + browser-wasm + + + + + +``` + +Then run: + +```dotnetcli +dotnet test +dotnet test -- --list-tests +``` + +Useful properties: + +| Property | Purpose | +| --- | --- | +| `TestingPlatformBrowserEnabled` | Enables or disables the package. It defaults to `true` only for `browser-*` runtime identifiers and can be set to `false` in the project or on the command line. | +| `TestingPlatformBrowserExecutable` | Overrides browser discovery with an explicit Chromium-family executable path. | +| `TestingPlatformBrowserHostCommand` | Command that starts the external browser-WASM host. | +| `TestingPlatformBrowserHostArguments` | Arguments for the host command. | +| `TestingPlatformBrowserHostWorkingDirectory` | Working directory for the host process. | +| `TestingPlatformBrowserUrlPath` | Path opened relative to the host URL. Defaults to `/`. | +| `TestingPlatformBrowserStartupTimeoutSeconds` | Host/browser startup timeout. Defaults to 60 seconds. | +| `TestingPlatformBrowserCompletionTimeoutSeconds` | Test completion timeout. Defaults to 600 seconds. | +| `TestingPlatformBrowserAdditionalArguments` | Additional Chromium command-line arguments. | + +The SDK bearer token is read from its owner-only response file, retained in memory, injected +into the browser runtime through the DevTools protocol, and redacted from launcher, host, and +browser diagnostics. It is never added to the browser URL. + +The current proof of concept intentionally leaves physical browser virtual-file-system +artifact export to a future artifact sink and leaves host implementation to the shared +browser-WASM host effort. + +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 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/Program.cs b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs new file mode 100644 index 0000000000..2e1af3721a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs @@ -0,0 +1,87 @@ +// 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 +{ + 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, options.UrlPath); + ChromiumBrowser browser = await ChromiumBrowser.LaunchAsync( + options, + browserUri, + diagnostics, + runCancellationTokenSource.Token).ConfigureAwait(false); + try + { + using var completionCancellationTokenSource = new CancellationTokenSource(); + Task browserCompletion = browser.WaitForCompletionAsync( + options.CompletionTimeout, + completionCancellationTokenSource.Token); + Task hostExit = host.WaitForExitAsync(completionCancellationTokenSource.Token); + Task browserExit = browser.WaitForExitAsync(completionCancellationTokenSource.Token); + + Task completed = await Task.WhenAny(browserCompletion, hostExit, browserExit).ConfigureAwait(false); + if (completed == browserCompletion) + { + int exitCode = await browserCompletion.ConfigureAwait(false); + await completionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + return exitCode; + } + + await completionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + if (completed == hostExit) + { + throw new BrowserLauncherException("The browser host exited before the test application completed."); + } + + throw new BrowserLauncherException("The browser exited before the test application completed."); + } + 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..97c0f9786b --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.props @@ -0,0 +1,10 @@ + + + 60 + 600 + / + $(DotNetHostPath) + dotnet + $(MSBuildProjectDirectory) + + 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..dfd907392a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.targets @@ -0,0 +1,68 @@ + + + true + <_TestingPlatformBrowserAssetsDirectory>$(MSBuildThisFileDirectory)assets\ + $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js + + + + + index.html + + + + + + <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll + <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.launch')) + run --no-build --configuration "$(Configuration)" --framework "$(TargetFramework)" --runtime "$(RuntimeIdentifier)" --project "$(MSBuildProjectFullPath)" -p:TestingPlatformBrowserEnabled=false -- --urls http://127.0.0.1:0 + + $(DotNetHostPath) + dotnet + exec "$(_TestingPlatformBrowserLauncher)" --config "$(_TestingPlatformBrowserLauncherConfiguration)" -- + $(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..b2a5aab529 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { dotnet } from './_framework/dotnet.js'; + +const status = document.querySelector('[role=status]'); +const argumentsFromLauncher = globalThis.__mtpBrowserArguments; + +if (!Array.isArray(argumentsFromLauncher)) { + throw new Error('Microsoft.Testing.Platform.Browser did not inject the test application arguments.'); +} + +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 { runMain } = await dotnet + .withApplicationArguments(...argumentsFromLauncher) + .create(); + + const exitCode = await runMain(); + globalThis.__mtpBrowserResult = { completed: true, exitCode }; + status.textContent = exitCode === 0 ? 'Passed' : `Failed (exit code ${exitCode})`; +} +catch (error) { + globalThis.__mtpBrowserResult = { + completed: true, + exitCode: 1, + error: error instanceof Error ? error.stack ?? error.message : String(error), + }; + status.textContent = 'Failed (launcher error)'; + throw error; +} 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..10367ad9f4 --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -0,0 +1,271 @@ +// 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.Acceptance.IntegrationTests; + +/// +/// End-to-end coverage for the optional Microsoft.Testing.Platform.Browser package. +/// +[TestClass] +public sealed class BrowserPackageExecutionTests : AcceptanceTestBase +{ + private static readonly string TargetFramework = TargetFrameworks.NetCurrent; + + private const string SourceCode = """ +#file BrowserPackageTestProject.csproj + + + + $TargetFramework$ + browser-wasm + Exe + true + true + true + enable + false + false + $(NoWarn);NETSDK1201 + + $Node$ + "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" + $Browser$ + --mtp-test-value=a;b + 60 + 120 + + + + + + + + + +#file BrowserPackageTests.cs +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class BrowserPackageTests +{ + [TestMethod] + public void RunsInsideBrowser() + { + Assert.IsTrue(OperatingSystem.IsBrowser()); + } +} + +#file server.mjs +import { createReadStream, existsSync, renameSync, statSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, resolve, sep } from 'node:path'; + +const root = resolve(process.argv[2]); +const launchInfoPath = process.env.TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE; +if (!launchInfoPath) { + throw new Error('TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE is required.'); +} + +const contentTypes = new Map([ + ['.css', 'text/css'], + ['.dat', 'application/octet-stream'], + ['.dll', 'application/octet-stream'], + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.wasm', 'application/wasm'], +]); + +const server = createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); + const relative = pathname === '/' ? 'index.html' : pathname.slice(1); + const file = resolve(root, relative); + if (file !== root && !file.startsWith(root + sep)) { + response.writeHead(403).end(); + return; + } + + if (!existsSync(file) || !statSync(file).isFile()) { + response.writeHead(404).end(); + return; + } + + response.setHeader('Content-Type', contentTypes.get(extname(file)) ?? 'application/octet-stream'); + createReadStream(file).pipe(response); +}); + +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const temporaryPath = `${launchInfoPath}.${process.pid}.tmp`; + writeFileSync(temporaryPath, JSON.stringify({ version: 1, url: `http://127.0.0.1:${address.port}/` }), { mode: 0o600 }); + renameSync(temporaryPath, launchInfoPath); +}); +"""; + + private const string DesktopSourceCode = """ +#file BrowserPackageDesktopTestProject.csproj + + + + $TargetFramework$ + Exe + true + 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? node = WasmRuntime.LocateNode(); + if (node is null) + { + Assert.Inconclusive(WasmRuntime.NodeUnavailableMessage); + return; + } + + 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("$Node$", EscapeMsBuildValue(node)) + .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); + + DotnetMuxerResult run = await DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + 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); + + DotnetMuxerResult list = await DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --list-tests", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + string listOutput = list.StandardOutput + list.StandardError; + Assert.AreEqual(0, list.ExitCode, list.ToString()); + Assert.Contains("RunsInsideBrowser", listOutput); + Assert.Contains("Discovered 1 tests", listOutput); + } + + [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()); + Assert.Contains($"({TargetFramework}|x64) passed [+1/x0/?0]", output); + } + + private static string GetBrowserPackageVersion() + { + const string packagePrefix = "Microsoft.Testing.Platform.Browser."; + string package = Directory + .EnumerateFiles(Constants.ArtifactsPackagesShipping, $"{packagePrefix}*.nupkg") + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault() + ?? throw new AssertFailedException( + $"Microsoft.Testing.Platform.Browser was not packed under '{Constants.ArtifactsPackagesShipping}'."); + + string fileName = Path.GetFileName(package); + return fileName[packagePrefix.Length..^".nupkg".Length]; + } + + private static string? LocateBrowser() + { + 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); +} 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..4f475d7264 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETFRAMEWORK + +using Microsoft.Testing.Platform.Browser; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class BrowserLauncherOptionsTests +{ + [TestMethod] + public void Parse_ExpandsSdkResponseFileAndPreservesArguments() + { + string responseFile = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://127.0.0.1:1234/dotnettest/run/ + --dotnet-test-http-token abcdef0123456789 + """); + + try + { + var options = BrowserLauncherOptions.Parse( + [ + "--host-command-base64", Encode("dotnet"), + "--host-arguments-base64", Encode("run --project \"path with spaces.csproj\""), + "--host-working-directory-base64", Encode(Path.GetTempPath()), + "--url-path-base64", Encode("/tests"), + "--browser-executable-base64", Encode(string.Empty), + "--browser-arguments-base64", Encode("--disable-gpu \"--custom=value with spaces\""), + "--startup-timeout-seconds", "30", + "--completion-timeout-seconds", "120", + "--", + "--list-tests", + $"@{responseFile}", + ]); + + Assert.AreEqual("dotnet", options.HostCommand); + Assert.AreSequenceEqual( + new[] { "run", "--project", "path with spaces.csproj" }, + options.HostArguments.ToArray()); + Assert.AreSequenceEqual( + new[] { "--disable-gpu", "--custom=value with spaces" }, + options.BrowserArguments.ToArray()); + Assert.Contains("--list-tests", options.TestApplicationArguments); + Assert.Contains("abcdef0123456789", options.TestApplicationArguments); + Assert.AreEqual("http://127.0.0.1:1234/dotnettest/run/", options.Bootstrap.Endpoint.AbsoluteUri); + Assert.AreEqual("abcdef0123456789", options.Bootstrap.Token); + } + finally + { + File.Delete(responseFile); + } + } + + [TestMethod] + public void Parse_RejectsNonLoopbackHttpBootstrap() + { + string[] arguments = + [ + "--host-command-base64", Encode("dotnet"), + "--host-arguments-base64", Encode("run"), + "--host-working-directory-base64", Encode(Path.GetTempPath()), + "--url-path-base64", Encode("/"), + "--browser-executable-base64", Encode(string.Empty), + "--browser-arguments-base64", Encode(string.Empty), + "--startup-timeout-seconds", "30", + "--completion-timeout-seconds", "120", + "--", + "--server", "dotnettestcli", + "--dotnet-test-transport", "http", + "--dotnet-test-http-endpoint", "http://example.com/dotnettest/run/", + "--dotnet-test-http-token", "secret", + ]; + + BrowserLauncherException exception = Assert.ThrowsExactly( + () => BrowserLauncherOptions.Parse(arguments)); + + Assert.Contains("valid loopback authenticated HTTP", exception.Message); + } + + [TestMethod] + public void Parse_AcceptsLoopbackHttpsIpv6Bootstrap() + { + string[] arguments = + [ + "--host-command-base64", Encode("dotnet"), + "--host-arguments-base64", Encode("run"), + "--host-working-directory-base64", Encode(Path.GetTempPath()), + "--url-path-base64", Encode("/"), + "--browser-executable-base64", Encode(string.Empty), + "--browser-arguments-base64", Encode(string.Empty), + "--startup-timeout-seconds", "30", + "--completion-timeout-seconds", "120", + "--", + "--server", "dotnettestcli", + "--dotnet-test-transport", "http", + "--dotnet-test-http-endpoint", "https://[::1]:1234/dotnettest/run/", + "--dotnet-test-http-token", "secret-token", + ]; + + var options = BrowserLauncherOptions.Parse(arguments); + + Assert.AreEqual("https://[::1]:1234/dotnettest/run/", options.Bootstrap.Endpoint.AbsoluteUri); + } + + [TestMethod] + public void Parse_ReadsMsBuildLauncherConfiguration() + { + string configurationFile = Path.GetTempFileName(); + try + { + File.WriteAllLines( + configurationFile, + [ + "host-command=node", + "host-arguments=server.mjs \"path with spaces\"", + $"host-working-directory={Path.GetTempPath()}", + "url-path=/tests", + "browser-executable=", + "browser-arguments=--disable-gpu", + "startup-timeout-seconds=30", + "completion-timeout-seconds=120", + ]); + + var options = BrowserLauncherOptions.Parse( + [ + "--config", configurationFile, + "--", + "--server", "dotnettestcli", + "--dotnet-test-transport", "http", + "--dotnet-test-http-endpoint", "http://127.0.0.1:1234/dotnettest/run/", + "--dotnet-test-http-token", "secret", + ]); + + Assert.AreEqual("node", options.HostCommand); + Assert.AreSequenceEqual(["server.mjs", "path with spaces"], options.HostArguments); + Assert.AreEqual("/tests", options.UrlPath); + Assert.AreSequenceEqual(["--disable-gpu"], options.BrowserArguments); + Assert.AreEqual(TimeSpan.FromSeconds(30), options.StartupTimeout); + Assert.AreEqual(TimeSpan.FromSeconds(120), options.CompletionTimeout); + } + finally + { + File.Delete(configurationFile); + } + } + + [TestMethod] + public void CommandLineTokenizer_RoundTripsQuotedHostArguments() + { + string[] arguments = CommandLineTokenizer.Split( + "run --project \"path with spaces.csproj\" --property \"Name=quoted \\\"value\\\"\""); + + Assert.AreSequenceEqual( + new[] { "run", "--project", "path with spaces.csproj", "--property", "Name=quoted \"value\"" }, + arguments); + } + + [TestMethod] + public void DiagnosticBuffer_RedactsBootstrapSecretsAndBoundsEntries() + { + var diagnostics = new DiagnosticBuffer("secret-token", "/dotnettest/private/"); + for (int i = 0; i < 210; i++) + { + diagnostics.Add("browser", $"{i}: secret-token /dotnettest/private/"); + } + + string output = diagnostics.Format(); + + Assert.DoesNotContain("secret-token", output); + Assert.DoesNotContain("/dotnettest/private/", output); + Assert.DoesNotContain("[browser] 0:", output); + Assert.Contains("[browser] 209:", output); + Assert.HasCount(200, output.Split(Environment.NewLine)); + } + + [TestMethod] + public void DiagnosticBuffer_DoesNotTreatShortUrlSegmentsAsSecrets() + { + var diagnostics = new DiagnosticBuffer("/"); + + diagnostics.Add("browser", "https://localhost/path"); + + Assert.Contains("https://localhost/path", diagnostics.Format()); + } + + [TestMethod] + public void BrowserExecutableLocator_PrefersConfiguredExecutable() + { + string executable = Path.GetTempFileName(); + try + { + Assert.AreEqual(Path.GetFullPath(executable), BrowserExecutableLocator.Locate(executable)); + } + finally + { + File.Delete(executable); + } + } + + private static string CreateResponseFile(string content) + { + string path = Path.Combine(Path.GetTempPath(), $"dotnet-test-http-{Guid.NewGuid():N}.rsp"); + File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + return path; + } + + private static string Encode(string value) + => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); +} + +#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..d2a7dbb911 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 @@ -67,6 +67,8 @@ + From 066ab8d55f34537834719b5cb846e1ddc173a21f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 14 Sep 2026 00:35:03 +0200 Subject: [PATCH 02/16] Secure browser launcher transport Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c54e99e-de18-4bcd-a5c4-6163e4aeb78c --- Directory.Packages.props | 1 + .../BrowserLauncherOptions.cs | 34 +- .../ChromiumBrowser.cs | 433 +++++------------- .../DevToolsConnection.cs | 162 ------- .../Microsoft.Testing.Platform.Browser.csproj | 12 +- .../PACKAGE.md | 19 +- .../BrowserPackageExecutionTests.cs | 6 + .../BrowserLauncherOptionsTests.cs | 42 ++ 8 files changed, 229 insertions(+), 480 deletions(-) delete mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs 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/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs index fa889e1f7d..df34bd9441 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -88,6 +88,8 @@ public static BrowserLauncherOptions Parse(string[] args) string[] expandedArguments = ResponseFileArgumentExpander.Expand(args[(separatorIndex + 1)..]); var bootstrap = DotnetTestHttpBootstrap.Parse(expandedArguments); + string[] parsedBrowserArguments = CommandLineTokenizer.Split(browserArguments); + ValidateBrowserArguments(parsedBrowserArguments); return new BrowserLauncherOptions( hostCommand, @@ -95,13 +97,43 @@ public static BrowserLauncherOptions Parse(string[] args) Path.GetFullPath(hostWorkingDirectory), NormalizeUrlPath(urlPath), browserExecutable, - CommandLineTokenizer.Split(browserArguments), + parsedBrowserArguments, startupTimeout, completionTimeout, expandedArguments, bootstrap); } + private static void ValidateBrowserArguments(IReadOnlyList arguments) + { + string[] forbiddenPrefixes = + [ + "--remote-debugging-address", + "--remote-debugging-pipe", + "--remote-debugging-port", + "--remote-allow-origins", + "--profile-directory", + "--user-data-dir", + ]; + + foreach (string argument in arguments) + { + string switchName = argument.TrimStart('-', '/'); + int valueSeparator = switchName.IndexOf('='); + if (valueSeparator >= 0) + { + switchName = switchName[..valueSeparator]; + } + + if (forbiddenPrefixes.Any(prefix => + switchName.Equals(prefix[2..], StringComparison.OrdinalIgnoreCase))) + { + throw new BrowserLauncherException( + $"Browser argument '{argument}' is controlled by Microsoft.Testing.Platform.Browser and cannot be overridden."); + } + } + } + private static string DecodeRequired(Dictionary options, string name) => DecodeOptional(options, name) is { Length: > 0 } value ? value diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs index db105551d7..8483336e20 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -1,42 +1,39 @@ // 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.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Serialization; + +using Microsoft.Playwright; namespace Microsoft.Testing.Platform.Browser; internal sealed class ChromiumBrowser : IAsyncDisposable { - private readonly Process _process; - private readonly string _profileDirectory; + private readonly IPlaywright _playwright; + private readonly IBrowser _browser; + private readonly IBrowserContext _context; + private readonly IPage _page; private readonly DiagnosticBuffer _diagnostics; - private readonly DevToolsConnection _devToolsConnection; - private readonly CancellationTokenSource _captureCancellationTokenSource; - private readonly Task _stdoutTask; - private readonly Task _stderrTask; + private readonly TaskCompletionSource _disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); private ChromiumBrowser( - Process process, - string profileDirectory, - DiagnosticBuffer diagnostics, - DevToolsConnection devToolsConnection, - CancellationTokenSource captureCancellationTokenSource, - Task stdoutTask, - Task stderrTask) + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + IPage page, + DiagnosticBuffer diagnostics) { - _process = process; - _profileDirectory = profileDirectory; + _playwright = playwright; + _browser = browser; + _context = context; + _page = page; _diagnostics = diagnostics; - _devToolsConnection = devToolsConnection; - _captureCancellationTokenSource = captureCancellationTokenSource; - _stdoutTask = stdoutTask; - _stderrTask = stderrTask; + _browser.Disconnected += (_, _) => _disconnected.TrySetResult(); + SubscribeToDiagnostics(); } public Task WaitForExitAsync(CancellationToken cancellationToken) - => _process.WaitForExitAsync(cancellationToken); + => _disconnected.Task.WaitAsync(cancellationToken); public static async Task LaunchAsync( BrowserLauncherOptions options, @@ -51,106 +48,56 @@ public static async Task LaunchAsync( CancellationToken startupCancellationToken = startupCancellationTokenSource.Token; string executable = BrowserExecutableLocator.Locate(options.BrowserExecutable); - string profileDirectory = Path.Combine(Path.GetTempPath(), $"mtp-browser-profile-{Guid.NewGuid():N}"); - Directory.CreateDirectory(profileDirectory); - - var startInfo = new ProcessStartInfo - { - FileName = executable, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - foreach (string argument in new[] - { - "--headless=new", - "--disable-background-networking", - "--disable-component-update", - "--disable-default-apps", - "--disable-dev-shm-usage", - "--disable-extensions", - "--disable-features=Translate", - "--disable-sync", - "--metrics-recording-only", - "--no-first-run", - "--no-default-browser-check", - "--remote-debugging-port=0", - $"--user-data-dir={profileDirectory}", - "about:blank", - }) - { - startInfo.ArgumentList.Add(argument); - } - - if (!OperatingSystem.IsWindows() && IsRunningInContainer()) - { - startInfo.ArgumentList.Add("--no-sandbox"); - } - - foreach (string argument in options.BrowserArguments) - { - startInfo.ArgumentList.Add(argument); - } + IPlaywright? playwright = null; + IBrowser? browser = null; + IBrowserContext? context = null; - Process process; try { - process = Process.Start(startInfo) - ?? throw new BrowserLauncherException($"The browser executable '{executable}' did not start."); - } - catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) - { - throw new BrowserLauncherException($"Unable to start the browser executable '{executable}'.", ex); - } - - var captureCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Task stdoutTask = CaptureAsync( - process.StandardOutput, - "browser stdout", - diagnostics, - captureCancellationTokenSource.Token); - Task stderrTask = CaptureAsync( - process.StandardError, - "browser stderr", - diagnostics, - captureCancellationTokenSource.Token); - - try - { - Uri devToolsEndpoint = await WaitForPageEndpointAsync( - process, - profileDirectory, - options.StartupTimeout, - startupCancellationToken).ConfigureAwait(false); + // Playwright's DEBUG=pw:channel:send / pw:protocol output contains the complete + // AddInitScript request, including the SDK bearer token. This is a dedicated launcher + // process, and its browser host child has already started, so keep DEBUG disabled for + // the remainder of the launcher lifetime. + Environment.SetEnvironmentVariable("DEBUG", null); + playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); - var connection = new DevToolsConnection(); - await connection.ConnectAsync(devToolsEndpoint, startupCancellationToken).ConfigureAwait(false); - var browser = new ChromiumBrowser( - process, - profileDirectory, - diagnostics, - connection, - captureCancellationTokenSource, - stdoutTask, - stderrTask); - browser.SubscribeToDiagnostics(); - await browser.InitializePageAsync( + browser = await playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions + { + ExecutablePath = executable, + Headless = true, + Args = [.. options.BrowserArguments], + Timeout = (float)options.StartupTimeout.TotalMilliseconds, + }).ConfigureAwait(false); + context = await browser.NewContextAsync( + new BrowserNewContextOptions + { + Locale = "en-US", + }).ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + var result = new ChromiumBrowser(playwright, browser, context, page, diagnostics); + await result.InitializePageAsync( options.TestApplicationArguments, browserUri, startupCancellationToken).ConfigureAwait(false); - return browser; + return result; } - catch + catch (Exception ex) when (ex is PlaywrightException or TimeoutException) { - await captureCancellationTokenSource.CancelAsync().ConfigureAwait(false); - KillProcess(process); - await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); - captureCancellationTokenSource.Dispose(); - process.Dispose(); - TryDeleteDirectory(profileDirectory, diagnostics); - throw; + if (context is not null) + { + await context.CloseAsync().ConfigureAwait(false); + } + + if (browser is not null) + { + await browser.CloseAsync().ConfigureAwait(false); + } + + playwright?.Dispose(); + throw new BrowserLauncherException( + $"Unable to launch the Chromium browser '{executable}'.", + ex); } } @@ -165,153 +112,67 @@ public async Task WaitForCompletionAsync(TimeSpan timeout, CancellationToke { while (true) { - if (_process.HasExited) + if (!_browser.IsConnected) { throw new BrowserLauncherException( - $"The browser exited with code {_process.ExitCode} before the test application completed."); + "The browser disconnected before the test application completed."); } - JsonElement result = await _devToolsConnection.SendCommandAsync( - "Runtime.evaluate", - new - { - expression = "globalThis.__mtpBrowserResult ?? null", - returnByValue = true, - awaitPromise = true, - }, - linkedCancellationTokenSource.Token).ConfigureAwait(false); - - JsonElement remoteObject = result.GetProperty("result"); - if (remoteObject.TryGetProperty("value", out JsonElement value) - && value.ValueKind == JsonValueKind.Object - && value.TryGetProperty("completed", out JsonElement completed) - && completed.GetBoolean()) + string? json = await _page.EvaluateAsync( + "() => JSON.stringify(globalThis.__mtpBrowserResult ?? null)").ConfigureAwait(false); + if (json is not null and not "null") { - if (value.TryGetProperty("error", out JsonElement error)) + using var result = JsonDocument.Parse(json); + JsonElement root = result.RootElement; + if (root.TryGetProperty("completed", out JsonElement completed) + && completed.GetBoolean()) { - _diagnostics.Add("browser", error.GetString() ?? error.GetRawText()); - } + if (root.TryGetProperty("error", out JsonElement error)) + { + _diagnostics.Add("browser", error.GetString() ?? error.GetRawText()); + } - return value.GetProperty("exitCode").GetInt32(); + return root.GetProperty("exitCode").GetInt32(); + } } - await Task.Delay(TimeSpan.FromMilliseconds(100), linkedCancellationTokenSource.Token).ConfigureAwait(false); + await Task.Delay( + TimeSpan.FromMilliseconds(100), + linkedCancellationTokenSource.Token).ConfigureAwait(false); } } catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) { - throw new BrowserLauncherException($"The browser test application did not complete within {timeout.TotalSeconds} seconds."); - } - } - - public async ValueTask DisposeAsync() - { - await _devToolsConnection.DisposeAsync().ConfigureAwait(false); - KillProcess(_process); - try - { - await _process.WaitForExitAsync().ConfigureAwait(false); + throw new BrowserLauncherException( + $"The browser test application did not complete within {timeout.TotalSeconds} seconds."); } - catch (InvalidOperationException) + catch (PlaywrightException ex) { + throw new BrowserLauncherException( + "Unable to read the browser test application result.", + ex); } - - await _captureCancellationTokenSource.CancelAsync().ConfigureAwait(false); - await Task.WhenAll(_stdoutTask, _stderrTask).ConfigureAwait(false); - _captureCancellationTokenSource.Dispose(); - _process.Dispose(); - TryDeleteDirectory(_profileDirectory, _diagnostics); } - private static async Task CaptureAsync( - StreamReader reader, - string source, - DiagnosticBuffer diagnostics, - CancellationToken cancellationToken) + public async ValueTask DisposeAsync() { try { - while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) - { - diagnostics.Add(source, line); - } + await _context.CloseAsync().ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (PlaywrightException) { } - } - - private static async Task WaitForPageEndpointAsync( - Process process, - string profileDirectory, - TimeSpan timeout, - CancellationToken cancellationToken) - { - string activePortPath = Path.Combine(profileDirectory, "DevToolsActivePort"); - using var timeoutCancellationTokenSource = new CancellationTokenSource(timeout); - using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - timeoutCancellationTokenSource.Token); try { - while (true) - { - if (process.HasExited) - { - throw new BrowserLauncherException( - $"The browser exited with code {process.ExitCode} before DevTools became available."); - } - - if (File.Exists(activePortPath)) - { - string[] lines; - try - { - lines = await File.ReadAllLinesAsync(activePortPath, linkedCancellationTokenSource.Token).ConfigureAwait(false); - } - catch (IOException) - { - await Task.Delay(TimeSpan.FromMilliseconds(50), linkedCancellationTokenSource.Token).ConfigureAwait(false); - continue; - } - - if (lines.Length > 0 - && int.TryParse(lines[0], NumberStyles.None, CultureInfo.InvariantCulture, out int port)) - { - using var httpClient = new HttpClient(); - BrowserTarget[]? targets; - try - { - targets = await httpClient.GetFromJsonAsync( - $"http://127.0.0.1:{port}/json/list", - linkedCancellationTokenSource.Token).ConfigureAwait(false); - } - catch (Exception ex) when (ex is HttpRequestException or JsonException) - { - await Task.Delay( - TimeSpan.FromMilliseconds(50), - linkedCancellationTokenSource.Token).ConfigureAwait(false); - continue; - } - - string? webSocketDebuggerUrl = targets? - .FirstOrDefault(static target => target.Type == "page") - ?.WebSocketDebuggerUrl; - if (Uri.TryCreate(webSocketDebuggerUrl, UriKind.Absolute, out Uri? endpoint)) - { - return endpoint; - } - } - } - - await Task.Delay(TimeSpan.FromMilliseconds(50), linkedCancellationTokenSource.Token).ConfigureAwait(false); - } + await _browser.CloseAsync().ConfigureAwait(false); } - catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) + catch (PlaywrightException) { - throw new BrowserLauncherException($"Chromium DevTools did not become ready within {timeout.TotalSeconds} seconds."); } + + _playwright.Dispose(); } private async Task InitializePageAsync( @@ -319,93 +180,45 @@ private async Task InitializePageAsync( Uri browserUri, CancellationToken cancellationToken) { - await _devToolsConnection.SendCommandAsync("Runtime.enable", parameters: null, cancellationToken).ConfigureAwait(false); - await _devToolsConnection.SendCommandAsync("Page.enable", parameters: null, cancellationToken).ConfigureAwait(false); - await _devToolsConnection.SendCommandAsync("Log.enable", parameters: null, cancellationToken).ConfigureAwait(false); - string serializedArguments = JsonSerializer.Serialize(testApplicationArguments); - await _devToolsConnection.SendCommandAsync( - "Page.addScriptToEvaluateOnNewDocument", - new - { - source = $"Object.defineProperty(globalThis, '__mtpBrowserArguments', {{ value: Object.freeze({serializedArguments}), configurable: false, enumerable: false, writable: false }});", - }, - cancellationToken).ConfigureAwait(false); - await _devToolsConnection.SendCommandAsync( - "Page.navigate", - new { url = browserUri.AbsoluteUri }, - cancellationToken).ConfigureAwait(false); - } - - private void SubscribeToDiagnostics() - => _devToolsConnection.EventReceived += (method, parameters) => - { - switch (method) - { - case "Runtime.consoleAPICalled": - string type = parameters.TryGetProperty("type", out JsonElement typeElement) - ? typeElement.GetString() ?? "console" - : "console"; - string message = parameters.TryGetProperty("args", out JsonElement arguments) - ? string.Join( - " ", - arguments.EnumerateArray().Select(static argument => - argument.TryGetProperty("value", out JsonElement value) - ? value.ToString() - : argument.TryGetProperty("description", out JsonElement description) - ? description.GetString() - : argument.GetRawText())) - : parameters.GetRawText(); - _diagnostics.Add($"browser {type}", message); - break; - - case "Runtime.exceptionThrown": - _diagnostics.Add("browser exception", parameters.GetRawText()); - break; - - case "Log.entryAdded": - _diagnostics.Add("browser log", parameters.GetRawText()); - break; + string expectedOrigin = browserUri.GetLeftPart(UriPartial.Authority); + string serializedExpectedOrigin = JsonSerializer.Serialize(expectedOrigin); + await _page.AddInitScriptAsync( + $$""" + if (globalThis.self === globalThis.top && globalThis.location.origin === {{serializedExpectedOrigin}}) { + Object.defineProperty(globalThis, '__mtpBrowserArguments', { value: Object.freeze({{serializedArguments}}), configurable: false, enumerable: false, writable: false }); } - }; - - private static void KillProcess(Process process) - { - try - { - if (!process.HasExited) + """) + .WaitAsync(cancellationToken).ConfigureAwait(false); + await _page.GotoAsync( + browserUri.AbsoluteUri, + new PageGotoOptions { - process.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) + WaitUntil = WaitUntilState.DOMContentLoaded, + }).WaitAsync(cancellationToken).ConfigureAwait(false); + + if (!Uri.TryCreate(_page.Url, UriKind.Absolute, out Uri? finalUri) + || !string.Equals( + finalUri.GetLeftPart(UriPartial.Authority), + expectedOrigin, + StringComparison.Ordinal)) { + throw new BrowserLauncherException( + "The browser navigated outside the expected loopback origin before the test application started."); } } - private static bool IsRunningInContainer() - => string.Equals( - Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), - "true", - StringComparison.OrdinalIgnoreCase) - || File.Exists("/.dockerenv"); - - private static void TryDeleteDirectory(string path, DiagnosticBuffer diagnostics) + private void SubscribeToDiagnostics() { - try - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - diagnostics.Add("launcher", $"Unable to delete the isolated browser profile: {ex.Message}"); - } + _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."); } - - private sealed record BrowserTarget( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("webSocketDebuggerUrl")] string WebSocketDebuggerUrl); } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs b/src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs deleted file mode 100644 index 6fb30964bb..0000000000 --- a/src/Platform/Microsoft.Testing.Platform.Browser/DevToolsConnection.cs +++ /dev/null @@ -1,162 +0,0 @@ -// 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.Net.WebSockets; -using System.Text.Json; - -namespace Microsoft.Testing.Platform.Browser; - -internal sealed class DevToolsConnection : IAsyncDisposable -{ - private readonly ClientWebSocket _webSocket = new(); - private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); - private readonly SemaphoreSlim _sendLock = new(1, 1); - private readonly ConcurrentDictionary> _pendingCommands = new(); - private Task? _receiveTask; - private int _nextCommandId; - - public event Action? EventReceived; - - public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken) - { - await _webSocket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false); - _receiveTask = ReceiveLoopAsync(_disposeCancellationTokenSource.Token); - } - - public async Task SendCommandAsync(string method, object? parameters, CancellationToken cancellationToken) - { - int id = Interlocked.Increment(ref _nextCommandId); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!_pendingCommands.TryAdd(id, completion)) - { - throw new BrowserLauncherException("Unable to register a Chromium DevTools command."); - } - - byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new { id, method, @params = parameters }); - await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); - } - finally - { - _sendLock.Release(); - } - - using CancellationTokenRegistration registration = cancellationToken.Register(() => - { - if (_pendingCommands.TryRemove(id, out TaskCompletionSource? pendingCommand)) - { - pendingCommand.TrySetCanceled(cancellationToken); - } - }); - return await completion.Task.ConfigureAwait(false); - } - - public async ValueTask DisposeAsync() - { - using var closeCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - if (_webSocket.State == WebSocketState.Open) - { - try - { - await _webSocket.CloseOutputAsync( - WebSocketCloseStatus.NormalClosure, - "Microsoft Testing Platform browser run completed.", - closeCancellationTokenSource.Token).ConfigureAwait(false); - } - catch (Exception ex) when (ex is WebSocketException or OperationCanceledException or InvalidOperationException) - { - } - } - - await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); - if (_receiveTask is not null) - { - try - { - await _receiveTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - } - - CompletePendingCommands(new OperationCanceledException("The Chromium DevTools connection was disposed.")); - _sendLock.Dispose(); - _webSocket.Dispose(); - _disposeCancellationTokenSource.Dispose(); - } - - private async Task ReceiveLoopAsync(CancellationToken cancellationToken) - { - byte[] buffer = new byte[16 * 1024]; - try - { - while (!cancellationToken.IsCancellationRequested && _webSocket.State == WebSocketState.Open) - { - using var message = new MemoryStream(); - while (true) - { - WebSocketReceiveResult result = await _webSocket.ReceiveAsync( - new ArraySegment(buffer), - cancellationToken).ConfigureAwait(false); - if (result.MessageType == WebSocketMessageType.Close) - { - return; - } - - await message.WriteAsync(buffer.AsMemory(0, result.Count), cancellationToken).ConfigureAwait(false); - if (result.EndOfMessage) - { - break; - } - } - - using var document = JsonDocument.Parse(message.ToArray()); - JsonElement root = document.RootElement; - if (root.TryGetProperty("id", out JsonElement idElement) - && _pendingCommands.TryRemove(idElement.GetInt32(), out TaskCompletionSource? completion)) - { - if (root.TryGetProperty("error", out JsonElement error)) - { - completion.TrySetException( - new BrowserLauncherException($"Chromium DevTools command failed: {error.GetRawText()}")); - } - else - { - completion.TrySetResult(root.GetProperty("result").Clone()); - } - } - else if (root.TryGetProperty("method", out JsonElement methodElement)) - { - EventReceived?.Invoke( - methodElement.GetString() ?? string.Empty, - root.TryGetProperty("params", out JsonElement parameters) ? parameters.Clone() : default); - } - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - } - catch (Exception ex) - { - CompletePendingCommands(ex); - } - finally - { - CompletePendingCommands(new BrowserLauncherException("The Chromium DevTools connection closed.")); - } - } - - private void CompletePendingCommands(Exception exception) - { - foreach (KeyValuePair> pendingCommand in _pendingCommands) - { - if (_pendingCommands.TryRemove(pendingCommand.Key, out TaskCompletionSource? completion)) - { - completion.TrySetException(exception); - } - } - } -} 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 index 51257f2f1a..0885193915 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj @@ -5,7 +5,6 @@ net8.0 false false - true $(MicrosoftTestingPlatformBrowserVersionPrefix) $(MicrosoftTestingPlatformBrowserPreReleaseVersionLabel) true @@ -18,6 +17,10 @@ $(CommonProductDescription)]]> + + + + true @@ -34,6 +37,13 @@ $(CommonProductDescription)]]> + +
diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index 2843e91352..ee1675b4b3 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -9,10 +9,10 @@ Microsoft.Testing.Platform. It contains: - build-transitive assets that provide a browser boot page and JavaScript supervisor; - an MSBuild `ComputeRunArguments` hook that selects the browser launcher only for `browser-*` runtime identifiers; -- a dependency-free .NET launcher that starts a configured WebAssembly host, launches an - installed Chromium-family browser in an isolated profile, injects the Microsoft Testing - Platform arguments before the runtime starts, captures bounded diagnostics, and cleans up - the browser and host process trees. +- an independently versioned .NET launcher that starts a configured WebAssembly host, uses + Playwright's private browser transport to launch an installed Chromium-family browser in + an isolated context, injects the Microsoft Testing Platform arguments before the runtime + starts, captures bounded diagnostics, and cleans up the browser and host process trees. Test discovery and results are not parsed or relayed by this package. The browser test application connects directly to the authenticated HTTP gateway created by the .NET SDK. @@ -69,8 +69,15 @@ Useful properties: | `TestingPlatformBrowserAdditionalArguments` | Additional Chromium command-line arguments. | The SDK bearer token is read from its owner-only response file, retained in memory, injected -into the browser runtime through the DevTools protocol, and redacted from launcher, host, and -browser diagnostics. It is never added to the browser URL. +into the browser runtime through Playwright's launcher-private transport, and redacted from +launcher, host, and browser diagnostics. It is never added to the browser URL or exposed +through an unauthenticated DevTools TCP listener. User browser arguments cannot override +Playwright's debugging transport or isolated profile. + +The optional package carries Playwright and its Node-based driver so that browser cadence +can be serviced independently of core Microsoft.Testing.Platform and the .NET SDK. This +introduces package-size, platform, offline/source-build, and Node security servicing +considerations that must be resolved before productization. The current proof of concept intentionally leaves physical browser virtual-file-system artifact export to a future artifact sink and leaves host implementation to the shared diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 10367ad9f4..683b9f69bf 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -166,6 +166,10 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa DotnetMuxerResult run = await DotnetCli.RunAsync( $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + environmentVariables: new Dictionary + { + ["DEBUG"] = "*", + }, warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -175,6 +179,8 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa 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); DotnetMuxerResult list = await DotnetCli.RunAsync( $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --list-tests", diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index 4f475d7264..6644b53683 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -178,6 +178,48 @@ public void DiagnosticBuffer_RedactsBootstrapSecretsAndBoundsEntries() Assert.HasCount(200, output.Split(Environment.NewLine)); } + [TestMethod] + [DataRow("--remote-debugging-port=9222")] + [DataRow("-remote-debugging-port=9222")] + [DataRow("/remote-debugging-port=9222")] + [DataRow("--remote-debugging-address=0.0.0.0")] + [DataRow("-remote-debugging-address=0.0.0.0")] + [DataRow("--remote-debugging-pipe")] + [DataRow("--remote-allow-origins=*")] + [DataRow("--profile-directory=Default")] + [DataRow("--user-data-dir=shared")] + public void Parse_RejectsBrowserArgumentsThatOverrideLauncherSecurity(string browserArgument) + { + string responseFile = CreateResponseFile( + """ + --server dotnettestcli + --dotnet-test-transport http + --dotnet-test-http-endpoint http://127.0.0.1:1234/dotnettest/run/ + --dotnet-test-http-token abcdef0123456789 + """); + + try + { + Assert.ThrowsExactly(() => BrowserLauncherOptions.Parse( + [ + "--host-command-base64", Encode("dotnet"), + "--host-arguments-base64", Encode("host.dll"), + "--host-working-directory-base64", Encode(Path.GetTempPath()), + "--url-path-base64", Encode("/"), + "--browser-executable-base64", Encode(string.Empty), + "--browser-arguments-base64", Encode(browserArgument), + "--startup-timeout-seconds", "30", + "--completion-timeout-seconds", "120", + "--", + "@" + responseFile, + ])); + } + finally + { + File.Delete(responseFile); + } + } + [TestMethod] public void DiagnosticBuffer_DoesNotTreatShortUrlSegmentsAsSecrets() { From 086b0636d9fa0fe400351a54dc4c1cbb6bd4196d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 14 Sep 2026 10:25:41 +0200 Subject: [PATCH 03/16] Harden browser launcher packaging and cleanup Bundle the private cross-platform Playwright runtime from clean builds, allow launcher runtime roll-forward, and make startup cancellation and teardown reliably close owned browser resources. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ChromiumBrowser.cs | 158 ++++++++++++++---- .../Microsoft.Testing.Platform.Browser.csproj | 38 +++-- .../BrowserPackageExecutionTests.cs | 60 ++++++- .../BrowserLauncherOptionsTests.cs | 19 +++ 4 files changed, 229 insertions(+), 46 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs index 8483336e20..a88d147df8 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -9,6 +9,8 @@ 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; @@ -51,6 +53,7 @@ public static async Task LaunchAsync( IPlaywright? playwright = null; IBrowser? browser = null; IBrowserContext? context = null; + Task? browserLaunchTask = null; try { @@ -59,22 +62,25 @@ public static async Task LaunchAsync( // process, and its browser host child has already started, so keep DEBUG disabled for // the remainder of the launcher lifetime. Environment.SetEnvironmentVariable("DEBUG", null); + EnsurePlaywrightNodeExecutable(); playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); - browser = await playwright.Chromium.LaunchAsync( + browserLaunchTask = playwright.Chromium.LaunchAsync( new BrowserTypeLaunchOptions { ExecutablePath = executable, Headless = true, Args = [.. options.BrowserArguments], Timeout = (float)options.StartupTimeout.TotalMilliseconds, - }).ConfigureAwait(false); + }); + browser = await browserLaunchTask.WaitAsync(startupCancellationToken).ConfigureAwait(false); context = await browser.NewContextAsync( new BrowserNewContextOptions { Locale = "en-US", - }).ConfigureAwait(false); - IPage page = await context.NewPageAsync().ConfigureAwait(false); + }).WaitAsync(startupCancellationToken).ConfigureAwait(false); + IPage page = await context.NewPageAsync() + .WaitAsync(startupCancellationToken).ConfigureAwait(false); var result = new ChromiumBrowser(playwright, browser, context, page, diagnostics); await result.InitializePageAsync( options.TestApplicationArguments, @@ -82,19 +88,27 @@ await result.InitializePageAsync( startupCancellationToken).ConfigureAwait(false); return result; } - catch (Exception ex) when (ex is PlaywrightException or TimeoutException) + catch (Exception ex) { - if (context is not null) + if (browser is null && browserLaunchTask is not null) { - await context.CloseAsync().ConfigureAwait(false); + browser = await TryObserveBrowserLaunchAsync(browserLaunchTask, diagnostics).ConfigureAwait(false); } - if (browser is not null) + await DisposeBrowserAsync(context, browser, playwright, diagnostics).ConfigureAwait(false); + if (startupTimeoutCancellationTokenSource.IsCancellationRequested + && !cancellationToken.IsCancellationRequested) { - await browser.CloseAsync().ConfigureAwait(false); + throw new BrowserLauncherException( + $"The Chromium browser did not become ready within {options.StartupTimeout.TotalSeconds} seconds.", + ex); + } + + if (ex is OperationCanceledException or BrowserLauncherException) + { + throw; } - playwright?.Dispose(); throw new BrowserLauncherException( $"Unable to launch the Chromium browser '{executable}'.", ex); @@ -119,7 +133,8 @@ public async Task WaitForCompletionAsync(TimeSpan timeout, CancellationToke } string? json = await _page.EvaluateAsync( - "() => JSON.stringify(globalThis.__mtpBrowserResult ?? null)").ConfigureAwait(false); + "() => JSON.stringify(globalThis.__mtpBrowserResult ?? null)") + .WaitAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false); if (json is not null and not "null") { using var result = JsonDocument.Parse(json); @@ -155,25 +170,7 @@ await Task.Delay( } public async ValueTask DisposeAsync() - { - try - { - await _context.CloseAsync().ConfigureAwait(false); - } - catch (PlaywrightException) - { - } - - try - { - await _browser.CloseAsync().ConfigureAwait(false); - } - catch (PlaywrightException) - { - } - - _playwright.Dispose(); - } + => await DisposeBrowserAsync(_context, _browser, _playwright, _diagnostics).ConfigureAwait(false); private async Task InitializePageAsync( IReadOnlyList testApplicationArguments, @@ -221,4 +218,105 @@ private void SubscribeToDiagnostics() _page.Crash += (_, _) => _diagnostics.Add("browser", "The browser page crashed."); } + + private static async Task DisposeBrowserAsync( + IBrowserContext? context, + IBrowser? browser, + IPlaywright? playwright, + DiagnosticBuffer diagnostics) + { + 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}"); + } + } + + try + { + playwright?.Dispose(); + } + catch (Exception ex) + { + diagnostics.Add("launcher cleanup", $"Unable to dispose Playwright: {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; + } + } + + private static void EnsurePlaywrightNodeExecutable() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + string nodePath = GetPlaywrightNodeExecutablePath( + 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 GetPlaywrightNodeExecutablePath( + 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/Microsoft.Testing.Platform.Browser.csproj b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj index 0885193915..de57385301 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj @@ -5,6 +5,12 @@ net8.0 false false + true + Major + linux-x64;linux-arm64;osx-x64;osx-arm64;win + + $(NoWarn);NU5111 $(MicrosoftTestingPlatformBrowserVersionPrefix) $(MicrosoftTestingPlatformBrowserPreReleaseVersionLabel) true @@ -18,9 +24,13 @@ $(CommonProductDescription)]]> - + + + $(TargetsForTfmSpecificContentInPackage);_AddBrowserLauncherPackageFiles + + true @@ -34,16 +44,22 @@ $(CommonProductDescription)]]> true buildTransitive - - - - - + + + + + + + + + +
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 683b9f69bf..c7d2b65a6a 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -1,6 +1,8 @@ // 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; + namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; /// @@ -214,21 +216,69 @@ public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() string output = run.StandardOutput + run.StandardError; Assert.AreEqual(0, run.ExitCode, run.ToString()); - Assert.Contains($"({TargetFramework}|x64) passed [+1/x0/?0]", output); + string architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + Assert.Contains($"({TargetFramework}|{architecture}) passed [+1/x0/?0]", output); + } + + [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); + + 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."); } 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."; - string package = Directory + return Directory .EnumerateFiles(Constants.ArtifactsPackagesShipping, $"{packagePrefix}*.nupkg") .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault() ?? throw new AssertFailedException( $"Microsoft.Testing.Platform.Browser was not packed under '{Constants.ArtifactsPackagesShipping}'."); - - string fileName = Path.GetFileName(package); - return fileName[packagePrefix.Length..^".nupkg".Length]; } private static string? LocateBrowser() diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index 6644b53683..e9839e451f 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -244,6 +244,25 @@ public void BrowserExecutableLocator_PrefersConfiguredExecutable() } } + [TestMethod] + [DataRow("linux", Architecture.X64, "linux-x64")] + [DataRow("linux", Architecture.Arm64, "linux-arm64")] + [DataRow("osx", Architecture.X64, "darwin-x64")] + [DataRow("osx", Architecture.Arm64, "darwin-arm64")] + public void GetPlaywrightNodeExecutablePath_MapsSupportedPlatforms( + string operatingSystem, + Architecture architecture, + string platformDirectory) + { + OSPlatform osPlatform = operatingSystem == "linux" ? OSPlatform.Linux : OSPlatform.OSX; + + string path = ChromiumBrowser.GetPlaywrightNodeExecutablePath("root", osPlatform, architecture); + + Assert.AreEqual( + Path.Combine("root", ".playwright", "node", platformDirectory, "node"), + path); + } + private static string CreateResponseFile(string content) { string path = Path.Combine(Path.GetTempPath(), $"dotnet-test-http-{Guid.NewGuid():N}.rsp"); From 083be6dabd3f098de1242a9b8e3da57636875d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 14 Sep 2026 18:42:36 +0200 Subject: [PATCH 04/16] Support framework-owned browser host pages Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c54e99e-de18-4bcd-a5c4-6163e4aeb78c --- src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md | 1 + .../Microsoft.Testing.Platform.Browser.props | 1 + .../Microsoft.Testing.Platform.Browser.targets | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index ee1675b4b3..5a7d53c28e 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -59,6 +59,7 @@ Useful properties: | Property | Purpose | | --- | --- | | `TestingPlatformBrowserEnabled` | Enables or disables the package. It defaults to `true` only for `browser-*` runtime identifiers and can be set to `false` in the project or on the command line. | +| `TestingPlatformBrowserGenerateHostAssets` | Controls whether the package supplies its default `index.html` and JavaScript supervisor. Set to `false` when a UI framework owns the page and integrates the launcher bootstrap/completion contract itself. | | `TestingPlatformBrowserExecutable` | Overrides browser discovery with an explicit Chromium-family executable path. | | `TestingPlatformBrowserHostCommand` | Command that starts the external browser-WASM host. | | `TestingPlatformBrowserHostArguments` | Arguments for the host command. | 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 index 97c0f9786b..e04a24a285 100644 --- 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 @@ -3,6 +3,7 @@ 60 600 / + true $(DotNetHostPath) dotnet $(MSBuildProjectDirectory) 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 index dfd907392a..409bb02314 100644 --- 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 @@ -2,10 +2,10 @@ true <_TestingPlatformBrowserAssetsDirectory>$(MSBuildThisFileDirectory)assets\ - $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js + $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js - + index.html From ab878a385322d6ec82846055d9c7baa6fb4bf1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 14 Sep 2026 19:14:22 +0200 Subject: [PATCH 05/16] Wrap computed browser hosts and version page API Preserve framework-provided ComputeRunArguments host contracts, including quoted empty arguments, while keeping explicit browser host overrides. Replace raw page globals with the documented testingPlatformBrowser contract version 1 and launcher-private completion binding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserLauncherOptions.cs | 9 +- .../ChromiumBrowser.cs | 140 ++++++++---- .../PACKAGE.md | 66 +++++- .../Microsoft.Testing.Platform.Browser.props | 3 - ...Microsoft.Testing.Platform.Browser.targets | 28 ++- ...Microsoft.Testing.Platform.Browser.main.js | 29 ++- .../BrowserPackageExecutionTests.cs | 199 +++++++++++++++++- .../BrowserLauncherOptionsTests.cs | 11 + 8 files changed, 417 insertions(+), 68 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs index df34bd9441..6f06d0a4c0 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -328,6 +328,7 @@ public static string[] Split(string commandLine) var arguments = new List(); var current = new StringBuilder(); bool inQuotes = false; + bool tokenStarted = false; int backslashCount = 0; void FlushBackslashes() @@ -336,6 +337,7 @@ void FlushBackslashes() { current.Append('\\', backslashCount); backslashCount = 0; + tokenStarted = true; } } @@ -350,6 +352,7 @@ void FlushBackslashes() if (character == '"') { + tokenStarted = true; current.Append('\\', backslashCount / 2); if (backslashCount % 2 == 0) { @@ -367,15 +370,17 @@ void FlushBackslashes() FlushBackslashes(); if (char.IsWhiteSpace(character) && !inQuotes) { - if (current.Length > 0) + if (tokenStarted) { arguments.Add(current.ToString()); current.Clear(); + tokenStarted = false; } continue; } + tokenStarted = true; current.Append(character); } @@ -385,7 +390,7 @@ void FlushBackslashes() throw new BrowserLauncherException("A launcher command line contains an unclosed quote."); } - if (current.Length > 0) + if (tokenStarted) { arguments.Add(current.ToString()); } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs index a88d147df8..066333061c 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -15,6 +15,8 @@ internal sealed class ChromiumBrowser : IAsyncDisposable private readonly IBrowser _browser; private readonly IBrowserContext _context; private readonly IPage _page; + private readonly IAsyncDisposable _completionBinding; + private readonly TaskCompletionSource _completion; private readonly DiagnosticBuffer _diagnostics; private readonly TaskCompletionSource _disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -23,12 +25,16 @@ private ChromiumBrowser( IBrowser browser, IBrowserContext context, IPage page, + IAsyncDisposable completionBinding, + TaskCompletionSource completion, DiagnosticBuffer diagnostics) { _playwright = playwright; _browser = browser; _context = context; _page = page; + _completionBinding = completionBinding; + _completion = completion; _diagnostics = diagnostics; _browser.Disconnected += (_, _) => _disconnected.TrySetResult(); SubscribeToDiagnostics(); @@ -53,6 +59,7 @@ public static async Task LaunchAsync( IPlaywright? playwright = null; IBrowser? browser = null; IBrowserContext? context = null; + IAsyncDisposable? completionBinding = null; Task? browserLaunchTask = null; try @@ -81,7 +88,20 @@ public static async Task LaunchAsync( }).WaitAsync(startupCancellationToken).ConfigureAwait(false); IPage page = await context.NewPageAsync() .WaitAsync(startupCancellationToken).ConfigureAwait(false); - var result = new ChromiumBrowser(playwright, browser, context, page, diagnostics); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + string expectedOrigin = browserUri.GetLeftPart(UriPartial.Authority); + completionBinding = await page.ExposeBindingAsync( + "__mtpBrowserCompleteV1", + (source, message) => CompleteBrowserRun(source, page, expectedOrigin, message, completion)) + .WaitAsync(startupCancellationToken).ConfigureAwait(false); + var result = new ChromiumBrowser( + playwright, + browser, + context, + page, + completionBinding, + completion, + diagnostics); await result.InitializePageAsync( options.TestApplicationArguments, browserUri, @@ -95,7 +115,7 @@ await result.InitializePageAsync( browser = await TryObserveBrowserLaunchAsync(browserLaunchTask, diagnostics).ConfigureAwait(false); } - await DisposeBrowserAsync(context, browser, playwright, diagnostics).ConfigureAwait(false); + await DisposeBrowserAsync(completionBinding, context, browser, playwright, diagnostics).ConfigureAwait(false); if (startupTimeoutCancellationTokenSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -124,66 +144,59 @@ public async Task WaitForCompletionAsync(TimeSpan timeout, CancellationToke try { - while (true) - { - if (!_browser.IsConnected) - { - throw new BrowserLauncherException( - "The browser disconnected before the test application completed."); - } - - string? json = await _page.EvaluateAsync( - "() => JSON.stringify(globalThis.__mtpBrowserResult ?? null)") - .WaitAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false); - if (json is not null and not "null") - { - using var result = JsonDocument.Parse(json); - JsonElement root = result.RootElement; - if (root.TryGetProperty("completed", out JsonElement completed) - && completed.GetBoolean()) - { - if (root.TryGetProperty("error", out JsonElement error)) - { - _diagnostics.Add("browser", error.GetString() ?? error.GetRawText()); - } - - return root.GetProperty("exitCode").GetInt32(); - } - } - - await Task.Delay( - TimeSpan.FromMilliseconds(100), - linkedCancellationTokenSource.Token).ConfigureAwait(false); - } + return await _completion.Task + .WaitAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) { throw new BrowserLauncherException( $"The browser test application did not complete within {timeout.TotalSeconds} seconds."); } - catch (PlaywrightException ex) - { - throw new BrowserLauncherException( - "Unable to read the browser test application result.", - ex); - } } public async ValueTask DisposeAsync() - => await DisposeBrowserAsync(_context, _browser, _playwright, _diagnostics).ConfigureAwait(false); + => await DisposeBrowserAsync( + _completionBinding, + _context, + _browser, + _playwright, + _diagnostics).ConfigureAwait(false); private async Task InitializePageAsync( IReadOnlyList testApplicationArguments, Uri browserUri, CancellationToken cancellationToken) { - string serializedArguments = JsonSerializer.Serialize(testApplicationArguments); string expectedOrigin = browserUri.GetLeftPart(UriPartial.Authority); + string serializedArguments = JsonSerializer.Serialize(testApplicationArguments); string serializedExpectedOrigin = JsonSerializer.Serialize(expectedOrigin); await _page.AddInitScriptAsync( $$""" if (globalThis.self === globalThis.top && globalThis.location.origin === {{serializedExpectedOrigin}}) { - Object.defineProperty(globalThis, '__mtpBrowserArguments', { value: Object.freeze({{serializedArguments}}), configurable: false, enumerable: false, writable: false }); + const argumentsFromLauncher = Object.freeze({{serializedArguments}}); + const completeTransport = globalThis.__mtpBrowserCompleteV1; + let completed = false; + const api = Object.freeze({ + contractVersion: 1, + getArguments() { + return Object.freeze([...argumentsFromLauncher]); + }, + complete(exitCode) { + if (completed) { + throw new Error('testingPlatformBrowser.complete can only be called once.'); + } + if (!Number.isInteger(exitCode)) { + throw new TypeError('testingPlatformBrowser.complete requires an integer exitCode.'); + } + + completed = true; + void completeTransport({ + contractVersion: 1, + exitCode, + }); + }, + }); + Object.defineProperty(globalThis, 'testingPlatformBrowser', { value: api, configurable: false, enumerable: true, writable: false }); } """) .WaitAsync(cancellationToken).ConfigureAwait(false); @@ -220,11 +233,24 @@ private void SubscribeToDiagnostics() } private static async Task DisposeBrowserAsync( + IAsyncDisposable? completionBinding, IBrowserContext? context, IBrowser? browser, IPlaywright? playwright, DiagnosticBuffer diagnostics) { + if (completionBinding is not null) + { + try + { + await completionBinding.DisposeAsync().AsTask().WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + diagnostics.Add("launcher cleanup", $"Unable to remove the browser completion binding: {ex.Message}"); + } + } + if (context is not null) { try @@ -274,6 +300,36 @@ private static async Task DisposeBrowserAsync( } } + private static bool CompleteBrowserRun( + BindingSource source, + IPage expectedPage, + string expectedOrigin, + JsonElement message, + TaskCompletionSource completion) + { + _ = ReferenceEquals(source.Page, expectedPage) + && source.Frame.ParentFrame is null + && Uri.TryCreate(source.Frame.Url, UriKind.Absolute, out Uri? sourceUri) + && string.Equals( + sourceUri.GetLeftPart(UriPartial.Authority), + expectedOrigin, + StringComparison.Ordinal) + ? true + : throw new BrowserLauncherException( + "The browser completion API was called outside the expected top-level loopback origin."); + + return message.ValueKind == JsonValueKind.Object + && message.TryGetProperty("contractVersion", out JsonElement contractVersion) + && contractVersion.ValueKind == JsonValueKind.Number + && contractVersion.GetInt32() == 1 + && message.TryGetProperty("exitCode", out JsonElement exitCode) + && exitCode.ValueKind == JsonValueKind.Number + && exitCode.TryGetInt32(out int exitCodeValue) + ? completion.TrySetResult(exitCodeValue) + : throw new BrowserLauncherException( + "The browser completion API received an invalid version 1 payload."); + } + private static void EnsurePlaywrightNodeExecutable() { if (OperatingSystem.IsWindows()) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index 5a7d53c28e..55c72485f0 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -61,9 +61,9 @@ Useful properties: | `TestingPlatformBrowserEnabled` | Enables or disables the package. It defaults to `true` only for `browser-*` runtime identifiers and can be set to `false` in the project or on the command line. | | `TestingPlatformBrowserGenerateHostAssets` | Controls whether the package supplies its default `index.html` and JavaScript supervisor. Set to `false` when a UI framework owns the page and integrates the launcher bootstrap/completion contract itself. | | `TestingPlatformBrowserExecutable` | Overrides browser discovery with an explicit Chromium-family executable path. | -| `TestingPlatformBrowserHostCommand` | Command that starts the external browser-WASM host. | -| `TestingPlatformBrowserHostArguments` | Arguments for the host command. | -| `TestingPlatformBrowserHostWorkingDirectory` | Working directory for the host process. | +| `TestingPlatformBrowserHostCommand` | Overrides the command that starts the external browser-WASM host. When unset, the package wraps the `RunCommand` produced by the project's original `ComputeRunArguments`. | +| `TestingPlatformBrowserHostArguments` | Overrides the arguments for the host command. When unset, the original computed `RunArguments` are preserved, including empty and quoted arguments. | +| `TestingPlatformBrowserHostWorkingDirectory` | Overrides the working directory for the host process. When unset, the original computed `RunWorkingDirectory` is used. | | `TestingPlatformBrowserUrlPath` | Path opened relative to the host URL. Defaults to `/`. | | `TestingPlatformBrowserStartupTimeoutSeconds` | Host/browser startup timeout. Defaults to 60 seconds. | | `TestingPlatformBrowserCompletionTimeoutSeconds` | Test completion timeout. Defaults to 600 seconds. | @@ -75,6 +75,66 @@ launcher, host, and browser diagnostics. It is never added to the browser URL or through an unauthenticated DevTools TCP listener. User browser arguments cannot override Playwright's debugging transport or isolated profile. +## Browser page API + +The launcher installs a versioned API on the top-level page only when its origin exactly +matches the loopback host origin: + +```js +globalThis.testingPlatformBrowser = { + contractVersion: 1, + getArguments(): string[], + complete(exitCode: number): void +}; +``` + +- `contractVersion` is `1`. A framework-owned page must reject versions it does not support. +- `getArguments()` returns a new frozen array containing the Microsoft Testing Platform + arguments prepared by `dotnet test`, including the authenticated HTTP transport + bootstrap. The page must pass the array directly to the managed test application; it + must not log, persist, put into a URL, or relay those arguments. +- `complete(exitCode)` reports the managed application's final exit code to the + launcher. It is one-shot and must be called exactly once. Failures should be written + to `console.error` before completion so the launcher captures their diagnostics. Test discovery and test + results do not flow through this method; MTP sends them directly to the SDK HTTP + gateway. + +The package-owned JavaScript supervisor implements this API contract automatically. A UI +framework that owns its browser page can set +`TestingPlatformBrowserGenerateHostAssets=false`, provide its own `WasmMainJSPath` and +page, then integrate the API: + +```js +import { dotnet } from './_framework/dotnet.js'; + +const api = globalThis.testingPlatformBrowser; +if (api?.contractVersion !== 1) { + throw new Error('testingPlatformBrowser contract version 1 is required.'); +} + +let exitCode; +let failure; +try { + const { runMain } = await dotnet + .withApplicationArguments(...api.getArguments()) + .create(); + exitCode = await runMain(); +} +catch (error) { + failure = error; + exitCode = 1; + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); +} + +api.complete(exitCode); +if (failure !== undefined) { + throw failure; +} +``` + +The Playwright binding used underneath `complete` is a private launcher transport detail +and is not part of the browser page API. + The optional package carries Playwright and its Node-based driver so that browser cadence can be serviced independently of core Microsoft.Testing.Platform and the .NET SDK. This introduces package-size, platform, offline/source-build, and Node security servicing 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 index e04a24a285..a694ee9f9b 100644 --- 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 @@ -4,8 +4,5 @@ 600 / true - $(DotNetHostPath) - dotnet - $(MSBuildProjectDirectory) 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 index 409bb02314..92f509e2b5 100644 --- 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 @@ -11,13 +11,29 @@ - + + <_TestingPlatformBrowserComputedHostCommand>$(RunCommand) + <_TestingPlatformBrowserComputedHostArguments>$(RunArguments) + <_TestingPlatformBrowserComputedHostWorkingDirectory>$(RunWorkingDirectory) + + + + <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.launch')) - run --no-build --configuration "$(Configuration)" --framework "$(TargetFramework)" --runtime "$(RuntimeIdentifier)" --project "$(MSBuildProjectFullPath)" -p:TestingPlatformBrowserEnabled=false -- --urls http://127.0.0.1:0 + <_TestingPlatformBrowserHostCommand Condition=" '$(TestingPlatformBrowserHostCommand)' != '' ">$(TestingPlatformBrowserHostCommand) + <_TestingPlatformBrowserHostCommand Condition=" '$(_TestingPlatformBrowserHostCommand)' == '' ">$(_TestingPlatformBrowserComputedHostCommand) + <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' != '' ">$(TestingPlatformBrowserHostArguments) + <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' == '' ">$(_TestingPlatformBrowserComputedHostArguments) + <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(TestingPlatformBrowserHostWorkingDirectory)' != '' ">$(TestingPlatformBrowserHostWorkingDirectory) + <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(_TestingPlatformBrowserComputedHostWorkingDirectory) + <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(MSBuildProjectDirectory) $(DotNetHostPath) dotnet @@ -27,17 +43,19 @@ + { console.error(`Unhandled browser error: ${event.message}`); }); @@ -18,21 +22,24 @@ globalThis.addEventListener('unhandledrejection', event => { console.error(`Unhandled browser rejection: ${String(event.reason)}`); }); +let exitCode; +let failure; try { const { runMain } = await dotnet .withApplicationArguments(...argumentsFromLauncher) .create(); - const exitCode = await runMain(); - globalThis.__mtpBrowserResult = { completed: true, exitCode }; + exitCode = await runMain(); status.textContent = exitCode === 0 ? 'Passed' : `Failed (exit code ${exitCode})`; } catch (error) { - globalThis.__mtpBrowserResult = { - completed: true, - exitCode: 1, - error: error instanceof Error ? error.stack ?? error.message : String(error), - }; + failure = error; + exitCode = 1; + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); status.textContent = 'Failed (launcher error)'; - throw error; +} + +browserApi.complete(exitCode); +if (failure !== undefined) { + throw failure; } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index c7d2b65a6a..2a9f8cc8f1 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -29,8 +29,6 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBasefalse $(NoWarn);NETSDK1201 - $Node$ - "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" $Browser$ --mtp-test-value=a;b 60 @@ -42,6 +40,14 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase + + + $Node$ + "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" "" --framework-marker "quoted value" + $(MSBuildProjectDirectory) + + + #file BrowserPackageTests.cs @@ -63,6 +69,10 @@ public void RunsInsideBrowser() import { extname, resolve, sep } from 'node:path'; const root = resolve(process.argv[2]); +if (process.argv[3] !== '' || process.argv[4] !== '--framework-marker' || process.argv[5] !== 'quoted value') { + throw new Error(`The computed host arguments did not round-trip: ${JSON.stringify(process.argv.slice(2))}`); +} + const launchInfoPath = process.env.TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE; if (!launchInfoPath) { throw new Error('TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE is required.'); @@ -96,6 +106,129 @@ public void RunsInsideBrowser() createReadStream(file).pipe(response); }); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const temporaryPath = `${launchInfoPath}.${process.pid}.tmp`; + writeFileSync(temporaryPath, JSON.stringify({ version: 1, url: `http://127.0.0.1:${address.port}/` }), { mode: 0o600 }); + renameSync(temporaryPath, launchInfoPath); +}); +"""; + + private const string FrameworkOwnedPageSourceCode = """ +#file BrowserFrameworkPageTestProject.csproj + + + + $TargetFramework$ + browser-wasm + Exe + true + true + true + enable + main.js + false + false + $(NoWarn);NETSDK1201 + + false + $Node$ + "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" + $(MSBuildProjectDirectory) + $Browser$ + 60 + 120 + + + + + + + + + + +#file BrowserFrameworkPageTests.cs +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class BrowserFrameworkPageTests +{ + [TestMethod] + public void UsesVersionedBrowserApi() + { + Assert.IsTrue(OperatingSystem.IsBrowser()); + } +} + +#file index.html + + +Framework-owned MTP page + + + +#file main.js +import { dotnet } from './_framework/dotnet.js'; + +const api = globalThis.testingPlatformBrowser; +if (api?.contractVersion !== 1) { + throw new Error('Expected testingPlatformBrowser contract version 1.'); +} + +let exitCode; +let failure; +try { + const { runMain } = await dotnet.withApplicationArguments(...api.getArguments()).create(); + exitCode = await runMain(); +} +catch (error) { + failure = error; + exitCode = 1; + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); +} + +api.complete(exitCode); +if (failure !== undefined) { + throw failure; +} + +#file server.mjs +import { createReadStream, existsSync, renameSync, statSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, resolve, sep } from 'node:path'; + +const root = resolve(process.argv[2]); +const launchInfoPath = process.env.TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE; +if (!launchInfoPath) { + throw new Error('TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE is required.'); +} + +const contentTypes = new Map([ + ['.dat', 'application/octet-stream'], + ['.dll', 'application/octet-stream'], + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.wasm', 'application/wasm'], +]); + +const server = createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); + const relative = pathname === '/' ? 'index.html' : pathname.slice(1); + const file = resolve(root, relative); + if (file !== root && !file.startsWith(root + sep)) { + response.writeHead(403).end(); + return; + } + if (!existsSync(file) || !statSync(file).isFile()) { + response.writeHead(404).end(); + return; + } + response.setHeader('Content-Type', contentTypes.get(extname(file)) ?? 'application/octet-stream'); + createReadStream(file).pipe(response); +}); + server.listen(0, '127.0.0.1', () => { const address = server.address(); const temporaryPath = `${launchInfoPath}.${process.pid}.tmp`; @@ -197,6 +330,58 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.Contains("Discovered 1 tests", listOutput); } + [TestMethod] + public async Task BrowserPackage_FrameworkOwnedPageUsesVersionedApi() + { + string? node = WasmRuntime.LocateNode(); + if (node is null) + { + Assert.Inconclusive(WasmRuntime.NodeUnavailableMessage); + return; + } + + 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( + "BrowserFrameworkPageTestProject", + FrameworkOwnedPageSourceCode + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) + .PatchCodeWithReplace("$Node$", EscapeMsBuildValue(node)) + .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); + + DotnetMuxerResult run = await DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + string output = run.StandardOutput + run.StandardError; + Assert.AreEqual(0, run.ExitCode, run.ToString()); + Assert.Contains($"({TargetFramework}|wasm) passed [+1/x0/?0]", output); + + string appBundle = Path.Combine( + generator.TargetAssetPath, + "bin", + "Release", + TargetFramework, + WasmRuntime.BrowserRid, + "AppBundle"); + Assert.IsTrue(File.Exists(Path.Combine(appBundle, "index.html"))); + Assert.IsTrue(File.Exists(Path.Combine(appBundle, "main.js"))); + Assert.IsFalse( + File.Exists(Path.Combine(appBundle, "Microsoft.Testing.Platform.Browser.main.js")), + "TestingPlatformBrowserGenerateHostAssets=false must not deploy the package-owned supervisor."); + } + [TestMethod] public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() { @@ -236,6 +421,16 @@ public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() Assert.Contains("tools/net8.0/any/.playwright/node/darwin-arm64/node", entries); Assert.Contains("tools/net8.0/any/.playwright/package/cli.js", entries); + 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("testingPlatformBrowser", browserMain); + Assert.DoesNotContain("__mtpBrowserArguments", browserMain); + Assert.DoesNotContain("__mtpBrowserResult", 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."); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index e9839e451f..7075c1f70e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -160,6 +160,17 @@ public void CommandLineTokenizer_RoundTripsQuotedHostArguments() arguments); } + [TestMethod] + public void CommandLineTokenizer_PreservesQuotedEmptyArguments() + { + string[] arguments = CommandLineTokenizer.Split( + "host.dll \"\" --name \"quoted value\" \"\" tail"); + + Assert.AreSequenceEqual( + new[] { "host.dll", string.Empty, "--name", "quoted value", string.Empty, "tail" }, + arguments); + } + [TestMethod] public void DiagnosticBuffer_RedactsBootstrapSecretsAndBoundsEntries() { From 651b5c36edc2989e93d21b6a5c1a90f9f520956d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 14 Sep 2026 21:07:58 +0200 Subject: [PATCH 06/16] Harden browser host and launch-info contracts Keep browser URLs on the validated loopback origin, use the invoking SDK muxer, avoid Playwright payload copies in unit tests, coordinate package-owned host assets, and secure the launch-info handoff with private per-run directories and handle-based validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserLauncherOptions.cs | 12 ++ .../ChromiumBrowser.cs | 48 +---- .../DiagnosticBuffer.cs | 4 + .../HostProcess.cs | 163 +++++++++++---- .../PACKAGE.md | 13 +- .../PlaywrightNodeExecutable.cs | 53 +++++ .../Program.cs | 2 +- ...Microsoft.Testing.Platform.Browser.targets | 9 +- .../BrowserPackageExecutionTests.cs | 74 ++++++- .../BrowserLauncherOptionsTests.cs | 185 +++++++++++++++++- ...rosoft.Testing.Extensions.UnitTests.csproj | 10 +- 11 files changed, 479 insertions(+), 94 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/PlaywrightNodeExecutable.cs diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs index 6f06d0a4c0..5d268ca31c 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -170,6 +170,18 @@ private static TimeSpan ParseTimeout(string value, string name) private static string NormalizeUrlPath(string path) => path.StartsWith("/", StringComparison.Ordinal) ? path : "/" + path; + internal static Uri ResolveBrowserUri(Uri hostUri, string urlPath) + { + var browserUri = new Uri(hostUri, NormalizeUrlPath(urlPath)); + return string.Equals( + browserUri.GetLeftPart(UriPartial.Authority), + hostUri.GetLeftPart(UriPartial.Authority), + StringComparison.Ordinal) + ? browserUri + : throw new BrowserLauncherException( + "The configured browser URL path resolves outside the browser host origin."); + } + private static string ReadConfigurationValue(string[] configuration, int index, string name) { string prefix = name + "="; diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs index 066333061c..0f6234c85b 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -69,7 +69,7 @@ public static async Task LaunchAsync( // process, and its browser host child has already started, so keep DEBUG disabled for // the remainder of the launcher lifetime. Environment.SetEnvironmentVariable("DEBUG", null); - EnsurePlaywrightNodeExecutable(); + PlaywrightNodeExecutable.EnsureExecutable(); playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); browserLaunchTask = playwright.Chromium.LaunchAsync( @@ -329,50 +329,4 @@ private static bool CompleteBrowserRun( : throw new BrowserLauncherException( "The browser completion API received an invalid version 1 payload."); } - - private static void EnsurePlaywrightNodeExecutable() - { - if (OperatingSystem.IsWindows()) - { - return; - } - - string nodePath = GetPlaywrightNodeExecutablePath( - 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 GetPlaywrightNodeExecutablePath( - 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/DiagnosticBuffer.cs b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs index f2f9532e4e..9b37f8c894 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs @@ -9,7 +9,11 @@ internal sealed class DiagnosticBuffer 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) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs index ef7072a7dc..7b00f25d10 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Net; +using System.Security.AccessControl; +using System.Security.Principal; using System.Text.Json; namespace Microsoft.Testing.Platform.Browser; @@ -14,16 +16,22 @@ internal sealed class HostProcess : IAsyncDisposable private readonly Process _process; private readonly DiagnosticBuffer _diagnostics; + private readonly string _launchInfoDirectory; private readonly string _launchInfoPath; private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); private readonly TaskCompletionSource _stdoutReadiness = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Task _stdoutTask; private readonly Task _stderrTask; - private HostProcess(Process process, DiagnosticBuffer diagnostics, string launchInfoPath) + private HostProcess( + Process process, + DiagnosticBuffer diagnostics, + string launchInfoDirectory, + string launchInfoPath) { _process = process; _diagnostics = diagnostics; + _launchInfoDirectory = launchInfoDirectory; _launchInfoPath = launchInfoPath; _stdoutTask = CaptureAsync(process.StandardOutput, "host stdout", inspectReadiness: true, _disposeCancellationTokenSource.Token); _stderrTask = CaptureAsync(process.StandardError, "host stderr", inspectReadiness: true, _disposeCancellationTokenSource.Token); @@ -34,7 +42,8 @@ public Task WaitForExitAsync(CancellationToken cancellationToken) public static HostProcess Start(BrowserLauncherOptions options, DiagnosticBuffer diagnostics) { - string launchInfoPath = Path.Combine(Path.GetTempPath(), $"mtp-browser-host-{Guid.NewGuid():N}.json"); + string launchInfoDirectory = CreateLaunchInfoDirectory(); + string launchInfoPath = Path.Combine(launchInfoDirectory, "launch-info.json"); var startInfo = new ProcessStartInfo { FileName = options.HostCommand, @@ -58,12 +67,13 @@ public static HostProcess Start(BrowserLauncherOptions options, DiagnosticBuffer process = Process.Start(startInfo) ?? throw new BrowserLauncherException($"The browser host command '{options.HostCommand}' did not start."); } - catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or BrowserLauncherException) { + TryDeleteLaunchInfoDirectory(launchInfoDirectory, diagnostics); throw new BrowserLauncherException($"Unable to start the browser host command '{options.HostCommand}'.", ex); } - return new HostProcess(process, diagnostics, launchInfoPath); + return new HostProcess(process, diagnostics, launchInfoDirectory, launchInfoPath); } public async Task WaitUntilReadyAsync(TimeSpan timeout, CancellationToken cancellationToken) @@ -84,7 +94,7 @@ public async Task WaitUntilReadyAsync(TimeSpan timeout, CancellationToken c $"The browser host exited with code {_process.ExitCode} before reporting readiness."); } - if (TryReadLaunchInfo() is { } launchInfoUri) + if (TryReadLaunchInfo(_launchInfoPath) is { } launchInfoUri) { return launchInfoUri; } @@ -134,11 +144,11 @@ public async ValueTask DisposeAsync() try { - File.Delete(_launchInfoPath); + Directory.Delete(_launchInfoDirectory, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _diagnostics.Add("launcher", $"Unable to delete the browser host launch-info file: {ex.Message}"); + _diagnostics.Add("launcher", $"Unable to delete the browser host launch-info directory: {ex.Message}"); } } @@ -153,19 +163,22 @@ private async Task CaptureAsync( while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) { _diagnostics.Add(source, line); - if (inspectReadiness - && ListeningUrlRegex.Match(line) is { Success: true } match - && Uri.TryCreate(match.Groups["url"].Value, UriKind.Absolute, out Uri? uri)) + if (!inspectReadiness) { - if (IsLoopbackHttpUri(uri)) + continue; + } + + try + { + if (TryParseListeningUri(line) is { } uri) { _stdoutReadiness.TrySetResult(uri); } - else - { - _stdoutReadiness.TrySetException( - new BrowserLauncherException("The browser host reported a non-loopback URL.")); - } + } + catch (BrowserLauncherException ex) + { + _stdoutReadiness.TrySetException(ex); + return; } } } @@ -174,50 +187,119 @@ private async Task CaptureAsync( } } - private Uri? TryReadLaunchInfo() + internal static string CreateLaunchInfoDirectory() + { + string baseDirectory = OperatingSystem.IsWindows() + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : Path.GetTempPath(); + if (string.IsNullOrWhiteSpace(baseDirectory)) + { + throw new BrowserLauncherException( + "Unable to determine a private directory for the browser host launch-info file."); + } + + string directoryPath = Path.Combine( + baseDirectory, + "Microsoft.Testing.Platform.Browser", + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + if (!OperatingSystem.IsWindows()) + { + return Directory.CreateDirectory( + directoryPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute).FullName; + } + + SecurityIdentifier currentUser = WindowsIdentity.GetCurrent().User + ?? throw new BrowserLauncherException("Unable to determine the current Windows security identifier."); + var directorySecurity = new DirectorySecurity(); + directorySecurity.SetOwner(currentUser); + directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + directorySecurity.AddAccessRule( + new FileSystemAccessRule( + currentUser, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + var directory = new DirectoryInfo(directoryPath); + directory.Create(directorySecurity); + return directory.FullName; + } + + internal static Uri? TryReadLaunchInfo(string launchInfoPath) { - if (!File.Exists(_launchInfoPath)) + try + { + if ((File.GetAttributes(launchInfoPath) & FileAttributes.ReparsePoint) != 0) + { + throw new BrowserLauncherException( + "The browser host launch-info path must not be a symbolic link or reparse point."); + } + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or UnauthorizedAccessException) { return null; } + FileStream stream; try { - ValidateLaunchInfoPermissions(); - using FileStream stream = File.OpenRead(_launchInfoPath); - BrowserHostLaunchInfo? launchInfo = JsonSerializer.Deserialize( - stream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - return launchInfo is { Version: 1 } - && Uri.TryCreate(launchInfo.Url, UriKind.Absolute, out Uri? uri) - && IsLoopbackHttpUri(uri) - ? uri - : throw new BrowserLauncherException("The browser host launch-info file is invalid."); + stream = new FileStream( + launchInfoPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); } - catch (IOException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return null; } - catch (JsonException ex) + + using (stream) { - throw new BrowserLauncherException("The browser host launch-info file is invalid.", ex); + try + { + ValidateLaunchInfoPermissions(stream); + BrowserHostLaunchInfo? launchInfo = JsonSerializer.Deserialize( + stream, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return launchInfo is { Version: 1 } + && Uri.TryCreate(launchInfo.Url, UriKind.Absolute, out Uri? uri) + && IsLoopbackHttpUri(uri) + ? uri + : throw new BrowserLauncherException("The browser host launch-info file is invalid."); + } + catch (JsonException ex) + { + throw new BrowserLauncherException("The browser host launch-info file is invalid.", ex); + } } } + internal static Uri? TryParseListeningUri(string line) + => ListeningUrlRegex.Match(line) is not { Success: true } match + ? null + : Uri.TryCreate(match.Groups["url"].Value, UriKind.Absolute, out Uri? uri) + && IsLoopbackHttpUri(uri) + ? uri + : throw new BrowserLauncherException("The browser host reported a non-loopback URL."); + private static bool IsLoopbackHttpUri(Uri uri) => (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) || string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)) && (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) || (IPAddress.TryParse(uri.Host, out IPAddress? address) && IPAddress.IsLoopback(address))); - private void ValidateLaunchInfoPermissions() + private static void ValidateLaunchInfoPermissions(FileStream stream) { if (OperatingSystem.IsWindows()) { + // The containing directory has a protected current-user-only DACL. The host creates the + // launch-info file inside that directory, so it inherits the same access boundary. return; } - UnixFileMode mode = File.GetUnixFileMode(_launchInfoPath); + UnixFileMode mode = File.GetUnixFileMode(stream.SafeFileHandle); const UnixFileMode disallowed = UnixFileMode.GroupRead | UnixFileMode.GroupWrite @@ -231,5 +313,20 @@ private void ValidateLaunchInfoPermissions() } } + private static void TryDeleteLaunchInfoDirectory(string directory, DiagnosticBuffer diagnostics) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + diagnostics.Add("launcher", $"Unable to delete the browser host launch-info directory: {ex.Message}"); + } + } + private sealed record BrowserHostLaunchInfo(int Version, string Url); } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index 55c72485f0..41a39a21d3 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -23,16 +23,21 @@ application connects directly to the authenticated HTTP gateway created by the . `browser-wasm`. - Microsoft Edge, Google Chrome, Chromium, or a compatible executable selected with `TestingPlatformBrowserExecutable`. -- A browser-WASM host. The proof of concept defaults to `dotnet run --no-build` and - recognizes the current `Now listening on: ` output. A shared host can instead write - the versioned launch-info file named by the +- A browser-WASM host. The package wraps the command, arguments, and working directory + produced by the project's original `ComputeRunArguments` target. It recognizes the + current `Now listening on: ` output. A shared host can instead write the versioned + launch-info file named by the `TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE` environment variable: ```json { "version": 1, "url": "http://127.0.0.1:12345/" } ``` - The host must create the file atomically and with owner-only permissions. The launcher + The launcher creates a fresh private directory for each run and passes a file path inside + it. On Unix the directory is mode `0700`; on Windows it has a protected current-user-only + DACL. The host must create the file atomically at that exact path with owner-only + permissions and must not replace the containing directory. The launcher rejects + symbolic links/reparse points, validates Unix permissions on the opened file handle, and prefers this contract over console parsing. ## Usage 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 index 2e1af3721a..dc2fee2143 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs @@ -31,7 +31,7 @@ public static async Task Main(string[] args) Uri hostUri = await host.WaitUntilReadyAsync( options.StartupTimeout, runCancellationTokenSource.Token).ConfigureAwait(false); - var browserUri = new Uri(hostUri, options.UrlPath); + Uri browserUri = BrowserLauncherOptions.ResolveBrowserUri(hostUri, options.UrlPath); ChromiumBrowser browser = await ChromiumBrowser.LaunchAsync( options, browserUri, 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 index 92f509e2b5..5e588453a8 100644 --- 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 @@ -2,10 +2,11 @@ true <_TestingPlatformBrowserAssetsDirectory>$(MSBuildThisFileDirectory)assets\ - $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js + <_TestingPlatformBrowserOwnsHostAssets Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' AND '$(TestingPlatformBrowserGenerateHostAssets)' == 'true' AND '$(WasmMainJSPath)' == '' ">true + $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js - + index.html @@ -35,8 +36,8 @@ <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(_TestingPlatformBrowserComputedHostWorkingDirectory) <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(MSBuildProjectDirectory) - $(DotNetHostPath) - dotnet + $(DOTNET_HOST_PATH) + dotnet exec "$(_TestingPlatformBrowserLauncher)" --config "$(_TestingPlatformBrowserLauncherConfiguration)" -- $(MSBuildProjectDirectory) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 2a9f8cc8f1..f5c41f0714 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -43,11 +43,20 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase $Node$ - "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" "" --framework-marker "quoted value" + "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" "" --framework-marker "quoted value" --launch-info-path-file "$(MSBuildProjectDirectory)\launch-info-path.txt" $(MSBuildProjectDirectory) + + + + + #file BrowserPackageTests.cs @@ -69,7 +78,11 @@ public void RunsInsideBrowser() import { extname, resolve, sep } from 'node:path'; const root = resolve(process.argv[2]); -if (process.argv[3] !== '' || process.argv[4] !== '--framework-marker' || process.argv[5] !== 'quoted value') { +if (process.argv[3] !== '' + || process.argv[4] !== '--framework-marker' + || process.argv[5] !== 'quoted value' + || process.argv[6] !== '--launch-info-path-file' + || !process.argv[7]) { throw new Error(`The computed host arguments did not round-trip: ${JSON.stringify(process.argv.slice(2))}`); } @@ -77,6 +90,7 @@ public void RunsInsideBrowser() if (!launchInfoPath) { throw new Error('TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE is required.'); } +writeFileSync(process.argv[7], launchInfoPath); const contentTypes = new Map([ ['.css', 'text/css'], @@ -316,6 +330,13 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.Contains("succeeded: 1", runOutput); Assert.DoesNotContain("--dotnet-test-http-token", runOutput); Assert.DoesNotContain("pw:channel", runOutput); + AssertLaunchInfoDirectoryCleaned(generator.TargetAssetPath); + + string[] launcherCommand = File.ReadAllLines( + Path.Combine(generator.TargetAssetPath, "browser-launcher-command.txt")); + Assert.HasCount(2, launcherCommand); + Assert.IsNotEmpty(launcherCommand[1], "DOTNET_HOST_PATH must be available to the browser launcher target."); + Assert.AreEqual(launcherCommand[1], launcherCommand[0]); DotnetMuxerResult list = await DotnetCli.RunAsync( $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --list-tests", @@ -328,6 +349,7 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.AreEqual(0, list.ExitCode, list.ToString()); Assert.Contains("RunsInsideBrowser", listOutput); Assert.Contains("Discovered 1 tests", listOutput); + AssertLaunchInfoDirectoryCleaned(generator.TargetAssetPath); } [TestMethod] @@ -382,6 +404,44 @@ public async Task BrowserPackage_FrameworkOwnedPageUsesVersionedApi() "TestingPlatformBrowserGenerateHostAssets=false must not deploy the package-owned supervisor."); } + [TestMethod] + public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() + { + string browserPackageVersion = GetBrowserPackageVersion(); + string source = FrameworkOwnedPageSourceCode + .Replace( + " false" + Environment.NewLine, + string.Empty, + StringComparison.Ordinal) + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) + .PatchCodeWithReplace("$Node$", "node") + .PatchCodeWithReplace("$Browser$", string.Empty); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserCustomMainJsProject", + source); + + 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()); + string appBundle = Path.Combine( + generator.TargetAssetPath, + "bin", + "Release", + TargetFramework, + WasmRuntime.BrowserRid, + "AppBundle"); + Assert.IsTrue(File.Exists(Path.Combine(appBundle, "index.html"))); + Assert.IsTrue(File.Exists(Path.Combine(appBundle, "main.js"))); + Assert.IsFalse(File.Exists(Path.Combine(appBundle, "Microsoft.Testing.Platform.Browser.main.js"))); + } + [TestMethod] public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() { @@ -519,4 +579,14 @@ private static string EscapeMsBuildValue(string value) => value.Replace("&", "&", StringComparison.Ordinal) .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal); + + private static void AssertLaunchInfoDirectoryCleaned(string targetAssetPath) + { + string pathFile = Path.Combine(targetAssetPath, "launch-info-path.txt"); + Assert.IsTrue(File.Exists(pathFile), "The browser host did not record its launch-info path."); + string launchInfoPath = File.ReadAllText(pathFile); + Assert.IsFalse( + Directory.Exists(Path.GetDirectoryName(launchInfoPath)), + $"The launcher left its private launch-info directory behind: '{launchInfoPath}'."); + } } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index 7075c1f70e..a08600bd59 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -3,6 +3,9 @@ #if !NETFRAMEWORK +using System.Security.AccessControl; +using System.Security.Principal; + using Microsoft.Testing.Platform.Browser; namespace Microsoft.Testing.Extensions.UnitTests; @@ -107,6 +110,27 @@ public void Parse_AcceptsLoopbackHttpsIpv6Bootstrap() Assert.AreEqual("https://[::1]:1234/dotnettest/run/", options.Bootstrap.Endpoint.AbsoluteUri); } + [TestMethod] + public void ResolveBrowserUri_RejectsProtocolRelativePath() + { + BrowserLauncherException exception = Assert.ThrowsExactly( + () => BrowserLauncherOptions.ResolveBrowserUri( + new Uri("http://127.0.0.1:1234/"), + "//example.com/tests")); + + Assert.Contains("outside the browser host origin", exception.Message); + } + + [TestMethod] + public void ResolveBrowserUri_PreservesLoopbackOrigin() + { + Uri uri = BrowserLauncherOptions.ResolveBrowserUri( + new Uri("http://127.0.0.1:1234/root/"), + "/tests/index.html"); + + Assert.AreEqual("http://127.0.0.1:1234/tests/index.html", uri.AbsoluteUri); + } + [TestMethod] public void Parse_ReadsMsBuildLauncherConfiguration() { @@ -255,6 +279,156 @@ public void BrowserExecutableLocator_PrefersConfiguredExecutable() } } + [TestMethod] + public void BrowserUnitTests_DoNotReferencePlaywrightRuntime() + { + string dependencyContextPath = Path.ChangeExtension( + typeof(BrowserLauncherOptionsTests).Assembly.Location, + ".deps.json"); + using FileStream stream = File.OpenRead(dependencyContextPath); + using var dependencyContext = System.Text.Json.JsonDocument.Parse(stream); + + string[] dependencies = + [ + .. dependencyContext.RootElement.GetProperty("libraries").EnumerateObject() + .Select(static library => library.Name), + ]; + Assert.IsNull( + dependencies.FirstOrDefault( + static dependency => dependency.StartsWith("Microsoft.Playwright/", StringComparison.Ordinal)), + "Browser helper tests must source-link the BCL-only files instead of copying the cross-platform Playwright payload."); + } + + [TestMethod] + public void TryParseListeningUri_ParsesLoopbackAndIgnoresOtherOutput() + { + Assert.IsNull(HostProcess.TryParseListeningUri("Application started.")); + Assert.AreEqual( + "http://127.0.0.1:4321/", + HostProcess.TryParseListeningUri("Now listening on: http://127.0.0.1:4321/")?.AbsoluteUri); + } + + [TestMethod] + public void TryParseListeningUri_RejectsNonLoopbackHost() + => Assert.ThrowsExactly( + () => HostProcess.TryParseListeningUri("Now listening on: http://example.com/")); + + [TestMethod] + public void TryReadLaunchInfo_PreservesValidationAndAbsenceBehavior() + { + string directory = Path.Combine(Path.GetTempPath(), $"mtp-browser-launch-info-test-{Guid.NewGuid():N}"); + string path = Path.Combine(directory, "launch-info.json"); + try + { + Assert.IsNull(HostProcess.TryReadLaunchInfo(path)); + + Directory.CreateDirectory(directory); + WriteLaunchInfo(path, """{"version":1,"url":"https://[::1]:1234/"}"""); + Assert.AreEqual("https://[::1]:1234/", HostProcess.TryReadLaunchInfo(path)?.AbsoluteUri); + + WriteLaunchInfo(path, """{"version":2,"url":"http://127.0.0.1:1234/"}"""); + Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(path)); + + WriteLaunchInfo(path, """{"version":1,"url":"http://example.com/"}"""); + Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(path)); + + WriteLaunchInfo(path, "{"); + Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(path)); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + [TestMethod] + [OSCondition(ConditionMode.Include, OperatingSystems.Windows, IgnoreMessage = "Validates the Windows launch-info directory DACL.")] + [SupportedOSPlatform("windows")] + public void CreateLaunchInfoDirectory_IsPrivateOnWindows() + { + string directory = HostProcess.CreateLaunchInfoDirectory(); + try + { + DirectorySecurity security = new DirectoryInfo(directory).GetAccessControl(); + SecurityIdentifier currentUser = WindowsIdentity.GetCurrent().User + ?? throw new AssertFailedException("The current Windows SID is unavailable."); + Assert.IsTrue(security.AreAccessRulesProtected); + Assert.AreEqual( + currentUser, + security.GetOwner(typeof(SecurityIdentifier))); + + AuthorizationRuleCollection rules = security.GetAccessRules( + includeExplicit: true, + includeInherited: true, + typeof(SecurityIdentifier)); + Assert.IsTrue( + rules.Cast().All(rule => + rule.IdentityReference.Equals(currentUser) + && rule.AccessControlType == AccessControlType.Allow)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [TestMethod] + [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows, IgnoreMessage = "Validates Unix launch-info directory permissions.")] + [UnsupportedOSPlatform("windows")] + public void CreateLaunchInfoDirectory_IsPrivateOnUnix() + { + string directory = HostProcess.CreateLaunchInfoDirectory(); + try + { + Assert.AreEqual( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, + File.GetUnixFileMode(directory)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [TestMethod] + [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows, IgnoreMessage = "Validates Unix file modes and symbolic-link rejection.")] + [UnsupportedOSPlatform("windows")] + public void TryReadLaunchInfo_RejectsSymlinkAndPermissiveUnixFile() + { + string directory = Path.Combine(Path.GetTempPath(), $"mtp-browser-launch-info-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + string target = Path.Combine(directory, "target.json"); + string link = Path.Combine(directory, "link.json"); + try + { + WriteLaunchInfo(target, """{"version":1,"url":"http://127.0.0.1:1234/"}"""); + File.SetUnixFileMode( + target, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead); + Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(target)); + + File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite); + try + { + File.CreateSymbolicLink(link, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + Assert.Inconclusive($"Symbolic links are unavailable: {ex.Message}"); + return; + } + + Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(link)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [TestMethod] [DataRow("linux", Architecture.X64, "linux-x64")] [DataRow("linux", Architecture.Arm64, "linux-arm64")] @@ -267,7 +441,7 @@ public void GetPlaywrightNodeExecutablePath_MapsSupportedPlatforms( { OSPlatform osPlatform = operatingSystem == "linux" ? OSPlatform.Linux : OSPlatform.OSX; - string path = ChromiumBrowser.GetPlaywrightNodeExecutablePath("root", osPlatform, architecture); + string path = PlaywrightNodeExecutable.GetPath("root", osPlatform, architecture); Assert.AreEqual( Path.Combine("root", ".playwright", "node", platformDirectory, "node"), @@ -288,6 +462,15 @@ private static string CreateResponseFile(string content) private static string Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + + private static void WriteLaunchInfo(string path, string content) + { + File.WriteAllText(path, content); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } } #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 d2a7dbb911..654ec54d18 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 @@ -67,8 +67,14 @@ - + + + + + + + + From 50932dcdd8fb720b282fa90310d630ed4de01804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 14 Sep 2026 21:28:57 +0200 Subject: [PATCH 07/16] Report fatal browser page integration errors Add a one-shot, origin-validated fatal-error method to the versioned browser page contract so framework bootstrap and completion negotiation failures terminate the launcher immediately instead of waiting for the completion timeout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ChromiumBrowser.cs | 105 +++++++++++++++-- .../PACKAGE.md | 7 +- .../BrowserPackageExecutionTests.cs | 108 ++++++++++++++---- 3 files changed, 185 insertions(+), 35 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs index 0f6234c85b..6bc7410c47 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -16,6 +16,7 @@ internal sealed class ChromiumBrowser : IAsyncDisposable private readonly IBrowserContext _context; private readonly IPage _page; private readonly IAsyncDisposable _completionBinding; + private readonly IAsyncDisposable _fatalErrorBinding; private readonly TaskCompletionSource _completion; private readonly DiagnosticBuffer _diagnostics; private readonly TaskCompletionSource _disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -26,6 +27,7 @@ private ChromiumBrowser( IBrowserContext context, IPage page, IAsyncDisposable completionBinding, + IAsyncDisposable fatalErrorBinding, TaskCompletionSource completion, DiagnosticBuffer diagnostics) { @@ -34,6 +36,7 @@ private ChromiumBrowser( _context = context; _page = page; _completionBinding = completionBinding; + _fatalErrorBinding = fatalErrorBinding; _completion = completion; _diagnostics = diagnostics; _browser.Disconnected += (_, _) => _disconnected.TrySetResult(); @@ -60,6 +63,7 @@ public static async Task LaunchAsync( IBrowser? browser = null; IBrowserContext? context = null; IAsyncDisposable? completionBinding = null; + IAsyncDisposable? fatalErrorBinding = null; Task? browserLaunchTask = null; try @@ -94,12 +98,23 @@ public static async Task LaunchAsync( "__mtpBrowserCompleteV1", (source, message) => CompleteBrowserRun(source, page, expectedOrigin, message, completion)) .WaitAsync(startupCancellationToken).ConfigureAwait(false); + fatalErrorBinding = await page.ExposeBindingAsync( + "__mtpBrowserFatalErrorV1", + (source, message) => ReportBrowserFatalError( + source, + page, + expectedOrigin, + message, + completion, + diagnostics)) + .WaitAsync(startupCancellationToken).ConfigureAwait(false); var result = new ChromiumBrowser( playwright, browser, context, page, completionBinding, + fatalErrorBinding, completion, diagnostics); await result.InitializePageAsync( @@ -115,7 +130,13 @@ await result.InitializePageAsync( browser = await TryObserveBrowserLaunchAsync(browserLaunchTask, diagnostics).ConfigureAwait(false); } - await DisposeBrowserAsync(completionBinding, context, browser, playwright, diagnostics).ConfigureAwait(false); + await DisposeBrowserAsync( + completionBinding, + fatalErrorBinding, + context, + browser, + playwright, + diagnostics).ConfigureAwait(false); if (startupTimeoutCancellationTokenSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -157,6 +178,7 @@ public async Task WaitForCompletionAsync(TimeSpan timeout, CancellationToke public async ValueTask DisposeAsync() => await DisposeBrowserAsync( _completionBinding, + _fatalErrorBinding, _context, _browser, _playwright, @@ -175,6 +197,7 @@ await _page.AddInitScriptAsync( if (globalThis.self === globalThis.top && globalThis.location.origin === {{serializedExpectedOrigin}}) { const argumentsFromLauncher = Object.freeze({{serializedArguments}}); const completeTransport = globalThis.__mtpBrowserCompleteV1; + const fatalErrorTransport = globalThis.__mtpBrowserFatalErrorV1; let completed = false; const api = Object.freeze({ contractVersion: 1, @@ -195,6 +218,20 @@ void completeTransport({ exitCode, }); }, + reportFatalError(error) { + if (completed) { + throw new Error('testingPlatformBrowser has already completed.'); + } + if (typeof error !== 'string' || error.length === 0) { + throw new TypeError('testingPlatformBrowser.reportFatalError requires a non-empty error string.'); + } + + completed = true; + void fatalErrorTransport({ + contractVersion: 1, + error, + }); + }, }); Object.defineProperty(globalThis, 'testingPlatformBrowser', { value: api, configurable: false, enumerable: true, writable: false }); } @@ -234,11 +271,24 @@ private void SubscribeToDiagnostics() private static async Task DisposeBrowserAsync( IAsyncDisposable? completionBinding, + IAsyncDisposable? fatalErrorBinding, IBrowserContext? context, IBrowser? browser, IPlaywright? playwright, DiagnosticBuffer diagnostics) { + if (fatalErrorBinding is not null) + { + try + { + await fatalErrorBinding.DisposeAsync().AsTask().WaitAsync(CleanupTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + diagnostics.Add("launcher cleanup", $"Unable to remove the browser fatal-error binding: {ex.Message}"); + } + } + if (completionBinding is not null) { try @@ -307,16 +357,7 @@ private static bool CompleteBrowserRun( JsonElement message, TaskCompletionSource completion) { - _ = ReferenceEquals(source.Page, expectedPage) - && source.Frame.ParentFrame is null - && Uri.TryCreate(source.Frame.Url, UriKind.Absolute, out Uri? sourceUri) - && string.Equals( - sourceUri.GetLeftPart(UriPartial.Authority), - expectedOrigin, - StringComparison.Ordinal) - ? true - : throw new BrowserLauncherException( - "The browser completion API was called outside the expected top-level loopback origin."); + ValidateBrowserApiSource(source, expectedPage, expectedOrigin); return message.ValueKind == JsonValueKind.Object && message.TryGetProperty("contractVersion", out JsonElement contractVersion) @@ -329,4 +370,46 @@ private static bool CompleteBrowserRun( : throw new BrowserLauncherException( "The browser completion API received an invalid version 1 payload."); } + + private static bool ReportBrowserFatalError( + BindingSource source, + IPage expectedPage, + string expectedOrigin, + JsonElement message, + TaskCompletionSource completion, + DiagnosticBuffer diagnostics) + { + ValidateBrowserApiSource(source, expectedPage, expectedOrigin); + + if (message.ValueKind != JsonValueKind.Object + || !message.TryGetProperty("contractVersion", out JsonElement contractVersion) + || contractVersion.ValueKind != JsonValueKind.Number + || contractVersion.GetInt32() != 1 + || !message.TryGetProperty("error", out JsonElement error) + || error.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(error.GetString())) + { + throw new BrowserLauncherException( + "The browser fatal-error API received an invalid version 1 payload."); + } + + diagnostics.Add("browser fatal", error.GetString()!); + return completion.TrySetException( + new BrowserLauncherException("The browser page reported a fatal integration error.")); + } + + private static void ValidateBrowserApiSource( + BindingSource source, + IPage expectedPage, + string expectedOrigin) + => _ = ReferenceEquals(source.Page, expectedPage) + && source.Frame.ParentFrame is null + && Uri.TryCreate(source.Frame.Url, UriKind.Absolute, out Uri? sourceUri) + && string.Equals( + sourceUri.GetLeftPart(UriPartial.Authority), + expectedOrigin, + StringComparison.Ordinal) + ? true + : throw new BrowserLauncherException( + "The browser page API was called outside the expected top-level loopback origin."); } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index 41a39a21d3..0ecb878d96 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -89,7 +89,8 @@ matches the loopback host origin: globalThis.testingPlatformBrowser = { contractVersion: 1, getArguments(): string[], - complete(exitCode: number): void + complete(exitCode: number): void, + reportFatalError(error: string): void }; ``` @@ -103,6 +104,10 @@ globalThis.testingPlatformBrowser = { to `console.error` before completion so the launcher captures their diagnostics. Test discovery and test results do not flow through this method; MTP sends them directly to the SDK HTTP gateway. +- `reportFatalError(error)` terminates the launcher immediately when the page cannot + negotiate the contract or cannot report normal completion. It is also one-shot. This + is only for fatal page/framework integration failures; ordinary test or application + exceptions must still be represented by the managed exit code passed to `complete`. The package-owned JavaScript supervisor implements this API contract automatically. A UI framework that owns its browser page can set diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index f5c41f0714..0454697230 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -183,29 +183,7 @@ public void UsesVersionedBrowserApi() #file main.js -import { dotnet } from './_framework/dotnet.js'; - -const api = globalThis.testingPlatformBrowser; -if (api?.contractVersion !== 1) { - throw new Error('Expected testingPlatformBrowser contract version 1.'); -} - -let exitCode; -let failure; -try { - const { runMain } = await dotnet.withApplicationArguments(...api.getArguments()).create(); - exitCode = await runMain(); -} -catch (error) { - failure = error; - exitCode = 1; - console.error(error instanceof Error ? error.stack ?? error.message : String(error)); -} - -api.complete(exitCode); -if (failure !== undefined) { - throw failure; -} +$FrameworkPageMain$ #file server.mjs import { createReadStream, existsSync, renameSync, statSync, writeFileSync } from 'node:fs'; @@ -249,6 +227,41 @@ public void UsesVersionedBrowserApi() writeFileSync(temporaryPath, JSON.stringify({ version: 1, url: `http://127.0.0.1:${address.port}/` }), { mode: 0o600 }); renameSync(temporaryPath, launchInfoPath); }); +"""; + + private const string FrameworkPageMainSource = """ +import { dotnet } from './_framework/dotnet.js'; + +const api = globalThis.testingPlatformBrowser; +if (api?.contractVersion !== 1) { + throw new Error('Expected testingPlatformBrowser contract version 1.'); +} + +let exitCode; +let failure; +try { + const { runMain } = await dotnet.withApplicationArguments(...api.getArguments()).create(); + exitCode = await runMain(); +} +catch (error) { + failure = error; + exitCode = 1; + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); +} + +api.complete(exitCode); +if (failure !== undefined) { + throw failure; +} +"""; + + private const string FatalFrameworkPageMainSource = """ +const api = globalThis.testingPlatformBrowser; +if (api?.contractVersion !== 1 || typeof api.reportFatalError !== 'function') { + throw new Error('Expected testingPlatformBrowser fatal-error API version 1.'); +} + +api.reportFatalError('framework-owned page fatal marker'); """; private const string DesktopSourceCode = """ @@ -373,6 +386,7 @@ public async Task BrowserPackage_FrameworkOwnedPageUsesVersionedApi() using TestAsset generator = await TestAsset.GenerateAssetAsync( "BrowserFrameworkPageTestProject", FrameworkOwnedPageSourceCode + .PatchCodeWithReplace("$FrameworkPageMain$", FrameworkPageMainSource) .PatchCodeWithReplace("$TargetFramework$", TargetFramework) .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) @@ -413,6 +427,7 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() " false" + Environment.NewLine, string.Empty, StringComparison.Ordinal) + .PatchCodeWithReplace("$FrameworkPageMain$", FrameworkPageMainSource) .PatchCodeWithReplace("$TargetFramework$", TargetFramework) .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) @@ -442,6 +457,53 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() Assert.IsFalse(File.Exists(Path.Combine(appBundle, "Microsoft.Testing.Platform.Browser.main.js"))); } + [TestMethod] + public async Task BrowserPackage_FrameworkFatalErrorTerminatesWithoutCompletionTimeout() + { + string? node = WasmRuntime.LocateNode(); + if (node is null) + { + Assert.Inconclusive(WasmRuntime.NodeUnavailableMessage); + return; + } + + 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(); + string source = FrameworkOwnedPageSourceCode + .Replace( + " 120", + " 5", + StringComparison.Ordinal) + .PatchCodeWithReplace("$FrameworkPageMain$", FatalFrameworkPageMainSource) + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) + .PatchCodeWithReplace("$Node$", EscapeMsBuildValue(node)) + .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser)); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserFatalFrameworkPageProject", + source); + + DotnetMuxerResult run = await DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + string output = run.StandardOutput + run.StandardError; + Assert.AreNotEqual(0, run.ExitCode, run.ToString()); + Assert.Contains("framework-owned page fatal marker", output); + Assert.Contains("fatal integration error", output); + Assert.DoesNotContain("did not complete within 5 seconds", output); + } + [TestMethod] public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() { From 760ee9e3b6d5b57bf78caf74cd2f098e17b668bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 14 Sep 2026 22:52:28 +0200 Subject: [PATCH 08/16] Fix browser launcher wrapping edge cases Support no-build packing, preserve doubled quotes and multiline launcher arguments, wrap late Directory.Build host providers, avoid false-positive custom host asset coverage, and clean generated launcher configuration files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserLauncherOptions.cs | 25 ++++-- .../Microsoft.Testing.Platform.Browser.csproj | 2 +- ...oft.Testing.Platform.Browser.After.targets | 75 +++++++++++++++++ .../Microsoft.Testing.Platform.Browser.props | 2 + ...Microsoft.Testing.Platform.Browser.targets | 73 +---------------- .../BrowserPackageExecutionTests.cs | 81 +++++++++++++++++-- .../BrowserLauncherOptionsTests.cs | 65 +++++++++++++-- 7 files changed, 231 insertions(+), 92 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs index 5d268ca31c..a8596fffda 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -49,12 +49,12 @@ public static BrowserLauncherOptions Parse(string[] args) throw new BrowserLauncherException("The browser launcher configuration file is invalid."); } - hostCommand = ReadConfigurationValue(configuration, 0, "host-command"); - hostArguments = ReadConfigurationValue(configuration, 1, "host-arguments"); - hostWorkingDirectory = ReadConfigurationValue(configuration, 2, "host-working-directory"); - urlPath = ReadConfigurationValue(configuration, 3, "url-path"); - browserExecutable = ReadConfigurationValue(configuration, 4, "browser-executable"); - browserArguments = ReadConfigurationValue(configuration, 5, "browser-arguments"); + hostCommand = ReadEncodedConfigurationValue(configuration, 0, "host-command-uri"); + hostArguments = ReadEncodedConfigurationValue(configuration, 1, "host-arguments-uri"); + hostWorkingDirectory = ReadEncodedConfigurationValue(configuration, 2, "host-working-directory-uri"); + urlPath = ReadEncodedConfigurationValue(configuration, 3, "url-path-uri"); + browserExecutable = ReadEncodedConfigurationValue(configuration, 4, "browser-executable-uri"); + browserArguments = ReadEncodedConfigurationValue(configuration, 5, "browser-arguments-uri"); startupTimeout = ParseTimeout( ReadConfigurationValue(configuration, 6, "startup-timeout-seconds"), "startup timeout"); @@ -189,6 +189,9 @@ private static string ReadConfigurationValue(string[] configuration, int index, ? configuration[index][prefix.Length..] : throw new BrowserLauncherException("The browser launcher configuration file is invalid."); } + + private static string ReadEncodedConfigurationValue(string[] configuration, int index, string name) + => Uri.UnescapeDataString(ReadConfigurationValue(configuration, index, name)); } internal sealed record DotnetTestHttpBootstrap(Uri Endpoint, string Token) @@ -366,6 +369,16 @@ void FlushBackslashes() { tokenStarted = true; current.Append('\\', backslashCount / 2); + if (inQuotes && backslashCount % 2 == 0 + && i + 1 < commandLine.Length + && commandLine[i + 1] == '"') + { + current.Append('"'); + backslashCount = 0; + i++; + continue; + } + if (backslashCount % 2 == 0) { inQuotes = !inQuotes; 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 index de57385301..fdb3a3edd3 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj @@ -46,7 +46,7 @@ $(CommonProductDescription)]]> - + diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets new file mode 100644 index 0000000000..92a7c83652 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets @@ -0,0 +1,75 @@ + + + + + + <_TestingPlatformBrowserComputedHostCommand>$(RunCommand) + <_TestingPlatformBrowserComputedHostArguments>$(RunArguments) + <_TestingPlatformBrowserComputedHostWorkingDirectory>$(RunWorkingDirectory) + + + + + + <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll + <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.launch')) + <_TestingPlatformBrowserHostCommand Condition=" '$(TestingPlatformBrowserHostCommand)' != '' ">$(TestingPlatformBrowserHostCommand) + <_TestingPlatformBrowserHostCommand Condition=" '$(_TestingPlatformBrowserHostCommand)' == '' ">$(_TestingPlatformBrowserComputedHostCommand) + <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' != '' ">$(TestingPlatformBrowserHostArguments) + <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' == '' ">$(_TestingPlatformBrowserComputedHostArguments) + <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(TestingPlatformBrowserHostWorkingDirectory)' != '' ">$(TestingPlatformBrowserHostWorkingDirectory) + <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(_TestingPlatformBrowserComputedHostWorkingDirectory) + <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(MSBuildProjectDirectory) + + $(DOTNET_HOST_PATH) + dotnet + exec "$(_TestingPlatformBrowserLauncher)" --config "$(_TestingPlatformBrowserLauncherConfiguration)" -- + $(MSBuildProjectDirectory) + + + + + + + + + + + + + + + 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 index a694ee9f9b..f2d6ac7fd4 100644 --- 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 @@ -4,5 +4,7 @@ 600 / true + <_TestingPlatformBrowserPreviousCustomAfterDirectoryBuildTargets>$(CustomAfterDirectoryBuildTargets) + $(MSBuildThisFileDirectory)Microsoft.Testing.Platform.Browser.After.targets 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 index 5e588453a8..69be0bb1cf 100644 --- 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 @@ -12,76 +12,7 @@ - - - <_TestingPlatformBrowserComputedHostCommand>$(RunCommand) - <_TestingPlatformBrowserComputedHostArguments>$(RunArguments) - <_TestingPlatformBrowserComputedHostWorkingDirectory>$(RunWorkingDirectory) - - - - - - <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll - <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.launch')) - <_TestingPlatformBrowserHostCommand Condition=" '$(TestingPlatformBrowserHostCommand)' != '' ">$(TestingPlatformBrowserHostCommand) - <_TestingPlatformBrowserHostCommand Condition=" '$(_TestingPlatformBrowserHostCommand)' == '' ">$(_TestingPlatformBrowserComputedHostCommand) - <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' != '' ">$(TestingPlatformBrowserHostArguments) - <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' == '' ">$(_TestingPlatformBrowserComputedHostArguments) - <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(TestingPlatformBrowserHostWorkingDirectory)' != '' ">$(TestingPlatformBrowserHostWorkingDirectory) - <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(_TestingPlatformBrowserComputedHostWorkingDirectory) - <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(MSBuildProjectDirectory) - - $(DOTNET_HOST_PATH) - dotnet - exec "$(_TestingPlatformBrowserLauncher)" --config "$(_TestingPlatformBrowserLauncherConfiguration)" -- - $(MSBuildProjectDirectory) - - - - - - - - - - - - - - - - - + + diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 0454697230..3ad059c5a0 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -9,6 +9,7 @@ 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; @@ -30,7 +31,7 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase$(NoWarn);NETSDK1201 $Browser$ - --mtp-test-value=a;b + --mtp-test-value="line1 line2;%#'" 60 120 @@ -40,10 +41,19 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase - +
+ +#file Directory.Build.targets + + + <_ParentDirectoryBuildTargets>$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..')) + + + + $Node$ - "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" "" --framework-marker "quoted value" --launch-info-path-file "$(MSBuildProjectDirectory)\launch-info-path.txt" + "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" "" --framework-marker "quoted value" --multiline "line1 line2;%#'" --launch-info-path-file "$(MSBuildProjectDirectory)\launch-info-path.txt" $(MSBuildProjectDirectory) @@ -56,7 +66,6 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase - #file BrowserPackageTests.cs @@ -81,8 +90,10 @@ public void RunsInsideBrowser() if (process.argv[3] !== '' || process.argv[4] !== '--framework-marker' || process.argv[5] !== 'quoted value' - || process.argv[6] !== '--launch-info-path-file' - || !process.argv[7]) { + || process.argv[6] !== '--multiline' + || process.argv[7] !== 'line1\r\nline2;%#\'' + || process.argv[8] !== '--launch-info-path-file' + || !process.argv[9]) { throw new Error(`The computed host arguments did not round-trip: ${JSON.stringify(process.argv.slice(2))}`); } @@ -90,7 +101,7 @@ public void RunsInsideBrowser() if (!launchInfoPath) { throw new Error('TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE is required.'); } -writeFileSync(process.argv[7], launchInfoPath); +writeFileSync(process.argv[9], launchInfoPath); const contentTypes = new Map([ ['.css', 'text/css'], @@ -424,7 +435,7 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() string browserPackageVersion = GetBrowserPackageVersion(); string source = FrameworkOwnedPageSourceCode .Replace( - " false" + Environment.NewLine, + " false", string.Empty, StringComparison.Ordinal) .PatchCodeWithReplace("$FrameworkPageMain$", FrameworkPageMainSource) @@ -436,6 +447,9 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() using TestAsset generator = await TestAsset.GenerateAssetAsync( "BrowserCustomMainJsProject", source); + Assert.DoesNotContain( + "TestingPlatformBrowserGenerateHostAssets", + File.ReadAllText(Path.Combine(generator.TargetAssetPath, "BrowserFrameworkPageTestProject.csproj"))); DotnetMuxerResult build = await DotnetCli.RunAsync( $"build {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", @@ -455,6 +469,32 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() Assert.IsTrue(File.Exists(Path.Combine(appBundle, "index.html"))); Assert.IsTrue(File.Exists(Path.Combine(appBundle, "main.js"))); Assert.IsFalse(File.Exists(Path.Combine(appBundle, "Microsoft.Testing.Platform.Browser.main.js"))); + + DotnetMuxerResult computeRunArguments = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, computeRunArguments.ExitCode, computeRunArguments.ToString()); + + string launchConfiguration = Path.Combine( + generator.TargetAssetPath, + "obj", + "Release", + TargetFramework, + WasmRuntime.BrowserRid, + "Microsoft.Testing.Platform.Browser.launch"); + Assert.IsTrue(File.Exists(launchConfiguration)); + + DotnetMuxerResult clean = await DotnetCli.RunAsync( + $"clean {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, clean.ExitCode, clean.ToString()); + Assert.IsFalse(File.Exists(launchConfiguration)); } [TestMethod] @@ -542,6 +582,7 @@ public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() 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.Contains("buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets", entries); ZipArchiveEntry browserMainEntry = archive.GetEntry( "buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js") @@ -580,6 +621,30 @@ .. nuspec.Descendants() "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 Debug --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")); + } + private static string GetBrowserPackageVersion() { string fileName = Path.GetFileName(GetBrowserPackagePath()); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index a08600bd59..4ec3bb9a47 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -140,12 +140,12 @@ public void Parse_ReadsMsBuildLauncherConfiguration() File.WriteAllLines( configurationFile, [ - "host-command=node", - "host-arguments=server.mjs \"path with spaces\"", - $"host-working-directory={Path.GetTempPath()}", - "url-path=/tests", - "browser-executable=", - "browser-arguments=--disable-gpu", + $"host-command-uri={Uri.EscapeDataString("node")}", + $"host-arguments-uri={Uri.EscapeDataString("server.mjs \"path with spaces\"")}", + $"host-working-directory-uri={Uri.EscapeDataString(Path.GetTempPath())}", + $"url-path-uri={Uri.EscapeDataString("/tests")}", + "browser-executable-uri=", + $"browser-arguments-uri={Uri.EscapeDataString("--disable-gpu")}", "startup-timeout-seconds=30", "completion-timeout-seconds=120", ]); @@ -173,6 +173,51 @@ public void Parse_ReadsMsBuildLauncherConfiguration() } } + [TestMethod] + public void Parse_ReadsEncodedMultilineMsBuildLauncherConfiguration() + { + const string hostArguments = "server.mjs \"line1\r\nline2\" \"\" --special \"%25;#'\""; + const string browserArguments = "--note \"line1\r\nline2;%#'\""; + string configurationFile = Path.GetTempFileName(); + try + { + File.WriteAllLines( + configurationFile, + [ + $"host-command-uri={Uri.EscapeDataString("node")}", + $"host-arguments-uri={Uri.EscapeDataString(hostArguments)}", + $"host-working-directory-uri={Uri.EscapeDataString(Path.GetTempPath())}", + $"url-path-uri={Uri.EscapeDataString("/tests?value=%25;#'")}", + "browser-executable-uri=", + $"browser-arguments-uri={Uri.EscapeDataString(browserArguments)}", + "startup-timeout-seconds=30", + "completion-timeout-seconds=120", + ]); + + var options = BrowserLauncherOptions.Parse( + [ + "--config", configurationFile, + "--", + "--server", "dotnettestcli", + "--dotnet-test-transport", "http", + "--dotnet-test-http-endpoint", "http://127.0.0.1:1234/dotnettest/run/", + "--dotnet-test-http-token", "secret", + ]); + + Assert.AreSequenceEqual( + new[] { "server.mjs", "line1\r\nline2", string.Empty, "--special", "%25;#'" }, + options.HostArguments); + Assert.AreSequenceEqual( + new[] { "--note", "line1\r\nline2;%#'" }, + options.BrowserArguments); + Assert.AreEqual("/tests?value=%25;#'", options.UrlPath); + } + finally + { + File.Delete(configurationFile); + } + } + [TestMethod] public void CommandLineTokenizer_RoundTripsQuotedHostArguments() { @@ -195,6 +240,14 @@ public void CommandLineTokenizer_PreservesQuotedEmptyArguments() arguments); } + [TestMethod] + public void CommandLineTokenizer_PreservesDoubledQuotesInsideQuotedArgument() + { + string[] arguments = CommandLineTokenizer.Split("\"a\"\"b\" \"\"\"quoted\"\"\""); + + Assert.AreSequenceEqual(new[] { "a\"b", "\"quoted\"" }, arguments); + } + [TestMethod] public void DiagnosticBuffer_RedactsBootstrapSecretsAndBoundsEntries() { From 89beb277539699e4842674d79d443259e3d6f37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 15 Sep 2026 12:20:47 +0200 Subject: [PATCH 09/16] Gate browser launcher preview contract Activate wrapping only for SDK-marked dotnet test invocations with HTTP bootstrap contract version 1, reject browser-inapplicable file and report options, and cover ordinary run, filtered/skipped/failing execution, and multi-target activation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserLauncherOptions.cs | 52 ++++ .../PACKAGE.md | 26 ++ ...oft.Testing.Platform.Browser.After.targets | 21 +- .../BrowserPackageExecutionTests.cs | 238 ++++++++++++++++-- .../BrowserLauncherOptionsTests.cs | 78 ++++++ 5 files changed, 397 insertions(+), 18 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs index a8596fffda..75bf89f2d7 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -87,6 +87,7 @@ public static BrowserLauncherOptions Parse(string[] args) } string[] expandedArguments = ResponseFileArgumentExpander.Expand(args[(separatorIndex + 1)..]); + ValidateTestApplicationArguments(expandedArguments); var bootstrap = DotnetTestHttpBootstrap.Parse(expandedArguments); string[] parsedBrowserArguments = CommandLineTokenizer.Split(browserArguments); ValidateBrowserArguments(parsedBrowserArguments); @@ -134,6 +135,57 @@ private static void ValidateBrowserArguments(IReadOnlyList arguments) } } + private static void ValidateTestApplicationArguments(IReadOnlyList arguments) + { + var unsupportedOptions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "config-file", + "diagnostic", + "diagnostic-file-prefix", + "diagnostic-output-directory", + "results-directory", + "settings", + "report-trx", + "report-trx-filename", + "report-html", + "report-html-filename", + "report-junit", + "report-junit-filename", + "report-ctrf", + "report-ctrf-filename", + "coverage", + "coverage-output", + "coverage-output-format", + "coverage-settings", + }; + + foreach (string argument in arguments) + { + int prefixLength = argument.StartsWith("--", StringComparison.Ordinal) + ? 2 + : argument.StartsWith("-", StringComparison.Ordinal) + ? 1 + : 0; + if (prefixLength == 0 || argument.Length == prefixLength) + { + continue; + } + + string optionName = argument[prefixLength..]; + int valueSeparator = optionName.IndexOfAny(['=', ':']); + if (valueSeparator >= 0) + { + optionName = optionName[..valueSeparator]; + } + + if (unsupportedOptions.Contains(optionName)) + { + throw new BrowserLauncherException( + $"Microsoft.Testing.Platform option '--{optionName}' is not supported by the browser launcher preview because its file input or output is not transferred between the browser virtual file system and the host."); + } + } + } + private static string DecodeRequired(Dictionary options, string name) => DecodeOptional(options, name) is { Length: > 0 } value ? value diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index 0ecb878d96..c4c589fe9b 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -59,6 +59,13 @@ dotnet test dotnet test -- --list-tests ``` +The launcher integration is a preview contract with the .NET SDK. The package wraps +`ComputeRunArguments` only when the SDK marks that ProjectInstance with both +`DotnetTestInvocation=true` and `DotnetTestHttpBootstrapVersion=1`. Ordinary `dotnet run` +and standalone `ComputeRunArguments` queries retain the framework-provided host command. +An SDK that supplies a missing or unsupported bootstrap version receives an actionable +MSBuild error instead of silently launching an incompatible browser host. + Useful properties: | Property | Purpose | @@ -80,6 +87,25 @@ launcher, host, and browser diagnostics. It is never added to the browser URL or through an unauthenticated DevTools TCP listener. User browser arguments cannot override Playwright's debugging transport or isolated profile. +### Preview option limitations + +The browser preview supports ordinary execution/discovery options such as `--help`, +`--list-tests`, `--filter`, and `--filter-uid`. It rejects options that read or write host +files because the browser virtual file system is not exported to the host yet: + +- configuration and host paths: `--config-file`, `--settings`, + `--diagnostic-output-directory`, `--diagnostic-file-prefix`, and + `--results-directory`; +- file diagnostics: `--diagnostic`; +- report artifacts: `--report-trx`, `--report-trx-filename`, `--report-html`, + `--report-html-filename`, `--report-junit`, `--report-junit-filename`, + `--report-ctrf`, and `--report-ctrf-filename`; +- coverage artifacts: `--coverage`, `--coverage-output`, `--coverage-output-format`, + and `--coverage-settings`. + +The launcher rejects these before starting the browser and names the unsupported option. +They can be enabled after a browser artifact sink exports their inputs and outputs. + ## Browser page API The launcher installs a versioned API on the top-level page only when its origin exactly diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets index 92a7c83652..b4d8651f5d 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets @@ -4,9 +4,21 @@ AND '$(_TestingPlatformBrowserPreviousCustomAfterDirectoryBuildTargets)' != '$(MSBuildThisFileFullPath)' AND Exists('$(_TestingPlatformBrowserPreviousCustomAfterDirectoryBuildTargets)') " /> - + Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' + AND $(RuntimeIdentifier.StartsWith('browser-')) + AND '$(DotnetTestInvocation)' == 'true' "> + + + + <_TestingPlatformBrowserComputedHostCommand>$(RunCommand) <_TestingPlatformBrowserComputedHostArguments>$(RunArguments) @@ -16,7 +28,10 @@ + Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' + AND $(RuntimeIdentifier.StartsWith('browser-')) + AND '$(DotnetTestInvocation)' == 'true' + AND '$(DotnetTestHttpBootstrapVersion)' == '1' "> <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.launch')) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 3ad059c5a0..e90c7eaa9b 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -58,6 +58,18 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase + + + + + + Assert.Fail("Intentional browser preview failure."); } #file server.mjs @@ -98,10 +120,10 @@ public void RunsInsideBrowser() } const launchInfoPath = process.env.TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE; +writeFileSync(process.argv[9], launchInfoPath ?? 'ordinary-run'); if (!launchInfoPath) { - throw new Error('TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE is required.'); + process.exit(0); } -writeFileSync(process.argv[9], launchInfoPath); const contentTypes = new Map([ ['.css', 'text/css'], @@ -137,6 +159,56 @@ public void RunsInsideBrowser() writeFileSync(temporaryPath, JSON.stringify({ version: 1, url: `http://127.0.0.1:${address.port}/` }), { mode: 0o600 }); renameSync(temporaryPath, launchInfoPath); }); +"""; + + private const string MultiTargetingSourceCode = """ +#file BrowserMultiTargetingProject.csproj + + + + net9.0;$TargetFramework$ + Exe + browser-wasm + false + + + + + + + + +#file Program.cs +return 0; + +#file Directory.Build.targets + + + <_ParentDirectoryBuildTargets>$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..')) + + + + + + framework-host-$(TargetFramework) + --framework $(TargetFramework) + $(MSBuildProjectDirectory) + + + + + + + + """; private const string FrameworkOwnedPageSourceCode = """ @@ -337,8 +409,38 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa .PatchCodeWithReplace("$Node$", EscapeMsBuildValue(node)) .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); + DotnetMuxerResult ordinaryRun = await DotnetCli.RunAsync( + $"run --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, ordinaryRun.ExitCode, ordinaryRun.ToString()); + Assert.AreEqual( + "ordinary-run", + File.ReadAllText(Path.Combine(generator.TargetAssetPath, "launch-info-path.txt"))); + Assert.IsEmpty( + Directory.EnumerateFiles( + Path.Combine(generator.TargetAssetPath, "obj"), + "Microsoft.Testing.Platform.Browser.launch", + SearchOption.AllDirectories)); + + DotnetMuxerResult plainQuery = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, plainQuery.ExitCode, plainQuery.ToString()); + string[] frameworkHost = File.ReadAllLines( + Path.Combine(generator.TargetAssetPath, "framework-host-command.txt")); + Assert.IsGreaterThanOrEqualTo(3, frameworkHost.Length); + Assert.AreEqual(node, frameworkHost[0]); + Assert.Contains("server.mjs", string.Join(Environment.NewLine, frameworkHost[1..^1])); + Assert.AreEqual(generator.TargetAssetPath, frameworkHost[^1]); + DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 --filter FullyQualifiedName~RunsInsideBrowser", environmentVariables: new Dictionary { ["DEBUG"] = "*", @@ -363,7 +465,7 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.AreEqual(launcherCommand[1], launcherCommand[0]); DotnetMuxerResult list = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --list-tests", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 --list-tests", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -372,8 +474,33 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa string listOutput = list.StandardOutput + list.StandardError; Assert.AreEqual(0, list.ExitCode, list.ToString()); Assert.Contains("RunsInsideBrowser", listOutput); - Assert.Contains("Discovered 1 tests", listOutput); + Assert.Contains("SkippedInsideBrowser", listOutput); + Assert.Contains("FailsInsideBrowser", listOutput); + Assert.Contains("Discovered 3 tests", listOutput); AssertLaunchInfoDirectoryCleaned(generator.TargetAssetPath); + + DotnetMuxerResult skipped = await DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 --filter FullyQualifiedName~SkippedInsideBrowser", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + 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 DotnetCli.RunAsync( + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 --filter FullyQualifiedName~FailsInsideBrowser", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + string failedOutput = failed.StandardOutput + failed.StandardError; + Assert.AreNotEqual(0, failed.ExitCode, failed.ToString()); + Assert.Contains("failed: 1", failedOutput); + Assert.Contains("Intentional browser preview failure.", failedOutput); + Assert.DoesNotContain("did not complete within", failedOutput); } [TestMethod] @@ -405,7 +532,7 @@ public async Task BrowserPackage_FrameworkOwnedPageUsesVersionedApi() .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -470,14 +597,6 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() Assert.IsTrue(File.Exists(Path.Combine(appBundle, "main.js"))); Assert.IsFalse(File.Exists(Path.Combine(appBundle, "Microsoft.Testing.Platform.Browser.main.js"))); - DotnetMuxerResult computeRunArguments = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, computeRunArguments.ExitCode, computeRunArguments.ToString()); - string launchConfiguration = Path.Combine( generator.TargetAssetPath, "obj", @@ -485,6 +604,41 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() TargetFramework, WasmRuntime.BrowserRid, "Microsoft.Testing.Platform.Browser.launch"); + + DotnetMuxerResult plainComputeRunArguments = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, plainComputeRunArguments.ExitCode, plainComputeRunArguments.ToString()); + Assert.IsFalse(File.Exists(launchConfiguration)); + + DotnetMuxerResult missingBootstrapVersion = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreNotEqual(0, missingBootstrapVersion.ExitCode); + Assert.Contains("requires DotnetTestHttpBootstrapVersion=1", missingBootstrapVersion.StandardOutput + missingBootstrapVersion.StandardError); + + DotnetMuxerResult unsupportedBootstrapVersion = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=2", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreNotEqual(0, unsupportedBootstrapVersion.ExitCode); + Assert.Contains("requires DotnetTestHttpBootstrapVersion=1", unsupportedBootstrapVersion.StandardOutput + unsupportedBootstrapVersion.StandardError); + + DotnetMuxerResult supportedComputeRunArguments = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, supportedComputeRunArguments.ExitCode, supportedComputeRunArguments.ToString()); Assert.IsTrue(File.Exists(launchConfiguration)); DotnetMuxerResult clean = await DotnetCli.RunAsync( @@ -531,7 +685,7 @@ public async Task BrowserPackage_FrameworkFatalErrorTerminatesWithoutCompletionT source); DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework}", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -544,6 +698,60 @@ public async Task BrowserPackage_FrameworkFatalErrorTerminatesWithoutCompletionT Assert.DoesNotContain("did not complete within 5 seconds", output); } + [TestMethod] + public async Task BrowserPackage_MultiTargetingActivatesOnlyForBrowserDotnetTestInvocation() + { + string browserPackageVersion = GetBrowserPackageVersion(); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserMultiTargetingProject", + MultiTargetingSourceCode + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion)); + + DotnetMuxerResult restore = await DotnetCli.RunAsync( + $"restore {generator.TargetAssetPath}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, restore.ExitCode, restore.ToString()); + + DotnetMuxerResult desktopQuery = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework=net9.0 -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, desktopQuery.ExitCode, desktopQuery.ToString()); + Assert.AreEqual( + "framework-host-net9.0", + File.ReadAllText(Path.Combine(generator.TargetAssetPath, "framework-host-net9.0.txt")).Trim()); + Assert.IsFalse(File.Exists(Path.Combine(generator.TargetAssetPath, "browser-launcher-net9.0.txt"))); + + DotnetMuxerResult plainBrowserQuery = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, plainBrowserQuery.ExitCode, plainBrowserQuery.ToString()); + Assert.AreEqual( + $"framework-host-{TargetFramework}", + File.ReadAllText(Path.Combine(generator.TargetAssetPath, $"framework-host-{TargetFramework}.txt")).Trim()); + Assert.IsFalse(File.Exists(Path.Combine(generator.TargetAssetPath, $"browser-launcher-{TargetFramework}.txt"))); + + DotnetMuxerResult dotnetTestBrowserQuery = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, dotnetTestBrowserQuery.ExitCode, dotnetTestBrowserQuery.ToString()); + string browserLauncherCommand = File.ReadAllText( + Path.Combine(generator.TargetAssetPath, $"browser-launcher-{TargetFramework}.txt")); + Assert.IsNotEmpty(browserLauncherCommand); + Assert.DoesNotContain($"framework-host-{TargetFramework}", browserLauncherCommand); + } + [TestMethod] public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index 4ec3bb9a47..4f6a6ac47e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -308,6 +308,60 @@ public void Parse_RejectsBrowserArgumentsThatOverrideLauncherSecurity(string bro } } + [TestMethod] + [DataRow("--config-file", null)] + [DataRow("--config-file=config.json", null)] + [DataRow("--config-file:config.json", null)] + [DataRow("--config-file", "config.json")] + [DataRow("-config-file", "config.json")] + [DataRow("--diagnostic", null)] + [DataRow("--diagnostic-output-directory", "diagnostics")] + [DataRow("--results-directory", "results")] + [DataRow("--settings", "settings.runsettings")] + [DataRow("--report-trx", null)] + [DataRow("--report-trx-filename", "results.trx")] + [DataRow("--report-html", null)] + [DataRow("--report-junit", null)] + [DataRow("--report-ctrf", null)] + [DataRow("--coverage", null)] + [DataRow("--coverage-output", "coverage.xml")] + [DataRow("-results-directory:results", null)] + public void Parse_RejectsBrowserInapplicableFileOptions(string option, string? value) + { + var testApplicationArguments = new List { option }; + if (value is not null) + { + testApplicationArguments.Add(value); + } + + testApplicationArguments.AddRange(CreateBootstrapArguments()); + + BrowserLauncherException exception = Assert.ThrowsExactly( + () => BrowserLauncherOptions.Parse(CreateLauncherArguments(testApplicationArguments))); + + Assert.Contains("not supported by the browser launcher preview", exception.Message); + } + + [TestMethod] + public void Parse_AllowsFiltersHelpAndListTests() + { + string[] testApplicationArguments = + [ + "--help", + "--list-tests", + "--filter", + "FullyQualifiedName~MyTests", + .. CreateBootstrapArguments(), + ]; + + var options = BrowserLauncherOptions.Parse( + CreateLauncherArguments(testApplicationArguments)); + + Assert.Contains("--help", options.TestApplicationArguments); + Assert.Contains("--list-tests", options.TestApplicationArguments); + Assert.Contains("--filter", options.TestApplicationArguments); + } + [TestMethod] public void DiagnosticBuffer_DoesNotTreatShortUrlSegmentsAsSecrets() { @@ -516,6 +570,30 @@ private static string CreateResponseFile(string content) private static string Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + private static string[] CreateLauncherArguments(IReadOnlyList testApplicationArguments) + => + [ + "--host-command-base64", Encode("dotnet"), + "--host-arguments-base64", Encode("host.dll"), + "--host-working-directory-base64", Encode(Path.GetTempPath()), + "--url-path-base64", Encode("/"), + "--browser-executable-base64", Encode(string.Empty), + "--browser-arguments-base64", Encode(string.Empty), + "--startup-timeout-seconds", "30", + "--completion-timeout-seconds", "120", + "--", + .. testApplicationArguments, + ]; + + private static string[] CreateBootstrapArguments() + => + [ + "--server", "dotnettestcli", + "--dotnet-test-transport", "http", + "--dotnet-test-http-endpoint", "http://127.0.0.1:1234/dotnettest/run/", + "--dotnet-test-http-token", "secret-token", + ]; + private static void WriteLaunchInfo(string path, string content) { File.WriteAllText(path, content); From fc15c640ea620dbd7330eb2e8e35afbc39144cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 15 Sep 2026 13:36:51 +0200 Subject: [PATCH 10/16] Isolate browser test invocations and cancellation Require SDK invocation IDs for per-evaluation launcher configs, clean legacy and invocation-specific files, and link post-launch cancellation into the completion race and cleanup path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserRunMonitor.cs | 35 ++++++ .../PACKAGE.md | 14 ++- .../Program.cs | 27 +--- ...oft.Testing.Platform.Browser.After.targets | 13 +- ...Microsoft.Testing.Platform.Browser.targets | 6 +- .../BrowserPackageExecutionTests.cs | 115 ++++++++++++++++-- .../BrowserLauncherOptionsTests.cs | 23 ++++ ...rosoft.Testing.Extensions.UnitTests.csproj | 1 + 8 files changed, 193 insertions(+), 41 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/BrowserRunMonitor.cs 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/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index c4c589fe9b..d950a57c70 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -61,10 +61,12 @@ dotnet test -- --list-tests The launcher integration is a preview contract with the .NET SDK. The package wraps `ComputeRunArguments` only when the SDK marks that ProjectInstance with both -`DotnetTestInvocation=true` and `DotnetTestHttpBootstrapVersion=1`. Ordinary `dotnet run` -and standalone `ComputeRunArguments` queries retain the framework-provided host command. -An SDK that supplies a missing or unsupported bootstrap version receives an actionable -MSBuild error instead of silently launching an incompatible browser host. +`DotnetTestInvocation=true` and `DotnetTestHttpBootstrapVersion=1`, and supplies a unique +32-character hexadecimal `DotnetTestInvocationId`. The ID isolates launcher configuration +files when the SDK evaluates the same project concurrently. Ordinary `dotnet run` and +standalone `ComputeRunArguments` queries retain the framework-provided host command. An SDK +that supplies a missing or unsupported bootstrap version or invocation ID receives an +actionable MSBuild error instead of silently launching an incompatible browser host. Useful properties: @@ -106,6 +108,10 @@ files because the browser virtual file system is not exported to the host yet: The launcher rejects these before starting the browser and names the unsupported option. They can be enabled after a browser artifact sink exports their inputs and outputs. +Cancellation delivered after browser startup is linked to the completion wait. The launcher +therefore exits that wait immediately and enters bounded browser/host cleanup rather than +waiting for `TestingPlatformBrowserCompletionTimeoutSeconds`. + ## Browser page API The launcher installs a versioned API on the top-level page only when its origin exactly diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs index dc2fee2143..929ea30531 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs @@ -39,28 +39,11 @@ public static async Task Main(string[] args) runCancellationTokenSource.Token).ConfigureAwait(false); try { - using var completionCancellationTokenSource = new CancellationTokenSource(); - Task browserCompletion = browser.WaitForCompletionAsync( - options.CompletionTimeout, - completionCancellationTokenSource.Token); - Task hostExit = host.WaitForExitAsync(completionCancellationTokenSource.Token); - Task browserExit = browser.WaitForExitAsync(completionCancellationTokenSource.Token); - - Task completed = await Task.WhenAny(browserCompletion, hostExit, browserExit).ConfigureAwait(false); - if (completed == browserCompletion) - { - int exitCode = await browserCompletion.ConfigureAwait(false); - await completionCancellationTokenSource.CancelAsync().ConfigureAwait(false); - return exitCode; - } - - await completionCancellationTokenSource.CancelAsync().ConfigureAwait(false); - if (completed == hostExit) - { - throw new BrowserLauncherException("The browser host exited before the test application completed."); - } - - throw new BrowserLauncherException("The browser exited before the test application completed."); + return await BrowserRunMonitor.WaitAsync( + token => browser.WaitForCompletionAsync(options.CompletionTimeout, token), + host.WaitForExitAsync, + browser.WaitForExitAsync, + runCancellationTokenSource.Token).ConfigureAwait(false); } finally { diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets index b4d8651f5d..eca55a0b2e 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets +++ b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets @@ -9,8 +9,13 @@ Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' AND $(RuntimeIdentifier.StartsWith('browser-')) AND '$(DotnetTestInvocation)' == 'true' "> + + <_TestingPlatformBrowserInvocationIdIsValid>$([System.Text.RegularExpressions.Regex]::IsMatch('$(DotnetTestInvocationId)', '^[0-9A-Fa-f]{32}$')) + + + AND '$(DotnetTestHttpBootstrapVersion)' == '1' + AND '$(_TestingPlatformBrowserInvocationIdIsValid)' == 'True' "> <_TestingPlatformBrowserComputedHostCommand>$(RunCommand) <_TestingPlatformBrowserComputedHostArguments>$(RunArguments) @@ -31,10 +37,11 @@ Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' AND $(RuntimeIdentifier.StartsWith('browser-')) AND '$(DotnetTestInvocation)' == 'true' - AND '$(DotnetTestHttpBootstrapVersion)' == '1' "> + AND '$(DotnetTestHttpBootstrapVersion)' == '1' + AND '$(_TestingPlatformBrowserInvocationIdIsValid)' == 'True' "> <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll - <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.launch')) + <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.$(DotnetTestInvocationId).launch')) <_TestingPlatformBrowserHostCommand Condition=" '$(TestingPlatformBrowserHostCommand)' != '' ">$(TestingPlatformBrowserHostCommand) <_TestingPlatformBrowserHostCommand Condition=" '$(_TestingPlatformBrowserHostCommand)' == '' ">$(_TestingPlatformBrowserComputedHostCommand) <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' != '' ">$(TestingPlatformBrowserHostArguments) 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 index 69be0bb1cf..303cd72c01 100644 --- 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 @@ -13,6 +13,10 @@ - + + <_TestingPlatformBrowserRunConfiguration Include="$(IntermediateOutputPath)Microsoft.Testing.Platform.Browser.launch" /> + <_TestingPlatformBrowserRunConfiguration Include="$(IntermediateOutputPath)Microsoft.Testing.Platform.Browser.*.launch" /> + +
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index e90c7eaa9b..bee63ddd1e 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -13,6 +13,16 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; public sealed class BrowserPackageExecutionTests : AcceptanceTestBase { private static readonly string TargetFramework = TargetFrameworks.NetCurrent; + private const string InvocationId1 = "11111111111111111111111111111111"; + private const string InvocationId2 = "22222222222222222222222222222222"; + private const string InvocationId3 = "33333333333333333333333333333333"; + private const string InvocationId4 = "44444444444444444444444444444444"; + private const string InvocationId5 = "55555555555555555555555555555555"; + private const string InvocationId6 = "66666666666666666666666666666666"; + private const string InvocationId7 = "77777777777777777777777777777777"; + private const string InvocationId8 = "88888888888888888888888888888888"; + private const string InvocationId9 = "99999999999999999999999999999999"; + private const string InvocationIdA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; private const string SourceCode = """ #file BrowserPackageTestProject.csproj @@ -191,7 +201,7 @@ public void FailsInsideBrowser() framework-host-$(TargetFramework) - --framework $(TargetFramework) + --framework $(TargetFramework) --invocation $(DotnetTestInvocationId) $(MSBuildProjectDirectory) { ["DEBUG"] = "*", @@ -465,7 +475,7 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.AreEqual(launcherCommand[1], launcherCommand[0]); DotnetMuxerResult list = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 --list-tests", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId2} --list-tests", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -480,7 +490,7 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa AssertLaunchInfoDirectoryCleaned(generator.TargetAssetPath); DotnetMuxerResult skipped = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 --filter FullyQualifiedName~SkippedInsideBrowser", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId3} --filter FullyQualifiedName~SkippedInsideBrowser", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -491,7 +501,7 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.DoesNotContain("did not complete within", skippedOutput); DotnetMuxerResult failed = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 --filter FullyQualifiedName~FailsInsideBrowser", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId4} --filter FullyQualifiedName~FailsInsideBrowser", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -532,7 +542,7 @@ public async Task BrowserPackage_FrameworkOwnedPageUsesVersionedApi() .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId5}", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -603,7 +613,7 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() "Release", TargetFramework, WasmRuntime.BrowserRid, - "Microsoft.Testing.Platform.Browser.launch"); + $"Microsoft.Testing.Platform.Browser.{InvocationId6}.launch"); DotnetMuxerResult plainComputeRunArguments = await DotnetCli.RunAsync( $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", @@ -632,14 +642,36 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() Assert.AreNotEqual(0, unsupportedBootstrapVersion.ExitCode); Assert.Contains("requires DotnetTestHttpBootstrapVersion=1", unsupportedBootstrapVersion.StandardOutput + unsupportedBootstrapVersion.StandardError); - DotnetMuxerResult supportedComputeRunArguments = await DotnetCli.RunAsync( + DotnetMuxerResult missingInvocationId = await DotnetCli.RunAsync( $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, cancellationToken: TestContext.CancellationToken); + Assert.AreNotEqual(0, missingInvocationId.ExitCode); + Assert.Contains("requires DotnetTestInvocationId", missingInvocationId.StandardOutput + missingInvocationId.StandardError); + + DotnetMuxerResult invalidInvocationId = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId=not-a-guid", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreNotEqual(0, invalidInvocationId.ExitCode); + Assert.Contains("32-character hexadecimal GUID", invalidInvocationId.StandardOutput + invalidInvocationId.StandardError); + + DotnetMuxerResult supportedComputeRunArguments = await DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId6}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); Assert.AreEqual(0, supportedComputeRunArguments.ExitCode, supportedComputeRunArguments.ToString()); Assert.IsTrue(File.Exists(launchConfiguration)); + string legacyLaunchConfiguration = Path.Combine( + Path.GetDirectoryName(launchConfiguration)!, + "Microsoft.Testing.Platform.Browser.launch"); + File.WriteAllText(legacyLaunchConfiguration, "legacy"); DotnetMuxerResult clean = await DotnetCli.RunAsync( $"clean {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", @@ -649,6 +681,7 @@ public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() cancellationToken: TestContext.CancellationToken); Assert.AreEqual(0, clean.ExitCode, clean.ToString()); Assert.IsFalse(File.Exists(launchConfiguration)); + Assert.IsFalse(File.Exists(legacyLaunchConfiguration)); } [TestMethod] @@ -685,7 +718,7 @@ public async Task BrowserPackage_FrameworkFatalErrorTerminatesWithoutCompletionT source); DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", + $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId7}", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -740,7 +773,7 @@ public async Task BrowserPackage_MultiTargetingActivatesOnlyForBrowserDotnetTest Assert.IsFalse(File.Exists(Path.Combine(generator.TargetAssetPath, $"browser-launcher-{TargetFramework}.txt"))); DotnetMuxerResult dotnetTestBrowserQuery = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId8}", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, @@ -750,6 +783,59 @@ public async Task BrowserPackage_MultiTargetingActivatesOnlyForBrowserDotnetTest Path.Combine(generator.TargetAssetPath, $"browser-launcher-{TargetFramework}.txt")); Assert.IsNotEmpty(browserLauncherCommand); Assert.DoesNotContain($"framework-host-{TargetFramework}", browserLauncherCommand); + + Task firstQueryTask = DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId9}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Task secondQueryTask = DotnetCli.RunAsync( + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationIdA}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + + DotnetMuxerResult firstQuery = await firstQueryTask; + DotnetMuxerResult secondQuery = await secondQueryTask; + Assert.AreEqual(0, firstQuery.ExitCode, firstQuery.ToString()); + Assert.AreEqual(0, secondQuery.ExitCode, secondQuery.ToString()); + + string intermediateDirectory = Path.Combine( + generator.TargetAssetPath, + "obj", + "Debug", + TargetFramework, + WasmRuntime.BrowserRid); + string firstConfiguration = Path.Combine( + intermediateDirectory, + $"Microsoft.Testing.Platform.Browser.{InvocationId9}.launch"); + string secondConfiguration = Path.Combine( + intermediateDirectory, + $"Microsoft.Testing.Platform.Browser.{InvocationIdA}.launch"); + Assert.IsTrue(File.Exists(firstConfiguration)); + Assert.IsTrue(File.Exists(secondConfiguration)); + + string firstArguments = ReadEncodedLaunchConfigurationValue(firstConfiguration, "host-arguments-uri"); + string secondArguments = ReadEncodedLaunchConfigurationValue(secondConfiguration, "host-arguments-uri"); + Assert.Contains(InvocationId9, firstArguments); + Assert.DoesNotContain(InvocationIdA, firstArguments); + Assert.Contains(InvocationIdA, secondArguments); + Assert.DoesNotContain(InvocationId9, secondArguments); + + DotnetMuxerResult clean = await DotnetCli.RunAsync( + $"clean {generator.TargetAssetPath} --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, clean.ExitCode, clean.ToString()); + Assert.IsEmpty( + Directory.EnumerateFiles( + intermediateDirectory, + "Microsoft.Testing.Platform.Browser.*.launch", + SearchOption.TopDirectoryOnly)); } [TestMethod] @@ -924,4 +1010,11 @@ private static void AssertLaunchInfoDirectoryCleaned(string targetAssetPath) Directory.Exists(Path.GetDirectoryName(launchInfoPath)), $"The launcher left its private launch-info directory behind: '{launchInfoPath}'."); } + + private static string ReadEncodedLaunchConfigurationValue(string path, string name) + { + string prefix = name + "="; + string line = File.ReadLines(path).Single(line => line.StartsWith(prefix, StringComparison.Ordinal)); + return Uri.UnescapeDataString(line[prefix.Length..]); + } } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index 4f6a6ac47e..b21f093eeb 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -248,6 +248,29 @@ public void CommandLineTokenizer_PreservesDoubledQuotesInsideQuotedArgument() Assert.AreSequenceEqual(new[] { "a\"b", "\"quoted\"" }, arguments); } + [TestMethod] + public async Task BrowserRunMonitor_CancellationStopsCompletionWaitPromptly() + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + var stopwatch = Stopwatch.StartNew(); + + await Assert.ThrowsAsync(() => BrowserRunMonitor.WaitAsync( + async token => + { + await Task.Delay(TimeSpan.FromMinutes(10), token); + return 0; + }, + token => Task.Delay(TimeSpan.FromMinutes(10), token), + token => Task.Delay(TimeSpan.FromMinutes(10), token), + cancellationTokenSource.Token)); + + stopwatch.Stop(); + Assert.IsLessThan( + TimeSpan.FromSeconds(5), + stopwatch.Elapsed, + "Run cancellation should enter launcher cleanup instead of waiting for the configured completion timeout."); + } + [TestMethod] public void DiagnosticBuffer_RedactsBootstrapSecretsAndBoundsEntries() { 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 654ec54d18..54e7ff340b 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 @@ -72,6 +72,7 @@ + From 6d81426cc3e0abdcf19ac438a2f5fa67a787871e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 16 Sep 2026 14:26:49 +0200 Subject: [PATCH 11/16] Make pure managed browser apps canonical Use the WebAssembly SDK and package-owned static host assets for the primary browser acceptance model, document the generated managed entry point and direct MTP HTTP transport, and recognize WasmAppHost App url readiness without matching debug URLs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../HostProcess.cs | 7 +- .../PACKAGE.md | 40 +++-- ...Microsoft.Testing.Platform.Browser.targets | 46 ++++- .../BrowserPackageExecutionTests.cs | 157 +----------------- .../BrowserLauncherOptionsTests.cs | 5 + 5 files changed, 85 insertions(+), 170 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs index 7b00f25d10..97fb1ac0f4 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs @@ -11,7 +11,7 @@ namespace Microsoft.Testing.Platform.Browser; internal sealed class HostProcess : IAsyncDisposable { private static readonly Regex ListeningUrlRegex = new( - @"Now listening on:\s+(?https?://\S+)", + @"^\s*(?Now listening on:|App url:)\s+(?https?://\S+)\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private readonly Process _process; @@ -281,7 +281,10 @@ internal static string CreateLaunchInfoDirectory() ? null : Uri.TryCreate(match.Groups["url"].Value, UriKind.Absolute, out Uri? uri) && IsLoopbackHttpUri(uri) - ? uri + ? match.Groups["kind"].Value.Equals("App url:", StringComparison.OrdinalIgnoreCase) + && uri.Scheme == Uri.UriSchemeHttps + ? null + : uri : throw new BrowserLauncherException("The browser host reported a non-loopback URL."); private static bool IsLoopbackHttpUri(Uri uri) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index d950a57c70..b8c361e70b 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -43,13 +43,18 @@ application connects directly to the authenticated HTTP gateway created by the . ## Usage ```xml - - browser-wasm - - - - - + + + net10.0 + Exe + true + + + + + + + ``` Then run: @@ -59,6 +64,20 @@ dotnet test dotnet test -- --list-tests ``` +This is the canonical model: the test application and Microsoft Testing Platform transport +are fully managed. MTP generates `Main(string[] args)`, and the package injects the arguments +prepared by `dotnet test` before that generated entry point runs. Managed `HttpClient` sends +discovery, progress, and results directly from MTP to the SDK's authenticated HTTP gateway. +User and test code need neither Blazor `IJSRuntime` nor `[JSImport]` for launch or result +transport, and the application does not need to author an HTML page or JavaScript boot file. + +Browser WebAssembly still requires JavaScript host infrastructure. The package therefore +supplies an `index.html` and a small boot supervisor. That package-owned script only imports +`_framework/dotnet.js`, configures the managed application arguments, invokes `runMain`, +reports fatal bootstrap failures, and returns the final managed exit code to the launcher. +It does not discover tests, execute test logic, parse results, or relay the MTP HTTP +protocol. + The launcher integration is a preview contract with the .NET SDK. The package wraps `ComputeRunArguments` only when the SDK marks that ProjectInstance with both `DotnetTestInvocation=true` and `DotnetTestHttpBootstrapVersion=1`, and supplies a unique @@ -112,7 +131,7 @@ Cancellation delivered after browser startup is linked to the completion wait. T therefore exits that wait immediately and enters bounded browser/host cleanup rather than waiting for `TestingPlatformBrowserCompletionTimeoutSeconds`. -## Browser page API +## Advanced UI-framework page integration The launcher installs a versioned API on the top-level page only when its origin exactly matches the loopback host origin: @@ -141,8 +160,9 @@ globalThis.testingPlatformBrowser = { is only for fatal page/framework integration failures; ordinary test or application exceptions must still be represented by the managed exit code passed to `complete`. -The package-owned JavaScript supervisor implements this API contract automatically. A UI -framework that owns its browser page can set +The package-owned JavaScript supervisor implements this API contract automatically, so the +canonical pure-managed application does not consume it directly. An advanced UI framework +that already owns its browser page can set `TestingPlatformBrowserGenerateHostAssets=false`, provide its own `WasmMainJSPath` and page, then integrate the API: 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 index 303cd72c01..c8dd6ca223 100644 --- 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 @@ -4,13 +4,49 @@ <_TestingPlatformBrowserAssetsDirectory>$(MSBuildThisFileDirectory)assets\ <_TestingPlatformBrowserOwnsHostAssets Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' AND '$(TestingPlatformBrowserGenerateHostAssets)' == 'true' AND '$(WasmMainJSPath)' == '' ">true $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js + $(_TestingPlatformBrowserAssetsDirectory)index.html - - - index.html - - + + + <_TestingPlatformBrowserHostStaticWebAssetsDirectory>$(IntermediateOutputPath)testing-platform-browser\wwwroot\ + + + + + + + + <_TestingPlatformBrowserHostStaticWebAsset Include="$(_TestingPlatformBrowserHostStaticWebAssetsDirectory)index.html"> + index.html + + <_TestingPlatformBrowserHostStaticWebAsset Include="$(_TestingPlatformBrowserHostStaticWebAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js"> + Microsoft.Testing.Platform.Browser.main.js + + + + + + + + + + + + diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index bee63ddd1e..8bf72d2822 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -26,13 +26,11 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase + $TargetFramework$ - browser-wasm Exe - true true true enable @@ -53,43 +51,6 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase -#file Directory.Build.targets - - - <_ParentDirectoryBuildTargets>$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..')) - - - - - - $Node$ - "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" "" --framework-marker "quoted value" --multiline "line1 line2;%#'" --launch-info-path-file "$(MSBuildProjectDirectory)\launch-info-path.txt" - $(MSBuildProjectDirectory) - - - - - - - - - - - - - - - #file BrowserPackageTests.cs using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -112,63 +73,6 @@ public void SkippedInsideBrowser() public void FailsInsideBrowser() => Assert.Fail("Intentional browser preview failure."); } - -#file server.mjs -import { createReadStream, existsSync, renameSync, statSync, writeFileSync } from 'node:fs'; -import { createServer } from 'node:http'; -import { extname, resolve, sep } from 'node:path'; - -const root = resolve(process.argv[2]); -if (process.argv[3] !== '' - || process.argv[4] !== '--framework-marker' - || process.argv[5] !== 'quoted value' - || process.argv[6] !== '--multiline' - || process.argv[7] !== 'line1\r\nline2;%#\'' - || process.argv[8] !== '--launch-info-path-file' - || !process.argv[9]) { - throw new Error(`The computed host arguments did not round-trip: ${JSON.stringify(process.argv.slice(2))}`); -} - -const launchInfoPath = process.env.TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE; -writeFileSync(process.argv[9], launchInfoPath ?? 'ordinary-run'); -if (!launchInfoPath) { - process.exit(0); -} - -const contentTypes = new Map([ - ['.css', 'text/css'], - ['.dat', 'application/octet-stream'], - ['.dll', 'application/octet-stream'], - ['.html', 'text/html; charset=utf-8'], - ['.js', 'text/javascript; charset=utf-8'], - ['.json', 'application/json; charset=utf-8'], - ['.wasm', 'application/wasm'], -]); - -const server = createServer((request, response) => { - const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); - const relative = pathname === '/' ? 'index.html' : pathname.slice(1); - const file = resolve(root, relative); - if (file !== root && !file.startsWith(root + sep)) { - response.writeHead(403).end(); - return; - } - - if (!existsSync(file) || !statSync(file).isFile()) { - response.writeHead(404).end(); - return; - } - - response.setHeader('Content-Type', contentTypes.get(extname(file)) ?? 'application/octet-stream'); - createReadStream(file).pipe(response); -}); - -server.listen(0, '127.0.0.1', () => { - const address = server.address(); - const temporaryPath = `${launchInfoPath}.${process.pid}.tmp`; - writeFileSync(temporaryPath, JSON.stringify({ version: 1, url: `http://127.0.0.1:${address.port}/` }), { mode: 0o600 }); - renameSync(temporaryPath, launchInfoPath); -}); """; private const string MultiTargetingSourceCode = """ @@ -395,13 +299,6 @@ public void RunsOutsideBrowser() [TestMethod] public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGateway() { - string? node = WasmRuntime.LocateNode(); - if (node is null) - { - Assert.Inconclusive(WasmRuntime.NodeUnavailableMessage); - return; - } - string? browser = LocateBrowser(); if (browser is null) { @@ -416,38 +313,10 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa .PatchCodeWithReplace("$TargetFramework$", TargetFramework) .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) - .PatchCodeWithReplace("$Node$", EscapeMsBuildValue(node)) .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); - - DotnetMuxerResult ordinaryRun = await DotnetCli.RunAsync( - $"run --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, ordinaryRun.ExitCode, ordinaryRun.ToString()); - Assert.AreEqual( - "ordinary-run", - File.ReadAllText(Path.Combine(generator.TargetAssetPath, "launch-info-path.txt"))); - Assert.IsEmpty( - Directory.EnumerateFiles( - Path.Combine(generator.TargetAssetPath, "obj"), - "Microsoft.Testing.Platform.Browser.*.launch", - SearchOption.AllDirectories)); - - DotnetMuxerResult plainQuery = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, plainQuery.ExitCode, plainQuery.ToString()); - string[] frameworkHost = File.ReadAllLines( - Path.Combine(generator.TargetAssetPath, "framework-host-command.txt")); - Assert.IsGreaterThanOrEqualTo(3, frameworkHost.Length); - Assert.AreEqual(node, frameworkHost[0]); - Assert.Contains("server.mjs", string.Join(Environment.NewLine, frameworkHost[1..^1])); - Assert.AreEqual(generator.TargetAssetPath, frameworkHost[^1]); + 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 DotnetCli.RunAsync( $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId1} --filter FullyQualifiedName~RunsInsideBrowser", @@ -466,13 +335,6 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.Contains("succeeded: 1", runOutput); Assert.DoesNotContain("--dotnet-test-http-token", runOutput); Assert.DoesNotContain("pw:channel", runOutput); - AssertLaunchInfoDirectoryCleaned(generator.TargetAssetPath); - - string[] launcherCommand = File.ReadAllLines( - Path.Combine(generator.TargetAssetPath, "browser-launcher-command.txt")); - Assert.HasCount(2, launcherCommand); - Assert.IsNotEmpty(launcherCommand[1], "DOTNET_HOST_PATH must be available to the browser launcher target."); - Assert.AreEqual(launcherCommand[1], launcherCommand[0]); DotnetMuxerResult list = await DotnetCli.RunAsync( $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId2} --list-tests", @@ -487,7 +349,6 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.Contains("SkippedInsideBrowser", listOutput); Assert.Contains("FailsInsideBrowser", listOutput); Assert.Contains("Discovered 3 tests", listOutput); - AssertLaunchInfoDirectoryCleaned(generator.TargetAssetPath); DotnetMuxerResult skipped = await DotnetCli.RunAsync( $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId3} --filter FullyQualifiedName~SkippedInsideBrowser", @@ -1001,16 +862,6 @@ private static string EscapeMsBuildValue(string value) .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal); - private static void AssertLaunchInfoDirectoryCleaned(string targetAssetPath) - { - string pathFile = Path.Combine(targetAssetPath, "launch-info-path.txt"); - Assert.IsTrue(File.Exists(pathFile), "The browser host did not record its launch-info path."); - string launchInfoPath = File.ReadAllText(pathFile); - Assert.IsFalse( - Directory.Exists(Path.GetDirectoryName(launchInfoPath)), - $"The launcher left its private launch-info directory behind: '{launchInfoPath}'."); - } - private static string ReadEncodedLaunchConfigurationValue(string path, string name) { string prefix = name + "="; diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index b21f093eeb..2ed654f58e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -433,9 +433,14 @@ .. dependencyContext.RootElement.GetProperty("libraries").EnumerateObject() public void TryParseListeningUri_ParsesLoopbackAndIgnoresOtherOutput() { Assert.IsNull(HostProcess.TryParseListeningUri("Application started.")); + Assert.IsNull(HostProcess.TryParseListeningUri("Debug at url: http://127.0.0.1:9876/")); + Assert.IsNull(HostProcess.TryParseListeningUri("App url: https://127.0.0.1:4322/")); Assert.AreEqual( "http://127.0.0.1:4321/", HostProcess.TryParseListeningUri("Now listening on: http://127.0.0.1:4321/")?.AbsoluteUri); + Assert.AreEqual( + "http://127.0.0.1:4322/", + HostProcess.TryParseListeningUri("App url: http://127.0.0.1:4322/")?.AbsoluteUri); } [TestMethod] From 62f9561ce310fe8344fb2d88784452ab79648833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 16 Sep 2026 18:05:36 +0200 Subject: [PATCH 12/16] Simplify browser launcher experiment Reduce the browser package to the minimum secure proof of concept while retaining managed MTP HTTP execution, private Playwright transport, strict origin checks, bounded lifecycle cleanup, and focused browser acceptance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserExecutableLocator.cs | 82 --- .../BrowserLauncherOptions.cs | 438 +++--------- .../ChromiumBrowser.cs | 354 +++++----- .../HostProcess.cs | 307 +++------ .../PACKAGE.md | 250 ++----- .../Program.cs | 3 +- ...oft.Testing.Platform.Browser.After.targets | 97 --- .../Microsoft.Testing.Platform.Browser.props | 4 - ...Microsoft.Testing.Platform.Browser.targets | 34 +- ...Microsoft.Testing.Platform.Browser.main.js | 39 +- .../BrowserPackageExecutionTests.cs | 588 ++-------------- .../BrowserLauncherOptionsTests.cs | 646 +++--------------- ...rosoft.Testing.Extensions.UnitTests.csproj | 1 - 13 files changed, 610 insertions(+), 2233 deletions(-) delete mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/BrowserExecutableLocator.cs delete mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserExecutableLocator.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserExecutableLocator.cs deleted file mode 100644 index b4df7b8380..0000000000 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserExecutableLocator.cs +++ /dev/null @@ -1,82 +0,0 @@ -// 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 BrowserExecutableLocator -{ - public static string Locate(string? configuredPath) - { - string? environmentPath = Environment.GetEnvironmentVariable("MTP_BROWSER_EXECUTABLE"); - foreach (string? candidate in EnumerateCandidates(configuredPath, environmentPath)) - { - if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate)) - { - return Path.GetFullPath(candidate); - } - } - - foreach (string executableName in GetExecutableNames()) - { - if (FindOnPath(executableName) is { } path) - { - return path; - } - } - - throw new BrowserLauncherException( - "No Chromium-family browser was found. Set TestingPlatformBrowserExecutable or MTP_BROWSER_EXECUTABLE."); - } - - internal static IEnumerable EnumerateCandidates(string? configuredPath, string? environmentPath) - { - yield return configuredPath; - yield return environmentPath; - - if (OperatingSystem.IsWindows()) - { - foreach (string root in new[] - { - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - }) - { - yield return Path.Combine(root, "Microsoft", "Edge", "Application", "msedge.exe"); - yield return Path.Combine(root, "Google", "Chrome", "Application", "chrome.exe"); - yield return Path.Combine(root, "Chromium", "Application", "chrome.exe"); - } - } - else if (OperatingSystem.IsMacOS()) - { - yield return "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; - yield return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; - yield return "/Applications/Chromium.app/Contents/MacOS/Chromium"; - } - } - - private static IEnumerable GetExecutableNames() - => OperatingSystem.IsWindows() - ? ["msedge.exe", "chrome.exe", "chromium.exe"] - : ["microsoft-edge", "microsoft-edge-stable", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]; - - private static string? FindOnPath(string executableName) - { - string? path = Environment.GetEnvironmentVariable("PATH"); - if (path is null) - { - return null; - } - - foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - string candidate = Path.Combine(directory, executableName); - if (File.Exists(candidate)) - { - return Path.GetFullPath(candidate); - } - } - - return null; - } -} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs index 75bf89f2d7..6ef91941aa 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -5,11 +5,9 @@ namespace Microsoft.Testing.Platform.Browser; internal sealed record BrowserLauncherOptions( string HostCommand, - IReadOnlyList HostArguments, + string HostArguments, string HostWorkingDirectory, - string UrlPath, - string? BrowserExecutable, - IReadOnlyList BrowserArguments, + string BrowserExecutable, TimeSpan StartupTimeout, TimeSpan CompletionTimeout, IReadOnlyList TestApplicationArguments, @@ -20,230 +18,82 @@ 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."); + throw new BrowserLauncherException( + "The launcher command line must contain '--' before the Microsoft Testing Platform arguments."); } - string hostCommand; - string hostArguments; - string hostWorkingDirectory; - string urlPath; - string? browserExecutable; - string browserArguments; - TimeSpan startupTimeout; - TimeSpan completionTimeout; - - if (separatorIndex == 2 && args[0] == "--config") + var options = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < separatorIndex; i += 2) { - string[] configuration; - try - { - configuration = File.ReadAllLines(args[1]); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + if (i + 1 >= separatorIndex || !args[i].StartsWith("--", StringComparison.Ordinal)) { - throw new BrowserLauncherException("Unable to read the browser launcher configuration file.", ex); + throw new BrowserLauncherException($"Invalid launcher option at position {i + 1}."); } - if (configuration.Length != 8) + if (!options.TryAdd(args[i], args[i + 1])) { - throw new BrowserLauncherException("The browser launcher configuration file is invalid."); + throw new BrowserLauncherException($"Launcher option '{args[i]}' was provided more than once."); } - - hostCommand = ReadEncodedConfigurationValue(configuration, 0, "host-command-uri"); - hostArguments = ReadEncodedConfigurationValue(configuration, 1, "host-arguments-uri"); - hostWorkingDirectory = ReadEncodedConfigurationValue(configuration, 2, "host-working-directory-uri"); - urlPath = ReadEncodedConfigurationValue(configuration, 3, "url-path-uri"); - browserExecutable = ReadEncodedConfigurationValue(configuration, 4, "browser-executable-uri"); - browserArguments = ReadEncodedConfigurationValue(configuration, 5, "browser-arguments-uri"); - startupTimeout = ParseTimeout( - ReadConfigurationValue(configuration, 6, "startup-timeout-seconds"), - "startup timeout"); - completionTimeout = ParseTimeout( - ReadConfigurationValue(configuration, 7, "completion-timeout-seconds"), - "completion timeout"); } - else - { - 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}."); - } - - options.Add(args[i], args[i + 1]); - } - hostCommand = DecodeRequired(options, "--host-command-base64"); - hostArguments = DecodeRequired(options, "--host-arguments-base64"); - hostWorkingDirectory = DecodeRequired(options, "--host-working-directory-base64"); - urlPath = DecodeRequired(options, "--url-path-base64"); - browserExecutable = DecodeOptional(options, "--browser-executable-base64"); - browserArguments = DecodeOptional(options, "--browser-arguments-base64") ?? string.Empty; + 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}'."); + } - startupTimeout = ParseTimeout(options, "--startup-timeout-seconds"); - completionTimeout = ParseTimeout(options, "--completion-timeout-seconds"); + 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 = ResponseFileArgumentExpander.Expand(args[(separatorIndex + 1)..]); - ValidateTestApplicationArguments(expandedArguments); + string[] expandedArguments = SdkResponseFileExpander.Expand(args[(separatorIndex + 1)..]); var bootstrap = DotnetTestHttpBootstrap.Parse(expandedArguments); - string[] parsedBrowserArguments = CommandLineTokenizer.Split(browserArguments); - ValidateBrowserArguments(parsedBrowserArguments); return new BrowserLauncherOptions( hostCommand, - CommandLineTokenizer.Split(hostArguments), + hostArguments, Path.GetFullPath(hostWorkingDirectory), - NormalizeUrlPath(urlPath), browserExecutable, - parsedBrowserArguments, startupTimeout, completionTimeout, expandedArguments, bootstrap); } - private static void ValidateBrowserArguments(IReadOnlyList arguments) - { - string[] forbiddenPrefixes = - [ - "--remote-debugging-address", - "--remote-debugging-pipe", - "--remote-debugging-port", - "--remote-allow-origins", - "--profile-directory", - "--user-data-dir", - ]; - - foreach (string argument in arguments) - { - string switchName = argument.TrimStart('-', '/'); - int valueSeparator = switchName.IndexOf('='); - if (valueSeparator >= 0) - { - switchName = switchName[..valueSeparator]; - } - - if (forbiddenPrefixes.Any(prefix => - switchName.Equals(prefix[2..], StringComparison.OrdinalIgnoreCase))) - { - throw new BrowserLauncherException( - $"Browser argument '{argument}' is controlled by Microsoft.Testing.Platform.Browser and cannot be overridden."); - } - } - } - - private static void ValidateTestApplicationArguments(IReadOnlyList arguments) - { - var unsupportedOptions = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "config-file", - "diagnostic", - "diagnostic-file-prefix", - "diagnostic-output-directory", - "results-directory", - "settings", - "report-trx", - "report-trx-filename", - "report-html", - "report-html-filename", - "report-junit", - "report-junit-filename", - "report-ctrf", - "report-ctrf-filename", - "coverage", - "coverage-output", - "coverage-output-format", - "coverage-settings", - }; - - foreach (string argument in arguments) - { - int prefixLength = argument.StartsWith("--", StringComparison.Ordinal) - ? 2 - : argument.StartsWith("-", StringComparison.Ordinal) - ? 1 - : 0; - if (prefixLength == 0 || argument.Length == prefixLength) - { - continue; - } - - string optionName = argument[prefixLength..]; - int valueSeparator = optionName.IndexOfAny(['=', ':']); - if (valueSeparator >= 0) - { - optionName = optionName[..valueSeparator]; - } - - if (unsupportedOptions.Contains(optionName)) - { - throw new BrowserLauncherException( - $"Microsoft.Testing.Platform option '--{optionName}' is not supported by the browser launcher preview because its file input or output is not transferred between the browser virtual file system and the host."); - } - } - } - - private static string DecodeRequired(Dictionary options, string name) - => DecodeOptional(options, name) is { Length: > 0 } value - ? value - : throw new BrowserLauncherException($"Required launcher option '{name}' is missing or empty."); - - private static string? DecodeOptional(Dictionary options, string name) + private static string ReadEncodedOption( + Dictionary options, + string name, + bool required) { if (!options.Remove(name, out string? encodedValue)) { - return null; + return required + ? throw new BrowserLauncherException($"Required launcher option '{name}' is missing.") + : string.Empty; } - try - { - return Encoding.UTF8.GetString(Convert.FromBase64String(encodedValue)); - } - catch (FormatException ex) - { - throw new BrowserLauncherException($"Launcher option '{name}' is not valid Base64.", ex); - } + 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) - ? ParseTimeout(value, name) - : throw new BrowserLauncherException($"Launcher option '{name}' must be a positive integer."); - - private static TimeSpan ParseTimeout(string value, string name) - => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int seconds) + && 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."); - - private static string NormalizeUrlPath(string path) - => path.StartsWith("/", StringComparison.Ordinal) ? path : "/" + path; - - internal static Uri ResolveBrowserUri(Uri hostUri, string urlPath) - { - var browserUri = new Uri(hostUri, NormalizeUrlPath(urlPath)); - return string.Equals( - browserUri.GetLeftPart(UriPartial.Authority), - hostUri.GetLeftPart(UriPartial.Authority), - StringComparison.Ordinal) - ? browserUri : throw new BrowserLauncherException( - "The configured browser URL path resolves outside the browser host origin."); - } - - private static string ReadConfigurationValue(string[] configuration, int index, string name) - { - string prefix = name + "="; - return configuration[index].StartsWith(prefix, StringComparison.Ordinal) - ? configuration[index][prefix.Length..] - : throw new BrowserLauncherException("The browser launcher configuration file is invalid."); - } - - private static string ReadEncodedConfigurationValue(string[] configuration, int index, string name) - => Uri.UnescapeDataString(ReadConfigurationValue(configuration, index, name)); + $"Launcher option '{name}' must be a positive integer."); } internal sealed record DotnetTestHttpBootstrap(Uri Endpoint, string Token) @@ -262,7 +112,8 @@ public static DotnetTestHttpBootstrap Parse(IReadOnlyList arguments) { if (++i >= arguments.Count) { - throw new BrowserLauncherException($"Microsoft Testing Platform option '{argument}' has no value."); + throw new BrowserLauncherException( + $"Microsoft Testing Platform option '{argument}' has no value."); } string value = arguments[i]; @@ -284,85 +135,86 @@ public static DotnetTestHttpBootstrap Parse(IReadOnlyList arguments) } } - if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? endpointUri)) - { - throw new BrowserLauncherException( - "The SDK did not provide a valid loopback authenticated HTTP dotnettestcli bootstrap."); - } + Uri endpointUri = Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? parsedEndpoint) + ? parsedEndpoint + : throw InvalidBootstrap(); - bool isValid = string.Equals(server, "dotnettestcli", StringComparison.OrdinalIgnoreCase) + 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); + && !token.Any(char.IsControl) + ? new DotnetTestHttpBootstrap(endpointUri, token) + : throw InvalidBootstrap(); - return isValid - ? new DotnetTestHttpBootstrap(endpointUri, token!) - : throw new BrowserLauncherException( + static BrowserLauncherException InvalidBootstrap() + => new( "The SDK did not provide a valid loopback authenticated HTTP dotnettestcli bootstrap."); } } -internal static class ResponseFileArgumentExpander +internal static class SdkResponseFileExpander { public static string[] Expand(IReadOnlyList arguments) { var expanded = new List(); - var activeFiles = new HashSet( - OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); - foreach (string argument in arguments) { - ExpandArgument(argument, expanded, activeFiles); - } - - return [.. expanded]; - } - - private static void ExpandArgument(string argument, List expanded, HashSet activeFiles) - { - if (!argument.StartsWith('@')) - { - expanded.Add(argument); - return; - } - - string path = Path.GetFullPath(argument[1..]); - if (!activeFiles.Add(path)) - { - throw new BrowserLauncherException("Recursive response files are not supported."); - } - - 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) + if (!argument.StartsWith('@')) { - string trimmed = line.Trim(); - if (trimmed.Length == 0 || trimmed[0] == '#') - { - continue; - } + expanded.Add(argument); + continue; + } - foreach (string nestedArgument in CommandLineTokenizer.Split(trimmed)) + string path = Path.GetFullPath(argument[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) { - ExpandArgument(nestedArgument, expanded, activeFiles); + 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); + } } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException) - { - throw new BrowserLauncherException($"Unable to read the SDK response file '{Path.GetFileName(path)}'.", ex); - } - finally - { - activeFiles.Remove(path); - } + + return [.. expanded]; } private static void ValidateResponseFilePermissions(string path) @@ -383,96 +235,8 @@ private static void ValidateResponseFilePermissions(string path) if ((mode & disallowed) != 0) { throw new BrowserLauncherException( - $"The SDK response file '{Path.GetFileName(path)}' is accessible by users other than its owner."); - } - } -} - -internal static class CommandLineTokenizer -{ - public static string[] Split(string commandLine) - { - var arguments = new List(); - var current = new StringBuilder(); - bool inQuotes = false; - bool tokenStarted = false; - int backslashCount = 0; - - void FlushBackslashes() - { - if (backslashCount > 0) - { - current.Append('\\', backslashCount); - backslashCount = 0; - tokenStarted = true; - } + "The SDK response file is accessible by users other than its owner."); } - - for (int i = 0; i < commandLine.Length; i++) - { - char character = commandLine[i]; - if (character == '\\') - { - backslashCount++; - continue; - } - - if (character == '"') - { - tokenStarted = true; - current.Append('\\', backslashCount / 2); - if (inQuotes && backslashCount % 2 == 0 - && i + 1 < commandLine.Length - && commandLine[i + 1] == '"') - { - current.Append('"'); - backslashCount = 0; - i++; - continue; - } - - if (backslashCount % 2 == 0) - { - inQuotes = !inQuotes; - } - else - { - current.Append('"'); - } - - backslashCount = 0; - continue; - } - - FlushBackslashes(); - if (char.IsWhiteSpace(character) && !inQuotes) - { - if (tokenStarted) - { - arguments.Add(current.ToString()); - current.Clear(); - tokenStarted = false; - } - - continue; - } - - tokenStarted = true; - current.Append(character); - } - - FlushBackslashes(); - if (inQuotes) - { - throw new BrowserLauncherException("A launcher command line contains an unclosed quote."); - } - - if (tokenStarted) - { - arguments.Add(current.ToString()); - } - - return [.. arguments]; } } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs index 6bc7410c47..50621a091a 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -15,28 +15,29 @@ internal sealed class ChromiumBrowser : IAsyncDisposable private readonly IBrowser _browser; private readonly IBrowserContext _context; private readonly IPage _page; - private readonly IAsyncDisposable _completionBinding; - private readonly IAsyncDisposable _fatalErrorBinding; - private readonly TaskCompletionSource _completion; + private readonly IAsyncDisposable _getArgumentsBinding; + private readonly IAsyncDisposable _terminalResultBinding; + private readonly TaskCompletionSource _completion; private readonly DiagnosticBuffer _diagnostics; - private readonly TaskCompletionSource _disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _disconnected = new( + TaskCreationOptions.RunContinuationsAsynchronously); private ChromiumBrowser( IPlaywright playwright, IBrowser browser, IBrowserContext context, IPage page, - IAsyncDisposable completionBinding, - IAsyncDisposable fatalErrorBinding, - TaskCompletionSource completion, + IAsyncDisposable getArgumentsBinding, + IAsyncDisposable terminalResultBinding, + TaskCompletionSource completion, DiagnosticBuffer diagnostics) { _playwright = playwright; _browser = browser; _context = context; _page = page; - _completionBinding = completionBinding; - _fatalErrorBinding = fatalErrorBinding; + _getArgumentsBinding = getArgumentsBinding; + _terminalResultBinding = terminalResultBinding; _completion = completion; _diagnostics = diagnostics; _browser.Disconnected += (_, _) => _disconnected.TrySetResult(); @@ -52,26 +53,24 @@ public static async Task LaunchAsync( DiagnosticBuffer diagnostics, CancellationToken cancellationToken) { - using var startupTimeoutCancellationTokenSource = new CancellationTokenSource(options.StartupTimeout); - using var startupCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - startupTimeoutCancellationTokenSource.Token); + using var startupTimeoutCancellationTokenSource = + new CancellationTokenSource(options.StartupTimeout); + using var startupCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + startupTimeoutCancellationTokenSource.Token); CancellationToken startupCancellationToken = startupCancellationTokenSource.Token; - string executable = BrowserExecutableLocator.Locate(options.BrowserExecutable); IPlaywright? playwright = null; IBrowser? browser = null; IBrowserContext? context = null; - IAsyncDisposable? completionBinding = null; - IAsyncDisposable? fatalErrorBinding = null; + IAsyncDisposable? getArgumentsBinding = null; + IAsyncDisposable? terminalResultBinding = null; Task? browserLaunchTask = null; try { - // Playwright's DEBUG=pw:channel:send / pw:protocol output contains the complete - // AddInitScript request, including the SDK bearer token. This is a dedicated launcher - // process, and its browser host child has already started, so keep DEBUG disabled for - // the remainder of the launcher lifetime. + // Playwright's DEBUG channel logs can contain binding payloads with the SDK bearer token. Environment.SetEnvironmentVariable("DEBUG", null); PlaywrightNodeExecutable.EnsureExecutable(); playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); @@ -79,9 +78,8 @@ public static async Task LaunchAsync( browserLaunchTask = playwright.Chromium.LaunchAsync( new BrowserTypeLaunchOptions { - ExecutablePath = executable, + ExecutablePath = options.BrowserExecutable, Headless = true, - Args = [.. options.BrowserArguments], Timeout = (float)options.StartupTimeout.TotalMilliseconds, }); browser = await browserLaunchTask.WaitAsync(startupCancellationToken).ConfigureAwait(false); @@ -92,51 +90,54 @@ public static async Task LaunchAsync( }).WaitAsync(startupCancellationToken).ConfigureAwait(false); IPage page = await context.NewPageAsync() .WaitAsync(startupCancellationToken).ConfigureAwait(false); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + string expectedOrigin = browserUri.GetLeftPart(UriPartial.Authority); - completionBinding = await page.ExposeBindingAsync( - "__mtpBrowserCompleteV1", - (source, message) => CompleteBrowserRun(source, page, expectedOrigin, message, completion)) - .WaitAsync(startupCancellationToken).ConfigureAwait(false); - fatalErrorBinding = await page.ExposeBindingAsync( - "__mtpBrowserFatalErrorV1", - (source, message) => ReportBrowserFatalError( - source, - page, - expectedOrigin, - message, - completion, - diagnostics)) - .WaitAsync(startupCancellationToken).ConfigureAwait(false); - var result = new ChromiumBrowser( + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + getArgumentsBinding = await page.ExposeBindingAsync( + "__mtpBrowserGetArguments", + source => + { + ValidateBindingSource(source, page, expectedOrigin); + return [.. options.TestApplicationArguments]; + }).WaitAsync(startupCancellationToken).ConfigureAwait(false); + terminalResultBinding = await page.ExposeBindingAsync( + "__mtpBrowserComplete", + (source, result) => + { + ValidateBindingSource(source, page, expectedOrigin); + Complete(result, completion); + }).WaitAsync(startupCancellationToken).ConfigureAwait(false); + + var chromiumBrowser = new ChromiumBrowser( playwright, browser, context, page, - completionBinding, - fatalErrorBinding, + getArgumentsBinding, + terminalResultBinding, completion, diagnostics); - await result.InitializePageAsync( - options.TestApplicationArguments, - browserUri, - startupCancellationToken).ConfigureAwait(false); - return result; + await chromiumBrowser.NavigateAsync(browserUri, startupCancellationToken) + .ConfigureAwait(false); + return chromiumBrowser; } catch (Exception ex) { if (browser is null && browserLaunchTask is not null) { - browser = await TryObserveBrowserLaunchAsync(browserLaunchTask, diagnostics).ConfigureAwait(false); + browser = await TryObserveBrowserLaunchAsync(browserLaunchTask, diagnostics) + .ConfigureAwait(false); } await DisposeBrowserAsync( - completionBinding, - fatalErrorBinding, + terminalResultBinding, + getArgumentsBinding, context, browser, playwright, diagnostics).ConfigureAwait(false); + if (startupTimeoutCancellationTokenSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -151,22 +152,31 @@ await DisposeBrowserAsync( } throw new BrowserLauncherException( - $"Unable to launch the Chromium browser '{executable}'.", + $"Unable to launch the Chromium browser '{options.BrowserExecutable}'.", ex); } } - public async Task WaitForCompletionAsync(TimeSpan timeout, CancellationToken cancellationToken) + public async Task WaitForCompletionAsync( + TimeSpan timeout, + CancellationToken cancellationToken) { using var timeoutCancellationTokenSource = new CancellationTokenSource(timeout); - using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - timeoutCancellationTokenSource.Token); + using var linkedCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellationTokenSource.Token); try { - return await _completion.Task + BrowserTerminalResult result = await _completion.Task .WaitAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false); + if (result.Error is not null) + { + _diagnostics.Add("browser", result.Error); + } + + return result.ExitCode; } catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) { @@ -177,66 +187,58 @@ public async Task WaitForCompletionAsync(TimeSpan timeout, CancellationToke public async ValueTask DisposeAsync() => await DisposeBrowserAsync( - _completionBinding, - _fatalErrorBinding, + _terminalResultBinding, + _getArgumentsBinding, _context, _browser, _playwright, _diagnostics).ConfigureAwait(false); - private async Task InitializePageAsync( - IReadOnlyList testApplicationArguments, - Uri browserUri, - CancellationToken cancellationToken) + private static void Complete( + JsonElement message, + TaskCompletionSource completion) { - string expectedOrigin = browserUri.GetLeftPart(UriPartial.Authority); - string serializedArguments = JsonSerializer.Serialize(testApplicationArguments); - string serializedExpectedOrigin = JsonSerializer.Serialize(expectedOrigin); - await _page.AddInitScriptAsync( - $$""" - if (globalThis.self === globalThis.top && globalThis.location.origin === {{serializedExpectedOrigin}}) { - const argumentsFromLauncher = Object.freeze({{serializedArguments}}); - const completeTransport = globalThis.__mtpBrowserCompleteV1; - const fatalErrorTransport = globalThis.__mtpBrowserFatalErrorV1; - let completed = false; - const api = Object.freeze({ - contractVersion: 1, - getArguments() { - return Object.freeze([...argumentsFromLauncher]); - }, - complete(exitCode) { - if (completed) { - throw new Error('testingPlatformBrowser.complete can only be called once.'); - } - if (!Number.isInteger(exitCode)) { - throw new TypeError('testingPlatformBrowser.complete requires an integer exitCode.'); - } - - completed = true; - void completeTransport({ - contractVersion: 1, - exitCode, - }); - }, - reportFatalError(error) { - if (completed) { - throw new Error('testingPlatformBrowser has already completed.'); - } - if (typeof error !== 'string' || error.length === 0) { - throw new TypeError('testingPlatformBrowser.reportFatalError requires a non-empty error string.'); - } - - completed = true; - void fatalErrorTransport({ - contractVersion: 1, - error, - }); - }, - }); - Object.defineProperty(globalThis, 'testingPlatformBrowser', { value: api, configurable: false, enumerable: true, writable: false }); + 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."); } - """) - .WaitAsync(cancellationToken).ConfigureAwait(false); + + error = errorElement.GetString(); + } + + completion.TrySetResult(new BrowserTerminalResult(exitCodeValue, error)); + } + + private static void ValidateBindingSource( + BindingSource source, + IPage expectedPage, + string expectedOrigin) + => _ = ReferenceEquals(source.Page, expectedPage) + && source.Frame.ParentFrame is null + && Uri.TryCreate(source.Frame.Url, UriKind.Absolute, out Uri? sourceUri) + && string.Equals( + sourceUri.GetLeftPart(UriPartial.Authority), + expectedOrigin, + StringComparison.Ordinal) + ? true + : 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 @@ -247,7 +249,7 @@ await _page.GotoAsync( if (!Uri.TryCreate(_page.Url, UriKind.Absolute, out Uri? finalUri) || !string.Equals( finalUri.GetLeftPart(UriPartial.Authority), - expectedOrigin, + browserUri.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal)) { throw new BrowserLauncherException( @@ -265,41 +267,26 @@ private void SubscribeToDiagnostics() => _diagnostics.Add( "browser request", $"{request.Method} {request.Url}: {request.Failure}"); - _page.Crash += (_, _) - => _diagnostics.Add("browser", "The browser page crashed."); + _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? completionBinding, - IAsyncDisposable? fatalErrorBinding, + IAsyncDisposable? terminalResultBinding, + IAsyncDisposable? getArgumentsBinding, IBrowserContext? context, IBrowser? browser, IPlaywright? playwright, DiagnosticBuffer diagnostics) { - if (fatalErrorBinding is not null) - { - try - { - await fatalErrorBinding.DisposeAsync().AsTask().WaitAsync(CleanupTimeout).ConfigureAwait(false); - } - catch (Exception ex) - { - diagnostics.Add("launcher cleanup", $"Unable to remove the browser fatal-error binding: {ex.Message}"); - } - } - - if (completionBinding is not null) - { - try - { - await completionBinding.DisposeAsync().AsTask().WaitAsync(CleanupTimeout).ConfigureAwait(false); - } - catch (Exception ex) - { - diagnostics.Add("launcher cleanup", $"Unable to remove the browser completion binding: {ex.Message}"); - } - } + await DisposeBindingAsync(terminalResultBinding, "terminal-result", diagnostics) + .ConfigureAwait(false); + await DisposeBindingAsync(getArgumentsBinding, "get-arguments", diagnostics) + .ConfigureAwait(false); if (context is not null) { @@ -309,7 +296,9 @@ private static async Task DisposeBrowserAsync( } catch (Exception ex) { - diagnostics.Add("launcher cleanup", $"Unable to close the browser context: {ex.Message}"); + diagnostics.Add( + "launcher cleanup", + $"Unable to close the browser context: {ex.Message}"); } } @@ -321,7 +310,9 @@ private static async Task DisposeBrowserAsync( } catch (Exception ex) { - diagnostics.Add("launcher cleanup", $"Unable to close the browser: {ex.Message}"); + diagnostics.Add( + "launcher cleanup", + $"Unable to close the browser: {ex.Message}"); } } @@ -331,85 +322,50 @@ private static async Task DisposeBrowserAsync( } catch (Exception ex) { - diagnostics.Add("launcher cleanup", $"Unable to dispose Playwright: {ex.Message}"); + diagnostics.Add( + "launcher cleanup", + $"Unable to dispose Playwright: {ex.Message}"); } } - private static async Task TryObserveBrowserLaunchAsync( - Task browserLaunchTask, + private static async Task DisposeBindingAsync( + IAsyncDisposable? binding, + string name, DiagnosticBuffer diagnostics) { + if (binding is null) + { + return; + } + try { - return await browserLaunchTask.WaitAsync(CleanupTimeout).ConfigureAwait(false); + await binding.DisposeAsync().AsTask().WaitAsync(CleanupTimeout).ConfigureAwait(false); } catch (Exception ex) { - diagnostics.Add("launcher cleanup", $"Unable to observe the interrupted browser launch: {ex.Message}"); - return null; + diagnostics.Add( + "launcher cleanup", + $"Unable to remove the browser {name} binding: {ex.Message}"); } } - private static bool CompleteBrowserRun( - BindingSource source, - IPage expectedPage, - string expectedOrigin, - JsonElement message, - TaskCompletionSource completion) - { - ValidateBrowserApiSource(source, expectedPage, expectedOrigin); - - return message.ValueKind == JsonValueKind.Object - && message.TryGetProperty("contractVersion", out JsonElement contractVersion) - && contractVersion.ValueKind == JsonValueKind.Number - && contractVersion.GetInt32() == 1 - && message.TryGetProperty("exitCode", out JsonElement exitCode) - && exitCode.ValueKind == JsonValueKind.Number - && exitCode.TryGetInt32(out int exitCodeValue) - ? completion.TrySetResult(exitCodeValue) - : throw new BrowserLauncherException( - "The browser completion API received an invalid version 1 payload."); - } - - private static bool ReportBrowserFatalError( - BindingSource source, - IPage expectedPage, - string expectedOrigin, - JsonElement message, - TaskCompletionSource completion, + private static async Task TryObserveBrowserLaunchAsync( + Task browserLaunchTask, DiagnosticBuffer diagnostics) { - ValidateBrowserApiSource(source, expectedPage, expectedOrigin); - - if (message.ValueKind != JsonValueKind.Object - || !message.TryGetProperty("contractVersion", out JsonElement contractVersion) - || contractVersion.ValueKind != JsonValueKind.Number - || contractVersion.GetInt32() != 1 - || !message.TryGetProperty("error", out JsonElement error) - || error.ValueKind != JsonValueKind.String - || string.IsNullOrWhiteSpace(error.GetString())) + try { - throw new BrowserLauncherException( - "The browser fatal-error API received an invalid version 1 payload."); + 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; } - - diagnostics.Add("browser fatal", error.GetString()!); - return completion.TrySetException( - new BrowserLauncherException("The browser page reported a fatal integration error.")); } - private static void ValidateBrowserApiSource( - BindingSource source, - IPage expectedPage, - string expectedOrigin) - => _ = ReferenceEquals(source.Page, expectedPage) - && source.Frame.ParentFrame is null - && Uri.TryCreate(source.Frame.Url, UriKind.Absolute, out Uri? sourceUri) - && string.Equals( - sourceUri.GetLeftPart(UriPartial.Authority), - expectedOrigin, - StringComparison.Ordinal) - ? true - : throw new BrowserLauncherException( - "The browser page API was called outside the expected top-level loopback origin."); + private sealed record BrowserTerminalResult(int ExitCode, string? Error); } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs index 97fb1ac0f4..64764f30b2 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs @@ -1,52 +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. -using System.Net; -using System.Security.AccessControl; -using System.Security.Principal; -using System.Text.Json; - namespace Microsoft.Testing.Platform.Browser; internal sealed class HostProcess : IAsyncDisposable { - private static readonly Regex ListeningUrlRegex = new( - @"^\s*(?Now listening on:|App url:)\s+(?https?://\S+)\s*$", + 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 string _launchInfoDirectory; - private readonly string _launchInfoPath; private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); - private readonly TaskCompletionSource _stdoutReadiness = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _readiness = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task _stdoutTask; private readonly Task _stderrTask; - private HostProcess( - Process process, - DiagnosticBuffer diagnostics, - string launchInfoDirectory, - string launchInfoPath) + private HostProcess(Process process, DiagnosticBuffer diagnostics) { _process = process; _diagnostics = diagnostics; - _launchInfoDirectory = launchInfoDirectory; - _launchInfoPath = launchInfoPath; - _stdoutTask = CaptureAsync(process.StandardOutput, "host stdout", inspectReadiness: true, _disposeCancellationTokenSource.Token); - _stderrTask = CaptureAsync(process.StandardError, "host stderr", inspectReadiness: true, _disposeCancellationTokenSource.Token); + _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) + public static HostProcess Start( + BrowserLauncherOptions options, + DiagnosticBuffer diagnostics) { - string launchInfoDirectory = CreateLaunchInfoDirectory(); - string launchInfoPath = Path.Combine(launchInfoDirectory, "launch-info.json"); var startInfo = new ProcessStartInfo { FileName = options.HostCommand, + Arguments = options.HostArguments, WorkingDirectory = options.HostWorkingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, @@ -54,71 +53,55 @@ public static HostProcess Start(BrowserLauncherOptions options, DiagnosticBuffer CreateNoWindow = true, }; - foreach (string argument in options.HostArguments) - { - startInfo.ArgumentList.Add(argument); - } - - startInfo.Environment["TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE"] = launchInfoPath; - - Process process; try { - process = Process.Start(startInfo) - ?? throw new BrowserLauncherException($"The browser host command '{options.HostCommand}' did not start."); + 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 or BrowserLauncherException) + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { - TryDeleteLaunchInfoDirectory(launchInfoDirectory, diagnostics); - throw new BrowserLauncherException($"Unable to start the browser host command '{options.HostCommand}'.", ex); + throw new BrowserLauncherException( + $"Unable to start the browser host command '{options.HostCommand}'.", + ex); } - - return new HostProcess(process, diagnostics, launchInfoDirectory, launchInfoPath); } - public async Task WaitUntilReadyAsync(TimeSpan timeout, CancellationToken cancellationToken) + public async Task WaitUntilReadyAsync( + TimeSpan timeout, + CancellationToken cancellationToken) { using var timeoutCancellationTokenSource = new CancellationTokenSource(timeout); - using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - timeoutCancellationTokenSource.Token, - _disposeCancellationTokenSource.Token); + using var linkedCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellationTokenSource.Token, + _disposeCancellationTokenSource.Token); try { - while (true) + Task processExit = _process.WaitForExitAsync(linkedCancellationTokenSource.Token); + Task completed = await Task.WhenAny(_readiness.Task, processExit).ConfigureAwait(false); + if (completed == _readiness.Task) { - if (_process.HasExited) - { - throw new BrowserLauncherException( - $"The browser host exited with code {_process.ExitCode} before reporting readiness."); - } - - if (TryReadLaunchInfo(_launchInfoPath) is { } launchInfoUri) - { - return launchInfoUri; - } - - var delay = Task.Delay(TimeSpan.FromMilliseconds(100), linkedCancellationTokenSource.Token); - Task completed = await Task.WhenAny(_stdoutReadiness.Task, delay).ConfigureAwait(false); - if (completed == _stdoutReadiness.Task) - { - return await _stdoutReadiness.Task.ConfigureAwait(false); - } - - linkedCancellationTokenSource.Token.ThrowIfCancellationRequested(); + 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."); + 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) @@ -126,32 +109,56 @@ public async ValueTask DisposeAsync() _process.Kill(entireProcessTree: true); } } - catch (InvalidOperationException) + 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().ConfigureAwait(false); + await _process.WaitForExitAsync().WaitAsync(CleanupTimeout).ConfigureAwait(false); } - catch (InvalidOperationException) + catch (Exception ex) when (ex is InvalidOperationException or TimeoutException) { + _diagnostics.Add( + "launcher cleanup", + $"Unable to confirm browser host process exit: {ex.Message}"); } - await Task.WhenAll(_stdoutTask, _stderrTask).ConfigureAwait(false); - _process.Dispose(); - _disposeCancellationTokenSource.Dispose(); - try { - Directory.Delete(_launchInfoDirectory, recursive: true); + await Task.WhenAll(_stdoutTask, _stderrTask) + .WaitAsync(CleanupTimeout).ConfigureAwait(false); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when ( + ex is IOException + or ObjectDisposedException + or TimeoutException) { - _diagnostics.Add("launcher", $"Unable to delete the browser host launch-info directory: {ex.Message}"); + _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, @@ -170,14 +177,14 @@ private async Task CaptureAsync( try { - if (TryParseListeningUri(line) is { } uri) + if (TryParseAppUrl(line) is { } uri) { - _stdoutReadiness.TrySetResult(uri); + _readiness.TrySetResult(uri); } } catch (BrowserLauncherException ex) { - _stdoutReadiness.TrySetException(ex); + _readiness.TrySetException(ex); return; } } @@ -186,150 +193,4 @@ private async Task CaptureAsync( { } } - - internal static string CreateLaunchInfoDirectory() - { - string baseDirectory = OperatingSystem.IsWindows() - ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - : Path.GetTempPath(); - if (string.IsNullOrWhiteSpace(baseDirectory)) - { - throw new BrowserLauncherException( - "Unable to determine a private directory for the browser host launch-info file."); - } - - string directoryPath = Path.Combine( - baseDirectory, - "Microsoft.Testing.Platform.Browser", - Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - if (!OperatingSystem.IsWindows()) - { - return Directory.CreateDirectory( - directoryPath, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute).FullName; - } - - SecurityIdentifier currentUser = WindowsIdentity.GetCurrent().User - ?? throw new BrowserLauncherException("Unable to determine the current Windows security identifier."); - var directorySecurity = new DirectorySecurity(); - directorySecurity.SetOwner(currentUser); - directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); - directorySecurity.AddAccessRule( - new FileSystemAccessRule( - currentUser, - FileSystemRights.FullControl, - InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, - PropagationFlags.None, - AccessControlType.Allow)); - var directory = new DirectoryInfo(directoryPath); - directory.Create(directorySecurity); - return directory.FullName; - } - - internal static Uri? TryReadLaunchInfo(string launchInfoPath) - { - try - { - if ((File.GetAttributes(launchInfoPath) & FileAttributes.ReparsePoint) != 0) - { - throw new BrowserLauncherException( - "The browser host launch-info path must not be a symbolic link or reparse point."); - } - } - catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or UnauthorizedAccessException) - { - return null; - } - - FileStream stream; - try - { - stream = new FileStream( - launchInfoPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return null; - } - - using (stream) - { - try - { - ValidateLaunchInfoPermissions(stream); - BrowserHostLaunchInfo? launchInfo = JsonSerializer.Deserialize( - stream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - return launchInfo is { Version: 1 } - && Uri.TryCreate(launchInfo.Url, UriKind.Absolute, out Uri? uri) - && IsLoopbackHttpUri(uri) - ? uri - : throw new BrowserLauncherException("The browser host launch-info file is invalid."); - } - catch (JsonException ex) - { - throw new BrowserLauncherException("The browser host launch-info file is invalid.", ex); - } - } - } - - internal static Uri? TryParseListeningUri(string line) - => ListeningUrlRegex.Match(line) is not { Success: true } match - ? null - : Uri.TryCreate(match.Groups["url"].Value, UriKind.Absolute, out Uri? uri) - && IsLoopbackHttpUri(uri) - ? match.Groups["kind"].Value.Equals("App url:", StringComparison.OrdinalIgnoreCase) - && uri.Scheme == Uri.UriSchemeHttps - ? null - : uri - : throw new BrowserLauncherException("The browser host reported a non-loopback URL."); - - private static bool IsLoopbackHttpUri(Uri uri) - => (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) - || string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)) - && (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) - || (IPAddress.TryParse(uri.Host, out IPAddress? address) && IPAddress.IsLoopback(address))); - - private static void ValidateLaunchInfoPermissions(FileStream stream) - { - if (OperatingSystem.IsWindows()) - { - // The containing directory has a protected current-user-only DACL. The host creates the - // launch-info file inside that directory, so it inherits the same access boundary. - return; - } - - UnixFileMode mode = File.GetUnixFileMode(stream.SafeFileHandle); - const UnixFileMode disallowed = - UnixFileMode.GroupRead - | UnixFileMode.GroupWrite - | UnixFileMode.GroupExecute - | UnixFileMode.OtherRead - | UnixFileMode.OtherWrite - | UnixFileMode.OtherExecute; - if ((mode & disallowed) != 0) - { - throw new BrowserLauncherException("The browser host launch-info file is accessible by users other than its owner."); - } - } - - private static void TryDeleteLaunchInfoDirectory(string directory, DiagnosticBuffer diagnostics) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - diagnostics.Add("launcher", $"Unable to delete the browser host launch-info directory: {ex.Message}"); - } - } - - private sealed record BrowserHostLaunchInfo(int Version, string Url); } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index b8c361e70b..de83dd2f51 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -1,44 +1,12 @@ # 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 -`browser-wasm` Microsoft Testing Platform applications through `dotnet test`. - -The package keeps browser dependencies and browser release cadence out of core -Microsoft.Testing.Platform. It contains: - -- build-transitive assets that provide a browser boot page and JavaScript supervisor; -- an MSBuild `ComputeRunArguments` hook that selects the browser launcher only for - `browser-*` runtime identifiers; -- an independently versioned .NET launcher that starts a configured WebAssembly host, uses - Playwright's private browser transport to launch an installed Chromium-family browser in - an isolated context, injects the Microsoft Testing Platform arguments before the runtime - starts, captures bounded diagnostics, and cleans up the browser and host process trees. - -Test discovery and results are not parsed or relayed by this package. The browser test -application connects directly to the authenticated HTTP gateway created by the .NET SDK. - -## Prerequisites - -- A .NET SDK that supplies the authenticated `dotnettestcli` HTTP bootstrap for - `browser-wasm`. -- Microsoft Edge, Google Chrome, Chromium, or a compatible executable selected with - `TestingPlatformBrowserExecutable`. -- A browser-WASM host. The package wraps the command, arguments, and working directory - produced by the project's original `ComputeRunArguments` target. It recognizes the - current `Now listening on: ` output. A shared host can instead write the versioned - launch-info file named by the - `TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE` environment variable: - - ```json - { "version": 1, "url": "http://127.0.0.1:12345/" } - ``` - - The launcher creates a fresh private directory for each run and passes a file path inside - it. On Unix the directory is mode `0700`; on Windows it has a protected current-user-only - DACL. The host must create the file atomically at that exact path with owner-only - permissions and must not replace the containing directory. The launcher rejects - symbolic links/reparse points, validates Unix permissions on the opened file handle, and - prefers this contract over console parsing. +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 @@ -48,6 +16,7 @@ application connects directly to the authenticated HTTP gateway created by the . net10.0 Exe true + PATH_TO_CHROMIUM @@ -57,154 +26,60 @@ application connects directly to the authenticated HTTP gateway created by the . ``` -Then run: - -```dotnetcli -dotnet test -dotnet test -- --list-tests -``` - -This is the canonical model: the test application and Microsoft Testing Platform transport -are fully managed. MTP generates `Main(string[] args)`, and the package injects the arguments -prepared by `dotnet test` before that generated entry point runs. Managed `HttpClient` sends -discovery, progress, and results directly from MTP to the SDK's authenticated HTTP gateway. -User and test code need neither Blazor `IJSRuntime` nor `[JSImport]` for launch or result -transport, and the application does not need to author an HTML page or JavaScript boot file. - -Browser WebAssembly still requires JavaScript host infrastructure. The package therefore -supplies an `index.html` and a small boot supervisor. That package-owned script only imports -`_framework/dotnet.js`, configures the managed application arguments, invokes `runMain`, -reports fatal bootstrap failures, and returns the final managed exit code to the launcher. -It does not discover tests, execute test logic, parse results, or relay the MTP HTTP -protocol. - -The launcher integration is a preview contract with the .NET SDK. The package wraps -`ComputeRunArguments` only when the SDK marks that ProjectInstance with both -`DotnetTestInvocation=true` and `DotnetTestHttpBootstrapVersion=1`, and supplies a unique -32-character hexadecimal `DotnetTestInvocationId`. The ID isolates launcher configuration -files when the SDK evaluates the same project concurrently. Ordinary `dotnet run` and -standalone `ComputeRunArguments` queries retain the framework-provided host command. An SDK -that supplies a missing or unsupported bootstrap version or invocation ID receives an -actionable MSBuild error instead of silently launching an incompatible browser host. - -Useful properties: - -| Property | Purpose | -| --- | --- | -| `TestingPlatformBrowserEnabled` | Enables or disables the package. It defaults to `true` only for `browser-*` runtime identifiers and can be set to `false` in the project or on the command line. | -| `TestingPlatformBrowserGenerateHostAssets` | Controls whether the package supplies its default `index.html` and JavaScript supervisor. Set to `false` when a UI framework owns the page and integrates the launcher bootstrap/completion contract itself. | -| `TestingPlatformBrowserExecutable` | Overrides browser discovery with an explicit Chromium-family executable path. | -| `TestingPlatformBrowserHostCommand` | Overrides the command that starts the external browser-WASM host. When unset, the package wraps the `RunCommand` produced by the project's original `ComputeRunArguments`. | -| `TestingPlatformBrowserHostArguments` | Overrides the arguments for the host command. When unset, the original computed `RunArguments` are preserved, including empty and quoted arguments. | -| `TestingPlatformBrowserHostWorkingDirectory` | Overrides the working directory for the host process. When unset, the original computed `RunWorkingDirectory` is used. | -| `TestingPlatformBrowserUrlPath` | Path opened relative to the host URL. Defaults to `/`. | -| `TestingPlatformBrowserStartupTimeoutSeconds` | Host/browser startup timeout. Defaults to 60 seconds. | -| `TestingPlatformBrowserCompletionTimeoutSeconds` | Test completion timeout. Defaults to 600 seconds. | -| `TestingPlatformBrowserAdditionalArguments` | Additional Chromium command-line arguments. | - -The SDK bearer token is read from its owner-only response file, retained in memory, injected -into the browser runtime through Playwright's launcher-private transport, and redacted from -launcher, host, and browser diagnostics. It is never added to the browser URL or exposed -through an unauthenticated DevTools TCP listener. User browser arguments cannot override -Playwright's debugging transport or isolated profile. - -### Preview option limitations - -The browser preview supports ordinary execution/discovery options such as `--help`, -`--list-tests`, `--filter`, and `--filter-uid`. It rejects options that read or write host -files because the browser virtual file system is not exported to the host yet: - -- configuration and host paths: `--config-file`, `--settings`, - `--diagnostic-output-directory`, `--diagnostic-file-prefix`, and - `--results-directory`; -- file diagnostics: `--diagnostic`; -- report artifacts: `--report-trx`, `--report-trx-filename`, `--report-html`, - `--report-html-filename`, `--report-junit`, `--report-junit-filename`, - `--report-ctrf`, and `--report-ctrf-filename`; -- coverage artifacts: `--coverage`, `--coverage-output`, `--coverage-output-format`, - and `--coverage-settings`. - -The launcher rejects these before starting the browser and names the unsupported option. -They can be enabled after a browser artifact sink exports their inputs and outputs. - -Cancellation delivered after browser startup is linked to the completion wait. The launcher -therefore exits that wait immediately and enters bounded browser/host cleanup rather than -waiting for `TestingPlatformBrowserCompletionTimeoutSeconds`. - -## Advanced UI-framework page integration - -The launcher installs a versioned API on the top-level page only when its origin exactly -matches the loopback host origin: - -```js -globalThis.testingPlatformBrowser = { - contractVersion: 1, - getArguments(): string[], - complete(exitCode: number): void, - reportFatalError(error: string): void -}; -``` - -- `contractVersion` is `1`. A framework-owned page must reject versions it does not support. -- `getArguments()` returns a new frozen array containing the Microsoft Testing Platform - arguments prepared by `dotnet test`, including the authenticated HTTP transport - bootstrap. The page must pass the array directly to the managed test application; it - must not log, persist, put into a URL, or relay those arguments. -- `complete(exitCode)` reports the managed application's final exit code to the - launcher. It is one-shot and must be called exactly once. Failures should be written - to `console.error` before completion so the launcher captures their diagnostics. Test discovery and test - results do not flow through this method; MTP sends them directly to the SDK HTTP - gateway. -- `reportFatalError(error)` terminates the launcher immediately when the page cannot - negotiate the contract or cannot report normal completion. It is also one-shot. This - is only for fatal page/framework integration failures; ordinary test or application - exceptions must still be represented by the managed exit code passed to `complete`. - -The package-owned JavaScript supervisor implements this API contract automatically, so the -canonical pure-managed application does not consume it directly. An advanced UI framework -that already owns its browser page can set -`TestingPlatformBrowserGenerateHostAssets=false`, provide its own `WasmMainJSPath` and -page, then integrate the API: - -```js -import { dotnet } from './_framework/dotnet.js'; - -const api = globalThis.testingPlatformBrowser; -if (api?.contractVersion !== 1) { - throw new Error('testingPlatformBrowser contract version 1 is required.'); -} - -let exitCode; -let failure; -try { - const { runMain } = await dotnet - .withApplicationArguments(...api.getArguments()) - .create(); - exitCode = await runMain(); -} -catch (error) { - failure = error; - exitCode = 1; - console.error(error instanceof Error ? error.stack ?? error.message : String(error)); -} - -api.complete(exitCode); -if (failure !== undefined) { - throw failure; -} -``` - -The Playwright binding used underneath `complete` is a private launcher transport detail -and is not part of the browser page API. - -The optional package carries Playwright and its Node-based driver so that browser cadence -can be serviced independently of core Microsoft.Testing.Platform and the .NET SDK. This -introduces package-size, platform, offline/source-build, and Node security servicing -considerations that must be resolved before productization. - -The current proof of concept intentionally leaves physical browser virtual-file-system -artifact export to a future artifact sink and leaves host implementation to the shared -browser-WASM host effort. +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 and private boot supervisor that dynamically imports +`_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 SDK sets `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 +package supplies its page only when the project has not already selected a +`WasmMainJSPath`; no public framework-page integration protocol is provided. + +Ordinary desktop targets and unmarked `ComputeRunArguments` calls remain +unchanged. + +## Current limitations + +- Browser virtual-file-system artifacts are not exported. TRX, coverage, + diagnostics, and other file reports may remain only in the browser VFS. +- The SDK authenticated HTTP bootstrap and invocation marker are experimental + cross-repository contracts. +- 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. +- 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 not included in the repository shipment layout. Microsoft.Testing.Platform is open source. You can find `Microsoft.Testing.Platform.Browser` in the @@ -212,7 +87,8 @@ Microsoft.Testing.Platform is open source. You can find ## Documentation -For comprehensive documentation, see . +For comprehensive Microsoft Testing Platform documentation, see +. ## Feedback & contributing diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs index 929ea30531..6398c08baf 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs @@ -31,10 +31,9 @@ public static async Task Main(string[] args) Uri hostUri = await host.WaitUntilReadyAsync( options.StartupTimeout, runCancellationTokenSource.Token).ConfigureAwait(false); - Uri browserUri = BrowserLauncherOptions.ResolveBrowserUri(hostUri, options.UrlPath); ChromiumBrowser browser = await ChromiumBrowser.LaunchAsync( options, - browserUri, + hostUri, diagnostics, runCancellationTokenSource.Token).ConfigureAwait(false); try diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets b/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets deleted file mode 100644 index eca55a0b2e..0000000000 --- a/src/Platform/Microsoft.Testing.Platform.Browser/buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - <_TestingPlatformBrowserInvocationIdIsValid>$([System.Text.RegularExpressions.Regex]::IsMatch('$(DotnetTestInvocationId)', '^[0-9A-Fa-f]{32}$')) - - - - - - - - <_TestingPlatformBrowserComputedHostCommand>$(RunCommand) - <_TestingPlatformBrowserComputedHostArguments>$(RunArguments) - <_TestingPlatformBrowserComputedHostWorkingDirectory>$(RunWorkingDirectory) - - - - - - <_TestingPlatformBrowserLauncher>$(MSBuildThisFileDirectory)..\tools\net8.0\any\Microsoft.Testing.Platform.Browser.dll - <_TestingPlatformBrowserLauncherConfiguration>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'Microsoft.Testing.Platform.Browser.$(DotnetTestInvocationId).launch')) - <_TestingPlatformBrowserHostCommand Condition=" '$(TestingPlatformBrowserHostCommand)' != '' ">$(TestingPlatformBrowserHostCommand) - <_TestingPlatformBrowserHostCommand Condition=" '$(_TestingPlatformBrowserHostCommand)' == '' ">$(_TestingPlatformBrowserComputedHostCommand) - <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' != '' ">$(TestingPlatformBrowserHostArguments) - <_TestingPlatformBrowserHostArguments Condition=" '$(TestingPlatformBrowserHostArguments)' == '' ">$(_TestingPlatformBrowserComputedHostArguments) - <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(TestingPlatformBrowserHostWorkingDirectory)' != '' ">$(TestingPlatformBrowserHostWorkingDirectory) - <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(_TestingPlatformBrowserComputedHostWorkingDirectory) - <_TestingPlatformBrowserHostWorkingDirectory Condition=" '$(_TestingPlatformBrowserHostWorkingDirectory)' == '' ">$(MSBuildProjectDirectory) - - $(DOTNET_HOST_PATH) - dotnet - exec "$(_TestingPlatformBrowserLauncher)" --config "$(_TestingPlatformBrowserLauncherConfiguration)" -- - $(MSBuildProjectDirectory) - - - - - - - - - - - - - - - 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 index f2d6ac7fd4..e97bbd8eaf 100644 --- 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 @@ -2,9 +2,5 @@ 60 600 - / - true - <_TestingPlatformBrowserPreviousCustomAfterDirectoryBuildTargets>$(CustomAfterDirectoryBuildTargets) - $(MSBuildThisFileDirectory)Microsoft.Testing.Platform.Browser.After.targets
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 index c8dd6ca223..20c15827bb 100644 --- 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 @@ -2,7 +2,7 @@ true <_TestingPlatformBrowserAssetsDirectory>$(MSBuildThisFileDirectory)assets\ - <_TestingPlatformBrowserOwnsHostAssets Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' AND '$(TestingPlatformBrowserGenerateHostAssets)' == 'true' AND '$(WasmMainJSPath)' == '' ">true + <_TestingPlatformBrowserOwnsHostAssets Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' AND '$(WasmMainJSPath)' == '' ">true $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js $(_TestingPlatformBrowserAssetsDirectory)index.html @@ -34,7 +34,7 @@ - - - <_TestingPlatformBrowserRunConfiguration Include="$(IntermediateOutputPath)Microsoft.Testing.Platform.Browser.launch" /> - <_TestingPlatformBrowserRunConfiguration Include="$(IntermediateOutputPath)Microsoft.Testing.Platform.Browser.*.launch" /> - - + + + <_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 index e0d4be76f0..123ba889b2 100644 --- 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 @@ -1,18 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { dotnet } from './_framework/dotnet.js'; - const status = document.querySelector('[role=status]'); -const browserApi = globalThis.testingPlatformBrowser; - -if (browserApi?.contractVersion !== 1 - || typeof browserApi.getArguments !== 'function' - || typeof browserApi.complete !== 'function') { - throw new Error('Microsoft.Testing.Platform.Browser did not provide browser API version 1.'); -} - -const argumentsFromLauncher = browserApi.getArguments(); globalThis.addEventListener('error', event => { console.error(`Unhandled browser error: ${event.message}`); @@ -22,24 +11,30 @@ globalThis.addEventListener('unhandledrejection', event => { console.error(`Unhandled browser rejection: ${String(event.reason)}`); }); -let exitCode; -let failure; 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(); - exitCode = await runMain(); + const exitCode = await runMain(); status.textContent = exitCode === 0 ? 'Passed' : `Failed (exit code ${exitCode})`; + if (launcherAvailable) { + await globalThis.__mtpBrowserComplete({ exitCode }); + } } catch (error) { - failure = error; - exitCode = 1; - console.error(error instanceof Error ? error.stack ?? error.message : String(error)); + const message = error instanceof Error + ? `${error.name}: ${error.message}\n${error.stack ?? ''}` + : String(error); + console.error(message); status.textContent = 'Failed (launcher error)'; -} - -browserApi.complete(exitCode); -if (failure !== undefined) { - throw failure; + if (typeof globalThis.__mtpBrowserComplete === 'function') { + await globalThis.__mtpBrowserComplete({ exitCode: 1, error: message }); + } } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 8bf72d2822..6a10a1817f 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -13,16 +13,6 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; public sealed class BrowserPackageExecutionTests : AcceptanceTestBase { private static readonly string TargetFramework = TargetFrameworks.NetCurrent; - private const string InvocationId1 = "11111111111111111111111111111111"; - private const string InvocationId2 = "22222222222222222222222222222222"; - private const string InvocationId3 = "33333333333333333333333333333333"; - private const string InvocationId4 = "44444444444444444444444444444444"; - private const string InvocationId5 = "55555555555555555555555555555555"; - private const string InvocationId6 = "66666666666666666666666666666666"; - private const string InvocationId7 = "77777777777777777777777777777777"; - private const string InvocationId8 = "88888888888888888888888888888888"; - private const string InvocationId9 = "99999999999999999999999999999999"; - private const string InvocationIdA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; private const string SourceCode = """ #file BrowserPackageTestProject.csproj @@ -39,7 +29,6 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase$(NoWarn);NETSDK1201 $Browser$ - --mtp-test-value="line1 line2;%#'" 60 120 @@ -71,194 +60,8 @@ public void SkippedInsideBrowser() [TestMethod] public void FailsInsideBrowser() - => Assert.Fail("Intentional browser preview failure."); + => Assert.Fail("Intentional browser experiment failure."); } -"""; - - private const string MultiTargetingSourceCode = """ -#file BrowserMultiTargetingProject.csproj - - - - net9.0;$TargetFramework$ - Exe - browser-wasm - false - - - - - - - - -#file Program.cs -return 0; - -#file Directory.Build.targets - - - <_ParentDirectoryBuildTargets>$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..')) - - - - - - framework-host-$(TargetFramework) - --framework $(TargetFramework) --invocation $(DotnetTestInvocationId) - $(MSBuildProjectDirectory) - - - - - - - - -"""; - - private const string FrameworkOwnedPageSourceCode = """ -#file BrowserFrameworkPageTestProject.csproj - - - - $TargetFramework$ - browser-wasm - Exe - true - true - true - enable - main.js - false - false - $(NoWarn);NETSDK1201 - - false - $Node$ - "$(MSBuildProjectDirectory)\server.mjs" "$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\AppBundle" - $(MSBuildProjectDirectory) - $Browser$ - 60 - 120 - - - - - - - - - - -#file BrowserFrameworkPageTests.cs -using Microsoft.VisualStudio.TestTools.UnitTesting; - -[TestClass] -public sealed class BrowserFrameworkPageTests -{ - [TestMethod] - public void UsesVersionedBrowserApi() - { - Assert.IsTrue(OperatingSystem.IsBrowser()); - } -} - -#file index.html - - -Framework-owned MTP page - - - -#file main.js -$FrameworkPageMain$ - -#file server.mjs -import { createReadStream, existsSync, renameSync, statSync, writeFileSync } from 'node:fs'; -import { createServer } from 'node:http'; -import { extname, resolve, sep } from 'node:path'; - -const root = resolve(process.argv[2]); -const launchInfoPath = process.env.TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE; -if (!launchInfoPath) { - throw new Error('TESTINGPLATFORM_BROWSER_LAUNCH_INFO_FILE is required.'); -} - -const contentTypes = new Map([ - ['.dat', 'application/octet-stream'], - ['.dll', 'application/octet-stream'], - ['.html', 'text/html; charset=utf-8'], - ['.js', 'text/javascript; charset=utf-8'], - ['.json', 'application/json; charset=utf-8'], - ['.wasm', 'application/wasm'], -]); - -const server = createServer((request, response) => { - const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); - const relative = pathname === '/' ? 'index.html' : pathname.slice(1); - const file = resolve(root, relative); - if (file !== root && !file.startsWith(root + sep)) { - response.writeHead(403).end(); - return; - } - if (!existsSync(file) || !statSync(file).isFile()) { - response.writeHead(404).end(); - return; - } - response.setHeader('Content-Type', contentTypes.get(extname(file)) ?? 'application/octet-stream'); - createReadStream(file).pipe(response); -}); - -server.listen(0, '127.0.0.1', () => { - const address = server.address(); - const temporaryPath = `${launchInfoPath}.${process.pid}.tmp`; - writeFileSync(temporaryPath, JSON.stringify({ version: 1, url: `http://127.0.0.1:${address.port}/` }), { mode: 0o600 }); - renameSync(temporaryPath, launchInfoPath); -}); -"""; - - private const string FrameworkPageMainSource = """ -import { dotnet } from './_framework/dotnet.js'; - -const api = globalThis.testingPlatformBrowser; -if (api?.contractVersion !== 1) { - throw new Error('Expected testingPlatformBrowser contract version 1.'); -} - -let exitCode; -let failure; -try { - const { runMain } = await dotnet.withApplicationArguments(...api.getArguments()).create(); - exitCode = await runMain(); -} -catch (error) { - failure = error; - exitCode = 1; - console.error(error instanceof Error ? error.stack ?? error.message : String(error)); -} - -api.complete(exitCode); -if (failure !== undefined) { - throw failure; -} -"""; - - private const string FatalFrameworkPageMainSource = """ -const api = globalThis.testingPlatformBrowser; -if (api?.contractVersion !== 1 || typeof api.reportFatalError !== 'function') { - throw new Error('Expected testingPlatformBrowser fatal-error API version 1.'); -} - -api.reportFatalError('framework-owned page fatal marker'); """; private const string DesktopSourceCode = """ @@ -318,17 +121,13 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.IsEmpty(Directory.EnumerateFiles(generator.TargetAssetPath, "*.html", SearchOption.TopDirectoryOnly)); Assert.IsEmpty(Directory.EnumerateFiles(generator.TargetAssetPath, "*.js", SearchOption.TopDirectoryOnly)); - DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId1} --filter FullyQualifiedName~RunsInsideBrowser", + DotnetMuxerResult run = await RunBrowserTestAsync( + generator, + "--filter FullyQualifiedName~RunsInsideBrowser", environmentVariables: new Dictionary { ["DEBUG"] = "*", - }, - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - + }); string runOutput = run.StandardOutput + run.StandardError; Assert.AreEqual(0, run.ExitCode, run.ToString()); Assert.Contains($"({TargetFramework}|wasm) passed [+1/x0/?0]", runOutput); @@ -336,13 +135,7 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.DoesNotContain("--dotnet-test-http-token", runOutput); Assert.DoesNotContain("pw:channel", runOutput); - DotnetMuxerResult list = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId2} --list-tests", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - + DotnetMuxerResult list = await RunBrowserTestAsync(generator, "--list-tests"); string listOutput = list.StandardOutput + list.StandardError; Assert.AreEqual(0, list.ExitCode, list.ToString()); Assert.Contains("RunsInsideBrowser", listOutput); @@ -350,257 +143,35 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa Assert.Contains("FailsInsideBrowser", listOutput); Assert.Contains("Discovered 3 tests", listOutput); - DotnetMuxerResult skipped = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId3} --filter FullyQualifiedName~SkippedInsideBrowser", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); + 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 DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId4} --filter FullyQualifiedName~FailsInsideBrowser", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); + 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 preview failure.", failedOutput); + Assert.Contains("Intentional browser experiment failure.", failedOutput); Assert.DoesNotContain("did not complete within", failedOutput); } [TestMethod] - public async Task BrowserPackage_FrameworkOwnedPageUsesVersionedApi() + public async Task BrowserPackage_WrapsOnlyMarkedBrowserComputeRunArguments() { - string? node = WasmRuntime.LocateNode(); - if (node is null) - { - Assert.Inconclusive(WasmRuntime.NodeUnavailableMessage); - return; - } - - 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( - "BrowserFrameworkPageTestProject", - FrameworkOwnedPageSourceCode - .PatchCodeWithReplace("$FrameworkPageMain$", FrameworkPageMainSource) + "BrowserPackageComputeRunArgumentsProject", + SourceCode .PatchCodeWithReplace("$TargetFramework$", TargetFramework) .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) - .PatchCodeWithReplace("$Node$", EscapeMsBuildValue(node)) - .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); - - DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId5}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - - string output = run.StandardOutput + run.StandardError; - Assert.AreEqual(0, run.ExitCode, run.ToString()); - Assert.Contains($"({TargetFramework}|wasm) passed [+1/x0/?0]", output); - - string appBundle = Path.Combine( - generator.TargetAssetPath, - "bin", - "Release", - TargetFramework, - WasmRuntime.BrowserRid, - "AppBundle"); - Assert.IsTrue(File.Exists(Path.Combine(appBundle, "index.html"))); - Assert.IsTrue(File.Exists(Path.Combine(appBundle, "main.js"))); - Assert.IsFalse( - File.Exists(Path.Combine(appBundle, "Microsoft.Testing.Platform.Browser.main.js")), - "TestingPlatformBrowserGenerateHostAssets=false must not deploy the package-owned supervisor."); - } - - [TestMethod] - public async Task BrowserPackage_CustomWasmMainJsPathDoesNotDeployPackagePage() - { - string browserPackageVersion = GetBrowserPackageVersion(); - string source = FrameworkOwnedPageSourceCode - .Replace( - " false", - string.Empty, - StringComparison.Ordinal) - .PatchCodeWithReplace("$FrameworkPageMain$", FrameworkPageMainSource) - .PatchCodeWithReplace("$TargetFramework$", TargetFramework) - .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) - .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) - .PatchCodeWithReplace("$Node$", "node") - .PatchCodeWithReplace("$Browser$", string.Empty); - using TestAsset generator = await TestAsset.GenerateAssetAsync( - "BrowserCustomMainJsProject", - source); - Assert.DoesNotContain( - "TestingPlatformBrowserGenerateHostAssets", - File.ReadAllText(Path.Combine(generator.TargetAssetPath, "BrowserFrameworkPageTestProject.csproj"))); - - 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()); - string appBundle = Path.Combine( - generator.TargetAssetPath, - "bin", - "Release", - TargetFramework, - WasmRuntime.BrowserRid, - "AppBundle"); - Assert.IsTrue(File.Exists(Path.Combine(appBundle, "index.html"))); - Assert.IsTrue(File.Exists(Path.Combine(appBundle, "main.js"))); - Assert.IsFalse(File.Exists(Path.Combine(appBundle, "Microsoft.Testing.Platform.Browser.main.js"))); - - string launchConfiguration = Path.Combine( - generator.TargetAssetPath, - "obj", - "Release", - TargetFramework, - WasmRuntime.BrowserRid, - $"Microsoft.Testing.Platform.Browser.{InvocationId6}.launch"); - - DotnetMuxerResult plainComputeRunArguments = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, plainComputeRunArguments.ExitCode, plainComputeRunArguments.ToString()); - Assert.IsFalse(File.Exists(launchConfiguration)); - - DotnetMuxerResult missingBootstrapVersion = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreNotEqual(0, missingBootstrapVersion.ExitCode); - Assert.Contains("requires DotnetTestHttpBootstrapVersion=1", missingBootstrapVersion.StandardOutput + missingBootstrapVersion.StandardError); - - DotnetMuxerResult unsupportedBootstrapVersion = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=2", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreNotEqual(0, unsupportedBootstrapVersion.ExitCode); - Assert.Contains("requires DotnetTestHttpBootstrapVersion=1", unsupportedBootstrapVersion.StandardOutput + unsupportedBootstrapVersion.StandardError); - - DotnetMuxerResult missingInvocationId = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreNotEqual(0, missingInvocationId.ExitCode); - Assert.Contains("requires DotnetTestInvocationId", missingInvocationId.StandardOutput + missingInvocationId.StandardError); - - DotnetMuxerResult invalidInvocationId = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId=not-a-guid", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreNotEqual(0, invalidInvocationId.ExitCode); - Assert.Contains("32-character hexadecimal GUID", invalidInvocationId.StandardOutput + invalidInvocationId.StandardError); - - DotnetMuxerResult supportedComputeRunArguments = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:Configuration=Release -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId6}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, supportedComputeRunArguments.ExitCode, supportedComputeRunArguments.ToString()); - Assert.IsTrue(File.Exists(launchConfiguration)); - string legacyLaunchConfiguration = Path.Combine( - Path.GetDirectoryName(launchConfiguration)!, - "Microsoft.Testing.Platform.Browser.launch"); - File.WriteAllText(legacyLaunchConfiguration, "legacy"); - - DotnetMuxerResult clean = await DotnetCli.RunAsync( - $"clean {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, clean.ExitCode, clean.ToString()); - Assert.IsFalse(File.Exists(launchConfiguration)); - Assert.IsFalse(File.Exists(legacyLaunchConfiguration)); - } - - [TestMethod] - public async Task BrowserPackage_FrameworkFatalErrorTerminatesWithoutCompletionTimeout() - { - string? node = WasmRuntime.LocateNode(); - if (node is null) - { - Assert.Inconclusive(WasmRuntime.NodeUnavailableMessage); - return; - } - - 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(); - string source = FrameworkOwnedPageSourceCode - .Replace( - " 120", - " 5", - StringComparison.Ordinal) - .PatchCodeWithReplace("$FrameworkPageMain$", FatalFrameworkPageMainSource) - .PatchCodeWithReplace("$TargetFramework$", TargetFramework) - .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) - .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) - .PatchCodeWithReplace("$Node$", EscapeMsBuildValue(node)) - .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser)); - using TestAsset generator = await TestAsset.GenerateAssetAsync( - "BrowserFatalFrameworkPageProject", - source); - - DotnetMuxerResult run = await DotnetCli.RunAsync( - $"test --project {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId7}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - - string output = run.StandardOutput + run.StandardError; - Assert.AreNotEqual(0, run.ExitCode, run.ToString()); - Assert.Contains("framework-owned page fatal marker", output); - Assert.Contains("fatal integration error", output); - Assert.DoesNotContain("did not complete within 5 seconds", output); - } - - [TestMethod] - public async Task BrowserPackage_MultiTargetingActivatesOnlyForBrowserDotnetTestInvocation() - { - string browserPackageVersion = GetBrowserPackageVersion(); - using TestAsset generator = await TestAsset.GenerateAssetAsync( - "BrowserMultiTargetingProject", - MultiTargetingSourceCode - .PatchCodeWithReplace("$TargetFramework$", TargetFramework) - .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion)); + .PatchCodeWithReplace("$Browser$", "browser-placeholder")); DotnetMuxerResult restore = await DotnetCli.RunAsync( $"restore {generator.TargetAssetPath}", @@ -609,94 +180,32 @@ public async Task BrowserPackage_MultiTargetingActivatesOnlyForBrowserDotnetTest cancellationToken: TestContext.CancellationToken); Assert.AreEqual(0, restore.ExitCode, restore.ToString()); - DotnetMuxerResult desktopQuery = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework=net9.0 -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, desktopQuery.ExitCode, desktopQuery.ToString()); - Assert.AreEqual( - "framework-host-net9.0", - File.ReadAllText(Path.Combine(generator.TargetAssetPath, "framework-host-net9.0.txt")).Trim()); - Assert.IsFalse(File.Exists(Path.Combine(generator.TargetAssetPath, "browser-launcher-net9.0.txt"))); - - DotnetMuxerResult plainBrowserQuery = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, plainBrowserQuery.ExitCode, plainBrowserQuery.ToString()); - Assert.AreEqual( - $"framework-host-{TargetFramework}", - File.ReadAllText(Path.Combine(generator.TargetAssetPath, $"framework-host-{TargetFramework}.txt")).Trim()); - Assert.IsFalse(File.Exists(Path.Combine(generator.TargetAssetPath, $"browser-launcher-{TargetFramework}.txt"))); + string commonArguments = + $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments" + + $" -property:TargetFramework={TargetFramework}" + + $" -property:RuntimeIdentifier={WasmRuntime.BrowserRid}" + + " -getProperty:RunCommand -getProperty:RunArguments"; - DotnetMuxerResult dotnetTestBrowserQuery = await DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId8}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, dotnetTestBrowserQuery.ExitCode, dotnetTestBrowserQuery.ToString()); - string browserLauncherCommand = File.ReadAllText( - Path.Combine(generator.TargetAssetPath, $"browser-launcher-{TargetFramework}.txt")); - Assert.IsNotEmpty(browserLauncherCommand); - Assert.DoesNotContain($"framework-host-{TargetFramework}", browserLauncherCommand); - - Task firstQueryTask = DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationId9}", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Task secondQueryTask = DotnetCli.RunAsync( - $"msbuild {generator.TargetAssetPath} -target:ComputeRunArguments -property:TargetFramework={TargetFramework} -property:RuntimeIdentifier={WasmRuntime.BrowserRid} -property:DotnetTestInvocation=true -property:DotnetTestHttpBootstrapVersion=1 -property:DotnetTestInvocationId={InvocationIdA}", + 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 firstQuery = await firstQueryTask; - DotnetMuxerResult secondQuery = await secondQueryTask; - Assert.AreEqual(0, firstQuery.ExitCode, firstQuery.ToString()); - Assert.AreEqual(0, secondQuery.ExitCode, secondQuery.ToString()); - - string intermediateDirectory = Path.Combine( - generator.TargetAssetPath, - "obj", - "Debug", - TargetFramework, - WasmRuntime.BrowserRid); - string firstConfiguration = Path.Combine( - intermediateDirectory, - $"Microsoft.Testing.Platform.Browser.{InvocationId9}.launch"); - string secondConfiguration = Path.Combine( - intermediateDirectory, - $"Microsoft.Testing.Platform.Browser.{InvocationIdA}.launch"); - Assert.IsTrue(File.Exists(firstConfiguration)); - Assert.IsTrue(File.Exists(secondConfiguration)); - - string firstArguments = ReadEncodedLaunchConfigurationValue(firstConfiguration, "host-arguments-uri"); - string secondArguments = ReadEncodedLaunchConfigurationValue(secondConfiguration, "host-arguments-uri"); - Assert.Contains(InvocationId9, firstArguments); - Assert.DoesNotContain(InvocationIdA, firstArguments); - Assert.Contains(InvocationIdA, secondArguments); - Assert.DoesNotContain(InvocationId9, secondArguments); - - DotnetMuxerResult clean = await DotnetCli.RunAsync( - $"clean {generator.TargetAssetPath} --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid}", + DotnetMuxerResult marked = await DotnetCli.RunAsync( + commonArguments + " -property:DotnetTestInvocation=true", warnAsError: false, failIfReturnValueIsNotZero: false, useMultithreadedMSBuild: false, cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, clean.ExitCode, clean.ToString()); - Assert.IsEmpty( - Directory.EnumerateFiles( - intermediateDirectory, - "Microsoft.Testing.Platform.Browser.*.launch", - SearchOption.TopDirectoryOnly)); + 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] @@ -737,7 +246,7 @@ public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() 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.Contains("buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets", entries); + Assert.DoesNotContain("buildMultiTargeting/Microsoft.Testing.Platform.Browser.After.targets", entries); ZipArchiveEntry browserMainEntry = archive.GetEntry( "buildMultiTargeting/assets/Microsoft.Testing.Platform.Browser.main.js") @@ -745,9 +254,11 @@ public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() using Stream browserMainStream = browserMainEntry.Open(); using var browserMainReader = new StreamReader(browserMainStream); string browserMain = browserMainReader.ReadToEnd(); - Assert.Contains("testingPlatformBrowser", browserMain); - Assert.DoesNotContain("__mtpBrowserArguments", browserMain); - Assert.DoesNotContain("__mtpBrowserResult", browserMain); + 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") @@ -762,7 +273,6 @@ public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() static entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal)); using Stream nuspecStream = nuspecEntry.Open(); var nuspec = XDocument.Load(nuspecStream); - string[] packageDependencies = [ .. nuspec.Descendants() @@ -800,6 +310,19 @@ public async Task BrowserPackage_PackNoBuildPreservesRuntimePayload() Assert.IsNotNull(archive.GetEntry("tools/net8.0/any/.playwright/package/cli.js")); } + 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 static string GetBrowserPackageVersion() { string fileName = Path.GetFileName(GetBrowserPackagePath()); @@ -861,11 +384,4 @@ private static string EscapeMsBuildValue(string value) => value.Replace("&", "&", StringComparison.Ordinal) .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal); - - private static string ReadEncodedLaunchConfigurationValue(string path, string name) - { - string prefix = name + "="; - string line = File.ReadLines(path).Single(line => line.StartsWith(prefix, StringComparison.Ordinal)); - return Uri.UnescapeDataString(line[prefix.Length..]); - } } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index 2ed654f58e..ec82dc477e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -1,11 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -#if !NETFRAMEWORK - -using System.Security.AccessControl; -using System.Security.Principal; - +#if NET using Microsoft.Testing.Platform.Browser; namespace Microsoft.Testing.Extensions.UnitTests; @@ -13,580 +9,190 @@ namespace Microsoft.Testing.Extensions.UnitTests; [TestClass] public sealed class BrowserLauncherOptionsTests { + private const string BootstrapToken = "secret-token"; + [TestMethod] - public void Parse_ExpandsSdkResponseFileAndPreservesArguments() + public void Parse_ReadsDirectLauncherOptionsAndSdkResponseFile() { string responseFile = CreateResponseFile( """ --server dotnettestcli --dotnet-test-transport http - --dotnet-test-http-endpoint http://127.0.0.1:1234/dotnettest/run/ - --dotnet-test-http-token abcdef0123456789 + --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-base64", Encode("dotnet"), - "--host-arguments-base64", Encode("run --project \"path with spaces.csproj\""), - "--host-working-directory-base64", Encode(Path.GetTempPath()), - "--url-path-base64", Encode("/tests"), - "--browser-executable-base64", Encode(string.Empty), - "--browser-arguments-base64", Encode("--disable-gpu \"--custom=value with spaces\""), + "--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", "120", + "--completion-timeout-seconds", "90", "--", - "--list-tests", $"@{responseFile}", ]); Assert.AreEqual("dotnet", options.HostCommand); - Assert.AreSequenceEqual( - new[] { "run", "--project", "path with spaces.csproj" }, - options.HostArguments.ToArray()); - Assert.AreSequenceEqual( - new[] { "--disable-gpu", "--custom=value with spaces" }, - options.BrowserArguments.ToArray()); - Assert.Contains("--list-tests", options.TestApplicationArguments); - Assert.Contains("abcdef0123456789", options.TestApplicationArguments); - Assert.AreEqual("http://127.0.0.1:1234/dotnettest/run/", options.Bootstrap.Endpoint.AbsoluteUri); - Assert.AreEqual("abcdef0123456789", options.Bootstrap.Token); - } - finally - { - File.Delete(responseFile); - } - } - - [TestMethod] - public void Parse_RejectsNonLoopbackHttpBootstrap() - { - string[] arguments = - [ - "--host-command-base64", Encode("dotnet"), - "--host-arguments-base64", Encode("run"), - "--host-working-directory-base64", Encode(Path.GetTempPath()), - "--url-path-base64", Encode("/"), - "--browser-executable-base64", Encode(string.Empty), - "--browser-arguments-base64", Encode(string.Empty), - "--startup-timeout-seconds", "30", - "--completion-timeout-seconds", "120", - "--", - "--server", "dotnettestcli", - "--dotnet-test-transport", "http", - "--dotnet-test-http-endpoint", "http://example.com/dotnettest/run/", - "--dotnet-test-http-token", "secret", - ]; - - BrowserLauncherException exception = Assert.ThrowsExactly( - () => BrowserLauncherOptions.Parse(arguments)); - - Assert.Contains("valid loopback authenticated HTTP", exception.Message); - } - - [TestMethod] - public void Parse_AcceptsLoopbackHttpsIpv6Bootstrap() - { - string[] arguments = - [ - "--host-command-base64", Encode("dotnet"), - "--host-arguments-base64", Encode("run"), - "--host-working-directory-base64", Encode(Path.GetTempPath()), - "--url-path-base64", Encode("/"), - "--browser-executable-base64", Encode(string.Empty), - "--browser-arguments-base64", Encode(string.Empty), - "--startup-timeout-seconds", "30", - "--completion-timeout-seconds", "120", - "--", - "--server", "dotnettestcli", - "--dotnet-test-transport", "http", - "--dotnet-test-http-endpoint", "https://[::1]:1234/dotnettest/run/", - "--dotnet-test-http-token", "secret-token", - ]; - - var options = BrowserLauncherOptions.Parse(arguments); - - Assert.AreEqual("https://[::1]:1234/dotnettest/run/", options.Bootstrap.Endpoint.AbsoluteUri); - } - - [TestMethod] - public void ResolveBrowserUri_RejectsProtocolRelativePath() - { - BrowserLauncherException exception = Assert.ThrowsExactly( - () => BrowserLauncherOptions.ResolveBrowserUri( - new Uri("http://127.0.0.1:1234/"), - "//example.com/tests")); - - Assert.Contains("outside the browser host origin", exception.Message); - } - - [TestMethod] - public void ResolveBrowserUri_PreservesLoopbackOrigin() - { - Uri uri = BrowserLauncherOptions.ResolveBrowserUri( - new Uri("http://127.0.0.1:1234/root/"), - "/tests/index.html"); - - Assert.AreEqual("http://127.0.0.1:1234/tests/index.html", uri.AbsoluteUri); - } - - [TestMethod] - public void Parse_ReadsMsBuildLauncherConfiguration() - { - string configurationFile = Path.GetTempFileName(); - try - { - File.WriteAllLines( - configurationFile, - [ - $"host-command-uri={Uri.EscapeDataString("node")}", - $"host-arguments-uri={Uri.EscapeDataString("server.mjs \"path with spaces\"")}", - $"host-working-directory-uri={Uri.EscapeDataString(Path.GetTempPath())}", - $"url-path-uri={Uri.EscapeDataString("/tests")}", - "browser-executable-uri=", - $"browser-arguments-uri={Uri.EscapeDataString("--disable-gpu")}", - "startup-timeout-seconds=30", - "completion-timeout-seconds=120", - ]); - - var options = BrowserLauncherOptions.Parse( - [ - "--config", configurationFile, - "--", - "--server", "dotnettestcli", - "--dotnet-test-transport", "http", - "--dotnet-test-http-endpoint", "http://127.0.0.1:1234/dotnettest/run/", - "--dotnet-test-http-token", "secret", - ]); - - Assert.AreEqual("node", options.HostCommand); - Assert.AreSequenceEqual(["server.mjs", "path with spaces"], options.HostArguments); - Assert.AreEqual("/tests", options.UrlPath); - Assert.AreSequenceEqual(["--disable-gpu"], options.BrowserArguments); + 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(120), options.CompletionTimeout); + 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(configurationFile); - } - } - - [TestMethod] - public void Parse_ReadsEncodedMultilineMsBuildLauncherConfiguration() - { - const string hostArguments = "server.mjs \"line1\r\nline2\" \"\" --special \"%25;#'\""; - const string browserArguments = "--note \"line1\r\nline2;%#'\""; - string configurationFile = Path.GetTempFileName(); - try - { - File.WriteAllLines( - configurationFile, - [ - $"host-command-uri={Uri.EscapeDataString("node")}", - $"host-arguments-uri={Uri.EscapeDataString(hostArguments)}", - $"host-working-directory-uri={Uri.EscapeDataString(Path.GetTempPath())}", - $"url-path-uri={Uri.EscapeDataString("/tests?value=%25;#'")}", - "browser-executable-uri=", - $"browser-arguments-uri={Uri.EscapeDataString(browserArguments)}", - "startup-timeout-seconds=30", - "completion-timeout-seconds=120", - ]); - - var options = BrowserLauncherOptions.Parse( - [ - "--config", configurationFile, - "--", - "--server", "dotnettestcli", - "--dotnet-test-transport", "http", - "--dotnet-test-http-endpoint", "http://127.0.0.1:1234/dotnettest/run/", - "--dotnet-test-http-token", "secret", - ]); - - Assert.AreSequenceEqual( - new[] { "server.mjs", "line1\r\nline2", string.Empty, "--special", "%25;#'" }, - options.HostArguments); - Assert.AreSequenceEqual( - new[] { "--note", "line1\r\nline2;%#'" }, - options.BrowserArguments); - Assert.AreEqual("/tests?value=%25;#'", options.UrlPath); - } - finally - { - File.Delete(configurationFile); - } - } - - [TestMethod] - public void CommandLineTokenizer_RoundTripsQuotedHostArguments() - { - string[] arguments = CommandLineTokenizer.Split( - "run --project \"path with spaces.csproj\" --property \"Name=quoted \\\"value\\\"\""); - - Assert.AreSequenceEqual( - new[] { "run", "--project", "path with spaces.csproj", "--property", "Name=quoted \"value\"" }, - arguments); - } - - [TestMethod] - public void CommandLineTokenizer_PreservesQuotedEmptyArguments() - { - string[] arguments = CommandLineTokenizer.Split( - "host.dll \"\" --name \"quoted value\" \"\" tail"); - - Assert.AreSequenceEqual( - new[] { "host.dll", string.Empty, "--name", "quoted value", string.Empty, "tail" }, - arguments); - } - - [TestMethod] - public void CommandLineTokenizer_PreservesDoubledQuotesInsideQuotedArgument() - { - string[] arguments = CommandLineTokenizer.Split("\"a\"\"b\" \"\"\"quoted\"\"\""); - - Assert.AreSequenceEqual(new[] { "a\"b", "\"quoted\"" }, arguments); - } - - [TestMethod] - public async Task BrowserRunMonitor_CancellationStopsCompletionWaitPromptly() - { - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); - var stopwatch = Stopwatch.StartNew(); - - await Assert.ThrowsAsync(() => BrowserRunMonitor.WaitAsync( - async token => - { - await Task.Delay(TimeSpan.FromMinutes(10), token); - return 0; - }, - token => Task.Delay(TimeSpan.FromMinutes(10), token), - token => Task.Delay(TimeSpan.FromMinutes(10), token), - cancellationTokenSource.Token)); - - stopwatch.Stop(); - Assert.IsLessThan( - TimeSpan.FromSeconds(5), - stopwatch.Elapsed, - "Run cancellation should enter launcher cleanup instead of waiting for the configured completion timeout."); - } - - [TestMethod] - public void DiagnosticBuffer_RedactsBootstrapSecretsAndBoundsEntries() - { - var diagnostics = new DiagnosticBuffer("secret-token", "/dotnettest/private/"); - for (int i = 0; i < 210; i++) - { - diagnostics.Add("browser", $"{i}: secret-token /dotnettest/private/"); + File.Delete(responseFile); + File.Delete(browserExecutable); } - - string output = diagnostics.Format(); - - Assert.DoesNotContain("secret-token", output); - Assert.DoesNotContain("/dotnettest/private/", output); - Assert.DoesNotContain("[browser] 0:", output); - Assert.Contains("[browser] 209:", output); - Assert.HasCount(200, output.Split(Environment.NewLine)); } [TestMethod] - [DataRow("--remote-debugging-port=9222")] - [DataRow("-remote-debugging-port=9222")] - [DataRow("/remote-debugging-port=9222")] - [DataRow("--remote-debugging-address=0.0.0.0")] - [DataRow("-remote-debugging-address=0.0.0.0")] - [DataRow("--remote-debugging-pipe")] - [DataRow("--remote-allow-origins=*")] - [DataRow("--profile-directory=Default")] - [DataRow("--user-data-dir=shared")] - public void Parse_RejectsBrowserArgumentsThatOverrideLauncherSecurity(string browserArgument) + public void Parse_PreservesEncodedNewlinesAndSpecialCharacters() { string responseFile = CreateResponseFile( """ --server dotnettestcli --dotnet-test-transport http - --dotnet-test-http-endpoint http://127.0.0.1:1234/dotnettest/run/ - --dotnet-test-http-token abcdef0123456789 + --dotnet-test-http-endpoint http://[::1]:1234/ + --dotnet-test-http-token secret-token """); + string browserExecutable = CreateEmptyFile(); + const string hostArguments = "line1\r\nline2;%#'\""; try { - Assert.ThrowsExactly(() => BrowserLauncherOptions.Parse( + var options = BrowserLauncherOptions.Parse( [ - "--host-command-base64", Encode("dotnet"), - "--host-arguments-base64", Encode("host.dll"), - "--host-working-directory-base64", Encode(Path.GetTempPath()), - "--url-path-base64", Encode("/"), - "--browser-executable-base64", Encode(string.Empty), - "--browser-arguments-base64", Encode(browserArgument), - "--startup-timeout-seconds", "30", - "--completion-timeout-seconds", "120", + "--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, - ])); + $"@{responseFile}", + ]); + + Assert.AreEqual(hostArguments, options.HostArguments); } finally { File.Delete(responseFile); + File.Delete(browserExecutable); } } [TestMethod] - [DataRow("--config-file", null)] - [DataRow("--config-file=config.json", null)] - [DataRow("--config-file:config.json", null)] - [DataRow("--config-file", "config.json")] - [DataRow("-config-file", "config.json")] - [DataRow("--diagnostic", null)] - [DataRow("--diagnostic-output-directory", "diagnostics")] - [DataRow("--results-directory", "results")] - [DataRow("--settings", "settings.runsettings")] - [DataRow("--report-trx", null)] - [DataRow("--report-trx-filename", "results.trx")] - [DataRow("--report-html", null)] - [DataRow("--report-junit", null)] - [DataRow("--report-ctrf", null)] - [DataRow("--coverage", null)] - [DataRow("--coverage-output", "coverage.xml")] - [DataRow("-results-directory:results", null)] - public void Parse_RejectsBrowserInapplicableFileOptions(string option, string? value) - { - var testApplicationArguments = new List { option }; - if (value is not null) - { - testApplicationArguments.Add(value); - } - - testApplicationArguments.AddRange(CreateBootstrapArguments()); - - BrowserLauncherException exception = Assert.ThrowsExactly( - () => BrowserLauncherOptions.Parse(CreateLauncherArguments(testApplicationArguments))); - - Assert.Contains("not supported by the browser launcher preview", exception.Message); - } - - [TestMethod] - public void Parse_AllowsFiltersHelpAndListTests() - { - string[] testApplicationArguments = - [ - "--help", - "--list-tests", - "--filter", - "FullyQualifiedName~MyTests", - .. CreateBootstrapArguments(), - ]; - - var options = BrowserLauncherOptions.Parse( - CreateLauncherArguments(testApplicationArguments)); - - Assert.Contains("--help", options.TestApplicationArguments); - Assert.Contains("--list-tests", options.TestApplicationArguments); - Assert.Contains("--filter", options.TestApplicationArguments); - } + [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 DiagnosticBuffer_DoesNotTreatShortUrlSegmentsAsSecrets() + public void ResponseFileExpander_DoesNotLogSecretsInErrors() { - var diagnostics = new DiagnosticBuffer("/"); + string missingFile = Path.Combine( + Path.GetTempPath(), + $"missing-{BootstrapToken}-{Guid.NewGuid():N}.rsp"); - diagnostics.Add("browser", "https://localhost/path"); + BrowserLauncherException exception = Assert.ThrowsExactly( + () => SdkResponseFileExpander.Expand([$"@{missingFile}"])); - Assert.Contains("https://localhost/path", diagnostics.Format()); + Assert.DoesNotContain(BootstrapToken, exception.Message); + Assert.AreEqual("Unable to read the SDK response file.", exception.Message); } [TestMethod] - public void BrowserExecutableLocator_PrefersConfiguredExecutable() + public void HostProcess_ParsesOnlyExactHttpAppUrlReadiness() { - string executable = Path.GetTempFileName(); - try - { - Assert.AreEqual(Path.GetFullPath(executable), BrowserExecutableLocator.Locate(executable)); - } - finally - { - File.Delete(executable); - } + 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 BrowserUnitTests_DoNotReferencePlaywrightRuntime() + public void DiagnosticBuffer_RedactsBootstrapValuesAndBoundsOutput() { - string dependencyContextPath = Path.ChangeExtension( - typeof(BrowserLauncherOptionsTests).Assembly.Location, - ".deps.json"); - using FileStream stream = File.OpenRead(dependencyContextPath); - using var dependencyContext = System.Text.Json.JsonDocument.Parse(stream); + var diagnostics = new DiagnosticBuffer( + BootstrapToken, + "http://127.0.0.1:1234/"); - string[] dependencies = - [ - .. dependencyContext.RootElement.GetProperty("libraries").EnumerateObject() - .Select(static library => library.Name), - ]; - Assert.IsNull( - dependencies.FirstOrDefault( - static dependency => dependency.StartsWith("Microsoft.Playwright/", StringComparison.Ordinal)), - "Browser helper tests must source-link the BCL-only files instead of copying the cross-platform Playwright payload."); - } + diagnostics.Add("one", $"token={BootstrapToken}"); + diagnostics.Add("two", "http://127.0.0.1:1234/"); + diagnostics.Add("three", new string('x', 5_000)); + string output = diagnostics.Format(); - [TestMethod] - public void TryParseListeningUri_ParsesLoopbackAndIgnoresOtherOutput() - { - Assert.IsNull(HostProcess.TryParseListeningUri("Application started.")); - Assert.IsNull(HostProcess.TryParseListeningUri("Debug at url: http://127.0.0.1:9876/")); - Assert.IsNull(HostProcess.TryParseListeningUri("App url: https://127.0.0.1:4322/")); - Assert.AreEqual( - "http://127.0.0.1:4321/", - HostProcess.TryParseListeningUri("Now listening on: http://127.0.0.1:4321/")?.AbsoluteUri); - Assert.AreEqual( - "http://127.0.0.1:4322/", - HostProcess.TryParseListeningUri("App url: http://127.0.0.1:4322/")?.AbsoluteUri); + Assert.DoesNotContain(BootstrapToken, output); + Assert.DoesNotContain("http://127.0.0.1:1234/", output); + Assert.Contains("[redacted]", output); + Assert.IsLessThan(4_500, output.Length); } [TestMethod] - public void TryParseListeningUri_RejectsNonLoopbackHost() - => Assert.ThrowsExactly( - () => HostProcess.TryParseListeningUri("Now listening on: http://example.com/")); - - [TestMethod] - public void TryReadLaunchInfo_PreservesValidationAndAbsenceBehavior() + public async Task BrowserRunMonitor_CancellationStopsCompletionWaitPromptly() { - string directory = Path.Combine(Path.GetTempPath(), $"mtp-browser-launch-info-test-{Guid.NewGuid():N}"); - string path = Path.Combine(directory, "launch-info.json"); - try - { - Assert.IsNull(HostProcess.TryReadLaunchInfo(path)); - - Directory.CreateDirectory(directory); - WriteLaunchInfo(path, """{"version":1,"url":"https://[::1]:1234/"}"""); - Assert.AreEqual("https://[::1]:1234/", HostProcess.TryReadLaunchInfo(path)?.AbsoluteUri); + using var cancellationTokenSource = new CancellationTokenSource( + TimeSpan.FromMilliseconds(100)); + var stopwatch = Stopwatch.StartNew(); - WriteLaunchInfo(path, """{"version":2,"url":"http://127.0.0.1:1234/"}"""); - Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(path)); + await Assert.ThrowsExactlyAsync( + () => BrowserRunMonitor.WaitAsync( + WaitForCompletionAsync, + WaitForExitAsync, + WaitForExitAsync, + cancellationTokenSource.Token)); - WriteLaunchInfo(path, """{"version":1,"url":"http://example.com/"}"""); - Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(path)); + Assert.IsLessThan(TimeSpan.FromSeconds(5), stopwatch.Elapsed); - WriteLaunchInfo(path, "{"); - Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(path)); - } - finally + static async Task WaitForCompletionAsync(CancellationToken cancellationToken) { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; } - } - - [TestMethod] - [OSCondition(ConditionMode.Include, OperatingSystems.Windows, IgnoreMessage = "Validates the Windows launch-info directory DACL.")] - [SupportedOSPlatform("windows")] - public void CreateLaunchInfoDirectory_IsPrivateOnWindows() - { - string directory = HostProcess.CreateLaunchInfoDirectory(); - try - { - DirectorySecurity security = new DirectoryInfo(directory).GetAccessControl(); - SecurityIdentifier currentUser = WindowsIdentity.GetCurrent().User - ?? throw new AssertFailedException("The current Windows SID is unavailable."); - Assert.IsTrue(security.AreAccessRulesProtected); - Assert.AreEqual( - currentUser, - security.GetOwner(typeof(SecurityIdentifier))); - AuthorizationRuleCollection rules = security.GetAccessRules( - includeExplicit: true, - includeInherited: true, - typeof(SecurityIdentifier)); - Assert.IsTrue( - rules.Cast().All(rule => - rule.IdentityReference.Equals(currentUser) - && rule.AccessControlType == AccessControlType.Allow)); - } - finally - { - Directory.Delete(directory, recursive: true); - } + static Task WaitForExitAsync(CancellationToken cancellationToken) + => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } - [TestMethod] - [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows, IgnoreMessage = "Validates Unix launch-info directory permissions.")] - [UnsupportedOSPlatform("windows")] - public void CreateLaunchInfoDirectory_IsPrivateOnUnix() + private static string[] SplitPair(string value) { - string directory = HostProcess.CreateLaunchInfoDirectory(); - try - { - Assert.AreEqual( - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, - File.GetUnixFileMode(directory)); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - - [TestMethod] - [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows, IgnoreMessage = "Validates Unix file modes and symbolic-link rejection.")] - [UnsupportedOSPlatform("windows")] - public void TryReadLaunchInfo_RejectsSymlinkAndPermissiveUnixFile() - { - string directory = Path.Combine(Path.GetTempPath(), $"mtp-browser-launch-info-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(directory); - string target = Path.Combine(directory, "target.json"); - string link = Path.Combine(directory, "link.json"); - try - { - WriteLaunchInfo(target, """{"version":1,"url":"http://127.0.0.1:1234/"}"""); - File.SetUnixFileMode( - target, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead); - Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(target)); - - File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite); - try - { - File.CreateSymbolicLink(link, target); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) - { - Assert.Inconclusive($"Symbolic links are unavailable: {ex.Message}"); - return; - } - - Assert.ThrowsExactly(() => HostProcess.TryReadLaunchInfo(link)); - } - finally - { - Directory.Delete(directory, recursive: true); - } + int separator = value.IndexOf(' '); + return [value[..separator], value[(separator + 1)..]]; } - [TestMethod] - [DataRow("linux", Architecture.X64, "linux-x64")] - [DataRow("linux", Architecture.Arm64, "linux-arm64")] - [DataRow("osx", Architecture.X64, "darwin-x64")] - [DataRow("osx", Architecture.Arm64, "darwin-arm64")] - public void GetPlaywrightNodeExecutablePath_MapsSupportedPlatforms( - string operatingSystem, - Architecture architecture, - string platformDirectory) - { - OSPlatform osPlatform = operatingSystem == "linux" ? OSPlatform.Linux : OSPlatform.OSX; - - string path = PlaywrightNodeExecutable.GetPath("root", osPlatform, architecture); - - Assert.AreEqual( - Path.Combine("root", ".playwright", "node", platformDirectory, "node"), - path); - } + private static string Encode(string value) => Uri.EscapeDataString(value); private static string CreateResponseFile(string content) { - string path = Path.Combine(Path.GetTempPath(), $"dotnet-test-http-{Guid.NewGuid():N}.rsp"); - File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + 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); @@ -595,41 +201,11 @@ private static string CreateResponseFile(string content) return path; } - private static string Encode(string value) - => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); - - private static string[] CreateLauncherArguments(IReadOnlyList testApplicationArguments) - => - [ - "--host-command-base64", Encode("dotnet"), - "--host-arguments-base64", Encode("host.dll"), - "--host-working-directory-base64", Encode(Path.GetTempPath()), - "--url-path-base64", Encode("/"), - "--browser-executable-base64", Encode(string.Empty), - "--browser-arguments-base64", Encode(string.Empty), - "--startup-timeout-seconds", "30", - "--completion-timeout-seconds", "120", - "--", - .. testApplicationArguments, - ]; - - private static string[] CreateBootstrapArguments() - => - [ - "--server", "dotnettestcli", - "--dotnet-test-transport", "http", - "--dotnet-test-http-endpoint", "http://127.0.0.1:1234/dotnettest/run/", - "--dotnet-test-http-token", "secret-token", - ]; - - private static void WriteLaunchInfo(string path, string content) + private static string CreateEmptyFile() { - File.WriteAllText(path, content); - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); - } + string path = Path.Combine(Path.GetTempPath(), $"mtp-browser-{Guid.NewGuid():N}.exe"); + File.WriteAllText(path, string.Empty); + return path; } } - #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 54e7ff340b..506532a6eb 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 @@ -70,7 +70,6 @@ - From d0eefc044d67c0e9bd4d262d2351b85cdbf22a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 16 Sep 2026 19:14:08 +0200 Subject: [PATCH 13/16] Fix browser host static asset ownership Treat the package-generated host page as a consuming-project build-only static web asset, cover build and publish manifests, and document the minimum ComputeRunArguments ordering limitation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PACKAGE.md | 8 ++ ...Microsoft.Testing.Platform.Browser.targets | 2 +- .../BrowserPackageExecutionTests.cs | 109 ++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index de83dd2f51..e306046a7a 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -66,6 +66,14 @@ package supplies its page only when the project has not already selected a 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, 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 index 20c15827bb..87bb76e0c7 100644 --- 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 @@ -34,7 +34,7 @@ false false $(NoWarn);NETSDK1201 + BrowserPackageConsumer $Browser$ 60 @@ -38,6 +40,22 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase + + + + + + + +
#file BrowserPackageTests.cs @@ -208,6 +226,68 @@ public async Task BrowserPackage_WrapsOnlyMarkedBrowserComputeRunArguments() Assert.DoesNotContain(".launch", marked.StandardOutput); } + [TestMethod] + public async Task BrowserPackage_HostAssetsAreBuildOnlyConsumerAssets() + { + string browserPackageVersion = GetBrowserPackageVersion(); + using TestAsset generator = await TestAsset.GenerateAssetAsync( + "BrowserPackageStaticWebAssetsProject", + SourceCode + .PatchCodeWithReplace("$TargetFramework$", TargetFramework) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) + .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) + .PatchCodeWithReplace("$Browser$", "browser-placeholder")); + + DotnetMuxerResult build = await DotnetCli.RunAsync( + $"build {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid} -property:TestingPlatformBrowserRecordStaticWebAssets=true", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, build.ExitCode, build.ToString()); + + using var buildManifest = JsonDocument.Parse( + File.ReadAllText(ReadRecordedPath( + generator.TargetAssetPath, + "browser-build-manifest-path.txt"))); + Assert.IsTrue(ContainsBrowserHostAsset(buildManifest, "index.html", "Build")); + Assert.IsTrue(ContainsBrowserHostAsset( + buildManifest, + "Microsoft.Testing.Platform.Browser.main.js", + "Build")); + + string publishDirectory = Path.Combine(generator.TargetAssetPath, "publish"); + DotnetMuxerResult publish = await DotnetCli.RunAsync( + $"publish {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid} --output {publishDirectory} -property:TestingPlatformBrowserRecordStaticWebAssets=true", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, publish.ExitCode, publish.ToString()); + + using var publishManifest = JsonDocument.Parse( + File.ReadAllText(ReadRecordedPath( + generator.TargetAssetPath, + "browser-publish-manifest-path.txt"))); + Assert.IsFalse(ContainsBrowserHostAsset(publishManifest, "index.html")); + Assert.IsFalse(ContainsBrowserHostAsset( + publishManifest, + "Microsoft.Testing.Platform.Browser.main.js")); + Assert.IsEmpty(Directory.EnumerateFiles( + publishDirectory, + "Microsoft.Testing.Platform.Browser.main.js", + SearchOption.AllDirectories)); + foreach (string index in Directory.EnumerateFiles( + publishDirectory, + "index.html", + SearchOption.AllDirectories)) + { + Assert.DoesNotContain( + "Microsoft.Testing.Platform.Browser.main.js", + File.ReadAllText(index)); + } + } + [TestMethod] public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() { @@ -248,6 +328,17 @@ public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() 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."); @@ -384,4 +475,22 @@ 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() == "BrowserPackageConsumer" + && asset.GetProperty("RelativePath").GetString() == relativePath + && (assetKind is null + || asset.GetProperty("AssetKind").GetString() == assetKind)); + + private static string ReadRecordedPath(string projectDirectory, string recordFile) + { + string path = File.ReadAllText(Path.Combine(projectDirectory, recordFile)).Trim(); + return Path.IsPathRooted(path) + ? path + : Path.GetFullPath(path, projectDirectory); + } } From e6d8e4e0ccbdacb56db71313fcff328c73182b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 16 Sep 2026 19:49:32 +0200 Subject: [PATCH 14/16] Remove redundant browser fixture property Rely on EnableMSTestRunner to mark generated browser and desktop fixtures as testing platform applications, with evaluated-property acceptance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserPackageExecutionTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 8a93c8f364..617ba8f1a4 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -23,7 +23,6 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase$TargetFramework$ Exe true - true enable false false @@ -90,7 +89,6 @@ public void FailsInsideBrowser() $TargetFramework$ Exe true - true enable @@ -152,6 +150,9 @@ public async Task BrowserPackage_DotnetTestRunsAndListsTestsThroughSdkHttpGatewa 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; @@ -309,6 +310,9 @@ public async Task BrowserPackage_DesktopTestApplicationIsUnaffected() 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] @@ -414,6 +418,21 @@ private async Task RunBrowserTestAsync( 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()); From 220f67563645dd5ca4438319e6094607d52dca23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 17 Sep 2026 10:39:34 +0200 Subject: [PATCH 15/16] Address browser package review feedback Report fatal supervisor diagnostics, preserve empty host arguments, respect the active package configuration, redact short tokens, and make expected validation and cancellation handling explicit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BrowserTerminalResult.cs | 19 +++++++ .../ChromiumBrowser.cs | 28 +++++----- .../DiagnosticBuffer.cs | 2 +- .../HostProcess.cs | 2 + ...Microsoft.Testing.Platform.Browser.targets | 2 +- .../BrowserPackageExecutionTests.cs | 32 ++++++++++- .../BrowserLauncherOptionsTests.cs | 53 ++++++++++++++++++- ...rosoft.Testing.Extensions.UnitTests.csproj | 1 + 8 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs 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..2b78495426 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs @@ -0,0 +1,19 @@ +// 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."); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs index 50621a091a..036afef1d0 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -171,12 +171,7 @@ public async Task WaitForCompletionAsync( { BrowserTerminalResult result = await _completion.Task .WaitAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false); - if (result.Error is not null) - { - _diagnostics.Add("browser", result.Error); - } - - return result.ExitCode; + return result.GetExitCode(_diagnostics); } catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) { @@ -226,16 +221,19 @@ private static void ValidateBindingSource( BindingSource source, IPage expectedPage, string expectedOrigin) - => _ = ReferenceEquals(source.Page, expectedPage) - && source.Frame.ParentFrame is null - && Uri.TryCreate(source.Frame.Url, UriKind.Absolute, out Uri? sourceUri) - && string.Equals( + { + 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) - ? true - : throw new BrowserLauncherException( - "The browser supervisor binding was called outside the expected top-level loopback origin."); + 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) { @@ -366,6 +364,4 @@ private static async Task DisposeBindingAsync( return null; } } - - private sealed record BrowserTerminalResult(int ExitCode, string? Error); } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs index 9b37f8c894..d53a8874e3 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs @@ -20,7 +20,7 @@ public DiagnosticBuffer(params string?[] secrets) => _secrets = [ .. secrets - .Where(static value => value is { Length: >= 8 }) + .Where(static value => !string.IsNullOrEmpty(value)) .Cast(), ]; diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs index 64764f30b2..a3fb350295 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/HostProcess.cs @@ -191,6 +191,8 @@ private async Task CaptureAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + // Cancellation is expected while shutting down the host. + return; } } } 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 index 87bb76e0c7..d3b2da74ca 100644 --- 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 @@ -62,7 +62,7 @@ $(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) -- + 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/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 617ba8f1a4..240f627066 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -55,6 +55,16 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase + + + browser-host-placeholder + + $(MSBuildProjectDirectory) + + +
#file BrowserPackageTests.cs @@ -225,6 +235,26 @@ public async Task BrowserPackage_WrapsOnlyMarkedBrowserComputeRunArguments() Assert.Contains("--host-command-uri", marked.StandardOutput); Assert.Contains("--host-arguments-uri", marked.StandardOutput); Assert.DoesNotContain(".launch", marked.StandardOutput); + + DotnetMuxerResult emptyHostArguments = await DotnetCli.RunAsync( + commonArguments + + " -property:DotnetTestInvocation=true" + + " -property:TestingPlatformBrowserEmptyHostArguments=true", + warnAsError: false, + failIfReturnValueIsNotZero: false, + useMultithreadedMSBuild: false, + cancellationToken: TestContext.CancellationToken); + Assert.AreEqual(0, emptyHostArguments.ExitCode, emptyHostArguments.ToString()); + using var emptyHostArgumentsOutput = JsonDocument.Parse( + emptyHostArguments.StandardOutput); + string emptyHostRunArguments = emptyHostArgumentsOutput.RootElement + .GetProperty("Properties") + .GetProperty("RunArguments") + .GetString() + ?? throw new AssertFailedException("ComputeRunArguments returned a null RunArguments value."); + Assert.Contains( + "--host-arguments-uri \"\"", + emptyHostRunArguments); } [TestMethod] @@ -390,7 +420,7 @@ public async Task BrowserPackage_PackNoBuildPreservesRuntimePayload() 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 Debug --no-build --no-restore -property:PackageOutputPath={packageOutput}", + $"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, diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs index ec82dc477e..299579379b 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -91,6 +91,41 @@ public void Parse_PreservesEncodedNewlinesAndSpecialCharacters() } } + [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")] @@ -142,19 +177,35 @@ public void DiagnosticBuffer_RedactsBootstrapValuesAndBoundsOutput() { var diagnostics = new DiagnosticBuffer( BootstrapToken, + "abc", "http://127.0.0.1:1234/"); diagnostics.Add("one", $"token={BootstrapToken}"); diagnostics.Add("two", "http://127.0.0.1:1234/"); - diagnostics.Add("three", new string('x', 5_000)); + 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 BrowserRunMonitor_CancellationStopsCompletionWaitPromptly() { 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 506532a6eb..2a92e7b03b 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 @@ -72,6 +72,7 @@ + From d3d54c37113fa9fee96c692c753249c2f8c2ba62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 17 Sep 2026 11:22:50 +0200 Subject: [PATCH 16/16] Harden minimal browser launcher boundaries Reserve package host assets, reject unsupported user response files, fault invalid browser bindings promptly, bound Playwright lifecycle calls, and keep the canonical acceptance consumer free of integration targets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BoundedResourceCleanup.cs | 63 ++++++ .../BrowserLauncherOptions.cs | 99 +++++---- .../BrowserTerminalResult.cs | 32 +++ .../ChromiumBrowser.cs | 53 +++-- .../DiagnosticBuffer.cs | 4 +- .../Microsoft.Testing.Platform.Browser.csproj | 4 - .../PACKAGE.md | 21 +- .../Program.cs | 5 +- ...Microsoft.Testing.Platform.Browser.targets | 18 +- ...Microsoft.Testing.Platform.Browser.main.js | 2 +- .../BrowserPackageExecutionTests.cs | 175 +++++++++------- .../BrowserLauncherOptionsTests.cs | 195 +++++++++++++++++- ...rosoft.Testing.Extensions.UnitTests.csproj | 2 + 13 files changed, 507 insertions(+), 166 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform.Browser/BoundedResourceCleanup.cs 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 index 6ef91941aa..20a85f3b91 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserLauncherOptions.cs @@ -159,59 +159,68 @@ internal static class SdkResponseFileExpander { public static string[] Expand(IReadOnlyList arguments) { - var expanded = new List(); - foreach (string argument in arguments) + if (arguments.Count == 0 + || !arguments[^1].StartsWith('@') + || arguments[^1].Length == 1) { - if (!argument.StartsWith('@')) + 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('@')) { - expanded.Add(argument); - continue; + 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)); - string path = Path.GetFullPath(argument[1..]); - try + while (reader.ReadLine() is { } line) { - 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] == '#') { - 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); - } + 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); - } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException) + { + throw new BrowserLauncherException( + "Unable to read the SDK response file.", + ex); } return [.. expanded]; diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs index 2b78495426..e58909ece8 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/BrowserTerminalResult.cs @@ -17,3 +17,35 @@ public int GetExitCode(DiagnosticBuffer diagnostics) "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 index 036afef1d0..de06a8e1c4 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/ChromiumBrowser.cs @@ -66,6 +66,7 @@ public static async Task LaunchAsync( IBrowserContext? context = null; IAsyncDisposable? getArgumentsBinding = null; IAsyncDisposable? terminalResultBinding = null; + Task? playwrightCreationTask = null; Task? browserLaunchTask = null; try @@ -73,7 +74,9 @@ public static async Task LaunchAsync( // Playwright's DEBUG channel logs can contain binding payloads with the SDK bearer token. Environment.SetEnvironmentVariable("DEBUG", null); PlaywrightNodeExecutable.EnsureExecutable(); - playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); + playwrightCreationTask = Microsoft.Playwright.Playwright.CreateAsync(); + playwright = await playwrightCreationTask + .WaitAsync(startupCancellationToken).ConfigureAwait(false); browserLaunchTask = playwright.Chromium.LaunchAsync( new BrowserTypeLaunchOptions @@ -96,18 +99,22 @@ public static async Task LaunchAsync( TaskCreationOptions.RunContinuationsAsynchronously); getArgumentsBinding = await page.ExposeBindingAsync( "__mtpBrowserGetArguments", - source => - { - ValidateBindingSource(source, page, expectedOrigin); - return [.. options.TestApplicationArguments]; - }).WaitAsync(startupCancellationToken).ConfigureAwait(false); + source => BrowserBindingCallback.Invoke( + completion, + () => + { + ValidateBindingSource(source, page, expectedOrigin); + return options.TestApplicationArguments.ToArray(); + })).WaitAsync(startupCancellationToken).ConfigureAwait(false); terminalResultBinding = await page.ExposeBindingAsync( "__mtpBrowserComplete", - (source, result) => - { - ValidateBindingSource(source, page, expectedOrigin); - Complete(result, completion); - }).WaitAsync(startupCancellationToken).ConfigureAwait(false); + (source, result) => BrowserBindingCallback.Invoke( + completion, + () => + { + ValidateBindingSource(source, page, expectedOrigin); + Complete(result, completion); + })).WaitAsync(startupCancellationToken).ConfigureAwait(false); var chromiumBrowser = new ChromiumBrowser( playwright, @@ -124,6 +131,16 @@ await chromiumBrowser.NavigateAsync(browserUri, startupCancellationToken) } 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) @@ -314,15 +331,13 @@ await DisposeBindingAsync(getArgumentsBinding, "get-arguments", diagnostics) } } - try - { - playwright?.Dispose(); - } - catch (Exception ex) + if (playwright is not null) { - diagnostics.Add( - "launcher cleanup", - $"Unable to dispose Playwright: {ex.Message}"); + await BoundedResourceCleanup.DisposeAsync( + playwright.Dispose, + CleanupTimeout, + message => diagnostics.Add("launcher cleanup", $"Playwright: {message}")) + .ConfigureAwait(false); } } diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs index d53a8874e3..0d54aa9bfb 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/DiagnosticBuffer.cs @@ -21,7 +21,9 @@ public DiagnosticBuffer(params string?[] secrets) [ .. secrets .Where(static value => !string.IsNullOrEmpty(value)) - .Cast(), + .Cast() + .Distinct(StringComparer.Ordinal) + .OrderByDescending(static value => value.Length), ]; public void Add(string source, string message) 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 index fdb3a3edd3..ad88baf8f0 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Microsoft.Testing.Platform.Browser.csproj @@ -19,10 +19,6 @@ $(CommonProductDescription)]]> - - - - diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md index e306046a7a..099e4a2d30 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Browser/PACKAGE.md @@ -33,8 +33,8 @@ 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 and private boot supervisor that dynamically imports -`_framework/dotnet.js`, obtains the MTP arguments through a private Playwright +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. @@ -44,7 +44,7 @@ results, or relay the MTP HTTP protocol. The package: - wraps the browser framework's existing `ComputeRunArguments` result only when - the SDK sets `DotnetTestInvocation=true`; + 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 @@ -60,8 +60,8 @@ The package: `TestingPlatformBrowserStartupTimeoutSeconds` defaults to 60 seconds and `TestingPlatformBrowserCompletionTimeoutSeconds` defaults to 600 seconds. The -package supplies its page only when the project has not already selected a -`WasmMainJSPath`; no public framework-page integration protocol is provided. +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. @@ -78,16 +78,23 @@ machinery. - 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. + 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 not included in the repository shipment layout. +- 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 diff --git a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs index 6398c08baf..f55c2a355c 100644 --- a/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs +++ b/src/Platform/Microsoft.Testing.Platform.Browser/Program.cs @@ -5,6 +5,8 @@ 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; @@ -31,9 +33,10 @@ public static async Task Main(string[] args) Uri hostUri = await host.WaitUntilReadyAsync( options.StartupTimeout, runCancellationTokenSource.Token).ConfigureAwait(false); + var browserUri = new Uri(hostUri, BrowserHostPath); ChromiumBrowser browser = await ChromiumBrowser.LaunchAsync( options, - hostUri, + browserUri, diagnostics, runCancellationTokenSource.Token).ConfigureAwait(false); try 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 index d3b2da74ca..2004f4de2c 100644 --- 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 @@ -2,33 +2,31 @@ true <_TestingPlatformBrowserAssetsDirectory>$(MSBuildThisFileDirectory)assets\ - <_TestingPlatformBrowserOwnsHostAssets Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' AND '$(WasmMainJSPath)' == '' ">true - $(_TestingPlatformBrowserAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js - $(_TestingPlatformBrowserAssetsDirectory)index.html + Condition=" '$(TestingPlatformBrowserEnabled)' == 'true' "> - <_TestingPlatformBrowserHostStaticWebAssetsDirectory>$(IntermediateOutputPath)testing-platform-browser\wwwroot\ + <_TestingPlatformBrowserHostStaticWebAssetsRoot>$(IntermediateOutputPath)testing-platform-browser\wwwroot\ + <_TestingPlatformBrowserHostStaticWebAssetsDirectory>$(_TestingPlatformBrowserHostStaticWebAssetsRoot)_mtp\ - <_TestingPlatformBrowserHostStaticWebAsset Include="$(_TestingPlatformBrowserHostStaticWebAssetsDirectory)index.html"> - index.html + <_TestingPlatformBrowserHostStaticWebAsset Include="$(_TestingPlatformBrowserHostStaticWebAssetsDirectory)browser-host.html"> + _mtp/browser-host.html <_TestingPlatformBrowserHostStaticWebAsset Include="$(_TestingPlatformBrowserHostStaticWebAssetsDirectory)Microsoft.Testing.Platform.Browser.main.js"> - Microsoft.Testing.Platform.Browser.main.js + _mtp/Microsoft.Testing.Platform.Browser.main.js @@ -36,7 +34,7 @@ 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 index 123ba889b2..20db52cb73 100644 --- 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 @@ -17,7 +17,7 @@ try { const argumentsFromLauncher = launcherAvailable ? await globalThis.__mtpBrowserGetArguments() : []; - const { dotnet } = await import('./_framework/dotnet.js'); + const { dotnet } = await import('../_framework/dotnet.js'); const { runMain } = await dotnet .withApplicationArguments(...argumentsFromLauncher) .create(); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs index 240f627066..c22cb4e7d1 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/BrowserPackageExecutionTests.cs @@ -27,7 +27,6 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBasefalse false $(NoWarn);NETSDK1201 - BrowserPackageConsumer $Browser$ 60 @@ -39,32 +38,6 @@ public sealed class BrowserPackageExecutionTests : AcceptanceTestBase - - - - - - - - - - - browser-host-placeholder - - $(MSBuildProjectDirectory) - - -
#file BrowserPackageTests.cs @@ -89,6 +62,16 @@ public void SkippedInsideBrowser() 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 = """ @@ -235,88 +218,81 @@ public async Task BrowserPackage_WrapsOnlyMarkedBrowserComputeRunArguments() Assert.Contains("--host-command-uri", marked.StandardOutput); Assert.Contains("--host-arguments-uri", marked.StandardOutput); Assert.DoesNotContain(".launch", marked.StandardOutput); - - DotnetMuxerResult emptyHostArguments = await DotnetCli.RunAsync( - commonArguments - + " -property:DotnetTestInvocation=true" - + " -property:TestingPlatformBrowserEmptyHostArguments=true", - warnAsError: false, - failIfReturnValueIsNotZero: false, - useMultithreadedMSBuild: false, - cancellationToken: TestContext.CancellationToken); - Assert.AreEqual(0, emptyHostArguments.ExitCode, emptyHostArguments.ToString()); - using var emptyHostArgumentsOutput = JsonDocument.Parse( - emptyHostArguments.StandardOutput); - string emptyHostRunArguments = emptyHostArgumentsOutput.RootElement - .GetProperty("Properties") - .GetProperty("RunArguments") - .GetString() - ?? throw new AssertFailedException("ComputeRunArguments returned a null RunArguments value."); - Assert.Contains( - "--host-arguments-uri \"\"", - emptyHostRunArguments); } [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 + (SourceCode + ConsumerPageSourceCode) .PatchCodeWithReplace("$TargetFramework$", TargetFramework) .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion) .PatchCodeWithReplace("$BrowserPackageVersion$", browserPackageVersion) - .PatchCodeWithReplace("$Browser$", "browser-placeholder")); + .PatchCodeWithReplace("$Browser$", EscapeMsBuildValue(browser))); DotnetMuxerResult build = await DotnetCli.RunAsync( - $"build {generator.TargetAssetPath} --configuration Release --framework {TargetFramework} --runtime {WasmRuntime.BrowserRid} -property:TestingPlatformBrowserRecordStaticWebAssets=true", + $"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(ReadRecordedPath( - generator.TargetAssetPath, - "browser-build-manifest-path.txt"))); - Assert.IsTrue(ContainsBrowserHostAsset(buildManifest, "index.html", "Build")); + using var buildManifest = JsonDocument.Parse(File.ReadAllText( + await GetEvaluatedPathAsync( + generator, + "StaticWebAssetBuildManifestPath"))); Assert.IsTrue(ContainsBrowserHostAsset( buildManifest, - "Microsoft.Testing.Platform.Browser.main.js", + "_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} -property:TestingPlatformBrowserRecordStaticWebAssets=true", + $"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(ReadRecordedPath( - generator.TargetAssetPath, - "browser-publish-manifest-path.txt"))); - Assert.IsFalse(ContainsBrowserHostAsset(publishManifest, "index.html")); + using var publishManifest = JsonDocument.Parse(File.ReadAllText( + await GetEvaluatedPathAsync( + generator, + "StaticWebAssetPublishManifestPath"))); Assert.IsFalse(ContainsBrowserHostAsset( publishManifest, - "Microsoft.Testing.Platform.Browser.main.js")); + "_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)); - foreach (string index in Directory.EnumerateFiles( + string consumerIndex = Directory.EnumerateFiles( publishDirectory, "index.html", - SearchOption.AllDirectories)) - { - Assert.DoesNotContain( - "Microsoft.Testing.Platform.Browser.main.js", - File.ReadAllText(index)); - } + SearchOption.AllDirectories).Single(); + Assert.Contains("consumer-page-marker", File.ReadAllText(consumerIndex)); } [TestMethod] @@ -379,7 +355,7 @@ public void BrowserPackage_ContainsPrivateCrossPlatformPlaywrightRuntime() 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("await import('../_framework/dotnet.js')", browserMain); Assert.Contains("__mtpBrowserGetArguments", browserMain); Assert.Contains("__mtpBrowserComplete", browserMain); Assert.Contains("launcherAvailable", browserMain); @@ -435,6 +411,31 @@ public async Task BrowserPackage_PackNoBuildPreservesRuntimePayload() 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, @@ -483,6 +484,16 @@ private static string GetBrowserPackagePath() 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[] { @@ -530,16 +541,30 @@ private static bool ContainsBrowserHostAsset( string relativePath, string? assetKind = null) => manifest.RootElement.GetProperty("Assets").EnumerateArray().Any( - asset => asset.GetProperty("SourceId").GetString() == "BrowserPackageConsumer" + asset => asset.GetProperty("SourceId").GetString() == "BrowserPackageTestProject" && asset.GetProperty("RelativePath").GetString() == relativePath && (assetKind is null || asset.GetProperty("AssetKind").GetString() == assetKind)); - private static string ReadRecordedPath(string projectDirectory, string recordFile) + private async Task GetEvaluatedPathAsync( + TestAsset generator, + string propertyName) { - string path = File.ReadAllText(Path.Combine(projectDirectory, recordFile)).Trim(); + 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, projectDirectory); + : 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 index 299579379b..d090894c1c 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/BrowserLauncherOptionsTests.cs @@ -11,6 +11,8 @@ public sealed class BrowserLauncherOptionsTests { private const string BootstrapToken = "secret-token"; + public TestContext TestContext { get; set; } = null!; + [TestMethod] public void Parse_ReadsDirectLauncherOptionsAndSdkResponseFile() { @@ -159,6 +161,126 @@ public void ResponseFileExpander_DoesNotLogSecretsInErrors() 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() { @@ -178,17 +300,18 @@ public void DiagnosticBuffer_RedactsBootstrapValuesAndBoundsOutput() var diagnostics = new DiagnosticBuffer( BootstrapToken, "abc", - "http://127.0.0.1:1234/"); + "http://127.0.0.1:1234/abc", + "abc"); diagnostics.Add("one", $"token={BootstrapToken}"); - diagnostics.Add("two", "http://127.0.0.1:1234/"); + 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.DoesNotContain("http://127.0.0.1:1234", output); Assert.Contains("[redacted]", output); Assert.IsLessThan(4_500, output.Length); } @@ -206,6 +329,64 @@ public void BrowserTerminalResult_FatalErrorIsReportedThroughDiagnostics() 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() { @@ -258,5 +439,13 @@ private static string CreateEmptyFile() 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 2a92e7b03b..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 @@ -70,6 +70,8 @@ + +