diff --git a/Directory.Build.props b/Directory.Build.props
index bcec266fbf..d043d95385 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -87,6 +87,13 @@
-->
1.0.0
+ alpha
+
+
+ 1.0.0
+
alpha
+ $(SupportedNetFrameworks)
+ true
+ $(RepoRoot)src\Platform\SharedExtensionHelpers\BuildInfo.cs.template
+
+ $(NoWarn);TPEXP
+
+ $(MicrosoftTestingExtensionsPackagedAppVersionPrefix)
+ $(MicrosoftTestingExtensionsPackagedAppPreReleaseVersionLabel)
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ buildMultiTargeting
+
+
+ buildTransitive/$(TargetFramework)
+
+
+ build/$(TargetFramework)
+
+
+
+
+
+
+
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md
new file mode 100644
index 0000000000..322f0c80ee
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md
@@ -0,0 +1,34 @@
+# Microsoft.Testing.Extensions.PackagedApp
+
+> [!IMPORTANT]
+> This package is experimental and consumes the experimental `ITestHostLauncher` extension point of [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform). The API may change or be removed in a future update.
+
+`Microsoft.Testing.Extensions.PackagedApp` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys a packaged Windows test host (UWP or packaged WinUI) into an isolated directory and launches it from there, instead of starting the test host in place.
+
+It is the consumer of the platform's `ITestHostLauncher` extension point for packaged Windows apps. UWP and packaged WinUI ship as MSIX and share the same launch mechanism, which is why VSTest exposes a single `UwpTestHostRuntimeProvider` for both:
+
+- **Deploy + launch** (implemented): the app's loose layout is deployed to a deployment directory and the produced executable is launched from there.
+- **Packaged AUMID activation** (planned): the loose layout is registered with the `PackageManager` and the app is activated by Application User Model ID (AUMID) via `IApplicationActivationManager` — the scenario behind [#2784](https://github.com/microsoft/testfx/issues/2784).
+
+Microsoft.Testing.Platform is open source. You can find `Microsoft.Testing.Extensions.PackagedApp` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository.
+
+## Install the package
+
+```dotnetcli
+dotnet add package Microsoft.Testing.Extensions.PackagedApp
+```
+
+## About
+
+This package extends Microsoft.Testing.Platform with:
+
+- **Deployment + launch**: stages the packaged Windows (UWP/WinUI) test host payload into an isolated directory and launches the deployed copy.
+- **Mechanism-agnostic monitoring**: returns an `ITestHostHandle` that exposes only the lifecycle the platform needs and no local process id.
+
+## Documentation
+
+For comprehensive documentation, see .
+
+## Feedback & contributing
+
+Microsoft.Testing.Platform is an open source project. Provide feedback or report issues in the [microsoft/testfx](https://github.com/microsoft/testfx/issues) GitHub repository.
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs
new file mode 100644
index 0000000000..6581c0c11d
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Extensions.PackagedApp;
+using Microsoft.Testing.Platform.Builder;
+
+namespace Microsoft.Testing.Extensions;
+
+///
+/// Provides extension methods for adding packaged Windows (UWP/WinUI) test host deployment support
+/// to the test application builder.
+///
+[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
+public static class PackagedAppExtensions
+{
+ ///
+ /// Registers a test host launcher that deploys the packaged Windows (UWP/WinUI) test host into
+ /// an isolated directory and launches it from there.
+ ///
+ /// The test application builder.
+ public static void AddPackagedAppDeployment(this ITestApplicationBuilder builder)
+ {
+ _ = builder ?? throw new System.ArgumentNullException(nameof(builder));
+ builder.TestHostControllers.AddTestHostLauncher(_ => new PackagedAppTestHostLauncher());
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs
new file mode 100644
index 0000000000..01debf9433
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Platform.Extensions.TestHostControllers;
+
+namespace Microsoft.Testing.Extensions.PackagedApp;
+
+///
+/// An over a deployed packaged Windows (UWP/WinUI) test host process
+/// that deliberately exposes no identifier, modelling a launch where no local, queryable PID is
+/// available.
+///
+internal sealed class PackagedAppTestHostHandle : ITestHostHandle
+{
+ private readonly Process _process;
+ private readonly string _deploymentDirectory;
+
+ public PackagedAppTestHostHandle(Process process, string deploymentDirectory)
+ {
+ _process = process;
+ _deploymentDirectory = deploymentDirectory;
+ }
+
+ // Intentionally null: the platform must not depend on any identifier. A packaged UWP/WinUI
+ // implementation could surface the AUMID-activated PID (as a string) here instead.
+ public string? Identifier => null;
+
+ public int ExitCode => _process.ExitCode;
+
+ public bool HasExited => _process.HasExited;
+
+ public Task WaitForExitAsync(CancellationToken cancellationToken) => _process.WaitForExitAsync(cancellationToken);
+
+ public void Terminate()
+ {
+ try
+ {
+ _process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ // The process has already exited.
+ }
+ }
+
+ public void Dispose()
+ {
+ _process.Dispose();
+
+ // The platform disposes the handle once the host has exited, so it is safe to remove the
+ // staged deployment now. Best-effort: never let cleanup failures surface as run failures.
+ try
+ {
+ if (Directory.Exists(_deploymentDirectory))
+ {
+ Directory.Delete(_deploymentDirectory, recursive: true);
+ }
+ }
+ catch (IOException ex)
+ {
+ Debug.WriteLine($"Best-effort cleanup of deployment directory '{_deploymentDirectory}' failed: {ex}");
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Debug.WriteLine($"Best-effort cleanup of deployment directory '{_deploymentDirectory}' failed: {ex}");
+ }
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs
new file mode 100644
index 0000000000..cff90874a8
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Extensions.PackagedApp.Resources;
+using Microsoft.Testing.Platform.Extensions.TestHostControllers;
+
+namespace Microsoft.Testing.Extensions.PackagedApp;
+
+///
+/// An for packaged Windows test applications (UWP and packaged
+/// WinUI): it deploys the test host's loose layout into an isolated directory and launches it from
+/// there, instead of starting the test host in place.
+///
+///
+///
+/// Packaged Windows apps — produced by both UWP and packaged WinUI projects, and shipped as MSIX —
+/// cannot be started with a plain Process.Start from the build output. They share the same
+/// launch mechanism: the loose layout is registered with the PackageManager and the app is
+/// activated by Application User Model ID (AUMID) via IApplicationActivationManager. That
+/// mechanism is the reason VSTest's UwpTestHostRuntimeProvider exists (it serves both UWP and
+/// WinUI); see https://github.com/microsoft/testfx/issues/2784.
+///
+///
+/// This reference implementation performs the deploy-and-launch step (stage the loose layout, then
+/// launch the produced executable from the deployment directory). The packaged AUMID-activation
+/// branch — registering the layout with PackageManager.RegisterPackageByUriAsync and calling
+/// IApplicationActivationManager.ActivateApplication — is a clearly-marked follow-up.
+///
+///
+/// In all cases the platform owns argument/environment preparation, the controller-to-host IPC pipe,
+/// the PID handshake, and the lifetime-handler dispatch; this launcher only performs the
+/// deploy-and-create step and returns an the platform monitors.
+///
+///
+internal sealed class PackagedAppTestHostLauncher : ITestHostLauncher
+{
+ public string Uid => nameof(PackagedAppTestHostLauncher);
+
+ public string Version => ExtensionVersion.DefaultSemVer;
+
+ public string DisplayName => ExtensionResources.PackagedAppExtensionDisplayName;
+
+ public string Description => ExtensionResources.PackagedAppExtensionDescription;
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+
+ public Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken)
+ {
+ // Honor immediate cancellation before doing any (potentially expensive) deployment work.
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string sourceDirectory = Path.GetDirectoryName(context.FileName)
+ ?? throw new InvalidOperationException($"Unable to determine the source directory of '{context.FileName}'.");
+
+ // 1. Deploy the app's loose layout into an isolated directory. For packaged UWP/WinUI this is
+ // also where the layout would be registered via PackageManager.RegisterPackageByUriAsync.
+ string deploymentDirectory = Path.Combine(Path.GetTempPath(), "MTPPackagedAppDeployment", Guid.NewGuid().ToString("N"));
+ CopyDirectory(sourceDirectory, deploymentDirectory, cancellationToken);
+
+ // 2. Launch the deployed test host, forwarding the platform-prepared arguments and
+ // environment (which include the controller IPC pipe name the host connects back on).
+ // Packaged UWP/WinUI would instead AUMID-activate here via IApplicationActivationManager
+ // and wrap the activated process id.
+ string deployedFileName = Path.Combine(deploymentDirectory, Path.GetFileName(context.FileName));
+ var startInfo = new ProcessStartInfo(deployedFileName)
+ {
+ UseShellExecute = false,
+ // Honor an explicitly requested working directory; otherwise run from the deployment dir.
+ WorkingDirectory = context.WorkingDirectory ?? deploymentDirectory,
+ };
+
+ foreach (string argument in context.Arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ foreach (KeyValuePair environmentVariable in context.EnvironmentVariables)
+ {
+ startInfo.Environment[environmentVariable.Key] = environmentVariable.Value;
+ }
+
+ Process process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException($"Failed to start deployed packaged-app test host '{deployedFileName}'.");
+
+ // 3. Return a handle that deliberately does NOT surface the underlying process id, validating
+ // that the platform relies purely on the lifecycle contract
+ // (WaitForExitAsync/ExitCode/HasExited/Terminate) and the IPC PID handshake. This
+ // matches launch mechanisms where no local, queryable PID is available (e.g. an
+ // AppContainer-sandboxed AUMID activation surfaced through a broker). The handle also owns
+ // cleanup of the deployment directory once the host has exited.
+ return Task.FromResult(new PackagedAppTestHostHandle(process, deploymentDirectory));
+ }
+
+ private static void CopyDirectory(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(destinationDirectory);
+
+ foreach (string file in Directory.EnumerateFiles(sourceDirectory))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ File.Copy(file, Path.Combine(destinationDirectory, Path.GetFileName(file)), overwrite: true);
+ }
+
+ foreach (string directory in Directory.EnumerateDirectories(sourceDirectory))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ CopyDirectory(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), cancellationToken);
+ }
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Shipped.txt
new file mode 100644
index 0000000000..7dc5c58110
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt
new file mode 100644
index 0000000000..9ec034f460
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt
@@ -0,0 +1,5 @@
+#nullable enable
+Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook
+[TPEXP]Microsoft.Testing.Extensions.PackagedAppExtensions
+static Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void
+[TPEXP]static Microsoft.Testing.Extensions.PackagedAppExtensions.AddPackagedAppDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/ExtensionResources.resx b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/ExtensionResources.resx
new file mode 100644
index 0000000000..b7d44974b2
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/ExtensionResources.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+ Packaged app test host launcher
+
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.cs.xlf
new file mode 100644
index 0000000000..54202cc05a
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.cs.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.de.xlf
new file mode 100644
index 0000000000..8c6857c5cc
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.de.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.es.xlf
new file mode 100644
index 0000000000..92a2bdbcc3
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.es.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.fr.xlf
new file mode 100644
index 0000000000..e0d30f6487
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.fr.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.it.xlf
new file mode 100644
index 0000000000..f6949f10a3
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.it.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ja.xlf
new file mode 100644
index 0000000000..046352f80f
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ja.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ko.xlf
new file mode 100644
index 0000000000..6991ac249f
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ko.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pl.xlf
new file mode 100644
index 0000000000..6c0f87c731
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pl.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pt-BR.xlf
new file mode 100644
index 0000000000..4cbd939fe1
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pt-BR.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ru.xlf
new file mode 100644
index 0000000000..0104272056
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ru.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.tr.xlf
new file mode 100644
index 0000000000..8255db9182
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.tr.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hans.xlf
new file mode 100644
index 0000000000..f00d0f8a2e
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hans.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hant.xlf
new file mode 100644
index 0000000000..0aa41bccd4
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hant.xlf
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+ Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there.
+
+
+
+ Packaged app test host launcher
+ Packaged app test host launcher
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/TestingPlatformBuilderHook.cs
new file mode 100644
index 0000000000..8490e1da24
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/TestingPlatformBuilderHook.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Platform.Builder;
+
+namespace Microsoft.Testing.Extensions.PackagedApp;
+
+///
+/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add packaged Windows (UWP/WinUI) test host deployment support.
+///
+public static class TestingPlatformBuilderHook
+{
+ ///
+ /// Adds packaged Windows (UWP/WinUI) test host deployment support to the Testing Platform Builder.
+ ///
+ /// The test application builder.
+ /// The command line arguments.
+ public static void AddExtensions(ITestApplicationBuilder testApplicationBuilder, string[] _)
+ => testApplicationBuilder.AddPackagedAppDeployment();
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/build/Microsoft.Testing.Extensions.PackagedApp.props b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/build/Microsoft.Testing.Extensions.PackagedApp.props
new file mode 100644
index 0000000000..6f8c20f81f
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/build/Microsoft.Testing.Extensions.PackagedApp.props
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildMultiTargeting/Microsoft.Testing.Extensions.PackagedApp.props b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildMultiTargeting/Microsoft.Testing.Extensions.PackagedApp.props
new file mode 100644
index 0000000000..518f46ee25
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildMultiTargeting/Microsoft.Testing.Extensions.PackagedApp.props
@@ -0,0 +1,9 @@
+
+
+
+
+ Microsoft.Testing.Extensions.PackagedApp
+ Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook
+
+
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props
new file mode 100644
index 0000000000..5ce029d450
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs
index efe15a1736..86cfd6f742 100644
--- a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs
@@ -352,7 +352,7 @@ public async Task OrchestrateTestHostExecutionAsync(CancellationToken cance
testHostProcess.Exited -= exitedHandler;
}
- await testHostProcess.WaitForExitAsync().ConfigureAwait(false);
+ await testHostProcess.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
exitCodes.Add(testHostProcess.ExitCode);
if (testHostProcess.ExitCode != (int)ExitCode.Success)
diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs
index 859514788d..beadb5cdc8 100644
--- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs
@@ -26,10 +26,20 @@ internal interface IProcess : IDisposable
DateTime StartTime { get; }
///
- /// Instructs the Process component to wait for the associated process to exit, or for the cancellationToken to be canceled.
+ /// Instructs the Process component to wait indefinitely for the associated process to exit.
///
+ ///
+ /// This overload is kept for binary compatibility with previously shipped extensions (for example
+ /// the Retry extension) that were compiled against it; new in-box callers use the
+ /// overload.
+ ///
Task WaitForExitAsync();
+ ///
+ /// Instructs the Process component to wait for the associated process to exit, or for the cancellationToken to be canceled.
+ ///
+ Task WaitForExitAsync(CancellationToken cancellationToken);
+
///
void WaitForExit();
diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs
index 3222589c3f..036bcd08d7 100644
--- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs
@@ -42,6 +42,9 @@ public void WaitForExit()
public Task WaitForExitAsync()
=> _process.WaitForExitAsync();
+ public Task WaitForExitAsync(CancellationToken cancellationToken)
+ => _process.WaitForExitAsync(cancellationToken);
+
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
public void Kill()
diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs
new file mode 100644
index 0000000000..0a2cff0c87
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Platform.Extensions.TestHostControllers;
+
+namespace Microsoft.Testing.Platform.Helpers;
+
+///
+/// Adapts a public returned by an to
+/// the internal monitoring contract used by the test host controller host.
+/// Only the members the platform consumes after launch are backed by the handle; the rest are not
+/// used in this flow.
+///
+[UnsupportedOSPlatform("browser")]
+internal sealed class TestHostHandleToProcessAdapter : IProcess
+{
+ private readonly ITestHostHandle _handle;
+ private readonly CancellationTokenSource _disposedCts = new();
+
+ public TestHostHandleToProcessAdapter(ITestHostHandle handle)
+ {
+ _handle = handle;
+
+ // The public handle deliberately has no Exited event (consumers use WaitForExitAsync). The
+ // internal IProcess contract still exposes one for the in-process logging hook, so synthesize
+ // it from the exit task. It is informational only, hence best-effort and fire-and-forget.
+ _ = RaiseExitedWhenDoneAsync();
+ }
+
+ public event EventHandler? Exited;
+
+ // A custom launcher does not necessarily back a local OS process, so there is no numeric PID to
+ // return. The controller host only reads Id for logging and tolerates this exception.
+ public int Id => throw new InvalidOperationException("The test host launcher does not expose a numeric process id; use 'ITestHostHandle.Identifier' for diagnostics.");
+
+ public string Name => string.Empty;
+
+ public int ExitCode => _handle.ExitCode;
+
+ public bool HasExited => _handle.HasExited;
+
+ public IMainModule? MainModule => null;
+
+ public DateTime StartTime => default;
+
+ public Task WaitForExitAsync() => _handle.WaitForExitAsync(CancellationToken.None);
+
+ public Task WaitForExitAsync(CancellationToken cancellationToken) => _handle.WaitForExitAsync(cancellationToken);
+
+ public void WaitForExit() => _handle.WaitForExitAsync(CancellationToken.None).GetAwaiter().GetResult();
+
+ public void Kill() => _handle.Terminate();
+
+ public void Dispose()
+ {
+ _disposedCts.Cancel();
+ _disposedCts.Dispose();
+ _handle.Dispose();
+ }
+
+ private async Task RaiseExitedWhenDoneAsync()
+ {
+ try
+ {
+ await _handle.WaitForExitAsync(_disposedCts.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // The wait faulted or was cancelled (e.g. the adapter was disposed before the host
+ // exited). The host did not necessarily exit, so do NOT raise the informational Exited
+ // event and never surface the failure from this best-effort path.
+ Debug.WriteLine($"Ignoring failure while awaiting test host exit for the informational Exited event: {ex}");
+ return;
+ }
+
+ Exited?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs
index 740dcf7c91..fdf4ae0537 100644
--- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs
@@ -246,18 +246,23 @@ protected override async Task InternalRunAsync(CancellationToken cancellati
processStartInfo.EnvironmentVariables.Add($"{EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_TESTHOSTPROCESSSTARTTIME}_{currentPid}", testHostProcessStartupTime);
await _logger.LogDebugAsync($"{EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_TESTHOSTPROCESSSTARTTIME}_{currentPid} '{testHostProcessStartupTime}'").ConfigureAwait(false);
await _logger.LogDebugAsync($"Starting test host process '{processStartInfo.FileName}' with args '{processStartInfo.Arguments}'").ConfigureAwait(false);
- using IProcess testHostProcess = process.Start(processStartInfo);
+
+ ITestHostLauncher? testHostLauncher = _testHostsInformation.TestHostLauncher;
+ using IProcess testHostProcess = testHostLauncher is null
+ ? process.Start(processStartInfo)
+ : await LaunchUsingCustomLauncherAsync(testHostLauncher, processStartInfo, partialCommandLine, cancellationToken).ConfigureAwait(false);
int? testHostProcessId = null;
try
{
testHostProcessId = testHostProcess.Id;
}
- catch (InvalidOperationException ex) when (testHostProcess.HasExited)
+ catch (InvalidOperationException ex) when (testHostProcess.HasExited || testHostLauncher is not null)
{
// Access PID can throw InvalidOperationException if the process has already exited:
// System.InvalidOperationException: No process is associated with this object.
- await _logger.LogDebugAsync($"Unable to obtain test host PID; process had already exited (ExitCode: {testHostProcess.ExitCode}). {ex.GetType().FullName}: {ex.Message}").ConfigureAwait(false);
+ // A custom launcher may also legitimately not expose a local PID (e.g. container/remote).
+ await _logger.LogDebugAsync($"Unable to obtain test host PID; process had already exited or does not expose a PID (HasExited: {testHostProcess.HasExited}). {ex.GetType().FullName}: {ex.Message}").ConfigureAwait(false);
}
testHostProcess.Exited += (_, _) =>
@@ -265,7 +270,12 @@ protected override async Task InternalRunAsync(CancellationToken cancellati
await _logger.LogDebugAsync($"Started test host process '{testHostProcessId}' HasExited: {testHostProcess.HasExited}").ConfigureAwait(false);
- if (testHostProcess.HasExited || testHostProcessId is null)
+ // Note: we intentionally gate on HasExited only and not on 'testHostProcessId is null'.
+ // A custom ITestHostLauncher may legitimately not expose a local PID (e.g. container,
+ // remote, or AUMID-activated apps); the real test host PID still arrives via the IPC
+ // handshake (_testHostPID). For the default Process.Start path, a null PID always
+ // coincides with HasExited == true, so behavior is unchanged there.
+ if (testHostProcess.HasExited)
{
await _logger.LogDebugAsync("Test host process exited prematurely").ConfigureAwait(false);
}
@@ -311,7 +321,32 @@ protected override async Task InternalRunAsync(CancellationToken cancellati
}
await _logger.LogDebugAsync("Wait for test host process exit").ConfigureAwait(false);
- await testHostProcess.WaitForExitAsync().ConfigureAwait(false);
+ try
+ {
+ await testHostProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The run was canceled while waiting for the test host to exit. Tear the host down
+ // and wait (without cancellation) for it to fully exit, so the exit-code
+ // reconciliation below still observes a real OS exit code.
+ await _logger.LogDebugAsync("Wait for test host process exit was canceled; terminating the test host").ConfigureAwait(false);
+ try
+ {
+ testHostProcess.Kill();
+ }
+ catch (Exception ex)
+ {
+ // Termination is best-effort. The host may have exited between the cancellation
+ // and this Kill call (InvalidOperationException), or Kill may delegate to a custom
+ // ITestHostLauncher's Terminate() which can throw anything (e.g. NotSupportedException,
+ // Win32Exception). Either way the host is on its way out, so swallow and log rather
+ // than letting it mask the cancellation teardown flow.
+ await _logger.LogDebugAsync($"Ignoring failure while terminating the test host during cancellation: {ex}").ConfigureAwait(false);
+ }
+
+ await testHostProcess.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
+ }
}
if (_testHostPID is null)
@@ -397,6 +432,30 @@ Test host did not exit gracefully.
return exitCode;
}
+ [UnsupportedOSPlatform("browser")]
+ private async Task LaunchUsingCustomLauncherAsync(
+ ITestHostLauncher testHostLauncher,
+ ProcessStartInfo processStartInfo,
+ IReadOnlyList arguments,
+ CancellationToken cancellationToken)
+ {
+#pragma warning disable IDE0028 // Collection initialization can be simplified — populated from a runtime loop.
+ Dictionary environmentVariables = new(StringComparer.Ordinal);
+#pragma warning restore IDE0028
+ foreach (string key in processStartInfo.EnvironmentVariables.Keys)
+ {
+ environmentVariables[key] = processStartInfo.EnvironmentVariables[key];
+ }
+
+ string? workingDirectory = RoslynString.IsNullOrEmpty(processStartInfo.WorkingDirectory) ? null : processStartInfo.WorkingDirectory;
+ TestHostLaunchContext context = new(processStartInfo.FileName, arguments, environmentVariables, workingDirectory);
+
+ await _logger.LogDebugAsync($"Delegating test host launch to '{testHostLauncher.DisplayName}' (UID: {testHostLauncher.Uid})").ConfigureAwait(false);
+ ITestHostHandle handle = await testHostLauncher.LaunchTestHostAsync(context, cancellationToken).ConfigureAwait(false);
+ await _logger.LogDebugAsync($"Test host launched by '{testHostLauncher.Uid}' (Identifier: '{handle.Identifier ?? ""}')").ConfigureAwait(false);
+ return new TestHostHandleToProcessAdapter(handle);
+ }
+
private async Task DisposeServicesAsync()
{
ITestHostEnvironmentVariableProvider[] variableProviders = _testHostsInformation.EnvironmentVariableProviders;
diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt
index 709927ceb1..ce5518ec82 100644
--- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt
@@ -1,2 +1,18 @@
#nullable enable
[TPEXP]Microsoft.Testing.Platform.Extensions.IBlockingDataConsumer
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.ExitCode.get -> int
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.HasExited.get -> bool
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.Identifier.get -> string?
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.Terminate() -> void
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.WaitForExitAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostLauncher
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostLauncher.LaunchTestHostAsync(Microsoft.Testing.Platform.Extensions.TestHostControllers.TestHostLaunchContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.TestHostLaunchContext
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.TestHostLaunchContext.Arguments.get -> System.Collections.Generic.IReadOnlyList!
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.TestHostLaunchContext.EnvironmentVariables.get -> System.Collections.Generic.IReadOnlyDictionary!
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.TestHostLaunchContext.FileName.get -> string!
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.TestHostLaunchContext.TestHostLaunchContext(string! fileName, System.Collections.Generic.IReadOnlyList! arguments, System.Collections.Generic.IReadOnlyDictionary! environmentVariables, string? workingDirectory) -> void
+[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.TestHostLaunchContext.WorkingDirectory.get -> string?
+[TPEXP]Microsoft.Testing.Platform.TestHostControllers.ITestHostControllersManager.AddTestHostLauncher(System.Func! testHostLauncherFactory) -> void
+[TPEXP]Microsoft.Testing.Platform.TestHostControllers.ITestHostControllersManager.AddTestHostLauncher(Microsoft.Testing.Platform.Extensions.CompositeExtensionFactory! compositeServiceFactory) -> void
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx
index 55a0521fca..eba7e4715e 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx
@@ -811,6 +811,9 @@ Valid values are 'allow-skipped' (the default) which counts skipped tests as run
Passing both '--treenode-filter' and '--filter-uid' is unsupported.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+
Allows to pause execution in order to attach to the process for debug purposes.
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf
index 27bd18ff7f..44283f88b1 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf
@@ -434,6 +434,11 @@
Předávání možností --treenode-filter a --filter-uid není podporováno.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedÚspěšné
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf
index 38a4eb0432..82620b861a 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf
@@ -434,6 +434,11 @@
Das gleichzeitige Übergeben von „--treenode-filter“ und „--filter-uid“ wird nicht unterstützt.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedBestanden
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf
index f412f394c3..61daed2cf5 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf
@@ -434,6 +434,11 @@
No se admite el paso de "--treenode-filter" y "--filter-uid".{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedCorrecta
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf
index d051ab07a5..506adc3f41 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf
@@ -434,6 +434,11 @@
Vous ne pouvez pas passer à la fois « --treenode-filter » et « --filter-uid ». Cette action n’est pas prise en charge.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedRéussite
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf
index f6840e96f7..d515455ad8 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf
@@ -434,6 +434,11 @@
Il passaggio di '--treenode-filter' e '--filter-uid' non è supportato.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedSuperato
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf
index 0c137c57a8..cd9b059ff4 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf
@@ -434,6 +434,11 @@
'--treenode-filter' と '--filter-uid' の両方を渡すことはサポートされていません。{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ Passed成功
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf
index bb2fb23aec..177d9f2d15 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf
@@ -434,6 +434,11 @@
'--treenode-filter'와 '--filter-uid'를 동시에 전달하는 것은 지원되지 않습니다.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ Passed통과
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf
index 5ea8deec65..448dfc0d68 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf
@@ -434,6 +434,11 @@
Przekazywanie obu parametrów „--treenode-filter” i „--filter-uid” jest nieobsługiwane.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedPowodzenie
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf
index 0ce76029b2..467437e9f6 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf
@@ -434,6 +434,11 @@
Não há suporte para a passagem de "--treenode-filter" e "--filter-uid".{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedAprovado
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf
index bb67a10557..1e25cdb798 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf
@@ -434,6 +434,11 @@
Передача одновременно "--treenode-filter" и "--filter-uid" не поддерживается.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedПройден
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf
index d61e0c6e7a..352480fea1 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf
@@ -434,6 +434,11 @@
Hem ‘--treenode-filter’ hem de ‘--filter-uid’ parametrelerinin birlikte kullanılması desteklenmemektedir.{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ PassedBaşarılı
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf
index 44c372b8e3..995510fd5e 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf
@@ -434,6 +434,11 @@
不支持同时传递“--treenode-filter”和“--filter-uid”。{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ Passed已通过
diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf
index c2614be631..99e920d14e 100644
--- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf
+++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf
@@ -434,6 +434,11 @@
不支援同時傳遞 '--treenode-filter' 和 '--filter-uid'。{Locked="--treenode-filter"}{Locked="--filter-uid"}
+
+ Only one test host launcher can be registered.
+ Only one test host launcher can be registered.
+
+ Passed成功
diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostControllerManager.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostControllerManager.cs
index 945fbf9114..35cea9d046 100644
--- a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostControllerManager.cs
+++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostControllerManager.cs
@@ -38,4 +38,22 @@ void AddEnvironmentVariableProvider(CompositeExtensionFactory compositeSer
/// The factory method that creates the composite service.
void AddProcessLifetimeHandler(CompositeExtensionFactory compositeServiceFactory)
where T : class, ITestHostProcessLifetimeHandler;
+
+ ///
+ /// Adds a test host launcher to the test host controller manager, replacing the platform's
+ /// default Process.Start behavior. At most one launcher can be registered.
+ ///
+ /// The factory method that creates the test host launcher.
+ [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
+ void AddTestHostLauncher(Func testHostLauncherFactory);
+
+ ///
+ /// Adds a test host launcher to the test host controller manager, replacing the platform's
+ /// default Process.Start behavior. At most one launcher can be registered.
+ ///
+ /// The type of the test host launcher.
+ /// The factory method that creates the composite service.
+ [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
+ void AddTestHostLauncher(CompositeExtensionFactory compositeServiceFactory)
+ where T : class, ITestHostLauncher;
}
diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs
new file mode 100644
index 0000000000..9f701c57b1
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs
@@ -0,0 +1,59 @@
+// 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.Extensions.TestHostControllers;
+
+///
+/// Represents a launched test host that the platform monitors for completion.
+///
+///
+/// The handle is intentionally agnostic of the underlying launch mechanism: it does not assume a
+/// local OS process. is therefore an optional, free-form string used only
+/// for diagnostics; the platform tracks completion through ,
+/// , and . The platform owns the handle for the whole
+/// lifetime of the test host and disposes it (via ) once the host has
+/// exited, so implementations should release any OS resources they hold (process objects, sockets,
+/// container clients, …) in .
+///
+[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
+public interface ITestHostHandle : IDisposable
+{
+ ///
+ /// Gets an optional, free-form identifier for the launched test host, used only for diagnostics.
+ ///
+ ///
+ /// The value can be anything meaningful for the launch mechanism — for example a process id, a
+ /// container id, or a remote host:pid. Returns when the launcher
+ /// has nothing useful to surface. The platform never relies on this value for control flow.
+ ///
+ string? Identifier { get; }
+
+ ///
+ /// Gets the exit code of the test host.
+ ///
+ ///
+ /// Only valid once is (or after
+ /// has completed). Reading it while the host is still running is
+ /// undefined; implementations are not required to throw.
+ ///
+ int ExitCode { get; }
+
+ ///
+ /// Gets a value indicating whether the test host has exited.
+ ///
+ bool HasExited { get; }
+
+ ///
+ /// Waits asynchronously for the test host to exit, or for to
+ /// be canceled. The platform may await this more than once.
+ ///
+ /// A token that cancels the wait.
+ /// A task that completes when the test host has exited.
+ Task WaitForExitAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Terminates the test host. The platform calls this for best-effort teardown (for example when
+ /// hang dump decides to abort the run).
+ ///
+ void Terminate();
+}
diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostLauncher.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostLauncher.cs
new file mode 100644
index 0000000000..6dc90cb713
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostLauncher.cs
@@ -0,0 +1,32 @@
+// 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.Extensions.TestHostControllers;
+
+///
+/// Allows an extension to control how the out-of-process test host is launched, replacing the
+/// platform's default Process.Start behavior.
+///
+///
+/// The platform keeps owning everything around the launch — argument and environment preparation,
+/// the controller-to-host IPC pipe, the PID handshake,
+/// callbacks, and exit-code reconciliation — and delegates only the single "create and start the
+/// test host" step to the registered launcher. The launcher does not have to start a local OS
+/// process: it can deploy and activate a packaged application, launch a container, or start the
+/// host on a remote machine, as long as it returns an the platform
+/// can monitor.
+///
+[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
+public interface ITestHostLauncher : ITestHostControllersExtension
+{
+ ///
+ /// Creates and starts the test host. The platform has already prepared the file name,
+ /// arguments, and environment variables (including the controller IPC pipe name) carried by
+ /// . The implementation must return a handle the platform can
+ /// monitor for completion.
+ ///
+ /// The fully prepared launch information.
+ /// The cancellation token.
+ /// A handle the platform monitors for the lifetime of the test host.
+ Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken);
+}
diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllerConfiguration.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllerConfiguration.cs
index 276efb3364..5ed414a171 100644
--- a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllerConfiguration.cs
+++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllerConfiguration.cs
@@ -9,6 +9,7 @@ namespace Microsoft.Testing.Platform.TestHostControllers;
internal sealed class TestHostControllerConfiguration(ITestHostEnvironmentVariableProvider[] environmentVariableProviders,
ITestHostProcessLifetimeHandler[] lifetimeHandlers,
IDataConsumer[] dataConsumer,
+ ITestHostLauncher? testHostLauncher,
bool requireProcessRestart)
{
public ITestHostEnvironmentVariableProvider[] EnvironmentVariableProviders { get; } = environmentVariableProviders;
@@ -17,5 +18,7 @@ internal sealed class TestHostControllerConfiguration(ITestHostEnvironmentVariab
public IDataConsumer[] DataConsumer { get; } = dataConsumer;
+ public ITestHostLauncher? TestHostLauncher { get; } = testHostLauncher;
+
public bool RequireProcessRestart { get; } = requireProcessRestart;
}
diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllersManager.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllersManager.cs
index 04af7ae197..92fa9b95fa 100644
--- a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllersManager.cs
+++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostControllersManager.cs
@@ -16,8 +16,10 @@ internal sealed class TestHostControllersManager : ITestHostControllersManager
private readonly List> _environmentVariableProviderFactories = [];
private readonly List> _lifetimeHandlerFactories = [];
+ private readonly List> _testHostLauncherFactories = [];
private readonly List _environmentVariableProviderCompositeFactories = [];
private readonly List _lifetimeHandlerCompositeFactories = [];
+ private readonly List _testHostLauncherCompositeFactories = [];
private readonly List _alreadyBuiltServices = [];
private readonly List _dataConsumersCompositeServiceFactories = [];
@@ -99,6 +101,38 @@ public void AddProcessLifetimeHandler(CompositeExtensionFactory compositeS
_factoryOrdering.Add(compositeServiceFactory);
}
+ [UnsupportedOSPlatform("browser")]
+ public void AddTestHostLauncher(Func testHostLauncherFactory)
+ {
+ if (OperatingSystem.IsBrowser())
+ {
+ throw new PlatformNotSupportedException(PlatformResources.TestHostControllerProcessRestartNotSupportedOnWebAssembly);
+ }
+
+ _ = testHostLauncherFactory ?? throw new ArgumentNullException(nameof(testHostLauncherFactory));
+ _testHostLauncherFactories.Add(testHostLauncherFactory);
+ _factoryOrdering.Add(testHostLauncherFactory);
+ }
+
+ [UnsupportedOSPlatform("browser")]
+ public void AddTestHostLauncher(CompositeExtensionFactory compositeServiceFactory)
+ where T : class, ITestHostLauncher
+ {
+ if (OperatingSystem.IsBrowser())
+ {
+ throw new PlatformNotSupportedException(PlatformResources.TestHostControllerProcessRestartNotSupportedOnWebAssembly);
+ }
+
+ _ = compositeServiceFactory ?? throw new ArgumentNullException(nameof(compositeServiceFactory));
+ if (_testHostLauncherCompositeFactories.Contains(compositeServiceFactory))
+ {
+ throw new ArgumentException(PlatformResources.CompositeServiceFactoryInstanceAlreadyRegistered);
+ }
+
+ _testHostLauncherCompositeFactories.Add(compositeServiceFactory);
+ _factoryOrdering.Add(compositeServiceFactory);
+ }
+
[UnsupportedOSPlatform("browser")]
public void AddDataConsumer(CompositeExtensionFactory compositeServiceFactory)
where T : class, IDataConsumer
@@ -279,10 +313,84 @@ internal async Task BuildAsync(ServiceProvider
}
bool requireProcessRestart = environmentVariableProviders.Count > 0 || lifetimeHandlers.Count > 0 || dataConsumers.Count > 0;
+
+ ITestHostLauncher? testHostLauncher = await BuildTestHostLauncherAsync(serviceProvider).ConfigureAwait(false);
+ if (testHostLauncher is not null)
+ {
+ // A custom launcher only makes sense when the out-of-process test host is started, so we
+ // force the controller (process restart) host even when no other controller extension is present.
+ requireProcessRestart = true;
+ }
+
return new TestHostControllerConfiguration(
[.. environmentVariableProviders.OrderBy(x => x.RegistrationOrder).Select(x => x.TestHostEnvironmentVariableProvider)],
[.. lifetimeHandlers.OrderBy(x => x.RegistrationOrder).Select(x => x.TestHostProcessLifetimeHandler)],
[.. dataConsumers.OrderBy(x => x.RegistrationOrder).Select(x => x.Consumer)],
+ testHostLauncher,
requireProcessRestart);
}
+
+ private async Task BuildTestHostLauncherAsync(ServiceProvider serviceProvider)
+ {
+ List launchers = [];
+
+ foreach (Func testHostLauncherFactory in _testHostLauncherFactories)
+ {
+ ITestHostLauncher launcher = testHostLauncherFactory(serviceProvider);
+
+ // Check if we have already extensions of the same type with same id registered
+ launchers.ValidateUniqueExtension(launcher);
+
+ // We initialize only if enabled
+ if (await launcher.IsEnabledAsync().ConfigureAwait(false))
+ {
+ await launcher.TryInitializeAsync().ConfigureAwait(false);
+ launchers.Add(launcher);
+ serviceProvider.TryAddService(launcher);
+ }
+ }
+
+ foreach (ICompositeExtensionFactory compositeServiceFactory in _testHostLauncherCompositeFactories)
+ {
+ // Get the singleton
+ var extension = (IExtension)compositeServiceFactory.GetInstance(serviceProvider);
+ bool isEnabledAsync = await extension.IsEnabledAsync().ConfigureAwait(false);
+
+ // Check if we have already built the singleton for this composite factory
+ if (!_alreadyBuiltServices.Contains(compositeServiceFactory))
+ {
+ launchers.ValidateUniqueExtension(extension);
+
+ // We initialize only if enabled
+ if (isEnabledAsync)
+ {
+ await extension.TryInitializeAsync().ConfigureAwait(false);
+ }
+
+ // Add to the list of shared singletons
+ _alreadyBuiltServices.Add(compositeServiceFactory);
+ }
+
+ // We register the extension only if enabled
+ if (isEnabledAsync)
+ {
+ if (extension is ITestHostLauncher testHostLauncher)
+ {
+ launchers.Add(testHostLauncher);
+ serviceProvider.TryAddService(testHostLauncher);
+ }
+ else
+ {
+ throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, PlatformResources.ExtensionDoesNotImplementGivenInterfaceErrorMessage, extension.GetType(), typeof(ITestHostLauncher)));
+ }
+ }
+ }
+
+ return launchers.Count switch
+ {
+ 0 => null,
+ 1 => launchers[0],
+ _ => throw new InvalidOperationException(PlatformResources.OnlyOneTestHostLauncherSupported),
+ };
+ }
}
diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostLaunchContext.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostLaunchContext.cs
new file mode 100644
index 0000000000..d7bcffd228
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostLaunchContext.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.Extensions.TestHostControllers;
+
+///
+/// Carries the fully prepared information the platform would have used to start the test host,
+/// passed to an .
+///
+[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
+public sealed class TestHostLaunchContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The test host executable path the platform would have started.
+ /// The arguments, already including the test host controller PID option.
+ ///
+ /// The final environment for the test host, including the controller-to-host IPC pipe name the
+ /// host must connect back on.
+ ///
+ /// The working directory, or to inherit the current one.
+ public TestHostLaunchContext(
+ string fileName,
+ IReadOnlyList arguments,
+ IReadOnlyDictionary environmentVariables,
+ string? workingDirectory)
+ {
+ FileName = fileName;
+ Arguments = arguments;
+ EnvironmentVariables = environmentVariables;
+ WorkingDirectory = workingDirectory;
+ }
+
+ ///
+ /// Gets the test host executable path the platform would have started.
+ ///
+ public string FileName { get; }
+
+ ///
+ /// Gets the arguments, already including the test host controller PID option.
+ ///
+ public IReadOnlyList Arguments { get; }
+
+ ///
+ /// Gets the final environment for the test host, after all
+ /// ran. Includes the controller-to-host IPC
+ /// pipe name the host must connect back on.
+ ///
+ public IReadOnlyDictionary EnvironmentVariables { get; }
+
+ ///
+ /// Gets the working directory, or to inherit the current one.
+ ///
+ public string? WorkingDirectory { get; }
+}
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs
index 166affa1e1..129c6be695 100644
--- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs
@@ -20,6 +20,7 @@ static AcceptanceTestBase()
MicrosoftTestingExtensionsLoggingVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.Logging.");
MicrosoftTestingExtensionsCtrfReportVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.CtrfReport.");
MicrosoftTestingExtensionsJUnitReportVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.JUnitReport.");
+ MicrosoftTestingExtensionsPackagedAppVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.PackagedApp.");
MicrosoftTestingExtensionsVideoRecorderVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.VideoRecorder.");
}
@@ -46,6 +47,8 @@ static AcceptanceTestBase()
public static string MicrosoftTestingExtensionsJUnitReportVersion { get; private set; }
+ public static string MicrosoftTestingExtensionsPackagedAppVersion { get; private set; }
+
public static string MicrosoftTestingExtensionsVideoRecorderVersion { get; private set; }
private static string ExtractVersionFromPackage(string rootFolder, string packagePrefixName)
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedApp.MSBuildRegistration.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedApp.MSBuildRegistration.cs
new file mode 100644
index 0000000000..8b32552342
--- /dev/null
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedApp.MSBuildRegistration.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SL = Microsoft.Build.Logging.StructuredLogger;
+
+namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests;
+
+[TestClass]
+public sealed class PackagedAppMSBuildRegistrationTests : AcceptanceTestBase
+{
+ // Validates that the PackagedApp extension's build/buildTransitive props correctly contribute its
+ // TestingPlatformBuilderHook to a consuming project, so the MSBuild integration auto-registers it.
+ // This is a build-time assertion (it reads the generated self-registration source from the
+ // binlog) and does not run the deployed test host, so it is safe on every OS.
+ [TestMethod]
+ public async Task PackagedApp_TestingPlatformBuilderHook_IsRegistered_ViaBuildProps()
+ {
+ using TestAsset testAsset = await TestAsset.GenerateAssetAsync(
+ nameof(PackagedApp_TestingPlatformBuilderHook_IsRegistered_ViaBuildProps),
+ SourceCode
+ .PatchCodeWithReplace("$TargetFramework$", TargetFrameworks.NetCurrent)
+ .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)
+ .PatchCodeWithReplace("$MicrosoftTestingExtensionsPackagedAppVersion$", MicrosoftTestingExtensionsPackagedAppVersion));
+
+ DotnetMuxerResult result = await DotnetCli.RunAsync(
+ $"build -c {BuildConfiguration.Release} {testAsset.TargetAssetPath} -v:n",
+ cancellationToken: TestContext.CancellationToken);
+
+ SL.Build binLog = SL.Serialization.Read(result.BinlogPath!);
+ SL.Target generateSelfRegisteredExtensions = binLog.FindChildrenRecursive().Single(t => t.Name == "_GenerateSelfRegisteredExtensions");
+ SL.Task testingPlatformSelfRegisteredExtensions = generateSelfRegisteredExtensions.FindChildrenRecursive().Single(t => t.Name == "TestingPlatformSelfRegisteredExtensions");
+ SL.Message generatedSource = testingPlatformSelfRegisteredExtensions.FindChildrenRecursive().Single(m => m.Text.Contains("SelfRegisteredExtensions source:"));
+
+ Assert.Contains("Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text);
+ }
+
+ public TestContext TestContext { get; set; }
+
+ private const string SourceCode = """
+#file PackagedAppRegistration.csproj
+
+
+
+
+ DummyTestFramework
+ PackagedAppRegistration.DummyTestFrameworkRegistration
+
+
+
+
+ $TargetFramework$
+ enable
+ enable
+ preview
+ Exe
+
+ $(NoWarn);NETSDK1201
+
+
+
+
+
+
+
+
+#file Program.cs
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Testing.Platform.Builder;
+using Microsoft.Testing.Platform.Capabilities;
+using Microsoft.Testing.Platform.Capabilities.TestFramework;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+
+namespace PackagedAppRegistration;
+
+public static class DummyTestFrameworkRegistration
+{
+ public static void AddExtensions(ITestApplicationBuilder testApplicationBuilder, string[] args)
+ => testApplicationBuilder.RegisterTestFramework(_ => new Capabilities(), (_, __) => new DummyTestFramework());
+}
+
+internal sealed class DummyTestFramework : ITestFramework, IDataProducer
+{
+ public string Uid => nameof(DummyTestFramework);
+
+ public string Version => string.Empty;
+
+ public string DisplayName => string.Empty;
+
+ public string Description => string.Empty;
+
+ public Type[] DataTypesProduced => new[] { typeof(TestNodeUpdateMessage) };
+
+ public Task CloseTestSessionAsync(CloseTestSessionContext context) => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });
+
+ public Task CreateTestSessionAsync(CreateTestSessionContext context) => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });
+
+ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
+ {
+ await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid,
+ new TestNode() { Uid = "1", DisplayName = "DummyTest", Properties = new(PassedTestNodeStateProperty.CachedInstance) }));
+ context.Complete();
+ }
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+}
+
+internal sealed class Capabilities : ITestFrameworkCapabilities
+{
+ IReadOnlyCollection ICapabilities.Capabilities => Array.Empty();
+}
+""";
+}
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs
new file mode 100644
index 0000000000..0cc192e785
--- /dev/null
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs
@@ -0,0 +1,140 @@
+// 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;
+
+[TestClass]
+public sealed class PackagedAppDeploymentTests : AcceptanceTestBase
+{
+ private const string AssetName = "PackagedAppDeploymentTest";
+
+ [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))]
+ [TestMethod]
+ [OSCondition(ConditionMode.Include, OperatingSystems.Windows, IgnoreMessage = "Packaged Windows apps (UWP/WinUI) are a Windows-only scenario.")]
+ public async Task PackagedAppDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(string currentTfm)
+ {
+ var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm);
+
+ // The deployed test host reports the directory it actually ran from into this file (it learns
+ // the path from the PACKAGEDAPP_BASEDIR_MARKER env var, which the platform forwards to the
+ // launched host). This keeps the proof of deployment in the test asset rather than the
+ // shipping extension.
+ string markerPath = Path.Combine(testHost.DirectoryName, "deployment-basedir.txt");
+
+ TestHostResult testHostResult = await testHost.ExecuteAsync(
+ environmentVariables: new Dictionary { ["PACKAGEDAPP_BASEDIR_MARKER"] = markerPath },
+ cancellationToken: TestContext.CancellationToken);
+
+ // The test host is deployed elsewhere and launched through a handle that exposes no local
+ // PID. The run must still complete successfully, proving the platform's launch contract works
+ // for "not just a dumb process".
+ testHostResult.AssertExitCodeIs(ExitCode.Success);
+
+ Assert.IsTrue(File.Exists(markerPath), $"Expected the deployed host to write its base directory to '{markerPath}'.");
+ string runtimeBaseDirectory = File.ReadAllText(markerPath).Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ string originalDirectory = testHost.DirectoryName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ Assert.AreNotEqual(originalDirectory, runtimeBaseDirectory, "The test host must have been deployed to and launched from a different directory.");
+ }
+
+ public sealed class TestAssetFixture() : TestAssetFixtureBase()
+ {
+ private const string Sources = """
+#file PackagedAppDeploymentTest.csproj
+
+
+
+ $TargetFrameworks$
+ Exe
+ true
+ enable
+ preview
+
+ $(NoWarn);TPEXP;NETSDK1201
+
+
+
+
+
+
+
+#file Program.cs
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Testing.Extensions;
+using Microsoft.Testing.Platform.Builder;
+using Microsoft.Testing.Platform.Capabilities.TestFramework;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+using Microsoft.Testing.Platform.Services;
+
+public class Startup
+{
+ public static async Task Main(string[] args)
+ {
+ // The deployed copy of this app reports the directory it is actually running from, so the
+ // acceptance test can confirm it was deployed-and-launched from a different location. Both the
+ // controller process (original directory) and the deployed test host (deployment directory)
+ // run this Main; the deployed host is launched later, so it is the last writer and the marker
+ // ends up pointing at the deployment directory. The marker path comes from the
+ // platform-forwarded environment.
+ string? markerPath = Environment.GetEnvironmentVariable("PACKAGEDAPP_BASEDIR_MARKER");
+ if (!string.IsNullOrEmpty(markerPath))
+ {
+ System.IO.File.WriteAllText(markerPath, AppContext.BaseDirectory);
+ }
+
+ var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
+ testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework());
+ testApplicationBuilder.AddPackagedAppDeployment();
+ using ITestApplication app = await testApplicationBuilder.BuildAsync();
+ return await app.RunAsync();
+ }
+}
+
+public class DummyTestFramework : ITestFramework, IDataProducer
+{
+ public string Uid => nameof(DummyTestFramework);
+
+ public string Version => "2.0.0";
+
+ public string DisplayName => nameof(DummyTestFramework);
+
+ public string Description => nameof(DummyTestFramework);
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+
+ public Type[] DataTypesProduced => new[] { typeof(TestNodeUpdateMessage) };
+
+ public Task CreateTestSessionAsync(CreateTestSessionContext context)
+ => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });
+
+ public Task CloseTestSessionAsync(CloseTestSessionContext context)
+ => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });
+
+ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
+ {
+ await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode()
+ {
+ Uid = "Test1",
+ DisplayName = "Test1",
+ Properties = new PropertyBag(new PassedTestNodeStateProperty()),
+ }));
+
+ context.Complete();
+ }
+}
+""";
+
+ public string TargetAssetPath => GetAssetPath(AssetName);
+
+ public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName,
+ Sources
+ .PatchTargetFrameworks(TargetFrameworks.Net)
+ .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)
+ .PatchCodeWithReplace("$MicrosoftTestingExtensionsPackagedAppVersion$", MicrosoftTestingExtensionsPackagedAppVersion));
+ }
+
+ public TestContext TestContext { get; set; }
+}
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs
new file mode 100644
index 0000000000..474ebcf6b9
--- /dev/null
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs
@@ -0,0 +1,181 @@
+// 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;
+
+[TestClass]
+public sealed class TestHostLauncherTests : AcceptanceTestBase
+{
+ private const string AssetName = "TestHostLauncher";
+
+ [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))]
+ [TestMethod]
+ public async Task CustomLauncher_IsUsedToStartTestHost_AndRunSucceeds(string currentTfm)
+ {
+ var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm);
+ TestHostResult testHostResult = await testHost.ExecuteAsync(cancellationToken: TestContext.CancellationToken);
+
+ // The custom launcher actually starts the test host, so the dummy test must run and the
+ // overall run must succeed.
+ testHostResult.AssertExitCodeIs(ExitCode.Success);
+
+ // Marker proves the platform delegated the launch to our ITestHostLauncher rather than
+ // doing the default Process.Start itself.
+ Assert.AreEqual("TestHostLauncher.LaunchTestHostAsync", File.ReadAllText(Path.Combine(testHost.DirectoryName, "LaunchTestHostAsync.txt")));
+ }
+
+ public sealed class TestAssetFixture() : TestAssetFixtureBase()
+ {
+ private const string Sources = """
+#file TestHostLauncher.csproj
+
+
+
+ $TargetFrameworks$
+ Exe
+ true
+ enable
+ preview
+
+ $(NoWarn);TPEXP
+
+
+
+
+
+
+#file Program.cs
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Testing.Platform.Builder;
+using Microsoft.Testing.Platform.Capabilities.TestFramework;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+using Microsoft.Testing.Platform.Extensions.TestHost;
+using Microsoft.Testing.Platform.Extensions.TestHostControllers;
+using Microsoft.Testing.Platform.Extensions;
+using Microsoft.Testing.Platform.Services;
+
+public class Startup
+{
+ public static async Task Main(string[] args)
+ {
+ var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
+ testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework());
+ testApplicationBuilder.TestHostControllers.AddTestHostLauncher(_ => new TestHostLauncher());
+ using ITestApplication app = await testApplicationBuilder.BuildAsync();
+ return await app.RunAsync();
+ }
+}
+
+public sealed class TestHostLauncher : ITestHostLauncher
+{
+ public string Uid => nameof(TestHostLauncher);
+
+ public string Version => string.Empty;
+
+ public string DisplayName => string.Empty;
+
+ public string Description => string.Empty;
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+
+ public Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken)
+ {
+ // Write the marker next to the test host executable (context.FileName) so its location is
+ // deterministic regardless of the controller process' current working directory. The test
+ // reads it from testHost.DirectoryName, which is the same folder.
+ string markerPath = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(context.FileName)!, "LaunchTestHostAsync.txt");
+ System.IO.File.WriteAllText(markerPath, "TestHostLauncher.LaunchTestHostAsync");
+
+ var startInfo = new ProcessStartInfo(context.FileName)
+ {
+ UseShellExecute = false,
+ };
+
+ foreach (string argument in context.Arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ foreach (KeyValuePair environmentVariable in context.EnvironmentVariables)
+ {
+ startInfo.Environment[environmentVariable.Key] = environmentVariable.Value;
+ }
+
+ if (context.WorkingDirectory is not null)
+ {
+ startInfo.WorkingDirectory = context.WorkingDirectory;
+ }
+
+ Process process = Process.Start(startInfo)!;
+ return Task.FromResult(new ProcessTestHostHandle(process));
+ }
+}
+
+public sealed class ProcessTestHostHandle : ITestHostHandle
+{
+ private readonly Process _process;
+
+ public ProcessTestHostHandle(Process process) => _process = process;
+
+ public string? Identifier => _process.Id.ToString();
+
+ public int ExitCode => _process.ExitCode;
+
+ public bool HasExited => _process.HasExited;
+
+ public Task WaitForExitAsync(CancellationToken cancellationToken) => _process.WaitForExitAsync(cancellationToken);
+
+ public void Terminate() => _process.Kill();
+
+ public void Dispose() => _process.Dispose();
+}
+
+public class DummyTestFramework : ITestFramework, IDataProducer
+{
+ public string Uid => nameof(DummyTestFramework);
+
+ public string Version => "2.0.0";
+
+ public string DisplayName => nameof(DummyTestFramework);
+
+ public string Description => nameof(DummyTestFramework);
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+
+ public Type[] DataTypesProduced => new[] { typeof(TestNodeUpdateMessage) };
+
+ public Task CreateTestSessionAsync(CreateTestSessionContext context)
+ => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });
+
+ public Task CloseTestSessionAsync(CloseTestSessionContext context)
+ => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });
+
+ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
+ {
+ await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode()
+ {
+ Uid = "Test1",
+ DisplayName = "Test1",
+ Properties = new PropertyBag(new PassedTestNodeStateProperty()),
+ }));
+
+ context.Complete();
+ }
+}
+""";
+
+ public string TargetAssetPath => GetAssetPath(AssetName);
+
+ public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName,
+ Sources
+ .PatchTargetFrameworks(TargetFrameworks.Net)
+ .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion));
+ }
+
+ public TestContext TestContext { get; set; }
+}
diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs
index dc9b6e4130..7ce3569269 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs
@@ -176,6 +176,38 @@ public async Task TestHostController_ComposeFactory_ShouldSucceed(bool withParam
Assert.AreEqual(((ICompositeExtensionFactory)compositeExtensionFactory).GetInstance(), configuration.EnvironmentVariableProviders[0]);
}
+#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates.
+ [TestMethod]
+ public async Task TestHostLauncher_WhenRegistered_ForcesProcessRestartAndIsStored()
+ {
+ TestHostControllersManager testHostControllerManager = new();
+ TestHostLauncher launcher = new("launcher");
+ testHostControllerManager.AddTestHostLauncher(_ => launcher);
+ TestHostControllerConfiguration configuration = await testHostControllerManager.BuildAsync(_serviceProvider);
+ Assert.IsTrue(configuration.RequireProcessRestart);
+ Assert.AreEqual((object)launcher, configuration.TestHostLauncher);
+ }
+
+ [TestMethod]
+ public async Task TestHostLauncher_MultipleRegistered_ShouldFail()
+ {
+ TestHostControllersManager testHostControllerManager = new();
+ testHostControllerManager.AddTestHostLauncher(_ => new TestHostLauncher("launcher1"));
+ testHostControllerManager.AddTestHostLauncher(_ => new TestHostLauncher("launcher2"));
+ await Assert.ThrowsExactlyAsync(() => testHostControllerManager.BuildAsync(_serviceProvider));
+ }
+
+ [TestMethod]
+ public async Task TestHostLauncher_DuplicatedId_ShouldFail()
+ {
+ TestHostControllersManager testHostControllerManager = new();
+ testHostControllerManager.AddTestHostLauncher(_ => new TestHostLauncher("duplicatedId"));
+ testHostControllerManager.AddTestHostLauncher(_ => new TestHostLauncher("duplicatedId"));
+ InvalidOperationException invalidOperationException = await Assert.ThrowsExactlyAsync(() => testHostControllerManager.BuildAsync(_serviceProvider));
+ Assert.IsTrue(invalidOperationException.Message.Contains("duplicatedId") && invalidOperationException.Message.Contains(typeof(TestHostLauncher).ToString()));
+ }
+#pragma warning restore TPEXP
+
[DataRow(true)]
[DataRow(false)]
[TestMethod]
@@ -273,6 +305,25 @@ private sealed class TestHostProcessLifetimeHandler : ITestHostProcessLifetimeHa
public Task OnTestHostProcessStartedAsync(ITestHostProcessInformation testHostProcessInformation, CancellationToken cancellationToken) => throw new NotImplementedException();
}
+#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates.
+ private sealed class TestHostLauncher : ITestHostLauncher
+ {
+ public TestHostLauncher(string id) => Uid = id;
+
+ public string Uid { get; }
+
+ public string Version => nameof(TestHostLauncher);
+
+ public string DisplayName => nameof(TestHostLauncher);
+
+ public string Description => nameof(TestHostLauncher);
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+
+ public Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken) => throw new NotImplementedException();
+ }
+#pragma warning restore TPEXP
+
private sealed class TestHostEnvironmentVariableProvider : ITestHostEnvironmentVariableProvider
{
public TestHostEnvironmentVariableProvider(string id) => Uid = id;