From 251f79fdf870c46eaa45c1edcbedb372ba534114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 23 Jun 2026 16:10:48 +0200 Subject: [PATCH 01/17] Add generic ITestHostLauncher test host launcher extension point Introduce a public, experimental Microsoft.Testing.Platform extension point that lets an extension control how the out-of-process test host is launched, replacing the platform's default Process.Start. The abstraction is agnostic of the launch mechanism (process, packaged/MSIX deploy+activate, container, remote): the launcher returns an ITestHostHandle exposing only lifecycle (WaitForExitAsync, ExitCode, HasExited, Exited, Terminate) with an optional ProcessId for diagnostics. - New public types: ITestHostLauncher, ITestHostHandle, TestHostLaunchContext (all [Experimental(TPEXP)], no init accessors). - ITestHostControllersManager.AddTestHostLauncher overloads + manager wiring; registering a launcher forces the controller host (RequireProcessRestart) and at most one launcher is allowed (localized OnlyOneTestHostLauncherSupported). - TestHostControllersTestHost delegates the launch to the registered launcher and adapts the returned handle to the internal IProcess monitoring contract, tolerating a null PID for container/remote launches. - Unit tests for restart-forcing, singleton, and duplicate-id validation. - Acceptance test with a real consuming launcher proving end-to-end delegation. - Rework RFC 017 to the generic ITestHostLauncher/ITestHostHandle shape and a package/deploy framing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/RFCs/017-TestHost-Launcher.md | 386 ++++++++++++++++++ .../System/TestHostHandleToProcessAdapter.cs | 53 +++ .../Hosts/TestHostControllersTestHost.cs | 34 +- .../PublicAPI/PublicAPI.Unshipped.txt | 17 + .../Resources/PlatformResources.resx | 3 + .../Resources/xlf/PlatformResources.cs.xlf | 5 + .../Resources/xlf/PlatformResources.de.xlf | 5 + .../Resources/xlf/PlatformResources.es.xlf | 5 + .../Resources/xlf/PlatformResources.fr.xlf | 5 + .../Resources/xlf/PlatformResources.it.xlf | 5 + .../Resources/xlf/PlatformResources.ja.xlf | 5 + .../Resources/xlf/PlatformResources.ko.xlf | 5 + .../Resources/xlf/PlatformResources.pl.xlf | 5 + .../Resources/xlf/PlatformResources.pt-BR.xlf | 5 + .../Resources/xlf/PlatformResources.ru.xlf | 5 + .../Resources/xlf/PlatformResources.tr.xlf | 5 + .../xlf/PlatformResources.zh-Hans.xlf | 5 + .../xlf/PlatformResources.zh-Hant.xlf | 5 + .../ITestHostControllerManager.cs | 18 + .../TestHostControllers/ITestHostHandle.cs | 54 +++ .../TestHostControllers/ITestHostLauncher.cs | 32 ++ .../TestHostControllerConfiguration.cs | 3 + .../TestHostControllersManager.cs | 108 +++++ .../TestHostLaunchContext.cs | 56 +++ .../TestHostLauncherTests.cs | 182 +++++++++ .../TestApplicationBuilderTests.cs | 51 +++ 26 files changed, 1059 insertions(+), 3 deletions(-) create mode 100644 docs/RFCs/017-TestHost-Launcher.md create mode 100644 src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostLauncher.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/TestHostControllers/TestHostLaunchContext.cs create mode 100644 test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md new file mode 100644 index 0000000000..106cb2418b --- /dev/null +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -0,0 +1,386 @@ +# RFC 017 - Custom test host launcher + +- [ ] Approved in principle +- [x] Under discussion +- [x] Implementation +- [ ] Shipped + +## Summary + +Introduce **`ITestHostLauncher`**: a public, experimental Microsoft.Testing.Platform (MTP) +extension point that lets an extension control **how** the out-of-process test host is launched, +instead of the platform always doing `Process.Start`. The platform still owns everything around +the launch — argument/environment preparation, the controller↔host IPC pipe, PID tracking, +`ITestHostProcessLifetimeHandler` callbacks, and exit-code reconciliation — and simply delegates +the single "create and start the test host" step to the registered launcher. + +The hook is deliberately **agnostic of the launch mechanism**: 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. To make this explicit, the launcher returns an `ITestHostHandle` that +exposes only the lifecycle the platform needs (`WaitForExitAsync`, `ExitCode`, `HasExited`, +`Exited`, `Terminate`); a process id is *optional* and used purely for diagnostics. + +The motivating scenario is **packaging and deployment of WinUI applications** (see +[#2784](https://github.com/microsoft/testfx/issues/2784)): packaged/MSIX apps cannot be started with +`Process.Start` and must be deployed and then activated by AUMID, while unpackaged WinUI apps +similarly benefit from a custom deploy + launch step. The same hook also enables launching the test +host under a debugger, elevated, inside a container, or on a remote machine. + +## Motivation + +MTP runs the test host out-of-process whenever a "test host controller" extension is active (hang +dump, crash dump, or any `ITestHostProcessLifetimeHandler` / `ITestHostEnvironmentVariableProvider`). +That work happens in `TestHostControllersTestHost`, which prepares a `ProcessStartInfo` (arguments, +environment variables including the `MONITORTOHOST` pipe name) and then launches the host with a +single call: + +```csharp +using IProcess testHostProcess = process.Start(processStartInfo); +``` + +Everything downstream only needs a handful of things from the returned handle — a way to observe +exit (`Exited`, `WaitForExitAsync()`, `ExitCode`, `HasExited`), optionally a PID for logging, and a +way to tear it down (`Kill`) — plus the child connecting back on the named pipe whose name was +injected via an environment variable. **`Process.Start` is the only assumption that does not hold +universally.** Several real scenarios need a different launch mechanism: + +- **Packaged WinUI/MSIX**: a packaged app must be deployed (in Developer Mode, register the loose + layout) and then activated by Application User Model ID (AUMID) via `IApplicationActivationManager`, + not started from an executable path. This is the blocker behind + [#2784](https://github.com/microsoft/testfx/issues/2784) and the reason VSTest's + `UwpTestHostRuntimeProvider` exists. +- **Debugger attach/launch**: start the host suspended (or under a debugger launcher such as + `vsdbg` / `WinDbg` / `dlv`) and only then resume. +- **Elevation**: run the test host as administrator (UAC) or as another user. +- **Container / remote**: launch the host inside a container (`docker run`) or on a remote device + over SSH/WinRM, then bridge the pipe — neither of which exposes a local, query-able PID. + +Today none of these is possible without forking the platform. The existing experimental +`ITestHostExecutionOrchestrator` sits at the wrong layer (see [Alternatives](#alternatives)). This +RFC adds the *minimal* hook at exactly the launch site. + +## Goals + +- Let an extension substitute the test host launch step while the platform keeps owning + argument/env preparation, IPC, lifetime-handler dispatch, and exit-code handling. +- Keep hang dump, crash dump, and all `ITestHostProcessLifetimeHandler` / + `ITestHostEnvironmentVariableProvider` extensions working unchanged when a custom launcher is + present. +- Be generic enough to cover WinUI deploy+activate, debugger, elevation, container, and remote + launch with one shape, **without assuming the launched thing is a local OS process**. +- Follow MTP's experimental-API conventions so the surface can evolve before stabilizing. + +## Non-goals + +- Replacing the *entire* run loop (that is `ITestHostExecutionOrchestrator`'s job). +- Remote **device deployment/bootstrapping** of the Windows App SDK framework + agent (VSTest's + `Microsoft.UniversalApps.Deployment` has no public redistributable; out of scope — local launch + only). +- Shipping the WinUI package/deploy extension itself. This RFC only adds the platform hook; the + package/deploy extension is a separate deliverable that consumes it. +- Changing the in-process (single-process, `ConsoleTestHost`) execution path. + +## Detailed design + +### Where it plugs in + +The hook lives in the **test host controllers** layer, next to the existing lifetime-handler and +environment-variable-provider extension points, and is registered through +`ITestHostControllersManager`. + +```csharp +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. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public interface ITestHostLauncher : ITestHostControllersExtension // : IExtension +{ + /// + /// 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. + /// + Task LaunchTestHostAsync( + TestHostLaunchContext context, + CancellationToken cancellationToken); +} +``` + +The platform passes the fully-prepared launch information: + +```csharp +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public sealed class TestHostLaunchContext +{ + public TestHostLaunchContext( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary environmentVariables, + string? workingDirectory); + + /// The default test host executable path the platform would have started. + public string FileName { get; } + + /// Arguments, already including the test host controller PID option. + public IReadOnlyList Arguments { get; } + + /// + /// The final environment for the test host, after all + /// ran. Includes the + /// controller↔host IPC pipe name the host must connect back on. + /// + public IReadOnlyDictionary EnvironmentVariables { get; } + + /// The working directory, or null to inherit the current one. + public string? WorkingDirectory { get; } +} +``` + +And the launcher returns a launch-mechanism-agnostic handle (the platform adapts it to its internal +monitoring contract): + +```csharp +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public interface ITestHostHandle +{ + event EventHandler Exited; + + /// The OS process id, when available. Null for container/remote launches. Logging only. + int? ProcessId { get; } + + int ExitCode { get; } + bool HasExited { get; } + Task WaitForExitAsync(); + + /// Best-effort teardown (e.g. when hang dump aborts the run). + void Terminate(); +} +``` + +Registration mirrors the existing methods on `ITestHostControllersManager`: + +```csharp +public interface ITestHostControllersManager +{ + // existing: AddEnvironmentVariableProvider(...), AddProcessLifetimeHandler(...) + + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + void AddTestHostLauncher(Func testHostLauncherFactory); + + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + void AddTestHostLauncher(CompositeExtensionFactory compositeServiceFactory) + where T : class, ITestHostLauncher; +} +``` + +### Platform integration (what changes inside MTP) + +1. **Swap the launch call.** In `TestHostControllersTestHost.InternalRunAsync`, at the current + `process.Start(processStartInfo)` site (after `BeforeTestHostProcessStartAsync` and after all + env-var providers ran), if a launcher is registered, build a `TestHostLaunchContext` from the + `ProcessStartInfo` and `await launcher.LaunchTestHostAsync(...)`. Otherwise keep the default + `process.Start`. The returned `ITestHostHandle` is adapted to the internal `IProcess` monitoring + contract — which only uses `Id` / `Exited` / `WaitForExitAsync` / `ExitCode` / `HasExited` / + `Kill`. Because `ProcessId` is optional, the PID-access path tolerates a `null` PID for + container/remote launchers. +2. **Force the controller host.** A launcher makes `RequireProcessRestart` `true` when one is + registered (computed in `TestHostControllersManager.BuildAsync`, checked in + `TestHostBuilder.Modes.cs`); without this, a run with *only* a launcher (no dump/lifetime + extension) would stay in-process and there would be nothing to launch. +3. **Singleton.** At most one launcher may be registered; a duplicate fails fast at build time with + a localized "only one test host launcher" error. +4. **Preserve ordering and services.** Because the call stays at the same point, + `ITestHostEnvironmentVariableProvider`, the `MONITORTOHOST` IPC pipe, the PID handshake, and + `ITestHostProcessLifetimeHandler` (and therefore hang dump and crash dump) all keep working with + no changes. + +### Contract requirements on the launcher + +- The launched host **must** receive `context.EnvironmentVariables` (so it connects back on the + controller pipe) and **must** be passed `context.Arguments`. +- The returned handle must report exit reliably (`WaitForExitAsync`, `ExitCode`, `HasExited`, + `Exited`) and support `Terminate()` (hang dump terminates the host through it). +- `ProcessId` may be `null` when there is no local, query-able process (container/remote). It is + used only for diagnostics. +- If the launcher cannot start the host it should throw; the platform surfaces it as a + platform-setup failure. + +## Examples + +All examples assume the extension is registered on the builder, e.g. from a `…Extensions` helper: + +```csharp +builder.TestHostControllers.AddTestHostLauncher(sp => new MyLauncher(sp)); +``` + +### 1. Packaged WinUI / MSIX (the motivating case) + +Deploy the loose layout (Developer Mode) and activate the packaged app by AUMID instead of starting +an exe. The activated app self-hosts MTP (as the `MSTestRunnerWinUI` sample already does) and +connects back on the env-provided pipe. + +```csharp +public Task LaunchTestHostAsync( + TestHostLaunchContext context, CancellationToken cancellationToken) +{ + // 1. Parse the .appxrecipe / AppxManifest.xml next to context.FileName to get the AUMID + // and (in Developer Mode) register the loose layout: + // new PackageManager().RegisterPackageByUriAsync(manifestUri, options); + string aumid = AppxManifest.ResolveAumid(context.FileName); + + // 2. Activate, passing the SAME args the platform prepared. + var aam = (IApplicationActivationManager)new ApplicationActivationManager(); + aam.ActivateApplication(aumid, string.Join(' ', context.Arguments), ACTIVATEOPTIONS.AO_NONE, out uint pid); + + // 3. Wrap the returned PID. The app inherits context.EnvironmentVariables + // (incl. the MONITORTOHOST pipe name) via its activation/launch profile. + return Task.FromResult(new ProcessIdHandle((int)pid)); +} +``` + +> Note: enabling the controller→host pipe across the AppContainer sandbox requires a loopback/pipe-ACL +> step (e.g. `CheckNetIsolation LoopbackExempt` or granting the package SID on the pipe). That belongs +> to the package/deploy extension, not the platform. + +### 2. Launch under a debugger + +```csharp +public async Task LaunchTestHostAsync( + TestHostLaunchContext context, CancellationToken cancellationToken) +{ + var psi = new ProcessStartInfo(context.FileName) { UseShellExecute = false }; + foreach (string arg in context.Arguments) psi.ArgumentList.Add(arg); + foreach (var kvp in context.EnvironmentVariables) psi.Environment[kvp.Key] = kvp.Value; + psi.Environment["DOTNET_DefaultDiagnosticPortSuspend"] = "1"; // start suspended + + Process p = Process.Start(psi)!; + await DebuggerLauncher.AttachAsync(p.Id, cancellationToken); // e.g. vsdbg / WinDbg / dlv + await DebuggerLauncher.ResumeAsync(p.Id, cancellationToken); + return new ProcessHandleAdapter(p); +} +``` + +### 3. Elevated (run as administrator) + +```csharp +public Task LaunchTestHostAsync( + TestHostLaunchContext context, CancellationToken cancellationToken) +{ + var psi = new ProcessStartInfo(context.FileName) + { + UseShellExecute = true, // required for the UAC "runas" verb + Verb = "runas", + }; + foreach (string arg in context.Arguments) psi.ArgumentList.Add(arg); + // NOTE: UseShellExecute = true cannot pass per-process env vars; an elevated launcher + // must forward context.EnvironmentVariables another way (e.g. a temp response file the + // host reads, or a broker that sets them) so the host still finds the controller pipe. + Process p = Process.Start(psi)!; + return Task.FromResult(new ProcessHandleAdapter(p)); +} +``` + +This example deliberately shows a sharp edge: elevation via the shell loses per-process environment +variables, so the launcher is responsible for re-delivering them. The platform contract only +requires that the host ends up with `context.EnvironmentVariables`. + +### 4. Container + +Run the test host inside a container and bridge the pipe. The returned handle tracks the +`docker run` client process; `Terminate()` tears down the container. + +```csharp +public Task LaunchTestHostAsync( + TestHostLaunchContext context, CancellationToken cancellationToken) +{ + var args = new List { "run", "--rm", "--init" }; + foreach (var kvp in context.EnvironmentVariables) { args.Add("-e"); args.Add($"{kvp.Key}={kvp.Value}"); } + // Map the controller pipe into the container (Windows named pipe / Unix domain socket mount). + args.Add("test-image:latest"); + args.Add(context.FileName); + args.AddRange(context.Arguments); + + var psi = new ProcessStartInfo("docker") { UseShellExecute = false }; + foreach (string a in args) psi.ArgumentList.Add(a); + Process p = Process.Start(psi)!; + return Task.FromResult(new ProcessHandleAdapter(p)); +} +``` + +### 5. Remote (SSH) + +```csharp +public Task LaunchTestHostAsync( + TestHostLaunchContext context, CancellationToken cancellationToken) +{ + string env = string.Join(' ', context.EnvironmentVariables + .Where(kv => kv.Value is not null) + .Select(kv => $"{kv.Key}={Quote(kv.Value!)}")); // values are nullable; skip unset vars + string remoteCmd = $"{env} {Quote(context.FileName)} {string.Join(' ', context.Arguments.Select(Quote))}"; + + var psi = new ProcessStartInfo("ssh") { UseShellExecute = false }; + psi.ArgumentList.Add("user@remote-host"); + psi.ArgumentList.Add(remoteCmd); + Process ssh = Process.Start(psi)!; // tunnel the controller pipe over the SSH connection + // The handle tracks the local ssh client; ProcessId here is the ssh client PID (diagnostic only). + return Task.FromResult(new ProcessHandleAdapter(ssh)); +} +``` + +## Alternatives considered + +### Reuse `ITestHostExecutionOrchestrator` + +MTP already ships an experimental `ITestHostExecutionOrchestrator` +(`ITestHostOrchestratorManager.AddTestHostOrchestrator`). It was rejected as the vehicle because it +sits **above** the controller: `OrchestrateTestHostExecutionAsync` runs in +`TestHostOrchestratorHost` and replaces the *entire* execution, returning only an exit code. An +implementation would have to re-create everything `TestHostControllersTestHost` provides — +environment-variable providers, the `MONITORTOHOST` IPC/PID handshake, and the +`ITestHostProcessLifetimeHandler` fan-out that **hang dump and crash dump depend on**. That is the +wrong granularity for "launch the host differently." The orchestrator remains the right tool for +whole-run concerns (e.g. retry/repeat that re-runs the host). + +### Make the internal `IProcessHandler` replaceable via DI + +`IProcessHandler` / `IProcess` are `internal` and surface `Process`-specific members (e.g. +`MainModule`). Exposing them publicly would leak implementation detail, over-commit the surface, and +bake in the "it's always a local process" assumption. A purpose-built, minimal, mechanism-agnostic +`ITestHostHandle` is cleaner and evolvable. + +### A process-centric `ITestHostProcessLauncher` returning a `ProcessId` + +An earlier draft of this RFC named the hook `ITestHostProcessLauncher` and returned an +`ITestHostProcessHandle` whose `ProcessId` was mandatory. That over-commits to "the test host is a +local OS process," which is false for container and remote launches and awkward for AUMID-activated +apps. The current design renames the types to drop "Process", makes `ProcessId` optional, and names +the teardown `Terminate()` instead of `Kill()`. + +### Do nothing (keep `Process.Start`) + +Leaves [#2784](https://github.com/microsoft/testfx/issues/2784) unsolvable on MTP for packaged apps +and blocks the debugger/elevation/container/remote scenarios, all of which today require forking the +platform. + +## Compatibility and conventions + +- **Experimental.** All new types and methods are gated behind + `[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]`, consistent + with the other test-host-controller-era experimental APIs. +- **Public API tracking.** New members are added to `PublicAPI.Unshipped.txt` with the `[TPEXP]` + prefix. +- **No `init` accessors** on any new public API, per repo policy. +- **No behavior change when unused.** If no launcher is registered, the platform behaves exactly as + today (`Process.Start`), and the controller host is selected only when it already would be. + +## Open questions + +- **CLI/debug integration.** Should the platform expose a built-in `--launcher`-style selector, or + is builder/MSBuild registration sufficient for v1? +- **Cancellation semantics.** Define precisely what `Terminate()` must guarantee for remote/container + launchers (best-effort teardown vs. synchronous termination). +- **Multiple launchers.** Singleton for v1; is there ever a composition story (e.g. debugger + + elevation), or do implementers compose manually? 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..1b641917fe --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +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; + + public TestHostHandleToProcessAdapter(ITestHostHandle handle) + { + _handle = handle; + _handle.Exited += OnHandleExited; + } + + public event EventHandler? Exited; + + public int Id => _handle.ProcessId ?? throw new InvalidOperationException(); + + public string Name => string.Empty; + + public int ExitCode => _handle.ExitCode; + + public bool HasExited => _handle.HasExited; + + public IMainModule? MainModule => null; + + public DateTime StartTime => default; + + private void OnHandleExited(object? sender, EventArgs e) + => Exited?.Invoke(this, e); + + public Task WaitForExitAsync() => _handle.WaitForExitAsync(); + + public void WaitForExit() => _handle.WaitForExitAsync().GetAwaiter().GetResult(); + + public void Kill() => _handle.Terminate(); + + public void Dispose() + { + _handle.Exited -= OnHandleExited; + (_handle as IDisposable)?.Dispose(); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs index 740dcf7c91..96e6871a80 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 += (_, _) => @@ -397,6 +402,29 @@ 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); + 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 7dc5c58110..3e52184a5f 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,18 @@ #nullable enable +[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle +[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.Exited -> System.EventHandler! +[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.ProcessId.get -> int? +[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.Terminate() -> void +[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.WaitForExitAsync() -> 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 6d0728af08..5eee1540a7 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx +++ b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx @@ -802,6 +802,9 @@ Takes one argument as a time value with an explicit unit suffix. Accepted suffix 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 a9b0fbe502..54f8075014 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 4a3289c03d..b8485aae88 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. + + Passed Bestanden 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 adb2809ee8..51fa4cedc5 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. + + Passed Correcta 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 73449fc7ba..2b5464c45c 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. + + Passed Ré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 fed4ca092e..2841943f42 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. + + Passed Superato 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 8b9d711f3d..ccbc0d6cd1 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 de0fce2e9d..432773909b 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 39e6793c0d..bdf966e010 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. + + Passed Powodzenie 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 2d8ebfedc7..b943933960 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. + + Passed Aprovado 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 e4353902c8..988ad6b344 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 c14460f934..12de6db8cb 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. + + Passed Baş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 52fe96d4d2..05d1a6c083 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 87a7ed0d2d..6d668c801b 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..f3ab1773b8 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs @@ -0,0 +1,54 @@ +// 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 optional and used only for diagnostics; +/// the platform tracks completion through , , +/// , and the event. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public interface ITestHostHandle +{ + /// + /// Occurs when the test host exits. + /// + event EventHandler Exited; + + /// + /// Gets the operating-system process identifier of the test host, when one is available. + /// + /// + /// Returns when the launch mechanism does not expose a local, queryable + /// process id (for example a container or a remote launch). The value is used only for logging. + /// + int? ProcessId { get; } + + /// + /// Gets the exit code of the test host. Only valid once is + /// . + /// + int ExitCode { get; } + + /// + /// Gets a value indicating whether the test host has exited. + /// + bool HasExited { get; } + + /// + /// Waits asynchronously for the test host to exit. + /// + /// A task that completes when the test host has exited. + Task WaitForExitAsync(); + + /// + /// 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/TestHostLauncherTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs new file mode 100644 index 0000000000..884b56ac48 --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs @@ -0,0 +1,182 @@ +// 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) + { + System.IO.File.WriteAllText("LaunchTestHostAsync.txt", "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; + _process.EnableRaisingEvents = true; + _process.Exited += (sender, e) => Exited?.Invoke(this, e); + } + + public event EventHandler? Exited; + + public int? ProcessId => _process.Id; + + public int ExitCode => _process.ExitCode; + + public bool HasExited => _process.HasExited; + + public Task WaitForExitAsync() => _process.WaitForExitAsync(); + + public void Terminate() => _process.Kill(); +} + +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; From 42a31e4cc44c530309380ee611121bcd8fbffefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 23 Jun 2026 18:12:28 +0200 Subject: [PATCH 02/17] Add AppDeployment extension and support PID-less test host launchers Prove the ITestHostLauncher hook works for a launch that is not a plain Process.Start: add a real shipping extension Microsoft.Testing.Extensions.AppDeployment that deploys (stages) the test host into an isolated directory, launches the deployed copy, and returns an ITestHostHandle that exposes no local process id. - Platform fix: the test host controller gated premature-exit on "testHostProcessId is null", which would reject a launcher that returns no PID (AUMID/container/remote). Gate on HasExited only; the real test host PID still arrives via the IPC handshake. No behavior change for the default Process.Start path (a null PID there always coincides with HasExited). - New extension: AddAppDeployment, AppDeploymentLauncher, DeployedTestHostHandle (ProcessId => null), TestingPlatformBuilderHook, build props, PublicAPI, PACKAGE.md; added to TestFx.slnx; targets the SupportedNetFrameworks set. - Acceptance test AppDeploymentTests references the packed package and asserts the host was deployed to a separate directory and the run succeeded with a PID-less handle. - RFC updated to describe the HasExited-only gating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- TestFx.slnx | 1 + docs/RFCs/017-TestHost-Launcher.md | 5 +- .../AppDeploymentExtensions.cs | 24 ++++ .../AppDeploymentLauncher.cs | 84 +++++++++++++ .../BannedSymbols.txt | 9 ++ .../DeployedTestHostHandle.cs | 54 ++++++++ ...ft.Testing.Extensions.AppDeployment.csproj | 53 ++++++++ .../PACKAGE.md | 31 +++++ .../PublicAPI/PublicAPI.Shipped.txt | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 5 + .../TestingPlatformBuilderHook.cs | 20 +++ ...oft.Testing.Extensions.AppDeployment.props | 3 + ...oft.Testing.Extensions.AppDeployment.props | 9 ++ ...oft.Testing.Extensions.AppDeployment.props | 3 + .../Hosts/TestHostControllersTestHost.cs | 7 +- .../AppDeploymentTests.cs | 118 ++++++++++++++++++ 16 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/BannedSymbols.txt create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Shipped.txt create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/TestingPlatformBuilderHook.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props create mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props create mode 100644 test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AppDeploymentTests.cs diff --git a/TestFx.slnx b/TestFx.slnx index 8a6df4c0a4..30ab91a2e6 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -38,6 +38,7 @@ + diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md index 106cb2418b..e94427bd6c 100644 --- a/docs/RFCs/017-TestHost-Launcher.md +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -184,8 +184,9 @@ public interface ITestHostControllersManager `ProcessStartInfo` and `await launcher.LaunchTestHostAsync(...)`. Otherwise keep the default `process.Start`. The returned `ITestHostHandle` is adapted to the internal `IProcess` monitoring contract — which only uses `Id` / `Exited` / `WaitForExitAsync` / `ExitCode` / `HasExited` / - `Kill`. Because `ProcessId` is optional, the PID-access path tolerates a `null` PID for - container/remote launchers. + `Kill`. Because `ProcessId` is optional, the premature-exit check is gated on `HasExited` only + (not on PID availability), so a launcher that returns no PID (container/remote/AUMID) is + monitored purely through the handle lifecycle and the IPC PID handshake. 2. **Force the controller host.** A launcher makes `RequireProcessRestart` `true` when one is registered (computed in `TestHostControllersManager.BuildAsync`, checked in `TestHostBuilder.Modes.cs`); without this, a run with *only* a launcher (no dump/lifetime diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs new file mode 100644 index 0000000000..3b44de56b0 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs @@ -0,0 +1,24 @@ +// 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.AppDeployment; +using Microsoft.Testing.Platform.Builder; + +namespace Microsoft.Testing.Extensions; + +/// +/// Provides extension methods for adding application deployment support to the test application builder. +/// +public static class AppDeploymentExtensions +{ + /// + /// Registers a test host launcher that deploys the test host into an isolated directory and + /// launches it from there. + /// + /// The test application builder. + public static void AddAppDeployment(this ITestApplicationBuilder builder) + { + _ = builder ?? throw new System.ArgumentNullException(nameof(builder)); + builder.TestHostControllers.AddTestHostLauncher(_ => new AppDeploymentLauncher()); + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs new file mode 100644 index 0000000000..15e47349a9 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs @@ -0,0 +1,84 @@ +// 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.AppDeployment; + +/// +/// An that deploys the test host payload into an isolated +/// directory and then launches it from there, instead of starting it in place. +/// +/// +/// This demonstrates a launch mechanism that is more than a pass-through Process.Start: it +/// performs a real deployment side effect, and it returns a handle that intentionally does not +/// expose a local process id — exercising the platform's mechanism-agnostic monitoring path (the +/// same shape an AUMID-activated packaged app, a container, or a remote launch would use). +/// +internal sealed class AppDeploymentLauncher : ITestHostLauncher +{ + public string Uid => nameof(AppDeploymentLauncher); + + public string Version => "1.0.0"; + + public string DisplayName => "Application deployment launcher"; + + public string Description => "Deploys the test host to an isolated directory and launches it from there."; + + public Task IsEnabledAsync() => Task.FromResult(true); + + public Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken) + { + string sourceDirectory = Path.GetDirectoryName(context.FileName) + ?? throw new InvalidOperationException($"Unable to determine the source directory of '{context.FileName}'."); + + // 1. Deploy: stage the whole test host payload into an isolated directory. + string deploymentDirectory = Path.Combine(Path.GetTempPath(), "MTPAppDeployment", Guid.NewGuid().ToString("N")); + CopyDirectory(sourceDirectory, deploymentDirectory); + + // 2. Launch the deployed copy, forwarding the platform-prepared arguments and environment + // (which include the controller IPC pipe name the host connects back on). + string deployedFileName = Path.Combine(deploymentDirectory, Path.GetFileName(context.FileName)); + var startInfo = new ProcessStartInfo(deployedFileName) + { + UseShellExecute = false, + 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 test host '{deployedFileName}'."); + + // Leave a breadcrumb next to the original app so the deployment is observable. + File.WriteAllText(Path.Combine(sourceDirectory, "AppDeployment.txt"), deploymentDirectory); + + // 3. Return a handle that does NOT surface the underlying process id: the platform must rely + // purely on the lifecycle contract (WaitForExitAsync/ExitCode/HasExited/Exited/Terminate) + // and the IPC PID handshake, exactly as it would for a container/remote/AUMID launch. + return Task.FromResult(new DeployedTestHostHandle(process)); + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + + foreach (string file in Directory.EnumerateFiles(sourceDirectory)) + { + File.Copy(file, Path.Combine(destinationDirectory, Path.GetFileName(file)), overwrite: true); + } + + foreach (string directory in Directory.EnumerateDirectories(sourceDirectory)) + { + CopyDirectory(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory))); + } + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/BannedSymbols.txt b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/BannedSymbols.txt new file mode 100644 index 0000000000..4ad892e392 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/BannedSymbols.txt @@ -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 diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs new file mode 100644 index 0000000000..e1ee957947 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs @@ -0,0 +1,54 @@ +// 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.AppDeployment; + +/// +/// An over a deployed test host process that deliberately hides the +/// underlying process id, modelling a launch where no local, query-able PID is available. +/// +internal sealed class DeployedTestHostHandle : ITestHostHandle, IDisposable +{ + private readonly Process _process; + + public DeployedTestHostHandle(Process process) + { + _process = process; + _process.EnableRaisingEvents = true; + _process.Exited += OnProcessExited; + } + + public event EventHandler? Exited; + + // Intentionally null: the platform must not depend on a local process id (container/remote/AUMID). + public int? ProcessId => null; + + public int ExitCode => _process.ExitCode; + + public bool HasExited => _process.HasExited; + + public Task WaitForExitAsync() => _process.WaitForExitAsync(); + + public void Terminate() + { + try + { + _process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // The process has already exited. + } + } + + public void Dispose() + { + _process.Exited -= OnProcessExited; + _process.Dispose(); + } + + private void OnProcessExited(object? sender, EventArgs e) + => Exited?.Invoke(this, e); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj new file mode 100644 index 0000000000..23516fea1f --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj @@ -0,0 +1,53 @@ + + + + + $(SupportedNetFrameworks) + + + License.txt + + $(NoWarn);TPEXP + + + + + + + + + + + + + + + + + + + + + + + + true + buildMultiTargeting + + + + buildTransitive/$(TargetFramework) + + + build/$(TargetFramework) + + + + + + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md new file mode 100644 index 0000000000..cde5267bc2 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md @@ -0,0 +1,31 @@ +# Microsoft.Testing.Extensions.AppDeployment + +> [!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.AppDeployment` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys the test host into an isolated directory and launches it from there, instead of starting it in place. + +It is a reference implementation of a custom `ITestHostLauncher`: a launch mechanism that does more than `Process.Start` and that does not surface a local process id, demonstrating the platform's mechanism-agnostic launch contract (the same shape an AUMID-activated packaged app, a container, or a remote launch would use). + +Microsoft.Testing.Platform is open source. You can find `Microsoft.Testing.Extensions.AppDeployment` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. + +## Install the package + +```dotnetcli +dotnet add package Microsoft.Testing.Extensions.AppDeployment +``` + +## About + +This package extends Microsoft.Testing.Platform with: + +- **Deployment + launch**: stages the 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.AppDeployment/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..c434adbb43 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +#nullable enable +Microsoft.Testing.Extensions.AppDeployment.TestingPlatformBuilderHook +Microsoft.Testing.Extensions.AppDeploymentExtensions +static Microsoft.Testing.Extensions.AppDeployment.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void +static Microsoft.Testing.Extensions.AppDeploymentExtensions.AddAppDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/TestingPlatformBuilderHook.cs new file mode 100644 index 0000000000..eb8b110da5 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/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.AppDeployment; + +/// +/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add application deployment support. +/// +public static class TestingPlatformBuilderHook +{ + /// + /// Adds application deployment support to the Testing Platform Builder. + /// + /// The test application builder. + /// The command line arguments. + public static void AddExtensions(ITestApplicationBuilder testApplicationBuilder, string[] _) + => testApplicationBuilder.AddAppDeployment(); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props new file mode 100644 index 0000000000..82d7e6c1f4 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props new file mode 100644 index 0000000000..1dcaa01b43 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props @@ -0,0 +1,9 @@ + + + + + Microsoft.Testing.Extensions.AppDeployment + Microsoft.Testing.Extensions.AppDeployment.TestingPlatformBuilderHook + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props new file mode 100644 index 0000000000..82d7e6c1f4 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs index 96e6871a80..cb824a707a 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs @@ -270,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); } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AppDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AppDeploymentTests.cs new file mode 100644 index 0000000000..2abaee0bd7 --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AppDeploymentTests.cs @@ -0,0 +1,118 @@ +// 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 AppDeploymentTests : AcceptanceTestBase +{ + private const string AssetName = "AppDeploymentTest"; + + [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task AppDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(string currentTfm) + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm); + TestHostResult testHostResult = await testHost.ExecuteAsync(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); + + // The launcher leaves a breadcrumb pointing at the isolated deployment directory it created + // and launched from. + string markerPath = Path.Combine(testHost.DirectoryName, "AppDeployment.txt"); + Assert.IsTrue(File.Exists(markerPath), $"Expected deployment marker at '{markerPath}'."); + + string deploymentDirectory = File.ReadAllText(markerPath); + Assert.IsTrue(Directory.Exists(deploymentDirectory), $"Expected deployment directory '{deploymentDirectory}' to exist."); + Assert.AreNotEqual(testHost.DirectoryName, deploymentDirectory, "The test host must have been deployed to a different directory."); + } + + public sealed class TestAssetFixture() : TestAssetFixtureBase() + { + private const string Sources = """ +#file AppDeploymentTest.csproj + + + + $TargetFrameworks$ + Exe + true + enable + preview + + + + + + + +#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) + { + var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args); + testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework()); + testApplicationBuilder.AddAppDeployment(); + 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)); + } + + public TestContext TestContext { get; set; } +} From 6e03c9b7a8aa45e3975b98dc5d32dfb1a57da8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 23 Jun 2026 19:04:27 +0200 Subject: [PATCH 03/17] Rename AppDeployment extension to scenario-specific WinUI The "AppDeployment" name was too generic: it over-promised a broad deployment feature while the implementation only demonstrated the ITestHostLauncher hook. Rename the consuming extension to Microsoft.Testing.Extensions.WinUI, anchoring it to the concrete motivating scenario (#2784) instead. - Microsoft.Testing.Extensions.WinUI: AddWinUIDeployment, WinUITestHostLauncher, WinUITestHostHandle (ProcessId => null), TestingPlatformBuilderHook (new GUID), build props, PublicAPI, PACKAGE.md; renamed in TestFx.slnx. - The launcher is framed for WinUI: it implements the unpackaged deploy-and-launch path (stage the loose layout, launch the deployed app) and documents the packaged AUMID-activation branch as clearly-marked follow-up. - Acceptance test renamed to WinUIDeploymentTests and gated to Windows; still proves end-to-end deploy + PID-less launch against the packed package. - RFC non-goal updated: a reference WinUI consumer now exists (unpackaged path); packaged AUMID activation remains a separate follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- TestFx.slnx | 2 +- docs/RFCs/017-TestHost-Launcher.md | 5 +- .../AppDeploymentLauncher.cs | 84 ------------- .../PACKAGE.md | 31 ----- .../PublicAPI/PublicAPI.Unshipped.txt | 5 - ...oft.Testing.Extensions.AppDeployment.props | 9 -- .../BannedSymbols.txt | 0 ...Microsoft.Testing.Extensions.WinUI.csproj} | 5 +- .../PACKAGE.md | 34 ++++++ .../PublicAPI/PublicAPI.Shipped.txt | 0 .../PublicAPI/PublicAPI.Unshipped.txt | 5 + .../TestingPlatformBuilderHook.cs | 8 +- .../WinUIExtensions.cs} | 14 +-- .../WinUITestHostHandle.cs} | 13 +- .../WinUITestHostLauncher.cs | 112 ++++++++++++++++++ .../Microsoft.Testing.Extensions.WinUI.props} | 2 +- .../Microsoft.Testing.Extensions.WinUI.props | 9 ++ .../Microsoft.Testing.Extensions.WinUI.props} | 2 +- ...oymentTests.cs => WinUIDeploymentTests.cs} | 15 +-- 19 files changed, 195 insertions(+), 160 deletions(-) delete mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs delete mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md delete mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt delete mode 100644 src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment => Microsoft.Testing.Extensions.WinUI}/BannedSymbols.txt (100%) rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj => Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj} (89%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment => Microsoft.Testing.Extensions.WinUI}/PublicAPI/PublicAPI.Shipped.txt (100%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Unshipped.txt rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment => Microsoft.Testing.Extensions.WinUI}/TestingPlatformBuilderHook.cs (70%) rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs => Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs} (55%) rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs => Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs} (71%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props => Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props} (58%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props rename src/Platform/{Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props => Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props} (58%) rename test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/{AppDeploymentTests.cs => WinUIDeploymentTests.cs} (88%) diff --git a/TestFx.slnx b/TestFx.slnx index 30ab91a2e6..a9556e042b 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -38,7 +38,6 @@ - @@ -55,6 +54,7 @@ + diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md index e94427bd6c..74aa3fd7d8 100644 --- a/docs/RFCs/017-TestHost-Launcher.md +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -76,8 +76,9 @@ RFC adds the *minimal* hook at exactly the launch site. - Remote **device deployment/bootstrapping** of the Windows App SDK framework + agent (VSTest's `Microsoft.UniversalApps.Deployment` has no public redistributable; out of scope — local launch only). -- Shipping the WinUI package/deploy extension itself. This RFC only adds the platform hook; the - package/deploy extension is a separate deliverable that consumes it. +- Shipping a *complete* packaged WinUI / MSIX deployment story. This RFC adds the platform hook; a + reference consumer (`Microsoft.Testing.Extensions.WinUI`) implements the unpackaged + deploy-and-launch path, while packaged AUMID activation remains a separate follow-up. - Changing the in-process (single-process, `ConsoleTestHost`) execution path. ## Detailed design diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs deleted file mode 100644 index 15e47349a9..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentLauncher.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using Microsoft.Testing.Platform.Extensions.TestHostControllers; - -namespace Microsoft.Testing.Extensions.AppDeployment; - -/// -/// An that deploys the test host payload into an isolated -/// directory and then launches it from there, instead of starting it in place. -/// -/// -/// This demonstrates a launch mechanism that is more than a pass-through Process.Start: it -/// performs a real deployment side effect, and it returns a handle that intentionally does not -/// expose a local process id — exercising the platform's mechanism-agnostic monitoring path (the -/// same shape an AUMID-activated packaged app, a container, or a remote launch would use). -/// -internal sealed class AppDeploymentLauncher : ITestHostLauncher -{ - public string Uid => nameof(AppDeploymentLauncher); - - public string Version => "1.0.0"; - - public string DisplayName => "Application deployment launcher"; - - public string Description => "Deploys the test host to an isolated directory and launches it from there."; - - public Task IsEnabledAsync() => Task.FromResult(true); - - public Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken) - { - string sourceDirectory = Path.GetDirectoryName(context.FileName) - ?? throw new InvalidOperationException($"Unable to determine the source directory of '{context.FileName}'."); - - // 1. Deploy: stage the whole test host payload into an isolated directory. - string deploymentDirectory = Path.Combine(Path.GetTempPath(), "MTPAppDeployment", Guid.NewGuid().ToString("N")); - CopyDirectory(sourceDirectory, deploymentDirectory); - - // 2. Launch the deployed copy, forwarding the platform-prepared arguments and environment - // (which include the controller IPC pipe name the host connects back on). - string deployedFileName = Path.Combine(deploymentDirectory, Path.GetFileName(context.FileName)); - var startInfo = new ProcessStartInfo(deployedFileName) - { - UseShellExecute = false, - 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 test host '{deployedFileName}'."); - - // Leave a breadcrumb next to the original app so the deployment is observable. - File.WriteAllText(Path.Combine(sourceDirectory, "AppDeployment.txt"), deploymentDirectory); - - // 3. Return a handle that does NOT surface the underlying process id: the platform must rely - // purely on the lifecycle contract (WaitForExitAsync/ExitCode/HasExited/Exited/Terminate) - // and the IPC PID handshake, exactly as it would for a container/remote/AUMID launch. - return Task.FromResult(new DeployedTestHostHandle(process)); - } - - private static void CopyDirectory(string sourceDirectory, string destinationDirectory) - { - Directory.CreateDirectory(destinationDirectory); - - foreach (string file in Directory.EnumerateFiles(sourceDirectory)) - { - File.Copy(file, Path.Combine(destinationDirectory, Path.GetFileName(file)), overwrite: true); - } - - foreach (string directory in Directory.EnumerateDirectories(sourceDirectory)) - { - CopyDirectory(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory))); - } - } -} diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md deleted file mode 100644 index cde5267bc2..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PACKAGE.md +++ /dev/null @@ -1,31 +0,0 @@ -# Microsoft.Testing.Extensions.AppDeployment - -> [!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.AppDeployment` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys the test host into an isolated directory and launches it from there, instead of starting it in place. - -It is a reference implementation of a custom `ITestHostLauncher`: a launch mechanism that does more than `Process.Start` and that does not surface a local process id, demonstrating the platform's mechanism-agnostic launch contract (the same shape an AUMID-activated packaged app, a container, or a remote launch would use). - -Microsoft.Testing.Platform is open source. You can find `Microsoft.Testing.Extensions.AppDeployment` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. - -## Install the package - -```dotnetcli -dotnet add package Microsoft.Testing.Extensions.AppDeployment -``` - -## About - -This package extends Microsoft.Testing.Platform with: - -- **Deployment + launch**: stages the 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.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt deleted file mode 100644 index c434adbb43..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Unshipped.txt +++ /dev/null @@ -1,5 +0,0 @@ -#nullable enable -Microsoft.Testing.Extensions.AppDeployment.TestingPlatformBuilderHook -Microsoft.Testing.Extensions.AppDeploymentExtensions -static Microsoft.Testing.Extensions.AppDeployment.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void -static Microsoft.Testing.Extensions.AppDeploymentExtensions.AddAppDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props b/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props deleted file mode 100644 index 1dcaa01b43..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildMultiTargeting/Microsoft.Testing.Extensions.AppDeployment.props +++ /dev/null @@ -1,9 +0,0 @@ - - - - - Microsoft.Testing.Extensions.AppDeployment - Microsoft.Testing.Extensions.AppDeployment.TestingPlatformBuilderHook - - - diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/BannedSymbols.txt b/src/Platform/Microsoft.Testing.Extensions.WinUI/BannedSymbols.txt similarity index 100% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/BannedSymbols.txt rename to src/Platform/Microsoft.Testing.Extensions.WinUI/BannedSymbols.txt diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj b/src/Platform/Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj similarity index 89% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj rename to src/Platform/Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj index 23516fea1f..92bf242ad2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/Microsoft.Testing.Extensions.AppDeployment.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj @@ -1,7 +1,8 @@ - $(SupportedNetFrameworks) @@ -16,7 +17,7 @@ +This package extends Microsoft Testing Platform to deploy and launch a WinUI test host through a custom mechanism instead of starting it in place.]]> diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md new file mode 100644 index 0000000000..6f084d91d1 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md @@ -0,0 +1,34 @@ +# Microsoft.Testing.Extensions.WinUI + +> [!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.WinUI` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys a WinUI test host 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 the WinUI scenario: + +- **Unpackaged WinUI** (implemented): the app's loose layout is deployed to a deployment directory and the produced executable is launched from there. +- **Packaged WinUI / MSIX** (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.WinUI` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. + +## Install the package + +```dotnetcli +dotnet add package Microsoft.Testing.Extensions.WinUI +``` + +## About + +This package extends Microsoft.Testing.Platform with: + +- **Deployment + launch**: stages the 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.AppDeployment/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Shipped.txt similarity index 100% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/PublicAPI/PublicAPI.Shipped.txt rename to src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Shipped.txt diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..20f65cca7a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +#nullable enable +Microsoft.Testing.Extensions.WinUI.TestingPlatformBuilderHook +Microsoft.Testing.Extensions.WinUIExtensions +static Microsoft.Testing.Extensions.WinUI.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void +static Microsoft.Testing.Extensions.WinUIExtensions.AddWinUIDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.WinUI/TestingPlatformBuilderHook.cs similarity index 70% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/TestingPlatformBuilderHook.cs rename to src/Platform/Microsoft.Testing.Extensions.WinUI/TestingPlatformBuilderHook.cs index eb8b110da5..22f814b85f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/TestingPlatformBuilderHook.cs +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/TestingPlatformBuilderHook.cs @@ -3,18 +3,18 @@ using Microsoft.Testing.Platform.Builder; -namespace Microsoft.Testing.Extensions.AppDeployment; +namespace Microsoft.Testing.Extensions.WinUI; /// -/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add application deployment support. +/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add WinUI test host deployment support. /// public static class TestingPlatformBuilderHook { /// - /// Adds application deployment support to the Testing Platform Builder. + /// Adds 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.AddAppDeployment(); + => testApplicationBuilder.AddWinUIDeployment(); } diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs similarity index 55% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs rename to src/Platform/Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs index 3b44de56b0..03a59d25f4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/AppDeploymentExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs @@ -1,24 +1,24 @@ // 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.AppDeployment; +using Microsoft.Testing.Extensions.WinUI; using Microsoft.Testing.Platform.Builder; namespace Microsoft.Testing.Extensions; /// -/// Provides extension methods for adding application deployment support to the test application builder. +/// Provides extension methods for adding WinUI test host deployment support to the test application builder. /// -public static class AppDeploymentExtensions +public static class WinUIExtensions { /// - /// Registers a test host launcher that deploys the test host into an isolated directory and - /// launches it from there. + /// Registers a test host launcher that deploys the WinUI test host into an isolated directory + /// and launches it from there. /// /// The test application builder. - public static void AddAppDeployment(this ITestApplicationBuilder builder) + public static void AddWinUIDeployment(this ITestApplicationBuilder builder) { _ = builder ?? throw new System.ArgumentNullException(nameof(builder)); - builder.TestHostControllers.AddTestHostLauncher(_ => new AppDeploymentLauncher()); + builder.TestHostControllers.AddTestHostLauncher(_ => new WinUITestHostLauncher()); } } diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs similarity index 71% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs rename to src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs index e1ee957947..c617bc02b7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/DeployedTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs @@ -3,17 +3,17 @@ using Microsoft.Testing.Platform.Extensions.TestHostControllers; -namespace Microsoft.Testing.Extensions.AppDeployment; +namespace Microsoft.Testing.Extensions.WinUI; /// -/// An over a deployed test host process that deliberately hides the -/// underlying process id, modelling a launch where no local, query-able PID is available. +/// An over a deployed WinUI test host process that deliberately hides +/// the underlying process id, modelling a launch where no local, query-able PID is available. /// -internal sealed class DeployedTestHostHandle : ITestHostHandle, IDisposable +internal sealed class WinUITestHostHandle : ITestHostHandle, IDisposable { private readonly Process _process; - public DeployedTestHostHandle(Process process) + public WinUITestHostHandle(Process process) { _process = process; _process.EnableRaisingEvents = true; @@ -22,7 +22,8 @@ public DeployedTestHostHandle(Process process) public event EventHandler? Exited; - // Intentionally null: the platform must not depend on a local process id (container/remote/AUMID). + // Intentionally null: the platform must not depend on a local process id. A packaged-WinUI + // implementation could surface the AUMID-activated PID here instead. public int? ProcessId => null; public int ExitCode => _process.ExitCode; diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs new file mode 100644 index 0000000000..f5b70bed73 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs @@ -0,0 +1,112 @@ +// 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.WinUI; + +/// +/// An for WinUI test applications: it deploys the test host output +/// (the app's loose layout) into an isolated directory and launches it from there, instead of +/// starting the test host in place. +/// +/// +/// +/// WinUI applications cannot always be started with a plain Process.Start from the build +/// output: +/// +/// +/// +/// +/// Unpackaged WinUI (the path implemented here): the app's loose layout is deployed to a +/// deployment directory and the produced executable is launched from there. +/// +/// +/// +/// +/// Packaged WinUI / MSIX (future work): the loose layout must be registered with the +/// PackageManager and the app activated by Application User Model ID (AUMID) via +/// IApplicationActivationManager. That step is the reason VSTest's +/// UwpTestHostRuntimeProvider exists; see https://github.com/microsoft/testfx/issues/2784. +/// The branch is left as a clearly-marked extension point below. +/// +/// +/// +/// +/// In both 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 WinUITestHostLauncher : ITestHostLauncher +{ + public string Uid => nameof(WinUITestHostLauncher); + + public string Version => "1.0.0"; + + public string DisplayName => "WinUI test host launcher"; + + public string Description => "Deploys a WinUI test host to an isolated directory and launches it from there."; + + public Task IsEnabledAsync() => Task.FromResult(true); + + public Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken) + { + 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 WinUI this is + // also where the layout would be registered via PackageManager.RegisterPackageByUriAsync. + string deploymentDirectory = Path.Combine(Path.GetTempPath(), "MTPWinUIDeployment", Guid.NewGuid().ToString("N")); + CopyDirectory(sourceDirectory, deploymentDirectory); + + // 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 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, + 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 WinUI test host '{deployedFileName}'."); + + // Leave a breadcrumb next to the original app so the deployment is observable by callers/tests. + File.WriteAllText(Path.Combine(sourceDirectory, "WinUIDeployment.txt"), deploymentDirectory); + + // 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/Exited/Terminate) and the IPC PID handshake. This + // matches launch mechanisms where no local, query-able PID is available (e.g. an + // AppContainer-sandboxed activation surfaced through a broker). + return Task.FromResult(new WinUITestHostHandle(process)); + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + + foreach (string file in Directory.EnumerateFiles(sourceDirectory)) + { + File.Copy(file, Path.Combine(destinationDirectory, Path.GetFileName(file)), overwrite: true); + } + + foreach (string directory in Directory.EnumerateDirectories(sourceDirectory)) + { + CopyDirectory(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory))); + } + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props b/src/Platform/Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props similarity index 58% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props rename to src/Platform/Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props index 82d7e6c1f4..8c083de002 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/build/Microsoft.Testing.Extensions.AppDeployment.props +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props @@ -1,3 +1,3 @@ - + diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props b/src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props new file mode 100644 index 0000000000..42dca28547 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props @@ -0,0 +1,9 @@ + + + + + Microsoft.Testing.Extensions.WinUI + Microsoft.Testing.Extensions.WinUI.TestingPlatformBuilderHook + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props b/src/Platform/Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props similarity index 58% rename from src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props rename to src/Platform/Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props index 82d7e6c1f4..8c083de002 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AppDeployment/buildTransitive/Microsoft.Testing.Extensions.AppDeployment.props +++ b/src/Platform/Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props @@ -1,3 +1,3 @@ - + diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AppDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WinUIDeploymentTests.cs similarity index 88% rename from test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AppDeploymentTests.cs rename to test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WinUIDeploymentTests.cs index 2abaee0bd7..0c6bcde1d6 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AppDeploymentTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WinUIDeploymentTests.cs @@ -4,13 +4,14 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; [TestClass] -public sealed class AppDeploymentTests : AcceptanceTestBase +public sealed class WinUIDeploymentTests : AcceptanceTestBase { - private const string AssetName = "AppDeploymentTest"; + private const string AssetName = "WinUIDeploymentTest"; [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] [TestMethod] - public async Task AppDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(string currentTfm) + [OSCondition(ConditionMode.Include, OperatingSystems.Windows, IgnoreMessage = "WinUI is a Windows-only scenario.")] + public async Task WinUIDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(string currentTfm) { var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm); TestHostResult testHostResult = await testHost.ExecuteAsync(cancellationToken: TestContext.CancellationToken); @@ -22,7 +23,7 @@ public async Task AppDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(strin // The launcher leaves a breadcrumb pointing at the isolated deployment directory it created // and launched from. - string markerPath = Path.Combine(testHost.DirectoryName, "AppDeployment.txt"); + string markerPath = Path.Combine(testHost.DirectoryName, "WinUIDeployment.txt"); Assert.IsTrue(File.Exists(markerPath), $"Expected deployment marker at '{markerPath}'."); string deploymentDirectory = File.ReadAllText(markerPath); @@ -33,7 +34,7 @@ public async Task AppDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(strin public sealed class TestAssetFixture() : TestAssetFixtureBase() { private const string Sources = """ -#file AppDeploymentTest.csproj +#file WinUIDeploymentTest.csproj @@ -45,7 +46,7 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase() - + @@ -66,7 +67,7 @@ public static async Task Main(string[] args) { var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args); testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework()); - testApplicationBuilder.AddAppDeployment(); + testApplicationBuilder.AddWinUIDeployment(); using ITestApplication app = await testApplicationBuilder.BuildAsync(); return await app.RunAsync(); } From d33ff64d847e8a66377254730e0ef4eb42166325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 23 Jun 2026 21:26:49 +0200 Subject: [PATCH 04/17] Rename WinUI extension to Msix to cover both UWP and WinUI UWP and packaged WinUI are not distinct scenarios for launching the test host: both produce MSIX packages and share the same deploy + AUMID-activate mechanism (which is why VSTest exposes a single UwpTestHostRuntimeProvider for both). Naming the extension after either app model is too narrow; name it after the shared packaging format instead. - Microsoft.Testing.Extensions.WinUI -> Microsoft.Testing.Extensions.Msix (AddMsixDeployment, MsixTestHostLauncher, MsixTestHostHandle, MsixExtensions). - Casing is PascalCase "Msix" (not "MSIX"), matching .NET guidelines and the repo convention for 3+ letter acronyms (HtmlReport, TrxReport, CtrfReport). - Docs/launcher text now describe UWP and packaged WinUI as the same MSIX mechanism; packaged AUMID activation remains a clearly-marked follow-up. - Acceptance test renamed to MsixDeploymentTests (Windows-gated); still proves end-to-end deploy + PID-less launch against the packed package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- TestFx.slnx | 2 +- docs/RFCs/017-TestHost-Launcher.md | 22 +++--- .../BannedSymbols.txt | 0 .../Microsoft.Testing.Extensions.Msix.csproj} | 6 +- .../MsixExtensions.cs} | 15 +++-- .../MsixTestHostHandle.cs} | 13 ++-- .../MsixTestHostLauncher.cs} | 67 +++++++++---------- .../PACKAGE.md | 34 ++++++++++ .../PublicAPI/PublicAPI.Shipped.txt | 0 .../PublicAPI/PublicAPI.Unshipped.txt | 5 ++ .../TestingPlatformBuilderHook.cs | 8 +-- .../Microsoft.Testing.Extensions.Msix.props} | 2 +- .../Microsoft.Testing.Extensions.Msix.props | 9 +++ .../Microsoft.Testing.Extensions.Msix.props} | 2 +- .../PACKAGE.md | 34 ---------- .../PublicAPI/PublicAPI.Unshipped.txt | 5 -- .../Microsoft.Testing.Extensions.WinUI.props | 9 --- ...loymentTests.cs => MsixDeploymentTests.cs} | 16 ++--- 18 files changed, 123 insertions(+), 126 deletions(-) rename src/Platform/{Microsoft.Testing.Extensions.WinUI => Microsoft.Testing.Extensions.Msix}/BannedSymbols.txt (100%) rename src/Platform/{Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj => Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj} (88%) rename src/Platform/{Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs => Microsoft.Testing.Extensions.Msix/MsixExtensions.cs} (53%) rename src/Platform/{Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs => Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs} (75%) rename src/Platform/{Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs => Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs} (57%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.Msix/PACKAGE.md rename src/Platform/{Microsoft.Testing.Extensions.WinUI => Microsoft.Testing.Extensions.Msix}/PublicAPI/PublicAPI.Shipped.txt (100%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt rename src/Platform/{Microsoft.Testing.Extensions.WinUI => Microsoft.Testing.Extensions.Msix}/TestingPlatformBuilderHook.cs (68%) rename src/Platform/{Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props => Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props} (61%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props rename src/Platform/{Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props => Microsoft.Testing.Extensions.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props} (61%) delete mode 100644 src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md delete mode 100644 src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Unshipped.txt delete mode 100644 src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props rename test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/{WinUIDeploymentTests.cs => MsixDeploymentTests.cs} (89%) diff --git a/TestFx.slnx b/TestFx.slnx index a9556e042b..ea40c79078 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -48,13 +48,13 @@ + - diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md index 74aa3fd7d8..9d52b1bbce 100644 --- a/docs/RFCs/017-TestHost-Launcher.md +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -20,11 +20,13 @@ the host on a remote machine. To make this explicit, the launcher returns an `IT exposes only the lifecycle the platform needs (`WaitForExitAsync`, `ExitCode`, `HasExited`, `Exited`, `Terminate`); a process id is *optional* and used purely for diagnostics. -The motivating scenario is **packaging and deployment of WinUI applications** (see -[#2784](https://github.com/microsoft/testfx/issues/2784)): packaged/MSIX apps cannot be started with -`Process.Start` and must be deployed and then activated by AUMID, while unpackaged WinUI apps -similarly benefit from a custom deploy + launch step. The same hook also enables launching the test -host under a debugger, elevated, inside a container, or on a remote machine. +The motivating scenario is **packaging and deployment of Msix-packaged applications — both UWP and +WinUI** (see [#2784](https://github.com/microsoft/testfx/issues/2784)): packaged/Msix apps cannot be +started with `Process.Start` and must be deployed and then activated by AUMID. UWP and packaged WinUI +share this exact mechanism, which is why VSTest exposes a single `UwpTestHostRuntimeProvider` for +both; unpackaged apps similarly benefit from a custom deploy + launch step. The same hook also +enables launching the test host under a debugger, elevated, inside a container, or on a remote +machine. ## Motivation @@ -44,7 +46,7 @@ way to tear it down (`Kill`) — plus the child connecting back on the named pip injected via an environment variable. **`Process.Start` is the only assumption that does not hold universally.** Several real scenarios need a different launch mechanism: -- **Packaged WinUI/MSIX**: a packaged app must be deployed (in Developer Mode, register the loose +- **Packaged WinUI/Msix**: a packaged app must be deployed (in Developer Mode, register the loose layout) and then activated by Application User Model ID (AUMID) via `IApplicationActivationManager`, not started from an executable path. This is the blocker behind [#2784](https://github.com/microsoft/testfx/issues/2784) and the reason VSTest's @@ -76,9 +78,9 @@ RFC adds the *minimal* hook at exactly the launch site. - Remote **device deployment/bootstrapping** of the Windows App SDK framework + agent (VSTest's `Microsoft.UniversalApps.Deployment` has no public redistributable; out of scope — local launch only). -- Shipping a *complete* packaged WinUI / MSIX deployment story. This RFC adds the platform hook; a - reference consumer (`Microsoft.Testing.Extensions.WinUI`) implements the unpackaged - deploy-and-launch path, while packaged AUMID activation remains a separate follow-up. +- Shipping a *complete* packaged UWP/WinUI (Msix) deployment story. This RFC adds the platform hook; + a reference consumer (`Microsoft.Testing.Extensions.Msix`) implements the deploy-and-launch path, + while packaged AUMID activation remains a separate follow-up. - Changing the in-process (single-process, `ConsoleTestHost`) execution path. ## Detailed design @@ -218,7 +220,7 @@ All examples assume the extension is registered on the builder, e.g. from a `… builder.TestHostControllers.AddTestHostLauncher(sp => new MyLauncher(sp)); ``` -### 1. Packaged WinUI / MSIX (the motivating case) +### 1. Packaged WinUI / Msix (the motivating case) Deploy the loose layout (Developer Mode) and activate the packaged app by AUMID instead of starting an exe. The activated app self-hosts MTP (as the `MSTestRunnerWinUI` sample already does) and diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/BannedSymbols.txt b/src/Platform/Microsoft.Testing.Extensions.Msix/BannedSymbols.txt similarity index 100% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/BannedSymbols.txt rename to src/Platform/Microsoft.Testing.Extensions.Msix/BannedSymbols.txt diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj b/src/Platform/Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj similarity index 88% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj rename to src/Platform/Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj index 92bf242ad2..9b28c5cb79 100644 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/Microsoft.Testing.Extensions.WinUI.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj @@ -1,8 +1,8 @@ - Microsoft.Testing.Extensions.WinUI - $(SupportedNetFrameworks) @@ -17,7 +17,7 @@ +This package extends Microsoft Testing Platform to deploy and launch an Msix-packaged (UWP/WinUI) test host through a custom mechanism instead of starting it in place.]]> diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.Msix/MsixExtensions.cs similarity index 53% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs rename to src/Platform/Microsoft.Testing.Extensions.Msix/MsixExtensions.cs index 03a59d25f4..7255c6a5be 100644 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUIExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/MsixExtensions.cs @@ -1,24 +1,25 @@ // 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.WinUI; +using Microsoft.Testing.Extensions.Msix; using Microsoft.Testing.Platform.Builder; namespace Microsoft.Testing.Extensions; /// -/// Provides extension methods for adding WinUI test host deployment support to the test application builder. +/// Provides extension methods for adding Msix-packaged (UWP/WinUI) test host deployment support to +/// the test application builder. /// -public static class WinUIExtensions +public static class MsixExtensions { /// - /// Registers a test host launcher that deploys the WinUI test host into an isolated directory - /// and launches it from there. + /// Registers a test host launcher that deploys the Msix-packaged (UWP/WinUI) test host into an + /// isolated directory and launches it from there. /// /// The test application builder. - public static void AddWinUIDeployment(this ITestApplicationBuilder builder) + public static void AddMsixDeployment(this ITestApplicationBuilder builder) { _ = builder ?? throw new System.ArgumentNullException(nameof(builder)); - builder.TestHostControllers.AddTestHostLauncher(_ => new WinUITestHostLauncher()); + builder.TestHostControllers.AddTestHostLauncher(_ => new MsixTestHostLauncher()); } } diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs similarity index 75% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs rename to src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs index c617bc02b7..7566beb76f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs @@ -3,17 +3,18 @@ using Microsoft.Testing.Platform.Extensions.TestHostControllers; -namespace Microsoft.Testing.Extensions.WinUI; +namespace Microsoft.Testing.Extensions.Msix; /// -/// An over a deployed WinUI test host process that deliberately hides -/// the underlying process id, modelling a launch where no local, query-able PID is available. +/// An over a deployed Msix (UWP/WinUI) test host process that +/// deliberately hides the underlying process id, modelling a launch where no local, query-able PID +/// is available. /// -internal sealed class WinUITestHostHandle : ITestHostHandle, IDisposable +internal sealed class MsixTestHostHandle : ITestHostHandle, IDisposable { private readonly Process _process; - public WinUITestHostHandle(Process process) + public MsixTestHostHandle(Process process) { _process = process; _process.EnableRaisingEvents = true; @@ -22,7 +23,7 @@ public WinUITestHostHandle(Process process) public event EventHandler? Exited; - // Intentionally null: the platform must not depend on a local process id. A packaged-WinUI + // Intentionally null: the platform must not depend on a local process id. A packaged UWP/WinUI // implementation could surface the AUMID-activated PID here instead. public int? ProcessId => null; diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs similarity index 57% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs rename to src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs index f5b70bed73..a470d018f3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/WinUITestHostLauncher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs @@ -3,50 +3,43 @@ using Microsoft.Testing.Platform.Extensions.TestHostControllers; -namespace Microsoft.Testing.Extensions.WinUI; +namespace Microsoft.Testing.Extensions.Msix; /// -/// An for WinUI test applications: it deploys the test host output -/// (the app's loose layout) into an isolated directory and launches it from there, instead of -/// starting the test host in place. +/// An for Msix-packaged 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. /// /// /// -/// WinUI applications cannot always be started with a plain Process.Start from the build -/// output: +/// Msix-packaged apps — produced by both UWP and packaged WinUI projects — 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. /// -/// -/// -/// -/// Unpackaged WinUI (the path implemented here): the app's loose layout is deployed to a -/// deployment directory and the produced executable is launched from there. -/// -/// -/// -/// -/// Packaged WinUI / MSIX (future work): the loose layout must be registered with the -/// PackageManager and the app activated by Application User Model ID (AUMID) via -/// IApplicationActivationManager. That step is the reason VSTest's -/// UwpTestHostRuntimeProvider exists; see https://github.com/microsoft/testfx/issues/2784. -/// The branch is left as a clearly-marked extension point below. -/// -/// -/// /// -/// In both 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 +/// 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 WinUITestHostLauncher : ITestHostLauncher +internal sealed class MsixTestHostLauncher : ITestHostLauncher { - public string Uid => nameof(WinUITestHostLauncher); + public string Uid => nameof(MsixTestHostLauncher); public string Version => "1.0.0"; - public string DisplayName => "WinUI test host launcher"; + public string DisplayName => "Msix test host launcher"; - public string Description => "Deploys a WinUI test host to an isolated directory and launches it from there."; + public string Description => "Deploys an Msix-packaged (UWP/WinUI) test host to an isolated directory and launches it from there."; public Task IsEnabledAsync() => Task.FromResult(true); @@ -55,15 +48,15 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, 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 WinUI this is + // 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(), "MTPWinUIDeployment", Guid.NewGuid().ToString("N")); + string deploymentDirectory = Path.Combine(Path.GetTempPath(), "MTPMsixDeployment", Guid.NewGuid().ToString("N")); CopyDirectory(sourceDirectory, deploymentDirectory); // 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 WinUI would instead AUMID-activate here via IApplicationActivationManager and - // wrap the activated process id. + // 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) { @@ -82,17 +75,17 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, } Process process = Process.Start(startInfo) - ?? throw new InvalidOperationException($"Failed to start deployed WinUI test host '{deployedFileName}'."); + ?? throw new InvalidOperationException($"Failed to start deployed Msix test host '{deployedFileName}'."); // Leave a breadcrumb next to the original app so the deployment is observable by callers/tests. - File.WriteAllText(Path.Combine(sourceDirectory, "WinUIDeployment.txt"), deploymentDirectory); + File.WriteAllText(Path.Combine(sourceDirectory, "MsixDeployment.txt"), deploymentDirectory); // 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/Exited/Terminate) and the IPC PID handshake. This // matches launch mechanisms where no local, query-able PID is available (e.g. an - // AppContainer-sandboxed activation surfaced through a broker). - return Task.FromResult(new WinUITestHostHandle(process)); + // AppContainer-sandboxed AUMID activation surfaced through a broker). + return Task.FromResult(new MsixTestHostHandle(process)); } private static void CopyDirectory(string sourceDirectory, string destinationDirectory) diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.Msix/PACKAGE.md new file mode 100644 index 0000000000..95f292b005 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/PACKAGE.md @@ -0,0 +1,34 @@ +# Microsoft.Testing.Extensions.Msix + +> [!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.Msix` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys an Msix-packaged 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 Msix-packaged apps. UWP and packaged WinUI 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.Msix` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. + +## Install the package + +```dotnetcli +dotnet add package Microsoft.Testing.Extensions.Msix +``` + +## About + +This package extends Microsoft.Testing.Platform with: + +- **Deployment + launch**: stages the Msix-packaged (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.WinUI/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Shipped.txt similarity index 100% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Shipped.txt rename to src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Shipped.txt diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..bf5fc0cf29 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +#nullable enable +Microsoft.Testing.Extensions.Msix.TestingPlatformBuilderHook +Microsoft.Testing.Extensions.MsixExtensions +static Microsoft.Testing.Extensions.Msix.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void +static Microsoft.Testing.Extensions.MsixExtensions.AddMsixDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.Msix/TestingPlatformBuilderHook.cs similarity index 68% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/TestingPlatformBuilderHook.cs rename to src/Platform/Microsoft.Testing.Extensions.Msix/TestingPlatformBuilderHook.cs index 22f814b85f..7b10d9d09a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/TestingPlatformBuilderHook.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/TestingPlatformBuilderHook.cs @@ -3,18 +3,18 @@ using Microsoft.Testing.Platform.Builder; -namespace Microsoft.Testing.Extensions.WinUI; +namespace Microsoft.Testing.Extensions.Msix; /// -/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add WinUI test host deployment support. +/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add Msix (UWP/WinUI) test host deployment support. /// public static class TestingPlatformBuilderHook { /// - /// Adds WinUI test host deployment support to the Testing Platform Builder. + /// Adds Msix (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.AddWinUIDeployment(); + => testApplicationBuilder.AddMsixDeployment(); } diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props b/src/Platform/Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props similarity index 61% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props rename to src/Platform/Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props index 8c083de002..cea49bfb91 100644 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/build/Microsoft.Testing.Extensions.WinUI.props +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props @@ -1,3 +1,3 @@ - + diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props b/src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props new file mode 100644 index 0000000000..767485e173 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props @@ -0,0 +1,9 @@ + + + + + Microsoft.Testing.Extensions.Msix + Microsoft.Testing.Extensions.Msix.TestingPlatformBuilderHook + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props b/src/Platform/Microsoft.Testing.Extensions.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props similarity index 61% rename from src/Platform/Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props rename to src/Platform/Microsoft.Testing.Extensions.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props index 8c083de002..cea49bfb91 100644 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/buildTransitive/Microsoft.Testing.Extensions.WinUI.props +++ b/src/Platform/Microsoft.Testing.Extensions.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props @@ -1,3 +1,3 @@ - + diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md deleted file mode 100644 index 6f084d91d1..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/PACKAGE.md +++ /dev/null @@ -1,34 +0,0 @@ -# Microsoft.Testing.Extensions.WinUI - -> [!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.WinUI` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys a WinUI test host 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 the WinUI scenario: - -- **Unpackaged WinUI** (implemented): the app's loose layout is deployed to a deployment directory and the produced executable is launched from there. -- **Packaged WinUI / MSIX** (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.WinUI` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. - -## Install the package - -```dotnetcli -dotnet add package Microsoft.Testing.Extensions.WinUI -``` - -## About - -This package extends Microsoft.Testing.Platform with: - -- **Deployment + launch**: stages the 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.WinUI/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Unshipped.txt deleted file mode 100644 index 20f65cca7a..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/PublicAPI/PublicAPI.Unshipped.txt +++ /dev/null @@ -1,5 +0,0 @@ -#nullable enable -Microsoft.Testing.Extensions.WinUI.TestingPlatformBuilderHook -Microsoft.Testing.Extensions.WinUIExtensions -static Microsoft.Testing.Extensions.WinUI.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void -static Microsoft.Testing.Extensions.WinUIExtensions.AddWinUIDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props b/src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props deleted file mode 100644 index 42dca28547..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.WinUI/buildMultiTargeting/Microsoft.Testing.Extensions.WinUI.props +++ /dev/null @@ -1,9 +0,0 @@ - - - - - Microsoft.Testing.Extensions.WinUI - Microsoft.Testing.Extensions.WinUI.TestingPlatformBuilderHook - - - diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WinUIDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MsixDeploymentTests.cs similarity index 89% rename from test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WinUIDeploymentTests.cs rename to test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MsixDeploymentTests.cs index 0c6bcde1d6..7dc4f122d9 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WinUIDeploymentTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MsixDeploymentTests.cs @@ -4,14 +4,14 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; [TestClass] -public sealed class WinUIDeploymentTests : AcceptanceTestBase +public sealed class MsixDeploymentTests : AcceptanceTestBase { - private const string AssetName = "WinUIDeploymentTest"; + private const string AssetName = "MsixDeploymentTest"; [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] [TestMethod] - [OSCondition(ConditionMode.Include, OperatingSystems.Windows, IgnoreMessage = "WinUI is a Windows-only scenario.")] - public async Task WinUIDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(string currentTfm) + [OSCondition(ConditionMode.Include, OperatingSystems.Windows, IgnoreMessage = "Msix packaging (UWP/WinUI) is a Windows-only scenario.")] + public async Task MsixDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(string currentTfm) { var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm); TestHostResult testHostResult = await testHost.ExecuteAsync(cancellationToken: TestContext.CancellationToken); @@ -23,7 +23,7 @@ public async Task WinUIDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(str // The launcher leaves a breadcrumb pointing at the isolated deployment directory it created // and launched from. - string markerPath = Path.Combine(testHost.DirectoryName, "WinUIDeployment.txt"); + string markerPath = Path.Combine(testHost.DirectoryName, "MsixDeployment.txt"); Assert.IsTrue(File.Exists(markerPath), $"Expected deployment marker at '{markerPath}'."); string deploymentDirectory = File.ReadAllText(markerPath); @@ -34,7 +34,7 @@ public async Task WinUIDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(str public sealed class TestAssetFixture() : TestAssetFixtureBase() { private const string Sources = """ -#file WinUIDeploymentTest.csproj +#file MsixDeploymentTest.csproj @@ -46,7 +46,7 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase() - + @@ -67,7 +67,7 @@ public static async Task Main(string[] args) { var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args); testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework()); - testApplicationBuilder.AddWinUIDeployment(); + testApplicationBuilder.AddMsixDeployment(); using ITestApplication app = await testApplicationBuilder.BuildAsync(); return await app.RunAsync(); } From d8fc92dd7339b70c31a5520b39976a5e157350bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 23 Jun 2026 22:00:02 +0200 Subject: [PATCH 05/17] Rename Msix extension to PackagedApp (scenario, not format/tooling) "Msix" reads like a tool that creates MSIX packages; this extension is about running tests *inside* a packaged Windows app. Name it after the scenario instead of the package format. - Microsoft.Testing.Extensions.Msix -> Microsoft.Testing.Extensions.PackagedApp (AddPackagedAppDeployment, PackagedAppTestHostLauncher, PackagedAppTestHostHandle, PackagedAppExtensions). - Still covers both UWP and packaged WinUI (both ship as MSIX and share the same deploy + AUMID-activate mechanism); docs keep "MSIX" as the format acronym in prose while the package/API name describes the scenario. - Acceptance test renamed to PackagedAppDeploymentTests (Windows-gated); still proves end-to-end deploy + PID-less launch against the packed package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- TestFx.slnx | 2 +- docs/RFCs/017-TestHost-Launcher.md | 12 +++---- .../PublicAPI/PublicAPI.Unshipped.txt | 5 --- .../Microsoft.Testing.Extensions.Msix.props | 9 ----- .../BannedSymbols.txt | 0 ...oft.Testing.Extensions.PackagedApp.csproj} | 6 ++-- .../PACKAGE.md | 12 +++---- .../PackagedAppExtensions.cs} | 16 ++++----- .../PackagedAppTestHostHandle.cs} | 12 +++---- .../PackagedAppTestHostLauncher.cs} | 36 +++++++++---------- .../PublicAPI/PublicAPI.Shipped.txt | 0 .../PublicAPI/PublicAPI.Unshipped.txt | 5 +++ .../TestingPlatformBuilderHook.cs | 8 ++--- ...soft.Testing.Extensions.PackagedApp.props} | 2 +- ...osoft.Testing.Extensions.PackagedApp.props | 9 +++++ ...soft.Testing.Extensions.PackagedApp.props} | 2 +- ...Tests.cs => PackagedAppDeploymentTests.cs} | 16 ++++----- 17 files changed, 76 insertions(+), 76 deletions(-) delete mode 100644 src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt delete mode 100644 src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props rename src/Platform/{Microsoft.Testing.Extensions.Msix => Microsoft.Testing.Extensions.PackagedApp}/BannedSymbols.txt (100%) rename src/Platform/{Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj => Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj} (88%) rename src/Platform/{Microsoft.Testing.Extensions.Msix => Microsoft.Testing.Extensions.PackagedApp}/PACKAGE.md (61%) rename src/Platform/{Microsoft.Testing.Extensions.Msix/MsixExtensions.cs => Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs} (51%) rename src/Platform/{Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs => Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs} (76%) rename src/Platform/{Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs => Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs} (72%) rename src/Platform/{Microsoft.Testing.Extensions.Msix => Microsoft.Testing.Extensions.PackagedApp}/PublicAPI/PublicAPI.Shipped.txt (100%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt rename src/Platform/{Microsoft.Testing.Extensions.Msix => Microsoft.Testing.Extensions.PackagedApp}/TestingPlatformBuilderHook.cs (66%) rename src/Platform/{Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props => Microsoft.Testing.Extensions.PackagedApp/build/Microsoft.Testing.Extensions.PackagedApp.props} (59%) create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildMultiTargeting/Microsoft.Testing.Extensions.PackagedApp.props rename src/Platform/{Microsoft.Testing.Extensions.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props => Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props} (59%) rename test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/{MsixDeploymentTests.cs => PackagedAppDeploymentTests.cs} (88%) diff --git a/TestFx.slnx b/TestFx.slnx index ea40c79078..8a3f4b64ea 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -48,8 +48,8 @@ - + diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md index 9d52b1bbce..0281e2f567 100644 --- a/docs/RFCs/017-TestHost-Launcher.md +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -20,8 +20,8 @@ the host on a remote machine. To make this explicit, the launcher returns an `IT exposes only the lifecycle the platform needs (`WaitForExitAsync`, `ExitCode`, `HasExited`, `Exited`, `Terminate`); a process id is *optional* and used purely for diagnostics. -The motivating scenario is **packaging and deployment of Msix-packaged applications — both UWP and -WinUI** (see [#2784](https://github.com/microsoft/testfx/issues/2784)): packaged/Msix apps cannot be +The motivating scenario is **packaging and deployment of MSIX-packaged applications — both UWP and +WinUI** (see [#2784](https://github.com/microsoft/testfx/issues/2784)): packaged/MSIX apps cannot be started with `Process.Start` and must be deployed and then activated by AUMID. UWP and packaged WinUI share this exact mechanism, which is why VSTest exposes a single `UwpTestHostRuntimeProvider` for both; unpackaged apps similarly benefit from a custom deploy + launch step. The same hook also @@ -46,7 +46,7 @@ way to tear it down (`Kill`) — plus the child connecting back on the named pip injected via an environment variable. **`Process.Start` is the only assumption that does not hold universally.** Several real scenarios need a different launch mechanism: -- **Packaged WinUI/Msix**: a packaged app must be deployed (in Developer Mode, register the loose +- **Packaged WinUI/MSIX**: a packaged app must be deployed (in Developer Mode, register the loose layout) and then activated by Application User Model ID (AUMID) via `IApplicationActivationManager`, not started from an executable path. This is the blocker behind [#2784](https://github.com/microsoft/testfx/issues/2784) and the reason VSTest's @@ -78,8 +78,8 @@ RFC adds the *minimal* hook at exactly the launch site. - Remote **device deployment/bootstrapping** of the Windows App SDK framework + agent (VSTest's `Microsoft.UniversalApps.Deployment` has no public redistributable; out of scope — local launch only). -- Shipping a *complete* packaged UWP/WinUI (Msix) deployment story. This RFC adds the platform hook; - a reference consumer (`Microsoft.Testing.Extensions.Msix`) implements the deploy-and-launch path, +- Shipping a *complete* packaged UWP/WinUI (MSIX) deployment story. This RFC adds the platform hook; + a reference consumer (`Microsoft.Testing.Extensions.PackagedApp`) implements the deploy-and-launch path, while packaged AUMID activation remains a separate follow-up. - Changing the in-process (single-process, `ConsoleTestHost`) execution path. @@ -220,7 +220,7 @@ All examples assume the extension is registered on the builder, e.g. from a `… builder.TestHostControllers.AddTestHostLauncher(sp => new MyLauncher(sp)); ``` -### 1. Packaged WinUI / Msix (the motivating case) +### 1. Packaged WinUI / MSIX (the motivating case) Deploy the loose layout (Developer Mode) and activate the packaged app by AUMID instead of starting an exe. The activated app self-hosts MTP (as the `MSTestRunnerWinUI` sample already does) and diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt deleted file mode 100644 index bf5fc0cf29..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Unshipped.txt +++ /dev/null @@ -1,5 +0,0 @@ -#nullable enable -Microsoft.Testing.Extensions.Msix.TestingPlatformBuilderHook -Microsoft.Testing.Extensions.MsixExtensions -static Microsoft.Testing.Extensions.Msix.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void -static Microsoft.Testing.Extensions.MsixExtensions.AddMsixDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props b/src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props deleted file mode 100644 index 767485e173..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/buildMultiTargeting/Microsoft.Testing.Extensions.Msix.props +++ /dev/null @@ -1,9 +0,0 @@ - - - - - Microsoft.Testing.Extensions.Msix - Microsoft.Testing.Extensions.Msix.TestingPlatformBuilderHook - - - diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/BannedSymbols.txt b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/BannedSymbols.txt similarity index 100% rename from src/Platform/Microsoft.Testing.Extensions.Msix/BannedSymbols.txt rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/BannedSymbols.txt diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj similarity index 88% rename from src/Platform/Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj index 9b28c5cb79..4013d3cfd4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/Microsoft.Testing.Extensions.Msix.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj @@ -1,8 +1,8 @@ - Microsoft.Testing.Extensions.Msix - $(SupportedNetFrameworks) @@ -17,7 +17,7 @@ +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.]]> diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md similarity index 61% rename from src/Platform/Microsoft.Testing.Extensions.Msix/PACKAGE.md rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md index 95f292b005..322f0c80ee 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PACKAGE.md @@ -1,28 +1,28 @@ -# Microsoft.Testing.Extensions.Msix +# 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.Msix` is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that deploys an Msix-packaged test host (UWP or packaged WinUI) into an isolated directory and launches it from there, instead of starting the test host in place. +`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 Msix-packaged apps. UWP and packaged WinUI share the same launch mechanism, which is why VSTest exposes a single `UwpTestHostRuntimeProvider` for both: +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.Msix` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. +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.Msix +dotnet add package Microsoft.Testing.Extensions.PackagedApp ``` ## About This package extends Microsoft.Testing.Platform with: -- **Deployment + launch**: stages the Msix-packaged (UWP/WinUI) test host payload into an isolated directory and launches the deployed copy. +- **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 diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/MsixExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs similarity index 51% rename from src/Platform/Microsoft.Testing.Extensions.Msix/MsixExtensions.cs rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs index 7255c6a5be..beee2a32d2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/MsixExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs @@ -1,25 +1,25 @@ // 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.Msix; +using Microsoft.Testing.Extensions.PackagedApp; using Microsoft.Testing.Platform.Builder; namespace Microsoft.Testing.Extensions; /// -/// Provides extension methods for adding Msix-packaged (UWP/WinUI) test host deployment support to -/// the test application builder. +/// Provides extension methods for adding packaged Windows (UWP/WinUI) test host deployment support +/// to the test application builder. /// -public static class MsixExtensions +public static class PackagedAppExtensions { /// - /// Registers a test host launcher that deploys the Msix-packaged (UWP/WinUI) test host into an - /// isolated directory and launches it from there. + /// 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 AddMsixDeployment(this ITestApplicationBuilder builder) + public static void AddPackagedAppDeployment(this ITestApplicationBuilder builder) { _ = builder ?? throw new System.ArgumentNullException(nameof(builder)); - builder.TestHostControllers.AddTestHostLauncher(_ => new MsixTestHostLauncher()); + builder.TestHostControllers.AddTestHostLauncher(_ => new PackagedAppTestHostLauncher()); } } diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs similarity index 76% rename from src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs index 7566beb76f..6a77284cb2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs @@ -3,18 +3,18 @@ using Microsoft.Testing.Platform.Extensions.TestHostControllers; -namespace Microsoft.Testing.Extensions.Msix; +namespace Microsoft.Testing.Extensions.PackagedApp; /// -/// An over a deployed Msix (UWP/WinUI) test host process that -/// deliberately hides the underlying process id, modelling a launch where no local, query-able PID -/// is available. +/// An over a deployed packaged Windows (UWP/WinUI) test host process +/// that deliberately hides the underlying process id, modelling a launch where no local, query-able +/// PID is available. /// -internal sealed class MsixTestHostHandle : ITestHostHandle, IDisposable +internal sealed class PackagedAppTestHostHandle : ITestHostHandle, IDisposable { private readonly Process _process; - public MsixTestHostHandle(Process process) + public PackagedAppTestHostHandle(Process process) { _process = process; _process.EnableRaisingEvents = true; diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs similarity index 72% rename from src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs index a470d018f3..7de471c280 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/MsixTestHostLauncher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs @@ -3,21 +3,21 @@ using Microsoft.Testing.Platform.Extensions.TestHostControllers; -namespace Microsoft.Testing.Extensions.Msix; +namespace Microsoft.Testing.Extensions.PackagedApp; /// -/// An for Msix-packaged 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. +/// 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. /// /// /// -/// Msix-packaged apps — produced by both UWP and packaged WinUI projects — 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. +/// 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 @@ -31,15 +31,15 @@ namespace Microsoft.Testing.Extensions.Msix; /// deploy-and-create step and returns an the platform monitors. /// /// -internal sealed class MsixTestHostLauncher : ITestHostLauncher +internal sealed class PackagedAppTestHostLauncher : ITestHostLauncher { - public string Uid => nameof(MsixTestHostLauncher); + public string Uid => nameof(PackagedAppTestHostLauncher); public string Version => "1.0.0"; - public string DisplayName => "Msix test host launcher"; + public string DisplayName => "Packaged app test host launcher"; - public string Description => "Deploys an Msix-packaged (UWP/WinUI) test host to an isolated directory and launches it from there."; + public string Description => "Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there."; public Task IsEnabledAsync() => Task.FromResult(true); @@ -50,7 +50,7 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, // 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(), "MTPMsixDeployment", Guid.NewGuid().ToString("N")); + string deploymentDirectory = Path.Combine(Path.GetTempPath(), "MTPPackagedAppDeployment", Guid.NewGuid().ToString("N")); CopyDirectory(sourceDirectory, deploymentDirectory); // 2. Launch the deployed test host, forwarding the platform-prepared arguments and @@ -75,17 +75,17 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, } Process process = Process.Start(startInfo) - ?? throw new InvalidOperationException($"Failed to start deployed Msix test host '{deployedFileName}'."); + ?? throw new InvalidOperationException($"Failed to start deployed packaged-app test host '{deployedFileName}'."); // Leave a breadcrumb next to the original app so the deployment is observable by callers/tests. - File.WriteAllText(Path.Combine(sourceDirectory, "MsixDeployment.txt"), deploymentDirectory); + File.WriteAllText(Path.Combine(sourceDirectory, "PackagedAppDeployment.txt"), deploymentDirectory); // 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/Exited/Terminate) and the IPC PID handshake. This // matches launch mechanisms where no local, query-able PID is available (e.g. an // AppContainer-sandboxed AUMID activation surfaced through a broker). - return Task.FromResult(new MsixTestHostHandle(process)); + return Task.FromResult(new PackagedAppTestHostHandle(process)); } private static void CopyDirectory(string sourceDirectory, string destinationDirectory) diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Shipped.txt similarity index 100% rename from src/Platform/Microsoft.Testing.Extensions.Msix/PublicAPI/PublicAPI.Shipped.txt rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Shipped.txt 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..7c8123dfbf --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +#nullable enable +Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook +Microsoft.Testing.Extensions.PackagedAppExtensions +static Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void +static Microsoft.Testing.Extensions.PackagedAppExtensions.AddPackagedAppDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/TestingPlatformBuilderHook.cs similarity index 66% rename from src/Platform/Microsoft.Testing.Extensions.Msix/TestingPlatformBuilderHook.cs rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/TestingPlatformBuilderHook.cs index 7b10d9d09a..8490e1da24 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/TestingPlatformBuilderHook.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/TestingPlatformBuilderHook.cs @@ -3,18 +3,18 @@ using Microsoft.Testing.Platform.Builder; -namespace Microsoft.Testing.Extensions.Msix; +namespace Microsoft.Testing.Extensions.PackagedApp; /// -/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add Msix (UWP/WinUI) test host deployment support. +/// 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 Msix (UWP/WinUI) test host deployment support to the Testing Platform Builder. + /// 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.AddMsixDeployment(); + => testApplicationBuilder.AddPackagedAppDeployment(); } diff --git a/src/Platform/Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/build/Microsoft.Testing.Extensions.PackagedApp.props similarity index 59% rename from src/Platform/Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/build/Microsoft.Testing.Extensions.PackagedApp.props index cea49bfb91..6f8c20f81f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/build/Microsoft.Testing.Extensions.Msix.props +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/build/Microsoft.Testing.Extensions.PackagedApp.props @@ -1,3 +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.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props similarity index 59% rename from src/Platform/Microsoft.Testing.Extensions.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props rename to src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props index cea49bfb91..6f8c20f81f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Msix/buildTransitive/Microsoft.Testing.Extensions.Msix.props +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props @@ -1,3 +1,3 @@ - + diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MsixDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs similarity index 88% rename from test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MsixDeploymentTests.cs rename to test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs index 7dc4f122d9..5dcac1478f 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MsixDeploymentTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs @@ -4,14 +4,14 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; [TestClass] -public sealed class MsixDeploymentTests : AcceptanceTestBase +public sealed class PackagedAppDeploymentTests : AcceptanceTestBase { - private const string AssetName = "MsixDeploymentTest"; + private const string AssetName = "PackagedAppDeploymentTest"; [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] [TestMethod] - [OSCondition(ConditionMode.Include, OperatingSystems.Windows, IgnoreMessage = "Msix packaging (UWP/WinUI) is a Windows-only scenario.")] - public async Task MsixDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(string currentTfm) + [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); TestHostResult testHostResult = await testHost.ExecuteAsync(cancellationToken: TestContext.CancellationToken); @@ -23,7 +23,7 @@ public async Task MsixDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(stri // The launcher leaves a breadcrumb pointing at the isolated deployment directory it created // and launched from. - string markerPath = Path.Combine(testHost.DirectoryName, "MsixDeployment.txt"); + string markerPath = Path.Combine(testHost.DirectoryName, "PackagedAppDeployment.txt"); Assert.IsTrue(File.Exists(markerPath), $"Expected deployment marker at '{markerPath}'."); string deploymentDirectory = File.ReadAllText(markerPath); @@ -34,7 +34,7 @@ public async Task MsixDeployment_DeploysAndLaunchesTestHost_WithoutLocalPid(stri public sealed class TestAssetFixture() : TestAssetFixtureBase() { private const string Sources = """ -#file MsixDeploymentTest.csproj +#file PackagedAppDeploymentTest.csproj @@ -46,7 +46,7 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase() - + @@ -67,7 +67,7 @@ public static async Task Main(string[] args) { var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args); testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework()); - testApplicationBuilder.AddMsixDeployment(); + testApplicationBuilder.AddPackagedAppDeployment(); using ITestApplication app = await testApplicationBuilder.BuildAsync(); return await app.RunAsync(); } From 6195ac4c05087036c0a2d6f07b655953c3831fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 13:44:41 +0200 Subject: [PATCH 06/17] Address review feedback on ITestHostLauncher implementation - TestHostHandleToProcessAdapter: give the "no process id" InvalidOperationException a descriptive message instead of an empty one, so the PID-less path is diagnosable. - PackagedApp launcher: clean up the staged deployment directory once the host has exited (the handle now owns the directory and best-effort deletes it on Dispose), preventing temp-dir accumulation in CI. Update the acceptance test to no longer require the deployment directory to remain on disk after the run. - Mirror the RFC review fixes (env-var wording, packaged-app example) in the doc shipped with the implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/RFCs/017-TestHost-Launcher.md | 27 ++++++++++++------- .../PackagedAppTestHostHandle.cs | 20 +++++++++++++- .../PackagedAppTestHostLauncher.cs | 5 ++-- .../System/TestHostHandleToProcessAdapter.cs | 2 +- .../PackagedAppDeploymentTests.cs | 7 ++--- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md index 0281e2f567..96acd0e0f1 100644 --- a/docs/RFCs/017-TestHost-Launcher.md +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -58,7 +58,7 @@ universally.** Several real scenarios need a different launch mechanism: over SSH/WinRM, then bridge the pipe — neither of which exposes a local, query-able PID. Today none of these is possible without forking the platform. The existing experimental -`ITestHostExecutionOrchestrator` sits at the wrong layer (see [Alternatives](#alternatives)). This +`ITestHostExecutionOrchestrator` sits at the wrong layer (see [Alternatives](#alternatives-considered)). This RFC adds the *minimal* hook at exactly the launch site. ## Goals @@ -203,8 +203,11 @@ public interface ITestHostControllersManager ### Contract requirements on the launcher -- The launched host **must** receive `context.EnvironmentVariables` (so it connects back on the - controller pipe) and **must** be passed `context.Arguments`. +- The launched host **must** end up with the values in `context.EnvironmentVariables` (so it connects + back on the controller pipe) and **must** receive `context.Arguments`. *How* those values reach the + host is left to the launcher — they can be inherited from the environment, passed as activation + arguments, or bridged through a broker. AUMID activation in particular cannot set per-launch + environment variables, so a packaged-app launcher must transfer them another way. - The returned handle must report exit reliably (`WaitForExitAsync`, `ExitCode`, `HasExited`, `Exited`) and support `Terminate()` (hang dump terminates the host through it). - `ProcessId` may be `null` when there is no local, query-able process (container/remote). It is @@ -223,8 +226,10 @@ builder.TestHostControllers.AddTestHostLauncher(sp => new MyLauncher(sp)); ### 1. Packaged WinUI / MSIX (the motivating case) Deploy the loose layout (Developer Mode) and activate the packaged app by AUMID instead of starting -an exe. The activated app self-hosts MTP (as the `MSTestRunnerWinUI` sample already does) and -connects back on the env-provided pipe. +an exe. The activated app self-hosts MTP (as the `MSTestRunnerWinUI` sample already does for the +in-process case); the launcher is responsible for getting the controller pipe name and correlation +id to it so it can connect back, since AUMID activation does not flow per-launch environment +variables on its own. ```csharp public Task LaunchTestHostAsync( @@ -235,12 +240,16 @@ public Task LaunchTestHostAsync( // new PackageManager().RegisterPackageByUriAsync(manifestUri, options); string aumid = AppxManifest.ResolveAumid(context.FileName); - // 2. Activate, passing the SAME args the platform prepared. + // 2. Activate, passing the SAME args the platform prepared. AUMID activation takes a single + // command-line string, so the launcher must escape/quote context.Arguments (e.g. with a + // PasteArguments-style helper) to preserve what ProcessStartInfo.ArgumentList would have done. var aam = (IApplicationActivationManager)new ApplicationActivationManager(); - aam.ActivateApplication(aumid, string.Join(' ', context.Arguments), ACTIVATEOPTIONS.AO_NONE, out uint pid); + aam.ActivateApplication(aumid, PasteArguments(context.Arguments), ACTIVATEOPTIONS.AO_NONE, out uint pid); - // 3. Wrap the returned PID. The app inherits context.EnvironmentVariables - // (incl. the MONITORTOHOST pipe name) via its activation/launch profile. + // 3. Wrap the returned PID. AUMID activation cannot set per-launch environment variables, so the + // launcher must bridge the values the host needs from context.EnvironmentVariables (the + // MONITORTOHOST pipe name, correlation id, etc.) another way — e.g. activation arguments or a + // broker process the activated app reads on startup. return Task.FromResult(new ProcessIdHandle((int)pid)); } ``` diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs index 6a77284cb2..bf109353f7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs @@ -13,10 +13,12 @@ namespace Microsoft.Testing.Extensions.PackagedApp; internal sealed class PackagedAppTestHostHandle : ITestHostHandle, IDisposable { private readonly Process _process; + private readonly string _deploymentDirectory; - public PackagedAppTestHostHandle(Process process) + public PackagedAppTestHostHandle(Process process, string deploymentDirectory) { _process = process; + _deploymentDirectory = deploymentDirectory; _process.EnableRaisingEvents = true; _process.Exited += OnProcessExited; } @@ -49,6 +51,22 @@ public void Dispose() { _process.Exited -= OnProcessExited; _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) + { + } + catch (UnauthorizedAccessException) + { + } } private void OnProcessExited(object? sender, EventArgs e) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs index 7de471c280..bb408258b1 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs @@ -84,8 +84,9 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, // that the platform relies purely on the lifecycle contract // (WaitForExitAsync/ExitCode/HasExited/Exited/Terminate) and the IPC PID handshake. This // matches launch mechanisms where no local, query-able PID is available (e.g. an - // AppContainer-sandboxed AUMID activation surfaced through a broker). - return Task.FromResult(new PackagedAppTestHostHandle(process)); + // 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) diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs index 1b641917fe..cca9440993 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs @@ -24,7 +24,7 @@ public TestHostHandleToProcessAdapter(ITestHostHandle handle) public event EventHandler? Exited; - public int Id => _handle.ProcessId ?? throw new InvalidOperationException(); + public int Id => _handle.ProcessId ?? throw new InvalidOperationException("The test host launcher did not expose a process id ('ITestHostHandle.ProcessId' is null)."); public string Name => string.Empty; diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs index 5dcac1478f..c891e0bfe9 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs @@ -21,13 +21,14 @@ public async Task PackagedAppDeployment_DeploysAndLaunchesTestHost_WithoutLocalP // for "not just a dumb process". testHostResult.AssertExitCodeIs(ExitCode.Success); - // The launcher leaves a breadcrumb pointing at the isolated deployment directory it created - // and launched from. + // The launcher leaves a breadcrumb (next to the original, non-deployed app) pointing at the + // isolated deployment directory it created and launched from. The directory itself is cleaned + // up once the host exits, so we only assert it was a distinct location rather than requiring + // it to still be on disk. string markerPath = Path.Combine(testHost.DirectoryName, "PackagedAppDeployment.txt"); Assert.IsTrue(File.Exists(markerPath), $"Expected deployment marker at '{markerPath}'."); string deploymentDirectory = File.ReadAllText(markerPath); - Assert.IsTrue(Directory.Exists(deploymentDirectory), $"Expected deployment directory '{deploymentDirectory}' to exist."); Assert.AreNotEqual(testHost.DirectoryName, deploymentDirectory, "The test host must have been deployed to a different directory."); } From cc4662cffad1fc4cdb7350f6355bb208ccd91f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 14:19:14 +0200 Subject: [PATCH 07/17] Refine ITestHostHandle: drop Exited, replace ProcessId with string Identifier Per design review on the RFC: - Remove the redundant Exited event from ITestHostHandle; consumers use WaitForExitAsync. The internal IProcess adapter synthesizes its informational Exited event from the exit task instead. - Replace int? ProcessId with an optional free-form string Identifier (diagnostics only: PID, container id, remote host:pid, ...). The controller host logs it where the handle is visible; the adapter no longer pretends to expose a numeric PID. - Update the PackagedApp handle (Identifier => null) and the acceptance asset handle (Identifier => process id string) accordingly, plus PublicAPI and the RFC doc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/RFCs/017-TestHost-Launcher.md | 59 +++++++++++-------- .../PackagedAppTestHostHandle.cs | 18 ++---- .../System/TestHostHandleToProcessAdapter.cs | 29 ++++++--- .../Hosts/TestHostControllersTestHost.cs | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 3 +- .../TestHostControllers/ITestHostHandle.cs | 22 +++---- .../TestHostLauncherTests.cs | 11 +--- 7 files changed, 72 insertions(+), 71 deletions(-) diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md index 96acd0e0f1..94be217c6f 100644 --- a/docs/RFCs/017-TestHost-Launcher.md +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -18,7 +18,7 @@ The hook is deliberately **agnostic of the launch mechanism**: the launcher does local OS process. It can deploy and activate a packaged application, launch a container, or start the host on a remote machine. To make this explicit, the launcher returns an `ITestHostHandle` that exposes only the lifecycle the platform needs (`WaitForExitAsync`, `ExitCode`, `HasExited`, -`Exited`, `Terminate`); a process id is *optional* and used purely for diagnostics. +`Terminate`) plus an optional free-form `Identifier` string used purely for diagnostics. The motivating scenario is **packaging and deployment of MSIX-packaged applications — both UWP and WinUI** (see [#2784](https://github.com/microsoft/testfx/issues/2784)): packaged/MSIX apps cannot be @@ -41,8 +41,8 @@ using IProcess testHostProcess = process.Start(processStartInfo); ``` Everything downstream only needs a handful of things from the returned handle — a way to observe -exit (`Exited`, `WaitForExitAsync()`, `ExitCode`, `HasExited`), optionally a PID for logging, and a -way to tear it down (`Kill`) — plus the child connecting back on the named pipe whose name was +exit (`WaitForExitAsync()`, `ExitCode`, `HasExited`), optionally an identifier for logging, and a +way to tear it down — plus the child connecting back on the named pipe whose name was injected via an environment variable. **`Process.Start` is the only assumption that does not hold universally.** Several real scenarios need a different launch mechanism: @@ -55,7 +55,7 @@ universally.** Several real scenarios need a different launch mechanism: `vsdbg` / `WinDbg` / `dlv`) and only then resume. - **Elevation**: run the test host as administrator (UAC) or as another user. - **Container / remote**: launch the host inside a container (`docker run`) or on a remote device - over SSH/WinRM, then bridge the pipe — neither of which exposes a local, query-able PID. + over SSH/WinRM, then bridge the pipe — neither of which exposes a local, queryable PID. Today none of these is possible without forking the platform. The existing experimental `ITestHostExecutionOrchestrator` sits at the wrong layer (see [Alternatives](#alternatives-considered)). This @@ -149,10 +149,11 @@ monitoring contract): [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] public interface ITestHostHandle { - event EventHandler Exited; - - /// The OS process id, when available. Null for container/remote launches. Logging only. - int? ProcessId { get; } + /// + /// Free-form diagnostic identifier (a PID, container id, remote host:pid, …) or null. The + /// platform never relies on it for control flow. + /// + string? Identifier { get; } int ExitCode { get; } bool HasExited { get; } @@ -186,10 +187,11 @@ public interface ITestHostControllersManager env-var providers ran), if a launcher is registered, build a `TestHostLaunchContext` from the `ProcessStartInfo` and `await launcher.LaunchTestHostAsync(...)`. Otherwise keep the default `process.Start`. The returned `ITestHostHandle` is adapted to the internal `IProcess` monitoring - contract — which only uses `Id` / `Exited` / `WaitForExitAsync` / `ExitCode` / `HasExited` / - `Kill`. Because `ProcessId` is optional, the premature-exit check is gated on `HasExited` only - (not on PID availability), so a launcher that returns no PID (container/remote/AUMID) is - monitored purely through the handle lifecycle and the IPC PID handshake. + contract — which only uses `WaitForExitAsync` / `ExitCode` / `HasExited` / `Kill` (and an + internal `Exited` event synthesized from the exit task for an informational log). The + premature-exit check is gated on `HasExited` only (not on whether an identifier is available), so + a launcher that returns no identifier (container/remote/AUMID) is monitored purely through the + handle lifecycle and the IPC PID handshake. 2. **Force the controller host.** A launcher makes `RequireProcessRestart` `true` when one is registered (computed in `TestHostControllersManager.BuildAsync`, checked in `TestHostBuilder.Modes.cs`); without this, a run with *only* a launcher (no dump/lifetime @@ -208,10 +210,11 @@ public interface ITestHostControllersManager host is left to the launcher — they can be inherited from the environment, passed as activation arguments, or bridged through a broker. AUMID activation in particular cannot set per-launch environment variables, so a packaged-app launcher must transfer them another way. -- The returned handle must report exit reliably (`WaitForExitAsync`, `ExitCode`, `HasExited`, - `Exited`) and support `Terminate()` (hang dump terminates the host through it). -- `ProcessId` may be `null` when there is no local, query-able process (container/remote). It is - used only for diagnostics. +- The returned handle must report exit reliably (`WaitForExitAsync`, `ExitCode`, `HasExited`) and + support `Terminate()` (hang dump terminates the host through it). `WaitForExitAsync` may be awaited + more than once. +- `Identifier` is an optional free-form diagnostic string (PID, container id, remote `host:pid`, …) + and may be `null`. The platform never relies on it for control flow. - If the launcher cannot start the host it should throw; the platform surfaces it as a platform-setup failure. @@ -246,11 +249,12 @@ public Task LaunchTestHostAsync( var aam = (IApplicationActivationManager)new ApplicationActivationManager(); aam.ActivateApplication(aumid, PasteArguments(context.Arguments), ACTIVATEOPTIONS.AO_NONE, out uint pid); - // 3. Wrap the returned PID. AUMID activation cannot set per-launch environment variables, so the + // 3. Wrap the activated app. AUMID activation cannot set per-launch environment variables, so the // launcher must bridge the values the host needs from context.EnvironmentVariables (the // MONITORTOHOST pipe name, correlation id, etc.) another way — e.g. activation arguments or a - // broker process the activated app reads on startup. - return Task.FromResult(new ProcessIdHandle((int)pid)); + // broker process the activated app reads on startup. The handle surfaces the activated PID as + // its (diagnostic-only) Identifier. + return Task.FromResult(new ActivatedAppHandle(pid)); } ``` @@ -266,7 +270,8 @@ public async Task LaunchTestHostAsync( { var psi = new ProcessStartInfo(context.FileName) { UseShellExecute = false }; foreach (string arg in context.Arguments) psi.ArgumentList.Add(arg); - foreach (var kvp in context.EnvironmentVariables) psi.Environment[kvp.Key] = kvp.Value; + foreach (var kvp in context.EnvironmentVariables.Where(kv => kv.Value is not null)) + psi.Environment[kvp.Key] = kvp.Value; // skip unset (null) vars psi.Environment["DOTNET_DefaultDiagnosticPortSuspend"] = "1"; // start suspended Process p = Process.Start(psi)!; @@ -310,7 +315,7 @@ public Task LaunchTestHostAsync( TestHostLaunchContext context, CancellationToken cancellationToken) { var args = new List { "run", "--rm", "--init" }; - foreach (var kvp in context.EnvironmentVariables) { args.Add("-e"); args.Add($"{kvp.Key}={kvp.Value}"); } + foreach (var kvp in context.EnvironmentVariables.Where(kv => kv.Value is not null)) { args.Add("-e"); args.Add($"{kvp.Key}={kvp.Value}"); } // skip unset (null) vars // Map the controller pipe into the container (Windows named pipe / Unix domain socket mount). args.Add("test-image:latest"); args.Add(context.FileName); @@ -338,7 +343,7 @@ public Task LaunchTestHostAsync( psi.ArgumentList.Add("user@remote-host"); psi.ArgumentList.Add(remoteCmd); Process ssh = Process.Start(psi)!; // tunnel the controller pipe over the SSH connection - // The handle tracks the local ssh client; ProcessId here is the ssh client PID (diagnostic only). + // The handle tracks the local ssh client; its Identifier could be the ssh client PID (diagnostic only). return Task.FromResult(new ProcessHandleAdapter(ssh)); } ``` @@ -367,10 +372,12 @@ bake in the "it's always a local process" assumption. A purpose-built, minimal, ### A process-centric `ITestHostProcessLauncher` returning a `ProcessId` An earlier draft of this RFC named the hook `ITestHostProcessLauncher` and returned an -`ITestHostProcessHandle` whose `ProcessId` was mandatory. That over-commits to "the test host is a -local OS process," which is false for container and remote launches and awkward for AUMID-activated -apps. The current design renames the types to drop "Process", makes `ProcessId` optional, and names -the teardown `Terminate()` instead of `Kill()`. +`ITestHostProcessHandle` whose `ProcessId` was a mandatory `int`. That over-commits to "the test host +is a local OS process," which is false for container and remote launches and awkward for +AUMID-activated apps. The current design renames the types to drop "Process", replaces the `int` +process id with an optional free-form `string Identifier` (diagnostic only), drops the redundant +`Exited` event in favour of `WaitForExitAsync`, and names the teardown `Terminate()` instead of +`Kill()`. ### Do nothing (keep `Process.Start`) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs index bf109353f7..c2690663b2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs @@ -7,8 +7,8 @@ namespace Microsoft.Testing.Extensions.PackagedApp; /// /// An over a deployed packaged Windows (UWP/WinUI) test host process -/// that deliberately hides the underlying process id, modelling a launch where no local, query-able -/// PID is available. +/// that deliberately exposes no identifier, modelling a launch where no local, query-able PID is +/// available. /// internal sealed class PackagedAppTestHostHandle : ITestHostHandle, IDisposable { @@ -19,15 +19,11 @@ public PackagedAppTestHostHandle(Process process, string deploymentDirectory) { _process = process; _deploymentDirectory = deploymentDirectory; - _process.EnableRaisingEvents = true; - _process.Exited += OnProcessExited; } - public event EventHandler? Exited; - - // Intentionally null: the platform must not depend on a local process id. A packaged UWP/WinUI - // implementation could surface the AUMID-activated PID here instead. - public int? ProcessId => null; + // 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; @@ -49,7 +45,6 @@ public void Terminate() public void Dispose() { - _process.Exited -= OnProcessExited; _process.Dispose(); // The platform disposes the handle once the host has exited, so it is safe to remove the @@ -68,7 +63,4 @@ public void Dispose() { } } - - private void OnProcessExited(object? sender, EventArgs e) - => Exited?.Invoke(this, e); } diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs index cca9440993..3d549a36a1 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs @@ -19,12 +19,18 @@ internal sealed class TestHostHandleToProcessAdapter : IProcess public TestHostHandleToProcessAdapter(ITestHostHandle handle) { _handle = handle; - _handle.Exited += OnHandleExited; + + // 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; - public int Id => _handle.ProcessId ?? throw new InvalidOperationException("The test host launcher did not expose a process id ('ITestHostHandle.ProcessId' is null)."); + // 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; @@ -36,18 +42,25 @@ public TestHostHandleToProcessAdapter(ITestHostHandle handle) public DateTime StartTime => default; - private void OnHandleExited(object? sender, EventArgs e) - => Exited?.Invoke(this, e); - public Task WaitForExitAsync() => _handle.WaitForExitAsync(); public void WaitForExit() => _handle.WaitForExitAsync().GetAwaiter().GetResult(); public void Kill() => _handle.Terminate(); - public void Dispose() + public void Dispose() => (_handle as IDisposable)?.Dispose(); + + private async Task RaiseExitedWhenDoneAsync() { - _handle.Exited -= OnHandleExited; - (_handle as IDisposable)?.Dispose(); + try + { + await _handle.WaitForExitAsync().ConfigureAwait(false); + } + catch + { + // The Exited event is informational only; never surface failures from this path. + } + + 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 cb824a707a..05ecbdcf05 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs @@ -427,6 +427,7 @@ private async Task LaunchUsingCustomLauncherAsync( 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); } diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 3e52184a5f..e172546d2b 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -1,9 +1,8 @@ #nullable enable [TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle -[TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostHandle.Exited -> System.EventHandler! [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.ProcessId.get -> int? +[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.Tasks.Task! [TPEXP]Microsoft.Testing.Platform.Extensions.TestHostControllers.ITestHostLauncher diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs index f3ab1773b8..c75ae3619c 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs @@ -8,26 +8,22 @@ namespace Microsoft.Testing.Platform.Extensions.TestHostControllers; /// /// /// The handle is intentionally agnostic of the underlying launch mechanism: it does not assume a -/// local OS process. is therefore optional and used only for diagnostics; -/// the platform tracks completion through , , -/// , and the event. +/// local OS process. is therefore an optional, free-form string used only +/// for diagnostics; the platform tracks completion through , +/// , and . /// [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] public interface ITestHostHandle { /// - /// Occurs when the test host exits. - /// - event EventHandler Exited; - - /// - /// Gets the operating-system process identifier of the test host, when one is available. + /// Gets an optional, free-form identifier for the launched test host, used only for diagnostics. /// /// - /// Returns when the launch mechanism does not expose a local, queryable - /// process id (for example a container or a remote launch). The value is used only for logging. + /// 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. /// - int? ProcessId { get; } + string? Identifier { get; } /// /// Gets the exit code of the test host. Only valid once is @@ -41,7 +37,7 @@ public interface ITestHostHandle bool HasExited { get; } /// - /// Waits asynchronously for the test host to exit. + /// Waits asynchronously for the test host to exit. The platform may await this more than once. /// /// A task that completes when the test host has exited. Task WaitForExitAsync(); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs index 884b56ac48..bcef3001c2 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs @@ -116,16 +116,9 @@ public sealed class ProcessTestHostHandle : ITestHostHandle { private readonly Process _process; - public ProcessTestHostHandle(Process process) - { - _process = process; - _process.EnableRaisingEvents = true; - _process.Exited += (sender, e) => Exited?.Invoke(this, e); - } - - public event EventHandler? Exited; + public ProcessTestHostHandle(Process process) => _process = process; - public int? ProcessId => _process.Id; + public string? Identifier => _process.Id.ToString(); public int ExitCode => _process.ExitCode; From e82cb3c813a7b20e8fbc6597d8b5567371e38722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 14:51:08 +0200 Subject: [PATCH 08/17] Address code-quality review: non-empty/specific catch handling - PackagedAppTestHostHandle.Dispose: the two best-effort cleanup catches (IOException, UnauthorizedAccessException) now log via Debug.WriteLine instead of being empty, keeping cleanup non-fatal while satisfying the empty-catch rule. - TestHostHandleToProcessAdapter.RaiseExitedWhenDoneAsync: replace the bare generic catch with catch (Exception ex) and a Debug.WriteLine, addressing the generic catch-clause finding while preserving the swallow-and-continue behavior for the informational Exited event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PackagedAppTestHostHandle.cs | 6 ++++-- .../Helpers/System/TestHostHandleToProcessAdapter.cs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs index c2690663b2..82d80566c8 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs @@ -56,11 +56,13 @@ public void Dispose() Directory.Delete(_deploymentDirectory, recursive: true); } } - catch (IOException) + catch (IOException ex) { + Debug.WriteLine($"Best-effort cleanup of deployment directory '{_deploymentDirectory}' failed: {ex}"); } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException ex) { + Debug.WriteLine($"Best-effort cleanup of deployment directory '{_deploymentDirectory}' failed: {ex}"); } } } diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs index 3d549a36a1..1b42e9f70f 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs @@ -56,9 +56,10 @@ private async Task RaiseExitedWhenDoneAsync() { await _handle.WaitForExitAsync().ConfigureAwait(false); } - catch + catch (Exception ex) { // The Exited event is informational only; never surface failures from this path. + Debug.WriteLine($"Ignoring failure while awaiting test host exit for the informational Exited event: {ex}"); } Exited?.Invoke(this, EventArgs.Empty); From f6d88b1ef677e19e9f8dbdeb75fd373ade9728e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 15:18:35 +0200 Subject: [PATCH 09/17] Fix stale Exited reference in PackagedApp launcher comment ITestHostHandle has no Exited event (consumers use WaitForExitAsync); drop the stale "Exited" from the lifecycle-contract comment so it matches the interface and the adapter comment. Also spell "queryable". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PackagedAppTestHostHandle.cs | 2 +- .../PackagedAppTestHostLauncher.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs index 82d80566c8..a836491ba9 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs @@ -7,7 +7,7 @@ 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, query-able PID is +/// that deliberately exposes no identifier, modelling a launch where no local, queryable PID is /// available. /// internal sealed class PackagedAppTestHostHandle : ITestHostHandle, IDisposable diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs index bb408258b1..923c3003e3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs @@ -82,8 +82,8 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, // 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/Exited/Terminate) and the IPC PID handshake. This - // matches launch mechanisms where no local, query-able PID is available (e.g. an + // (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)); From 19236921854830554deefc26d5d2a23464807b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 15:54:45 +0200 Subject: [PATCH 10/17] Add CancellationToken to WaitForExitAsync and make ITestHostHandle IDisposable Per RFC design review: - ITestHostHandle now extends IDisposable so the platform deterministically releases handle-held OS resources (it already wraps the handle in `using` via the adapter). - WaitForExitAsync takes a CancellationToken (the runtime API and the repo's netstandard2.0 polyfill both support one). Threaded through the internal IProcess contract, SystemProcess, and the handle adapter. - TestHostControllersTestHost passes its cancellation token when waiting for the host to exit; on cancellation it terminates the host and waits (uncancelable) for full exit so the existing exit-code reconciliation still observes a real OS exit code. The normal (non-canceled) path is unchanged. - Pre-existing internal callers (Retry, HangDump) pass CancellationToken.None to keep their exact prior behavior; the adapter synthesizes its informational Exited event with a dispose-linked token. - Document ExitCode-before-HasExited as undefined, clarify Quote/PasteArguments are placeholders, and fix the docker example so Terminate() tears down the container rather than only killing the local client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/RFCs/017-TestHost-Launcher.md | 46 +++++++++++++++---- .../HangDumpProcessLifetimeHandler.cs | 2 +- .../PackagedAppTestHostHandle.cs | 2 +- .../RetryOrchestrator.cs | 2 +- .../Helpers/System/IProcess.cs | 2 +- .../Helpers/System/SystemProcess.cs | 4 +- .../System/TestHostHandleToProcessAdapter.cs | 17 +++++-- .../Hosts/TestHostControllersTestHost.cs | 14 +++++- .../PublicAPI/PublicAPI.Unshipped.txt | 2 +- .../TestHostControllers/ITestHostHandle.cs | 21 ++++++--- .../TestHostLauncherTests.cs | 4 +- 11 files changed, 88 insertions(+), 28 deletions(-) diff --git a/docs/RFCs/017-TestHost-Launcher.md b/docs/RFCs/017-TestHost-Launcher.md index 94be217c6f..a77aab427a 100644 --- a/docs/RFCs/017-TestHost-Launcher.md +++ b/docs/RFCs/017-TestHost-Launcher.md @@ -147,7 +147,7 @@ monitoring contract): ```csharp [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] -public interface ITestHostHandle +public interface ITestHostHandle : IDisposable { /// /// Free-form diagnostic identifier (a PID, container id, remote host:pid, …) or null. The @@ -155,15 +155,25 @@ public interface ITestHostHandle /// string? Identifier { get; } + /// Only valid once is true (reading it earlier is undefined). int ExitCode { get; } bool HasExited { get; } - Task WaitForExitAsync(); + + /// Waits for exit, or for the token to be canceled. May be awaited more than once. + Task WaitForExitAsync(CancellationToken cancellationToken); /// Best-effort teardown (e.g. when hang dump aborts the run). void Terminate(); } ``` +`ITestHostHandle` extends `IDisposable`: the platform owns the handle for the whole lifetime of the +test host and disposes it once the host has exited, so implementations release any OS resources they +hold (process objects, sockets, container clients, …) in `Dispose`. `WaitForExitAsync` takes a +`CancellationToken` so the platform can stop waiting on cancellation; the controller host still +reconciles the real OS exit code afterwards (on cancellation it terminates the host and waits for it +to fully exit). + Registration mirrors the existing methods on `ITestHostControllersManager`: ```csharp @@ -212,12 +222,23 @@ public interface ITestHostControllersManager environment variables, so a packaged-app launcher must transfer them another way. - The returned handle must report exit reliably (`WaitForExitAsync`, `ExitCode`, `HasExited`) and support `Terminate()` (hang dump terminates the host through it). `WaitForExitAsync` may be awaited - more than once. + more than once, and must honor its `CancellationToken`. `ExitCode` is only required to be valid + once `HasExited` is `true` (or after `WaitForExitAsync` completes); reading it on a still-running + handle is undefined and implementations are not required to throw. +- The handle is `IDisposable`; the platform disposes it once the host has exited, so the launcher + should release any OS resources it holds (process object, sockets, container client, …) in + `Dispose`. - `Identifier` is an optional free-form diagnostic string (PID, container id, remote `host:pid`, …) and may be `null`. The platform never relies on it for control flow. - If the launcher cannot start the host it should throw; the platform surfaces it as a platform-setup failure. +> The `Quote` and `PasteArguments` helpers used in the examples below are placeholders for whatever +> argument/shell quoting the target mechanism needs (e.g. `PasteArguments` from dotnet/runtime for +> Windows command lines, POSIX single-quoting for a shell). Implement them carefully to avoid +> argument-injection bugs; the reference `Microsoft.Testing.Extensions.PackagedApp` extension (see the +> implementation PR) shows a concrete approach. + ## Examples All examples assume the extension is registered on the builder, e.g. from a `…Extensions` helper: @@ -307,8 +328,11 @@ requires that the host ends up with `context.EnvironmentVariables`. ### 4. Container -Run the test host inside a container and bridge the pipe. The returned handle tracks the -`docker run` client process; `Terminate()` tears down the container. +Run the test host inside a container and bridge the pipe. `docker run --rm` is used so the container +is removed when it stops. The returned handle tracks the `docker run` client process; note that +killing the client alone does not reliably stop the container, so a real implementation should make +`Terminate()` run `docker stop`/`docker rm` (or run with `--init` and rely on `--rm`) rather than +just killing the local client. ```csharp public Task LaunchTestHostAsync( @@ -317,6 +341,9 @@ public Task LaunchTestHostAsync( var args = new List { "run", "--rm", "--init" }; foreach (var kvp in context.EnvironmentVariables.Where(kv => kv.Value is not null)) { args.Add("-e"); args.Add($"{kvp.Key}={kvp.Value}"); } // skip unset (null) vars // Map the controller pipe into the container (Windows named pipe / Unix domain socket mount). + args.Add("--name"); + string containerName = $"mtp-{Guid.NewGuid():N}"; + args.Add(containerName); args.Add("test-image:latest"); args.Add(context.FileName); args.AddRange(context.Arguments); @@ -324,7 +351,9 @@ public Task LaunchTestHostAsync( var psi = new ProcessStartInfo("docker") { UseShellExecute = false }; foreach (string a in args) psi.ArgumentList.Add(a); Process p = Process.Start(psi)!; - return Task.FromResult(new ProcessHandleAdapter(p)); + // Wrap so Terminate() runs `docker stop ` (which tears down the container), not + // just Kill() on the local docker client. + return Task.FromResult(new DockerRunHandle(p, containerName)); } ``` @@ -376,8 +405,9 @@ An earlier draft of this RFC named the hook `ITestHostProcessLauncher` and retur is a local OS process," which is false for container and remote launches and awkward for AUMID-activated apps. The current design renames the types to drop "Process", replaces the `int` process id with an optional free-form `string Identifier` (diagnostic only), drops the redundant -`Exited` event in favour of `WaitForExitAsync`, and names the teardown `Terminate()` instead of -`Kill()`. +`Exited` event in favour of `WaitForExitAsync`, gives `WaitForExitAsync` a `CancellationToken` and +makes the handle `IDisposable` (so the platform can honor cancellation and deterministically release +handle resources), and names the teardown `Terminate()` instead of `Kill()`. ### Do nothing (keep `Process.Start`) diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs index f5038b35c9..d632969bff 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs @@ -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) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs index a836491ba9..08371d9f9e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs @@ -29,7 +29,7 @@ public PackagedAppTestHostHandle(Process process, string deploymentDirectory) public bool HasExited => _process.HasExited; - public Task WaitForExitAsync() => _process.WaitForExitAsync(); + public Task WaitForExitAsync(CancellationToken cancellationToken) => _process.WaitForExitAsync(cancellationToken); public void Terminate() { 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..b9bb14d2c0 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs @@ -28,7 +28,7 @@ internal interface IProcess : IDisposable /// /// Instructs the Process component to wait for the associated process to exit, or for the cancellationToken to be canceled. /// - Task WaitForExitAsync(); + 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..cc42cbbe5a 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs @@ -39,8 +39,8 @@ private void OnProcessExited(object? sender, EventArgs e) public void WaitForExit() => _process.WaitForExit(); - public Task WaitForExitAsync() - => _process.WaitForExitAsync(); + public Task WaitForExitAsync(CancellationToken cancellationToken) + => _process.WaitForExitAsync(cancellationToken); [UnsupportedOSPlatform("ios")] [UnsupportedOSPlatform("tvos")] diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs index 1b42e9f70f..0b3a279442 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs @@ -15,6 +15,7 @@ namespace Microsoft.Testing.Platform.Helpers; internal sealed class TestHostHandleToProcessAdapter : IProcess { private readonly ITestHostHandle _handle; + private readonly CancellationTokenSource _disposedCts = new(); public TestHostHandleToProcessAdapter(ITestHostHandle handle) { @@ -42,23 +43,29 @@ public TestHostHandleToProcessAdapter(ITestHostHandle handle) public DateTime StartTime => default; - public Task WaitForExitAsync() => _handle.WaitForExitAsync(); + public Task WaitForExitAsync(CancellationToken cancellationToken) => _handle.WaitForExitAsync(cancellationToken); - public void WaitForExit() => _handle.WaitForExitAsync().GetAwaiter().GetResult(); + public void WaitForExit() => _handle.WaitForExitAsync(CancellationToken.None).GetAwaiter().GetResult(); public void Kill() => _handle.Terminate(); - public void Dispose() => (_handle as IDisposable)?.Dispose(); + public void Dispose() + { + _disposedCts.Cancel(); + _disposedCts.Dispose(); + _handle.Dispose(); + } private async Task RaiseExitedWhenDoneAsync() { try { - await _handle.WaitForExitAsync().ConfigureAwait(false); + await _handle.WaitForExitAsync(_disposedCts.Token).ConfigureAwait(false); } catch (Exception ex) { - // The Exited event is informational only; never surface failures from this path. + // The Exited event is informational only; never surface failures (including cancellation + // when the adapter is disposed before the host exits) from this path. Debug.WriteLine($"Ignoring failure while awaiting test host exit for the informational Exited event: {ex}"); } diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs index 05ecbdcf05..667aef18b9 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs @@ -321,7 +321,19 @@ 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); + testHostProcess.Kill(); + await testHostProcess.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } } if (_testHostPID is null) diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index c0caafae7c..ce5518ec82 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -5,7 +5,7 @@ [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.Tasks.Task! +[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 diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs index c75ae3619c..9f701c57b1 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/ITestHostHandle.cs @@ -10,10 +10,13 @@ namespace Microsoft.Testing.Platform.Extensions.TestHostControllers; /// 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 . +/// , 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 +public interface ITestHostHandle : IDisposable { /// /// Gets an optional, free-form identifier for the launched test host, used only for diagnostics. @@ -26,9 +29,13 @@ public interface ITestHostHandle string? Identifier { get; } /// - /// Gets the exit code of the test host. Only valid once is - /// . + /// 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; } /// @@ -37,10 +44,12 @@ public interface ITestHostHandle bool HasExited { get; } /// - /// Waits asynchronously for the test host to exit. The platform may await this more than once. + /// 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(); + Task WaitForExitAsync(CancellationToken cancellationToken); /// /// Terminates the test host. The platform calls this for best-effort teardown (for example when diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs index bcef3001c2..e96d094de8 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs @@ -124,9 +124,11 @@ public sealed class ProcessTestHostHandle : ITestHostHandle public bool HasExited => _process.HasExited; - public Task WaitForExitAsync() => _process.WaitForExitAsync(); + public Task WaitForExitAsync(CancellationToken cancellationToken) => _process.WaitForExitAsync(cancellationToken); public void Terminate() => _process.Kill(); + + public void Dispose() => _process.Dispose(); } public class DummyTestFramework : ITestFramework, IDataProducer From 5f09f29d395039cfeb98ef569eca83be20cb431b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 16:35:57 +0200 Subject: [PATCH 11/17] Address launcher review: no shipping breadcrumb, honor cancellation + WorkingDirectory - Remove the PackagedApp launcher's write of a deployment-marker file into the original output directory (a shipping-side effect that could fail on read-only dirs and leave files behind). The acceptance test now proves deployment by having the deployed test host self-report its AppContext.BaseDirectory via a platform-forwarded env var, keeping the affordance in the test asset. - Honor cancellation: ThrowIfCancellationRequested before the deploy and during the recursive copy. - Honor TestHostLaunchContext.WorkingDirectory when set, defaulting to the deployment directory only when it is null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PackagedAppTestHostLauncher.cs | 17 +++++---- .../PackagedAppDeploymentTests.cs | 36 +++++++++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs index 923c3003e3..2485125a33 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs @@ -45,13 +45,16 @@ internal sealed class PackagedAppTestHostLauncher : ITestHostLauncher 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); + 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). @@ -61,7 +64,8 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, var startInfo = new ProcessStartInfo(deployedFileName) { UseShellExecute = false, - WorkingDirectory = deploymentDirectory, + // Honor an explicitly requested working directory; otherwise run from the deployment dir. + WorkingDirectory = context.WorkingDirectory ?? deploymentDirectory, }; foreach (string argument in context.Arguments) @@ -77,9 +81,6 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, Process process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Failed to start deployed packaged-app test host '{deployedFileName}'."); - // Leave a breadcrumb next to the original app so the deployment is observable by callers/tests. - File.WriteAllText(Path.Combine(sourceDirectory, "PackagedAppDeployment.txt"), deploymentDirectory); - // 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 @@ -89,18 +90,20 @@ public Task LaunchTestHostAsync(TestHostLaunchContext context, return Task.FromResult(new PackagedAppTestHostHandle(process, deploymentDirectory)); } - private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + 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)) { - CopyDirectory(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory))); + cancellationToken.ThrowIfCancellationRequested(); + CopyDirectory(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), cancellationToken); } } } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs index c891e0bfe9..3d87d30f27 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs @@ -14,22 +14,26 @@ public sealed class PackagedAppDeploymentTests : AcceptanceTestBase { ["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); - // The launcher leaves a breadcrumb (next to the original, non-deployed app) pointing at the - // isolated deployment directory it created and launched from. The directory itself is cleaned - // up once the host exits, so we only assert it was a distinct location rather than requiring - // it to still be on disk. - string markerPath = Path.Combine(testHost.DirectoryName, "PackagedAppDeployment.txt"); - Assert.IsTrue(File.Exists(markerPath), $"Expected deployment marker at '{markerPath}'."); - - string deploymentDirectory = File.ReadAllText(markerPath); - Assert.AreNotEqual(testHost.DirectoryName, deploymentDirectory, "The test host must have been deployed to a different directory."); + 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() @@ -66,6 +70,18 @@ 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(); From ca2edd86aa9c87e994d21580a855c8b29360326b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 17:31:15 +0200 Subject: [PATCH 12/17] Make PackagedApp APIs and package version experimental; add MSBuild registration acceptance test - Mark PackagedAppExtensions / AddPackagedAppDeployment [Experimental("TPEXP")] and prefix the PublicAPI entries with [TPEXP] (the MSBuild codegen hook stays non-experimental). - Give the package an experimental (alpha) version via MicrosoftTestingExtensionsPackagedApp{VersionPrefix,PreReleaseVersionLabel} + SuppressFinalPackageVersion, matching the OpenTelemetry/CtrfReport convention. - Update the buildTransitive props to the build/ forwarding pattern (#9431). - Plumb MicrosoftTestingExtensionsPackagedAppVersion through AcceptanceTestBase and the PackagedApp deployment asset (NoWarn TPEXP/NETSDK1201). - Add PackagedApp.MSBuildRegistration acceptance test asserting the build/buildTransitive props auto-register the TestingPlatformBuilderHook via the MSBuild self-registration codegen. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 7 ++ ...soft.Testing.Extensions.PackagedApp.csproj | 4 + .../PackagedAppExtensions.cs | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 4 +- ...osoft.Testing.Extensions.PackagedApp.props | 2 +- .../Helpers/AcceptanceTestBase.cs | 3 + .../PackagedApp.MSBuildRegistration.cs | 115 ++++++++++++++++++ .../PackagedAppDeploymentTests.cs | 9 +- 8 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedApp.MSBuildRegistration.cs diff --git a/Directory.Build.props b/Directory.Build.props index 45e417c62d..a2b36a85ba 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -86,6 +86,13 @@ This is an early preview package, keep 1.0.0-alpha or similar suffix even in official builds. --> 1.0.0 + + alpha + + + 1.0.0 diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj index 4013d3cfd4..040fefeb6a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj @@ -10,6 +10,10 @@ License.txt $(NoWarn);TPEXP + + $(MicrosoftTestingExtensionsPackagedAppVersionPrefix) + $(MicrosoftTestingExtensionsPackagedAppPreReleaseVersionLabel) + true diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs index beee2a32d2..6581c0c11d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppExtensions.cs @@ -10,6 +10,7 @@ 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 { /// diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt index 7c8123dfbf..9ec034f460 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PublicAPI/PublicAPI.Unshipped.txt @@ -1,5 +1,5 @@ #nullable enable Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook -Microsoft.Testing.Extensions.PackagedAppExtensions +[TPEXP]Microsoft.Testing.Extensions.PackagedAppExtensions static Microsoft.Testing.Extensions.PackagedApp.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void -static Microsoft.Testing.Extensions.PackagedAppExtensions.AddPackagedAppDeployment(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> 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/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/buildTransitive/Microsoft.Testing.Extensions.PackagedApp.props index 6f8c20f81f..5ce029d450 100644 --- 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 @@ -1,3 +1,3 @@ - + 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 26077917cd..281679e2b2 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."); } internal static string RID { get; } @@ -45,6 +46,8 @@ static AcceptanceTestBase() public static string MicrosoftTestingExtensionsJUnitReportVersion { get; private set; } + public static string MicrosoftTestingExtensionsPackagedAppVersion { get; private set; } + private static string ExtractVersionFromPackage(string rootFolder, string packagePrefixName) { string[] matches = Directory.GetFiles(rootFolder, packagePrefixName + "*" + NuGetPackageExtensionName, SearchOption.TopDirectoryOnly); 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 index 3d87d30f27..8578c6e99a 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs @@ -48,10 +48,14 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase() true enable preview + + $(NoWarn);TPEXP + + $(NoWarn);NETSDK1201 - + @@ -129,7 +133,8 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName, Sources .PatchTargetFrameworks(TargetFrameworks.Net) - .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)); + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsPackagedAppVersion$", MicrosoftTestingExtensionsPackagedAppVersion)); } public TestContext TestContext { get; set; } From 6380a3988eefce78c9aa7a30fd9aae7bf306764e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 17:43:33 +0200 Subject: [PATCH 13/17] Address review: don't raise synthetic Exited on fault/cancel; deterministic marker path; drop redundant IDisposable - TestHostHandleToProcessAdapter: return from the catch in RaiseExitedWhenDoneAsync so the informational Exited event only fires on a genuine host exit, not when the wait faults or is cancelled (e.g. the adapter is disposed before the host exits). - TestHostLauncherTests asset: write the LaunchTestHostAsync marker next to context.FileName so its location is deterministic regardless of the controller process working directory. - PackagedAppTestHostHandle: ITestHostHandle already extends IDisposable, so drop the redundant IDisposable on the class declaration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PackagedAppTestHostHandle.cs | 2 +- .../Helpers/System/TestHostHandleToProcessAdapter.cs | 6 ++++-- .../TestHostLauncherTests.cs | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs index 08371d9f9e..01debf9433 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostHandle.cs @@ -10,7 +10,7 @@ namespace Microsoft.Testing.Extensions.PackagedApp; /// that deliberately exposes no identifier, modelling a launch where no local, queryable PID is /// available. /// -internal sealed class PackagedAppTestHostHandle : ITestHostHandle, IDisposable +internal sealed class PackagedAppTestHostHandle : ITestHostHandle { private readonly Process _process; private readonly string _deploymentDirectory; diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs index 0b3a279442..fabde4d3d4 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs @@ -64,9 +64,11 @@ private async Task RaiseExitedWhenDoneAsync() } catch (Exception ex) { - // The Exited event is informational only; never surface failures (including cancellation - // when the adapter is disposed before the host exits) from this path. + // 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/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs index e96d094de8..474ebcf6b9 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TestHostLauncherTests.cs @@ -85,7 +85,11 @@ public sealed class TestHostLauncher : ITestHostLauncher public Task LaunchTestHostAsync(TestHostLaunchContext context, CancellationToken cancellationToken) { - System.IO.File.WriteAllText("LaunchTestHostAsync.txt", "TestHostLauncher.LaunchTestHostAsync"); + // 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) { From 5ee38da61025f6e9cff4a74dcf647d3545f5aaf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 18:09:39 +0200 Subject: [PATCH 14/17] Add PackagedApp to NonWindowsTests/Platform solution filters so the package packs on Linux/macOS The Linux/macOS CI legs build and pack via NonWindowsTests.slnf (NonWindowsBuild=true); Microsoft.Testing.Platform.slnf is the matching dev filter. Because the PackagedApp project was not a member of either filter, its NuGet package was never produced on those legs, so artifacts/packages/Debug/Shipping had no Microsoft.Testing.Extensions.PackagedApp.*.nupkg. AcceptanceTestBase's static constructor calls ExtractVersionFromPackage("Microsoft.Testing.Extensions.PackagedApp.") which throws when the package is absent, failing the type initializer and therefore EVERY acceptance test in the assembly (523 failures on Linux Debug). Windows legs build the full TestFx.slnx, so the package was already present there. Add the project alongside OpenTelemetry (its experimental sibling) in both filters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Testing.Platform.slnf | 1 + NonWindowsTests.slnf | 1 + 2 files changed, 2 insertions(+) diff --git a/Microsoft.Testing.Platform.slnf b/Microsoft.Testing.Platform.slnf index ef379078e4..c958ad508e 100644 --- a/Microsoft.Testing.Platform.slnf +++ b/Microsoft.Testing.Platform.slnf @@ -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", diff --git a/NonWindowsTests.slnf b/NonWindowsTests.slnf index ac5009fdce..401a443cec 100644 --- a/NonWindowsTests.slnf +++ b/NonWindowsTests.slnf @@ -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", From a6dff3998f3d3b900577dd37abeaaf94d2563c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 26 Jun 2026 18:13:08 +0200 Subject: [PATCH 15/17] Address review: best-effort Kill on cancel; single NoWarn in deployment asset - TestHostControllersTestHost: when the wait is cancelled, the host may exit between cancellation and the Kill() call (Kill throws InvalidOperationException when there is no process left to terminate). Make termination best-effort by swallowing that exception; the subsequent WaitForExitAsync still reconciles the real exit code. - PackagedAppDeploymentTests asset: collapse the two consecutive entries into a single $(NoWarn);TPEXP;NETSDK1201 to avoid any ambiguity about the effective value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Hosts/TestHostControllersTestHost.cs | 12 +++++++++++- .../PackagedAppDeploymentTests.cs | 7 +++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs index 667aef18b9..2a86d75d09 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs @@ -331,7 +331,17 @@ protected override async Task InternalRunAsync(CancellationToken cancellati // 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); - testHostProcess.Kill(); + try + { + testHostProcess.Kill(); + } + catch (InvalidOperationException) + { + // The host may have exited between the cancellation and this Kill call, in which + // case Kill throws because there is no longer a process to terminate. That is the + // outcome we wanted, so treat termination as best-effort and ignore it. + } + await testHostProcess.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs index 8578c6e99a..0cc192e785 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/PackagedAppDeploymentTests.cs @@ -48,10 +48,9 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase() true enable preview - - $(NoWarn);TPEXP - - $(NoWarn);NETSDK1201 + + $(NoWarn);TPEXP;NETSDK1201 From 42cdfbed0e16ba8d151ad02338f9665bd72d1a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sun, 28 Jun 2026 22:14:12 +0200 Subject: [PATCH 16/17] Fix forward-compat regression (restore IProcess.WaitForExitAsync()) and broaden cancel-time Kill catch - Restore the parameterless IProcess.WaitForExitAsync() overload alongside the WaitForExitAsync(CancellationToken) one. A previous commit replaced the parameterless method, which is a binary-breaking change to the internal IProcess contract that previously shipped extensions are compiled against. The shipped Retry extension calls IProcess.WaitForExitAsync(), so the new platform threw MissingMethodException under ForwardCompatibilityTests (NewerPlatform_WithPreviousExtensions_ShouldExecuteTests). Both overloads now exist; in-box callers use the token overload, shipped extensions keep using the parameterless one. - TestHostControllersTestHost: broaden the best-effort Kill() catch in the cancellation path from InvalidOperationException to Exception (logged). Kill() can now delegate to a custom ITestHostLauncher's Terminate(), which may throw arbitrary exceptions; termination must not mask the cancellation teardown flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/System/IProcess.cs | 10 ++++++++++ .../Helpers/System/SystemProcess.cs | 3 +++ .../Helpers/System/TestHostHandleToProcessAdapter.cs | 2 ++ .../Hosts/TestHostControllersTestHost.cs | 11 +++++++---- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs index b9bb14d2c0..beadb5cdc8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/IProcess.cs @@ -25,6 +25,16 @@ internal interface IProcess : IDisposable /// DateTime StartTime { get; } + /// + /// 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. /// diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs index cc42cbbe5a..036bcd08d7 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemProcess.cs @@ -39,6 +39,9 @@ private void OnProcessExited(object? sender, EventArgs e) public void WaitForExit() => _process.WaitForExit(); + public Task WaitForExitAsync() + => _process.WaitForExitAsync(); + public Task WaitForExitAsync(CancellationToken cancellationToken) => _process.WaitForExitAsync(cancellationToken); diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs index fabde4d3d4..0a2cff0c87 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/System/TestHostHandleToProcessAdapter.cs @@ -43,6 +43,8 @@ public TestHostHandleToProcessAdapter(ITestHostHandle handle) 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(); diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs index 2a86d75d09..fdf4ae0537 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.cs @@ -335,11 +335,14 @@ protected override async Task InternalRunAsync(CancellationToken cancellati { testHostProcess.Kill(); } - catch (InvalidOperationException) + catch (Exception ex) { - // The host may have exited between the cancellation and this Kill call, in which - // case Kill throws because there is no longer a process to terminate. That is the - // outcome we wanted, so treat termination as best-effort and ignore it. + // 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); From e4855eaf5fb6a05978ab35cc4da9ec15ffaa8f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 29 Jun 2026 12:27:58 +0200 Subject: [PATCH 17/17] Address review: keep MIT license, localize launcher strings, use build version - csproj: keep the default MIT license (drop PackageLicenseExpression cancel + License.txt packaging). Matches CtrfReport/HangDump siblings. - PackagedAppTestHostLauncher: localize DisplayName/Description via a new Resources/ExtensionResources.resx (+ generated xlf for all locales), matching the HangDump convention; Version now uses ExtensionVersion.DefaultSemVer (GenerateBuildInfo) instead of a hardcoded "1.0.0". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...soft.Testing.Extensions.PackagedApp.csproj | 6 +- .../PackagedAppTestHostLauncher.cs | 7 +- .../Resources/ExtensionResources.resx | 126 ++++++++++++++++++ .../Resources/xlf/ExtensionResources.cs.xlf | 17 +++ .../Resources/xlf/ExtensionResources.de.xlf | 17 +++ .../Resources/xlf/ExtensionResources.es.xlf | 17 +++ .../Resources/xlf/ExtensionResources.fr.xlf | 17 +++ .../Resources/xlf/ExtensionResources.it.xlf | 17 +++ .../Resources/xlf/ExtensionResources.ja.xlf | 17 +++ .../Resources/xlf/ExtensionResources.ko.xlf | 17 +++ .../Resources/xlf/ExtensionResources.pl.xlf | 17 +++ .../xlf/ExtensionResources.pt-BR.xlf | 17 +++ .../Resources/xlf/ExtensionResources.ru.xlf | 17 +++ .../Resources/xlf/ExtensionResources.tr.xlf | 17 +++ .../xlf/ExtensionResources.zh-Hans.xlf | 17 +++ .../xlf/ExtensionResources.zh-Hant.xlf | 17 +++ 16 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/ExtensionResources.resx create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.cs.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.de.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.es.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.fr.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.it.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ja.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ko.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pl.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.pt-BR.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.ru.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.tr.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hans.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.PackagedApp/Resources/xlf/ExtensionResources.zh-Hant.xlf diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj index 040fefeb6a..0bf1c9c6f8 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/Microsoft.Testing.Extensions.PackagedApp.csproj @@ -5,9 +5,8 @@ $(SupportedNetFrameworks) - - - License.txt + true + $(RepoRoot)src\Platform\SharedExtensionHelpers\BuildInfo.cs.template $(NoWarn);TPEXP @@ -42,7 +41,6 @@ This package extends Microsoft Testing Platform to deploy and launch a packaged true buildMultiTargeting - buildTransitive/$(TargetFramework) diff --git a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs index 2485125a33..cff90874a8 100644 --- a/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.PackagedApp/PackagedAppTestHostLauncher.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.Testing.Extensions.PackagedApp.Resources; using Microsoft.Testing.Platform.Extensions.TestHostControllers; namespace Microsoft.Testing.Extensions.PackagedApp; @@ -35,11 +36,11 @@ internal sealed class PackagedAppTestHostLauncher : ITestHostLauncher { public string Uid => nameof(PackagedAppTestHostLauncher); - public string Version => "1.0.0"; + public string Version => ExtensionVersion.DefaultSemVer; - public string DisplayName => "Packaged app test host launcher"; + public string DisplayName => ExtensionResources.PackagedAppExtensionDisplayName; - public string Description => "Deploys a packaged Windows (UWP/WinUI) test host to an isolated directory and launches it from there."; + public string Description => ExtensionResources.PackagedAppExtensionDescription; public Task IsEnabledAsync() => Task.FromResult(true); 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