From a2e43c3e01abb3f3a5e8b0141292e45cb63cb1d7 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Mon, 19 Oct 2026 00:22:05 -0700 Subject: [PATCH 1/3] test: Align MSTest adapter and framework versions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c331d8..465ad1c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + From a71c3ee7cd549f9653adebfa2c4b16d1684039fc Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Mon, 19 Oct 2026 00:22:08 -0700 Subject: [PATCH 2/3] Fix shutdown cancellation and process waits under parallel load Handle locally timed-out shutdown RPC cancellation without masking unexpected errors. Replace process polling with bounded synchronous and asynchronous exit waits, retaining forced termination and log cleanup. Add disposal regressions and repeated ten-host startup/shutdown coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 8 ++ XAMLTest.Tests/AppDisposalTests.cs | 207 +++++++++++++++++++++++++++ XAMLTest.Tests/NativeMethods.txt | 1 - XAMLTest.Tests/XAMLTest.Tests.csproj | 4 - XAMLTest/Internal/App.cs | 93 ++++++++---- XAMLTest/XAMLTest.csproj | 1 + 6 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 XAMLTest.Tests/AppDisposalTests.cs delete mode 100644 XAMLTest.Tests/NativeMethods.txt diff --git a/README.md b/README.md index c891a00..aa9ec5b 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,11 @@ After the 1.0.0 release, this library will follow [Semantic Versioning](https:// ## Samples? See the tests (XAMLTests.Tests) for samples of how tests can be setup. [Material Design In XAML Toolkit](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit) is also leveraging this library for the purpose of testing its various styles and templates. You can see samples of its tests [here](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/tree/master/MaterialDesignThemes.UITests). + +## Application cleanup +Dispose each `IApp` after use, preferably with `await using`. Disposal allows up to +10 seconds for the shutdown RPC, then waits up to 10 seconds for process exit. +If the host does not exit, it is killed and given another 10 seconds to terminate. +Asynchronous disposal waits without blocking a thread or polling process state. +Local shutdown timeouts are logged and process cleanup still runs; unexpected +RPC errors remain visible to the caller. diff --git a/XAMLTest.Tests/AppDisposalTests.cs b/XAMLTest.Tests/AppDisposalTests.cs new file mode 100644 index 0000000..c0a9816 --- /dev/null +++ b/XAMLTest.Tests/AppDisposalTests.cs @@ -0,0 +1,207 @@ +using Grpc.Core; +using XamlTest.Host; + +namespace XamlTest.Tests; + +[TestClass] +public class AppDisposalTests +{ + [TestMethod] + [DataRow(false, false)] + [DataRow(true, false)] + [DataRow(false, true)] + [DataRow(true, true)] + public async Task LocallyCancelledShutdown_DoesNotFailDisposal(bool asynchronous, bool rpcCancellation) + { + using Process process = await StartExitedProcess(); + List messages = []; + var client = new ShutdownClient(async options => + { + try + { + await Task.Delay(Timeout.Infinite, options.CancellationToken); + } + catch (OperationCanceledException) when (options.CancellationToken.IsCancellationRequested) + { + if (!rpcCancellation) + { + throw; + } + } + throw new RpcException(new Status(StatusCode.Cancelled, "")); + }); + var app = new Internal.App(process, client, new AppOptions { LogMessage = messages.Add }); + + await Dispose(app, asynchronous); + + Assert.IsTrue(messages.Any(x => x.Contains("Shutdown request timed out"))); + Assert.IsTrue(process.HasExited); + } + + [TestMethod] + [DataRow(false, StatusCode.Cancelled)] + [DataRow(true, StatusCode.Cancelled)] + [DataRow(false, StatusCode.Internal)] + [DataRow(true, StatusCode.Internal)] + public async Task UnexpectedRpcError_IsNotSuppressed(bool asynchronous, StatusCode statusCode) + { + using Process process = await StartExitedProcess(); + var client = new ShutdownClient(_ => Task.FromException( + new RpcException(new Status(statusCode, "Unexpected shutdown error")))); + var app = new Internal.App(process, client, new AppOptions()); + + RpcException exception = await Assert.ThrowsAsync(() => Dispose(app, asynchronous)); + + Assert.AreEqual(statusCode, exception.StatusCode); + Assert.IsTrue(process.HasExited); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task AlreadyExitedProcess_DisposesSuccessfully(bool asynchronous) + { + using Process process = await StartExitedProcess(); + var client = new ShutdownClient(_ => Task.FromResult(new ShutdownResult())); + var app = new Internal.App(process, client, new AppOptions()); + + await Dispose(app, asynchronous); + + Assert.IsTrue(process.HasExited); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task SlowShutdownReply_DoesNotHitFormerOneSecondTimeout(bool asynchronous) + { + using Process process = await StartExitedProcess(); + var client = new ShutdownClient(async options => + { + await Task.Delay(TimeSpan.FromSeconds(1.5), options.CancellationToken); + return new ShutdownResult(); + }); + var app = new Internal.App(process, client, new AppOptions()); + + await Dispose(app, asynchronous); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ShutdownErrorReply_IsNotSuppressed(bool asynchronous) + { + using Process process = await StartExitedProcess(); + var client = new ShutdownClient(_ => + { + ShutdownResult reply = new(); + reply.ErrorMessages.Add("Shutdown failed"); + return Task.FromResult(reply); + }); + var app = new Internal.App(process, client, new AppOptions()); + + XamlTestException exception = await Assert.ThrowsAsync(() => Dispose(app, asynchronous)); + + Assert.AreEqual("Shutdown failed", exception.Message); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task TenConcurrentApps_CanStartAndDisposeRepeatedly(bool asynchronous) + { + for (int batch = 0; batch < 3; batch++) + { + await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(async () => + { + IApp app = await App.StartRemote(new AppOptions + { + AllowVisualStudioDebuggerAttach = false + }); + var host = (Internal.App)app; + try + { + await Dispose(host, asynchronous); + Assert.IsTrue(host.Process.HasExited); + } + finally + { + if (!host.Process.HasExited) + { + host.Process.Kill(); + await host.Process.WaitForExitAsync(); + } + host.Process.Dispose(); + } + }))); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UnresponsiveProcess_IsKilledAndReaped(bool asynchronous) + { + using Process process = Process.Start(new ProcessStartInfo("powershell.exe", + "-NoProfile -NonInteractive -Command Start-Sleep -Seconds 120") + { + UseShellExecute = false, + CreateNoWindow = true + })!; + try + { + var client = new ShutdownClient(_ => Task.FromResult(new ShutdownResult())); + var app = new Internal.App(process, client, new AppOptions()); + + Task disposal = Dispose(app, asynchronous); + if (asynchronous) + { + Assert.IsFalse(disposal.IsCompleted, "Async disposal must yield while waiting for the host."); + } + await disposal; + + Assert.IsTrue(process.HasExited); + } + finally + { + if (!process.HasExited) + { + process.Kill(); + await process.WaitForExitAsync(); + } + } + } + + private static async Task StartExitedProcess() + { + Process process = Process.Start(new ProcessStartInfo("cmd.exe", "/c exit 0") + { + UseShellExecute = false, + CreateNoWindow = true + })!; + await process.WaitForExitAsync(); + return process; + } + + private static async Task Dispose(Internal.App app, bool asynchronous) + { + if (asynchronous) + { + await app.DisposeAsync(); + } + else + { + app.Dispose(); + } + } + + private sealed class ShutdownClient(Func> shutdown) : Protocol.ProtocolClient + { + public override ShutdownResult Shutdown(ShutdownRequest request, CallOptions options) + => shutdown(options).GetAwaiter().GetResult(); + + public override AsyncUnaryCall ShutdownAsync(ShutdownRequest request, CallOptions options) + => new(shutdown(options), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, + () => new Metadata(), () => { }); + } +} diff --git a/XAMLTest.Tests/NativeMethods.txt b/XAMLTest.Tests/NativeMethods.txt deleted file mode 100644 index b28b022..0000000 --- a/XAMLTest.Tests/NativeMethods.txt +++ /dev/null @@ -1 +0,0 @@ -GetWindowPlacement \ No newline at end of file diff --git a/XAMLTest.Tests/XAMLTest.Tests.csproj b/XAMLTest.Tests/XAMLTest.Tests.csproj index 9dc9edb..71e67c6 100644 --- a/XAMLTest.Tests/XAMLTest.Tests.csproj +++ b/XAMLTest.Tests/XAMLTest.Tests.csproj @@ -11,10 +11,6 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/XAMLTest/Internal/App.cs b/XAMLTest/Internal/App.cs index 647974a..18b22db 100644 --- a/XAMLTest/Internal/App.cs +++ b/XAMLTest/Internal/App.cs @@ -8,6 +8,9 @@ internal sealed class App( Protocol.ProtocolClient client, AppOptions appOptions) : IApp { + private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ProcessExitTimeout = TimeSpan.FromSeconds(10); + public Process Process { get; } = process ?? throw new ArgumentNullException(nameof(process)); private Protocol.ProtocolClient Client { get; } = client ?? throw new ArgumentNullException(nameof(client)); @@ -27,10 +30,9 @@ public void Dispose() ExitCode = 0 }; LogMessage?.Invoke($"{nameof(IApp)}.{nameof(Dispose)}()"); + using CancellationTokenSource cts = new(ShutdownTimeout); try { - using CancellationTokenSource cts = new(); - cts.CancelAfter(TimeSpan.FromSeconds(1)); if (Client.Shutdown(request, cancellationToken: cts.Token) is { } reply) { if (reply.ErrorMessages.Any()) @@ -41,14 +43,26 @@ public void Dispose() } throw new XamlTestException("Failed to get a reply"); } - catch (OperationCanceledException) - { } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Cancelled && cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) { } finally { - KillProcess(); - CleanupLogFiles(); + try + { + KillProcess(); + } + finally + { + CleanupLogFiles(); + } } } @@ -59,10 +73,9 @@ public async ValueTask DisposeAsync() ExitCode = 0 }; LogMessage?.Invoke($"{nameof(IApp)}.{nameof(DisposeAsync)}()"); + using CancellationTokenSource cts = new(ShutdownTimeout); try { - using CancellationTokenSource cts = new(); - cts.CancelAfter(TimeSpan.FromSeconds(1)); if (await Client.ShutdownAsync(request, cancellationToken: cts.Token) is { } reply) { if (reply.ErrorMessages.Any()) @@ -73,14 +86,26 @@ public async ValueTask DisposeAsync() } throw new XamlTestException("Failed to get a reply"); } - catch (OperationCanceledException) - { } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Cancelled && cts.IsCancellationRequested) + { + LogMessage?.Invoke("Shutdown request timed out; waiting for process exit."); + } catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) { } finally { - KillProcess(); - await CleanupLogFilesAsync(); + try + { + await KillProcessAsync(); + } + finally + { + await CleanupLogFilesAsync(); + } } } @@ -133,29 +158,41 @@ private void CleanupLogFiles() private void KillProcess() { LogMessage?.Invoke("Waiting for process exit"); - using CancellationTokenSource cts = new(); - cts.CancelAfter(TimeSpan.FromSeconds(10)); - Process? process = null; - do + if (!Process.WaitForExit((int)ProcessExitTimeout.TotalMilliseconds)) { - try - { - process = Process.GetProcessById(Process.Id); - } - catch (ArgumentException) + LogMessage?.Invoke($"Invoking kill"); + Process.Kill(); + if (!Process.WaitForExit((int)ProcessExitTimeout.TotalMilliseconds)) { - //Thrown when the process specified by the processId parameter is not running. + throw new XamlTestException("Host process did not exit after being killed."); } } - while (process?.HasExited == false && !cts.IsCancellationRequested); + LogMessage?.Invoke($"Process Exited? {Process.HasExited}"); + } - LogMessage?.Invoke($"Process Exited? {process?.HasExited}"); - if (process?.HasExited == false) + private async Task KillProcessAsync() + { + LogMessage?.Invoke("Waiting for process exit"); + using CancellationTokenSource cts = new(ProcessExitTimeout); + try { - LogMessage?.Invoke($"Invoking kill"); - process.Kill(); - process.WaitForExit(1_000); + await Process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + LogMessage?.Invoke("Invoking kill"); + Process.Kill(); + using CancellationTokenSource killCts = new(ProcessExitTimeout); + try + { + await Process.WaitForExitAsync(killCts.Token); + } + catch (OperationCanceledException e) when (killCts.IsCancellationRequested) + { + throw new XamlTestException("Host process did not exit after being killed.", e); + } } + LogMessage?.Invoke($"Process Exited? {Process.HasExited}"); } public async Task Initialize(string applicationResourceXaml, params string[] assemblies) diff --git a/XAMLTest/XAMLTest.csproj b/XAMLTest/XAMLTest.csproj index 4c6f579..4637d57 100644 --- a/XAMLTest/XAMLTest.csproj +++ b/XAMLTest/XAMLTest.csproj @@ -12,6 +12,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive From 35f9ffd01d460ed5e03c394645ffe04dd31d7c55 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Mon, 19 Oct 2026 11:04:35 -0700 Subject: [PATCH 3/3] Enforce public API testing and ban friend assemblies Remove InternalsVisibleTo and implementation-level disposal mocks. Exercise real host lifecycle scenarios through IApp and RemoteExecute, restore test-local Win32 declarations, and enforce the repository-wide API ban with BannedApiAnalyzers and RS0030 errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .editorconfig | 3 + BannedSymbols.txt | 1 + Directory.Build.props | 5 + Directory.Packages.props | 1 + README.md | 6 + XAMLTest.Tests/AppDisposalTests.cs | 231 +++++++++++---------------- XAMLTest.Tests/NativeMethods.txt | 1 + XAMLTest.Tests/XAMLTest.Tests.csproj | 4 + XAMLTest/XAMLTest.csproj | 1 - 9 files changed, 118 insertions(+), 135 deletions(-) create mode 100644 BannedSymbols.txt create mode 100644 XAMLTest.Tests/NativeMethods.txt diff --git a/.editorconfig b/.editorconfig index fc38dfb..6835e89 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,6 +18,9 @@ csharp_style_expression_bodied_accessors = true:silent csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent +# Tests must exercise the public API rather than friend assemblies. +dotnet_diagnostic.RS0030.severity = error + [*.{cs,vb}] dotnet_style_operator_placement_when_wrapping = beginning_of_line tab_width = 4 diff --git a/BannedSymbols.txt b/BannedSymbols.txt new file mode 100644 index 0000000..73b6fb0 --- /dev/null +++ b/BannedSymbols.txt @@ -0,0 +1 @@ +T:System.Runtime.CompilerServices.InternalsVisibleToAttribute;Do not expose internals to other assemblies. Tests must use the public API. diff --git a/Directory.Build.props b/Directory.Build.props index de6f762..1e98d7d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,6 +29,11 @@ snupkg + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 465ad1c..1063180 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/README.md b/README.md index aa9ec5b..eea4da0 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,9 @@ If the host does not exit, it is killed and given another 10 seconds to terminat Asynchronous disposal waits without blocking a thread or polling process state. Local shutdown timeouts are logged and process cleanup still runs; unexpected RPC errors remain visible to the caller. + +## Testing policy +Tests must exercise public APIs, not internal implementation details. +`Microsoft.CodeAnalysis.BannedApiAnalyzers` runs in every project, using the +repository-wide `BannedSymbols.txt`. `InternalsVisibleToAttribute` is banned; +violations fail the build with RS0030. diff --git a/XAMLTest.Tests/AppDisposalTests.cs b/XAMLTest.Tests/AppDisposalTests.cs index c0a9816..fac6d6f 100644 --- a/XAMLTest.Tests/AppDisposalTests.cs +++ b/XAMLTest.Tests/AppDisposalTests.cs @@ -1,108 +1,30 @@ -using Grpc.Core; -using XamlTest.Host; +using System.Collections.Concurrent; namespace XamlTest.Tests; [TestClass] public class AppDisposalTests { - [TestMethod] - [DataRow(false, false)] - [DataRow(true, false)] - [DataRow(false, true)] - [DataRow(true, true)] - public async Task LocallyCancelledShutdown_DoesNotFailDisposal(bool asynchronous, bool rpcCancellation) - { - using Process process = await StartExitedProcess(); - List messages = []; - var client = new ShutdownClient(async options => - { - try - { - await Task.Delay(Timeout.Infinite, options.CancellationToken); - } - catch (OperationCanceledException) when (options.CancellationToken.IsCancellationRequested) - { - if (!rpcCancellation) - { - throw; - } - } - throw new RpcException(new Status(StatusCode.Cancelled, "")); - }); - var app = new Internal.App(process, client, new AppOptions { LogMessage = messages.Add }); - - await Dispose(app, asynchronous); - - Assert.IsTrue(messages.Any(x => x.Contains("Shutdown request timed out"))); - Assert.IsTrue(process.HasExited); - } - - [TestMethod] - [DataRow(false, StatusCode.Cancelled)] - [DataRow(true, StatusCode.Cancelled)] - [DataRow(false, StatusCode.Internal)] - [DataRow(true, StatusCode.Internal)] - public async Task UnexpectedRpcError_IsNotSuppressed(bool asynchronous, StatusCode statusCode) - { - using Process process = await StartExitedProcess(); - var client = new ShutdownClient(_ => Task.FromException( - new RpcException(new Status(statusCode, "Unexpected shutdown error")))); - var app = new Internal.App(process, client, new AppOptions()); - - RpcException exception = await Assert.ThrowsAsync(() => Dispose(app, asynchronous)); - - Assert.AreEqual(statusCode, exception.StatusCode); - Assert.IsTrue(process.HasExited); - } - [TestMethod] [DataRow(false)] [DataRow(true)] public async Task AlreadyExitedProcess_DisposesSuccessfully(bool asynchronous) { - using Process process = await StartExitedProcess(); - var client = new ShutdownClient(_ => Task.FromResult(new ShutdownResult())); - var app = new Internal.App(process, client, new AppOptions()); - - await Dispose(app, asynchronous); - - Assert.IsTrue(process.HasExited); - } - - [TestMethod] - [DataRow(false)] - [DataRow(true)] - public async Task SlowShutdownReply_DoesNotHitFormerOneSecondTimeout(bool asynchronous) - { - using Process process = await StartExitedProcess(); - var client = new ShutdownClient(async options => + IApp app = await StartApp(); + using Process process = await GetHostProcess(app); + try { - await Task.Delay(TimeSpan.FromSeconds(1.5), options.CancellationToken); - return new ShutdownResult(); - }); - var app = new Internal.App(process, client, new AppOptions()); + process.Kill(); + await process.WaitForExitAsync(); - await Dispose(app, asynchronous); - } + await Dispose(app, asynchronous); - [TestMethod] - [DataRow(false)] - [DataRow(true)] - public async Task ShutdownErrorReply_IsNotSuppressed(bool asynchronous) - { - using Process process = await StartExitedProcess(); - var client = new ShutdownClient(_ => + Assert.IsTrue(process.HasExited); + } + finally { - ShutdownResult reply = new(); - reply.ErrorMessages.Add("Shutdown failed"); - return Task.FromResult(reply); - }); - var app = new Internal.App(process, client, new AppOptions()); - - XamlTestException exception = await Assert.ThrowsAsync(() => Dispose(app, asynchronous)); - - Assert.AreEqual("Shutdown failed", exception.Message); + await EnsureProcessExited(process); + } } [TestMethod] @@ -114,24 +36,17 @@ public async Task TenConcurrentApps_CanStartAndDisposeRepeatedly(bool asynchrono { await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(async () => { - IApp app = await App.StartRemote(new AppOptions - { - AllowVisualStudioDebuggerAttach = false - }); - var host = (Internal.App)app; + IApp app = await StartApp(); + using Process process = await GetHostProcess(app); try { - await Dispose(host, asynchronous); - Assert.IsTrue(host.Process.HasExited); + await Dispose(app, asynchronous); + + Assert.IsTrue(process.HasExited); } finally { - if (!host.Process.HasExited) - { - host.Process.Kill(); - await host.Process.WaitForExitAsync(); - } - host.Process.Dispose(); + await EnsureProcessExited(process); } }))); } @@ -140,18 +55,47 @@ await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(async () => [TestMethod] [DataRow(false)] [DataRow(true)] - public async Task UnresponsiveProcess_IsKilledAndReaped(bool asynchronous) + public async Task SlowExit_DisposalWaitsForProcess(bool asynchronous) { - using Process process = Process.Start(new ProcessStartInfo("powershell.exe", - "-NoProfile -NonInteractive -Command Start-Sleep -Seconds 120") + ConcurrentQueue messages = new(); + IApp app = await StartApp(messages.Enqueue); + using Process process = await GetHostProcess(app); + try { - UseShellExecute = false, - CreateNoWindow = true - })!; + static void DelayExit(Application application) + { + application.Exit += (_, _) => Thread.Sleep(TimeSpan.FromSeconds(1.5)); + } + await app.RemoteExecute(DelayExit); + + Stopwatch stopwatch = Stopwatch.StartNew(); + await Dispose(app, asynchronous); + + Assert.IsTrue(process.HasExited); + Assert.IsTrue(stopwatch.Elapsed >= TimeSpan.FromSeconds(1.5)); + Assert.IsFalse(messages.Any(x => x.Contains("Invoking kill"))); + } + finally + { + await EnsureProcessExited(process); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UnresponsiveExit_IsKilledAndReaped(bool asynchronous) + { + ConcurrentQueue messages = new(); + IApp app = await StartApp(messages.Enqueue); + using Process process = await GetHostProcess(app); try { - var client = new ShutdownClient(_ => Task.FromResult(new ShutdownResult())); - var app = new Internal.App(process, client, new AppOptions()); + static void BlockExit(Application application) + { + application.Exit += (_, _) => Thread.Sleep(Timeout.Infinite); + } + await app.RemoteExecute(BlockExit); Task disposal = Dispose(app, asynchronous); if (asynchronous) @@ -161,29 +105,58 @@ public async Task UnresponsiveProcess_IsKilledAndReaped(bool asynchronous) await disposal; Assert.IsTrue(process.HasExited); + Assert.IsTrue(messages.Any(x => x.Contains("Invoking kill"))); } finally { - if (!process.HasExited) - { - process.Kill(); - await process.WaitForExitAsync(); - } + await EnsureProcessExited(process); + } + } + + private static async Task StartApp(Action? logMessage = null) + { + IApp app = await App.StartRemote(new AppOptions + { + AllowVisualStudioDebuggerAttach = false, + LogMessage = logMessage + }); + try + { + await app.InitializeWithDefaults(Assembly.GetExecutingAssembly().Location); + return app; + } + catch + { + await app.DisposeAsync(); + throw; + } + } + + private static async Task GetHostProcess(IApp app) + { + static int GetProcessId(Application _) => Environment.ProcessId; + try + { + int processId = await app.RemoteExecute(GetProcessId); + return Process.GetProcessById(processId); + } + catch + { + await app.DisposeAsync(); + throw; } } - private static async Task StartExitedProcess() + private static async Task EnsureProcessExited(Process process) { - Process process = Process.Start(new ProcessStartInfo("cmd.exe", "/c exit 0") + if (!process.HasExited) { - UseShellExecute = false, - CreateNoWindow = true - })!; - await process.WaitForExitAsync(); - return process; + process.Kill(); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } } - private static async Task Dispose(Internal.App app, bool asynchronous) + private static async Task Dispose(IApp app, bool asynchronous) { if (asynchronous) { @@ -194,14 +167,4 @@ private static async Task Dispose(Internal.App app, bool asynchronous) app.Dispose(); } } - - private sealed class ShutdownClient(Func> shutdown) : Protocol.ProtocolClient - { - public override ShutdownResult Shutdown(ShutdownRequest request, CallOptions options) - => shutdown(options).GetAwaiter().GetResult(); - - public override AsyncUnaryCall ShutdownAsync(ShutdownRequest request, CallOptions options) - => new(shutdown(options), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, - () => new Metadata(), () => { }); - } } diff --git a/XAMLTest.Tests/NativeMethods.txt b/XAMLTest.Tests/NativeMethods.txt new file mode 100644 index 0000000..a2764d2 --- /dev/null +++ b/XAMLTest.Tests/NativeMethods.txt @@ -0,0 +1 @@ +GetWindowPlacement diff --git a/XAMLTest.Tests/XAMLTest.Tests.csproj b/XAMLTest.Tests/XAMLTest.Tests.csproj index 71e67c6..9dc9edb 100644 --- a/XAMLTest.Tests/XAMLTest.Tests.csproj +++ b/XAMLTest.Tests/XAMLTest.Tests.csproj @@ -11,6 +11,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/XAMLTest/XAMLTest.csproj b/XAMLTest/XAMLTest.csproj index 4637d57..4c6f579 100644 --- a/XAMLTest/XAMLTest.csproj +++ b/XAMLTest/XAMLTest.csproj @@ -12,7 +12,6 @@ - all runtime; build; native; contentfiles; analyzers; buildtransitive