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