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