Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
251f79f
Add generic ITestHostLauncher test host launcher extension point
Evangelink Jun 23, 2026
42a31e4
Add AppDeployment extension and support PID-less test host launchers
Evangelink Jun 23, 2026
6e03c9b
Rename AppDeployment extension to scenario-specific WinUI
Evangelink Jun 23, 2026
d33ff64
Rename WinUI extension to Msix to cover both UWP and WinUI
Evangelink Jun 23, 2026
d8fc92d
Rename Msix extension to PackagedApp (scenario, not format/tooling)
Evangelink Jun 23, 2026
6195ac4
Address review feedback on ITestHostLauncher implementation
Evangelink Jun 26, 2026
cc4662c
Refine ITestHostHandle: drop Exited, replace ProcessId with string Id…
Evangelink Jun 26, 2026
e82cb3c
Address code-quality review: non-empty/specific catch handling
Evangelink Jun 26, 2026
afaf79d
Merge remote-tracking branch 'origin/main' into evangelink-generic-te…
Evangelink Jun 26, 2026
f6d88b1
Fix stale Exited reference in PackagedApp launcher comment
Evangelink Jun 26, 2026
1923692
Add CancellationToken to WaitForExitAsync and make ITestHostHandle ID…
Evangelink Jun 26, 2026
5f09f29
Address launcher review: no shipping breadcrumb, honor cancellation +…
Evangelink Jun 26, 2026
2ecaea0
Merge remote-tracking branch 'origin/main' into evangelink-generic-te…
Evangelink Jun 26, 2026
ca2edd8
Make PackagedApp APIs and package version experimental; add MSBuild r…
Evangelink Jun 26, 2026
6380a39
Address review: don't raise synthetic Exited on fault/cancel; determi…
Evangelink Jun 26, 2026
5ee38da
Add PackagedApp to NonWindowsTests/Platform solution filters so the p…
Evangelink Jun 26, 2026
a6dff39
Address review: best-effort Kill on cancel; single NoWarn in deployme…
Evangelink Jun 26, 2026
b577f63
Merge remote-tracking branch 'origin/main' into evangelink-generic-te…
Evangelink Jun 28, 2026
42cdfbe
Fix forward-compat regression (restore IProcess.WaitForExitAsync()) a…
Evangelink Jun 28, 2026
e4855ea
Address review: keep MIT license, localize launcher strings, use buil…
Evangelink Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@
-->
<MicrosoftTestingExtensionsOpenTelemetryVersionPrefix>1.0.0</MicrosoftTestingExtensionsOpenTelemetryVersionPrefix>

<MicrosoftTestingExtensionsPackagedAppPreReleaseVersionLabel>alpha</MicrosoftTestingExtensionsPackagedAppPreReleaseVersionLabel>

<!--
This is an early preview package, keep 1.0.0-alpha or similar suffix even in official builds.
-->
<MicrosoftTestingExtensionsPackagedAppVersionPrefix>1.0.0</MicrosoftTestingExtensionsPackagedAppVersionPrefix>

<MicrosoftTestingExtensionsVideoRecorderPreReleaseVersionLabel>alpha</MicrosoftTestingExtensionsVideoRecorderPreReleaseVersionLabel>

<!--
Expand Down
1 change: 1 addition & 0 deletions Microsoft.Testing.Platform.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"src\\Platform\\Microsoft.Testing.Extensions.Logging\\Microsoft.Testing.Extensions.Logging.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.MSBuild\\Microsoft.Testing.Extensions.MSBuild.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.OpenTelemetry\\Microsoft.Testing.Extensions.OpenTelemetry.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.PackagedApp\\Microsoft.Testing.Extensions.PackagedApp.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.Retry\\Microsoft.Testing.Extensions.Retry.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.Telemetry\\Microsoft.Testing.Extensions.Telemetry.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.TrxReport.Abstractions\\Microsoft.Testing.Extensions.TrxReport.Abstractions.csproj",
Expand Down
1 change: 1 addition & 0 deletions NonWindowsTests.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"src\\Platform\\Microsoft.Testing.Extensions.Logging\\Microsoft.Testing.Extensions.Logging.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.MSBuild\\Microsoft.Testing.Extensions.MSBuild.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.OpenTelemetry\\Microsoft.Testing.Extensions.OpenTelemetry.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.PackagedApp\\Microsoft.Testing.Extensions.PackagedApp.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.Retry\\Microsoft.Testing.Extensions.Retry.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.Telemetry\\Microsoft.Testing.Extensions.Telemetry.csproj",
"src\\Platform\\Microsoft.Testing.Extensions.TrxReport.Abstractions\\Microsoft.Testing.Extensions.TrxReport.Abstractions.csproj",
Expand Down
1 change: 1 addition & 0 deletions TestFx.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
<Project Path="src/Platform/Microsoft.Testing.Extensions.Logging/Microsoft.Testing.Extensions.Logging.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.MSBuild/Microsoft.Testing.Extensions.MSBuild.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/Microsoft.Testing.Extensions.OpenTelemetry.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.Retry/Microsoft.Testing.Extensions.Retry.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.Telemetry/Microsoft.Testing.Extensions.Telemetry.csproj" />
<Project Path="src/Platform/Microsoft.Testing.Extensions.TrxReport.Abstractions/Microsoft.Testing.Extensions.TrxReport.Abstractions.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ private async Task TakeDumpOfTreeAsync(CancellationToken cancellationToken)
if (!p.HasExited)
{
p.Kill();
await p.WaitForExitAsync().ConfigureAwait(false);
await p.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
}
}
catch (Exception e)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
P:System.DateTime.Now; Use 'IClock' instead
P:System.DateTime.UtcNow; Use 'IClock' instead
M:System.Threading.Tasks.Task.Run(System.Action); Use 'ITask' instead
M:System.Threading.Tasks.Task.WhenAll(System.Threading.Tasks.Task[]); Use 'ITask' instead
M:System.Threading.Tasks.Task.WhenAll(System.Collections.Generic.IEnumerable{System.Threading.Tasks.Task}); Use 'ITask' instead
M:System.String.IsNullOrEmpty(System.String); Use 'RoslynString.IsNullOrEmpty' instead
M:System.String.IsNullOrWhiteSpace(System.String); Use 'RoslynString.IsNullOrWhiteSpace' instead
M:System.Diagnostics.Debug.Assert(System.Boolean); Use 'RoslynDebug.Assert' instead
M:System.Diagnostics.Debug.Assert(System.Boolean,System.String); Use 'RoslynDebug.Assert' instead
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<RootNamespace>Microsoft.Testing.Extensions.PackagedApp</RootNamespace>
<!-- The packaged-app launcher uses modern Process APIs (ArgumentList, WaitForExitAsync,
Kill(entireProcessTree)) and targets runtime hosts, so it ships only for .NET. -->
<TargetFrameworks>$(SupportedNetFrameworks)</TargetFrameworks>
<GenerateBuildInfo>true</GenerateBuildInfo>
<BuildInfoTemplateFile>$(RepoRoot)src\Platform\SharedExtensionHelpers\BuildInfo.cs.template</BuildInfoTemplateFile>
<!-- This extension consumes the experimental ITestHostLauncher extension point. -->
<NoWarn>$(NoWarn);TPEXP</NoWarn>
<!-- Early preview/experimental package: keep an experimental (alpha) version even in official builds. -->
<VersionPrefix>$(MicrosoftTestingExtensionsPackagedAppVersionPrefix)</VersionPrefix>
<PreReleaseVersionLabel>$(MicrosoftTestingExtensionsPackagedAppPreReleaseVersionLabel)</PreReleaseVersionLabel>
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
</PropertyGroup>

<!-- NuGet properties -->
<PropertyGroup>
<PackageDescription>
<![CDATA[This package provides extensions to Microsoft Testing Platform intended to extend base functionalities.

This package extends Microsoft Testing Platform to deploy and launch a packaged Windows (UWP/WinUI) test host through a custom mechanism instead of starting it in place.]]>
</PackageDescription>
</PropertyGroup>

<ItemGroup>
<AdditionalFiles Include="BannedSymbols.txt" />
<AdditionalFiles Include="PublicAPI/PublicAPI.Shipped.txt" />
<AdditionalFiles Include="PublicAPI/PublicAPI.Unshipped.txt" />
</ItemGroup>

<ItemGroup>
<Compile Include="$(RepoRoot)src/Polyfills/**/*.cs" Link="Polyfills\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

<!-- NuGet package layout -->
<!-- NuGet folders https://learn.microsoft.com/nuget/create-packages/creating-a-package#from-a-convention-based-working-directory -->
<ItemGroup>
<Content Include="buildMultiTargeting/**">
<Pack>true</Pack>
<PackagePath>buildMultiTargeting</PackagePath>
</Content>
<TfmSpecificPackageFile Include="buildTransitive/**">
<PackagePath>buildTransitive/$(TargetFramework)</PackagePath>
</TfmSpecificPackageFile>
<TfmSpecificPackageFile Include="build/**">
<PackagePath>build/$(TargetFramework)</PackagePath>
</TfmSpecificPackageFile>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Microsoft.Testing.Platform.csproj" />
</ItemGroup>

</Project>
34 changes: 34 additions & 0 deletions src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md
Original file line number Diff line number Diff line change
@@ -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 <https://aka.ms/testingplatform>.

## 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.
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides extension methods for adding packaged Windows (UWP/WinUI) test host deployment support
/// to the test application builder.
/// </summary>
[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
public static class PackagedAppExtensions
{
/// <summary>
/// Registers a test host launcher that deploys the packaged Windows (UWP/WinUI) test host into
/// an isolated directory and launches it from there.
/// </summary>
/// <param name="builder">The test application builder.</param>
public static void AddPackagedAppDeployment(this ITestApplicationBuilder builder)
{
_ = builder ?? throw new System.ArgumentNullException(nameof(builder));
builder.TestHostControllers.AddTestHostLauncher(_ => new PackagedAppTestHostLauncher());
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// An <see cref="ITestHostHandle"/> 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.
/// </summary>
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}");
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// An <see cref="ITestHostLauncher"/> 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.
/// </summary>
/// <remarks>
/// <para>
/// Packaged Windows apps — produced by both UWP and packaged WinUI projects, and shipped as MSIX —
/// cannot be started with a plain <c>Process.Start</c> from the build output. They share the same
/// launch mechanism: the loose layout is registered with the <c>PackageManager</c> and the app is
/// activated by Application User Model ID (AUMID) via <c>IApplicationActivationManager</c>. That
/// mechanism is the reason VSTest's <c>UwpTestHostRuntimeProvider</c> exists (it serves both UWP and
/// WinUI); see https://github.com/microsoft/testfx/issues/2784.
/// </para>
/// <para>
/// 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 <c>PackageManager.RegisterPackageByUriAsync</c> and calling
/// <c>IApplicationActivationManager.ActivateApplication</c> — is a clearly-marked follow-up.
/// </para>
/// <para>
/// 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 <see cref="ITestHostHandle"/> the platform monitors.
/// </para>
/// </remarks>
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<bool> IsEnabledAsync() => Task.FromResult(true);

public Task<ITestHostHandle> 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);

Comment thread
Evangelink marked this conversation as resolved.
// 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<string, string?> 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}'.");
Comment thread
Evangelink marked this conversation as resolved.

// 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<ITestHostHandle>(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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#nullable enable
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading