From 43ca4c74f939586d26a436cff81862231e403bf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 14:13:10 +0000 Subject: [PATCH 01/11] Initial plan From 953b64ae7383314cc89de80081c0a5a0f1086df5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 14:38:21 +0000 Subject: [PATCH 02/11] Add unit test for async TestInitialize SynchronizationContext preservation Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../Execution/TestMethodInfo.Execution.cs | 11 ++- .../Execution/TestMethodInfoTests.cs | 76 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs index 5553c89d64..de1ae6cda9 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs @@ -117,14 +117,21 @@ private async Task ExecuteInternalAsync(object?[]? arguments, Cancel // For any failure after this point, we must run TestCleanup _isTestContextSet = true; - if (await RunTestInitializeMethodAsync(_classInstance!, result, timeoutTokenSource).ConfigureAwait(false)) + // Intentionally not using ConfigureAwait(false) here to preserve SynchronizationContext. + // This ensures that the test method and TestCleanup run on the same thread as the SynchronizationContext + // that was active when the test started (e.g., the UI thread for WinUI tests). + // Without this, an async TestInitialize would cause the test method to be invoked on a + // thread pool thread instead of the UI thread. + if (await RunTestInitializeMethodAsync(_classInstance!, result, timeoutTokenSource)) { if (_executionContext is null) { Task? invokeResult = MethodInfo.GetInvokeResultAsync(_classInstance, arguments); if (invokeResult is not null) { - await invokeResult.ConfigureAwait(false); +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task - Intentionally not using ConfigureAwait(false) to preserve SynchronizationContext + await invokeResult; +#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task } } else diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index b3618c7991..91f114d785 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -662,6 +662,49 @@ public async Task TestMethodInfoInvokeShouldCallAsyncTestInitializeAndWaitForCom result.Outcome.Should().Be(UnitTestOutcome.Passed); } + public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationContextForTestMethod() + { + // Arrange + SynchronizationContext? capturedContextInTestMethod = null; + + DummyTestClass.TestInitializeMethodBodyAsync = async _ => + { + // Simulate async work (like Task.Delay in WinUI scenarios) + await Task.Delay(1); + }; + DummyTestClass.TestMethodBody = _ => capturedContextInTestMethod = SynchronizationContext.Current; + _testClassInfo.TestInitializeMethod = typeof(DummyTestClass).GetMethod("DummyTestInitializeMethodAsync")!; + + using var syncContext = new SingleThreadedSynchronizationContextForTesting(); + var tcs = new TaskCompletionSource(); + + // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute + // dispatching the test to the UI thread. + syncContext.Post( + _ => _testMethodInfo.InvokeAsync(null).ContinueWith( + t => + { + if (t.IsFaulted) + { + tcs.SetException(t.Exception!); + } + else + { + tcs.SetResult(t.Result); + } + }, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default), + null); + + TestResult result = await tcs.Task; + + // Assert: the SynchronizationContext should be preserved in the test method even after async TestInitialize + result.Outcome.Should().Be(UnitTestOutcome.Passed); + capturedContextInTestMethod.Should().BeSameAs(syncContext); + } + public async Task TestMethodInfoInvokeShouldCallTestInitializeOfAllBaseClasses() { var callOrder = new List(); @@ -1628,6 +1671,39 @@ private static async Task RunWithTestablePlatformService(TestablePlatformService #endregion #region Test data + + private sealed class SingleThreadedSynchronizationContextForTesting : SynchronizationContext, IDisposable + { + private readonly BlockingCollection<(SendOrPostCallback d, object? state)> _queue = []; + private readonly Thread _thread; + + public SingleThreadedSynchronizationContextForTesting() + { + _thread = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(this); + foreach ((SendOrPostCallback d, object? state) in _queue.GetConsumingEnumerable()) + { + d(state); + } + }) + { + IsBackground = true, + }; + _thread.Start(); + } + + public override void Post(SendOrPostCallback d, object? state) + => _queue.Add((d, state)); + + public void Dispose() + { + _queue.CompleteAdding(); + _thread.Join(); + _queue.Dispose(); + } + } + public class DummyTestClassBase { public static Action BaseTestClassMethodBody { get; set; } = null!; From e67a14649a6773028d1676e13bab64f952fbaa37 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Mon, 11 May 2026 17:14:01 +0200 Subject: [PATCH 03/11] Fix diagnostics --- .../Execution/TestMethodInfoTests.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index 91f114d785..8c0f52e484 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -667,11 +667,7 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon // Arrange SynchronizationContext? capturedContextInTestMethod = null; - DummyTestClass.TestInitializeMethodBodyAsync = async _ => - { - // Simulate async work (like Task.Delay in WinUI scenarios) - await Task.Delay(1); - }; + DummyTestClass.TestInitializeMethodBodyAsync = async _ => await Task.Delay(1); DummyTestClass.TestMethodBody = _ => capturedContextInTestMethod = SynchronizationContext.Current; _testClassInfo.TestInitializeMethod = typeof(DummyTestClass).GetMethod("DummyTestInitializeMethodAsync")!; @@ -681,7 +677,7 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute // dispatching the test to the UI thread. syncContext.Post( - _ => _testMethodInfo.InvokeAsync(null).ContinueWith( + state => _ = _testMethodInfo.InvokeAsync(null).ContinueWith( t => { if (t.IsFaulted) @@ -1674,7 +1670,7 @@ private static async Task RunWithTestablePlatformService(TestablePlatformService private sealed class SingleThreadedSynchronizationContextForTesting : SynchronizationContext, IDisposable { - private readonly BlockingCollection<(SendOrPostCallback d, object? state)> _queue = []; + private readonly BlockingCollection<(SendOrPostCallback D, object? State)> _queue = []; private readonly Thread _thread; public SingleThreadedSynchronizationContextForTesting() From 576c0466b347d2099e6ed8759a4e952b13514074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 11 May 2026 18:23:57 +0200 Subject: [PATCH 04/11] Fix SynchronizationContext test: use no-timeout TestMethodInfo The test was using _testMethodInfo which has a 1-hour timeout set, causing IsTimeoutSet to be true. This routes through ExecuteInternalWithTimeoutAsync's non-cooperative path which runs ExecuteInternalAsync on a separate thread pool thread where SynchronizationContext.Current is null. Create a local TestMethodInfo without a timeout so the test goes through the direct ExecuteInternalAsync path on the SC's thread, which is the code path the production fix targets. --- .../Execution/TestMethodInfoTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index 8c0f52e484..4e2933a440 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -671,13 +671,20 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon DummyTestClass.TestMethodBody = _ => capturedContextInTestMethod = SynchronizationContext.Current; _testClassInfo.TestInitializeMethod = typeof(DummyTestClass).GetMethod("DummyTestInitializeMethodAsync")!; + // Create a TestMethodInfo without a timeout so we go through ExecuteInternalAsync directly + // (not the non-cooperative timeout path which runs on a separate thread pool thread). + var testMethodInfo = new TestMethodInfo(_methodInfo, _testClassInfo) + { + Executor = _testMethodAttribute, + }; + using var syncContext = new SingleThreadedSynchronizationContextForTesting(); var tcs = new TaskCompletionSource(); // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute // dispatching the test to the UI thread. syncContext.Post( - state => _ = _testMethodInfo.InvokeAsync(null).ContinueWith( + state => _ = testMethodInfo.InvokeAsync(null).ContinueWith( t => { if (t.IsFaulted) From 2b8b318cd319626e93d604910291209fb9c6406a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 16:49:36 +0000 Subject: [PATCH 05/11] Address review comments: add timeout, exception handling, IsCanceled handling, TestCleanup test, and fix comment accuracy Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../Execution/TestMethodInfo.Execution.cs | 4 +- .../Execution/TestMethodInfoTests.cs | 70 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs index de1ae6cda9..a9ccc83ddd 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs @@ -118,8 +118,8 @@ private async Task ExecuteInternalAsync(object?[]? arguments, Cancel _isTestContextSet = true; // Intentionally not using ConfigureAwait(false) here to preserve SynchronizationContext. - // This ensures that the test method and TestCleanup run on the same thread as the SynchronizationContext - // that was active when the test started (e.g., the UI thread for WinUI tests). + // This ensures that the test method starts running on the same thread as the SynchronizationContext + // that was active when ExecuteInternalAsync was called (e.g., the UI thread for WinUI tests). // Without this, an async TestInitialize would cause the test method to be invoked on a // thread pool thread instead of the UI thread. if (await RunTestInitializeMethodAsync(_classInstance!, result, timeoutTokenSource)) diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index 4e2933a440..558ea80e34 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -691,6 +691,10 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon { tcs.SetException(t.Exception!); } + else if (t.IsCanceled) + { + tcs.SetCanceled(); + } else { tcs.SetResult(t.Result); @@ -701,13 +705,67 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon TaskScheduler.Default), null); - TestResult result = await tcs.Task; + TestResult result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30)); // Assert: the SynchronizationContext should be preserved in the test method even after async TestInitialize result.Outcome.Should().Be(UnitTestOutcome.Passed); capturedContextInTestMethod.Should().BeSameAs(syncContext); } + public async Task TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContextForTestCleanup() + { + // Arrange + SynchronizationContext? capturedContextInTestCleanup = null; + + DummyTestClass.TestCleanupMethodBodyAsync = async _ => + { + capturedContextInTestCleanup = SynchronizationContext.Current; + await Task.Delay(1); + }; + DummyTestClass.TestMethodBody = _ => { }; + _testClassInfo.TestCleanupMethod = typeof(DummyTestClass).GetMethod("DummyTestCleanupMethodAsync")!; + + // Create a TestMethodInfo without a timeout so we go through ExecuteInternalAsync directly + // (not the non-cooperative timeout path which runs on a separate thread pool thread). + var testMethodInfo = new TestMethodInfo(_methodInfo, _testClassInfo) + { + Executor = _testMethodAttribute, + }; + + using var syncContext = new SingleThreadedSynchronizationContextForTesting(); + var tcs = new TaskCompletionSource(); + + // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute + // dispatching the test to the UI thread. + syncContext.Post( + state => _ = testMethodInfo.InvokeAsync(null).ContinueWith( + t => + { + if (t.IsFaulted) + { + tcs.SetException(t.Exception!); + } + else if (t.IsCanceled) + { + tcs.SetCanceled(); + } + else + { + tcs.SetResult(t.Result); + } + }, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default), + null); + + TestResult result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: the SynchronizationContext should be preserved at the start of TestCleanup + result.Outcome.Should().Be(UnitTestOutcome.Passed); + capturedContextInTestCleanup.Should().BeSameAs(syncContext); + } + public async Task TestMethodInfoInvokeShouldCallTestInitializeOfAllBaseClasses() { var callOrder = new List(); @@ -1687,7 +1745,15 @@ public SingleThreadedSynchronizationContextForTesting() SynchronizationContext.SetSynchronizationContext(this); foreach ((SendOrPostCallback d, object? state) in _queue.GetConsumingEnumerable()) { - d(state); + try + { + d(state); + } + catch + { + // Swallow exceptions to keep the thread alive. The caller's TCS will handle + // any exceptions that occur during test execution. + } } }) { From f5aeb2787d1260a09b128535dea15277ac7c62aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 11 May 2026 21:25:52 +0200 Subject: [PATCH 06/11] Use RunContinuationsAsynchronously on TaskCompletionSource to prevent reentrancy --- .../Execution/TestMethodInfoTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index 558ea80e34..5911d7d761 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -679,7 +679,7 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon }; using var syncContext = new SingleThreadedSynchronizationContextForTesting(); - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute // dispatching the test to the UI thread. @@ -705,7 +705,10 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon TaskScheduler.Default), null); - TestResult result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + Task completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); + Assert.AreEqual(tcs.Task, completed, "Timed out waiting for InvokeAsync to complete."); + TestResult result = await tcs.Task; // Assert: the SynchronizationContext should be preserved in the test method even after async TestInitialize result.Outcome.Should().Be(UnitTestOutcome.Passed); @@ -733,7 +736,7 @@ public async Task TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContex }; using var syncContext = new SingleThreadedSynchronizationContextForTesting(); - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute // dispatching the test to the UI thread. @@ -759,7 +762,10 @@ public async Task TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContex TaskScheduler.Default), null); - TestResult result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + Task completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); + Assert.AreEqual(tcs.Task, completed, "Timed out waiting for InvokeAsync to complete."); + TestResult result = await tcs.Task; // Assert: the SynchronizationContext should be preserved at the start of TestCleanup result.Outcome.Should().Be(UnitTestOutcome.Passed); From fa147d5a5cbfbc7ae8ff17443152c85d08b371af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 11 May 2026 22:02:59 +0200 Subject: [PATCH 07/11] Address review comments: guard Post against dispose, fix catch comment, use FluentAssertions, suppress CA2007 --- .../Execution/TestMethodInfo.Execution.cs | 2 ++ .../Execution/TestMethodInfoTests.cs | 11 ++++++----- .../Services/DesktopTestSourceHostTests.cs | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs index a9ccc83ddd..3e3788a22a 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs @@ -122,7 +122,9 @@ private async Task ExecuteInternalAsync(object?[]? arguments, Cancel // that was active when ExecuteInternalAsync was called (e.g., the UI thread for WinUI tests). // Without this, an async TestInitialize would cause the test method to be invoked on a // thread pool thread instead of the UI thread. +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task - Intentionally not using ConfigureAwait(false) to preserve SynchronizationContext if (await RunTestInitializeMethodAsync(_classInstance!, result, timeoutTokenSource)) +#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task { if (_executionContext is null) { diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index 5911d7d761..f9cd4996b7 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -707,7 +707,7 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); Task completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); - Assert.AreEqual(tcs.Task, completed, "Timed out waiting for InvokeAsync to complete."); + completed.Should().BeSameAs(tcs.Task, "Timed out waiting for InvokeAsync to complete."); TestResult result = await tcs.Task; // Assert: the SynchronizationContext should be preserved in the test method even after async TestInitialize @@ -764,7 +764,7 @@ public async Task TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContex using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); Task completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); - Assert.AreEqual(tcs.Task, completed, "Timed out waiting for InvokeAsync to complete."); + completed.Should().BeSameAs(tcs.Task, "Timed out waiting for InvokeAsync to complete."); TestResult result = await tcs.Task; // Assert: the SynchronizationContext should be preserved at the start of TestCleanup @@ -1757,8 +1757,9 @@ public SingleThreadedSynchronizationContextForTesting() } catch { - // Swallow exceptions to keep the thread alive. The caller's TCS will handle - // any exceptions that occur during test execution. + // Swallow exceptions to keep the thread alive for subsequent queue items. + // Note: only async exceptions inside InvokeAsync propagate to the TCS; + // synchronous throws here leave the TCS unset and will cause the test to time out. } } }) @@ -1769,7 +1770,7 @@ public SingleThreadedSynchronizationContextForTesting() } public override void Post(SendOrPostCallback d, object? state) - => _queue.Add((d, state)); + => _queue.TryAdd((d, state)); public void Dispose() { diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs index 4dbd1b9025..e602b2b26d 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs @@ -104,7 +104,7 @@ public void SetupHostShouldSetChildDomainsAppBaseToTestSourceLocation() _ = new DummyClass(); string location = typeof(TestSourceHost).Assembly.Location; - Mock sourceHost = new(location, null) { CallBase = true }; + Mock sourceHost = new(location, (IRunSettings?)null) { CallBase = true }; try { @@ -161,7 +161,7 @@ public void SetupHostShouldSetResolutionsPaths() DummyClass dummyClass = new(); string location = typeof(TestSourceHost).Assembly.Location; - Mock sourceHost = new(location, null) { CallBase = true }; + Mock sourceHost = new(location, (IRunSettings?)null) { CallBase = true }; try { From f094320b4c58de7c3316e5a598e0163b041b8f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 11 May 2026 22:29:58 +0200 Subject: [PATCH 08/11] Address review comments: remove dead try/catch, guard Post() against dispose, fix null! and AwesomeAssertions --- .../Execution/TestMethodInfoTests.cs | 26 +++++++++++-------- .../Services/DesktopTestSourceHostTests.cs | 4 +-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index f9cd4996b7..13893b9c95 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -1751,16 +1751,7 @@ public SingleThreadedSynchronizationContextForTesting() SynchronizationContext.SetSynchronizationContext(this); foreach ((SendOrPostCallback d, object? state) in _queue.GetConsumingEnumerable()) { - try - { - d(state); - } - catch - { - // Swallow exceptions to keep the thread alive for subsequent queue items. - // Note: only async exceptions inside InvokeAsync propagate to the TCS; - // synchronous throws here leave the TCS unset and will cause the test to time out. - } + d(state); } }) { @@ -1770,7 +1761,20 @@ public SingleThreadedSynchronizationContextForTesting() } public override void Post(SendOrPostCallback d, object? state) - => _queue.TryAdd((d, state)); + { + try + { + _queue.TryAdd((d, state)); + } + catch (InvalidOperationException) + { + // Collection completed (after Dispose called CompleteAdding) + } + catch (ObjectDisposedException) + { + // Collection disposed + } + } public void Dispose() { diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs index e602b2b26d..c30b8edf73 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/DesktopTestSourceHostTests.cs @@ -104,7 +104,7 @@ public void SetupHostShouldSetChildDomainsAppBaseToTestSourceLocation() _ = new DummyClass(); string location = typeof(TestSourceHost).Assembly.Location; - Mock sourceHost = new(location, (IRunSettings?)null) { CallBase = true }; + Mock sourceHost = new(location, null!) { CallBase = true }; try { @@ -161,7 +161,7 @@ public void SetupHostShouldSetResolutionsPaths() DummyClass dummyClass = new(); string location = typeof(TestSourceHost).Assembly.Location; - Mock sourceHost = new(location, (IRunSettings?)null) { CallBase = true }; + Mock sourceHost = new(location, null!) { CallBase = true }; try { From 91abe2356c011408e50ec3953e9aa8cc87e8180c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 11 May 2026 22:55:22 +0200 Subject: [PATCH 09/11] Address review: ConfigureAwait(true), extract helper, add async init to cleanup test, bounded Join, fix CS0160 --- .../Execution/TestMethodInfo.Execution.cs | 12 +- .../Execution/TestMethodInfoTests.cs | 111 +++++++----------- 2 files changed, 49 insertions(+), 74 deletions(-) diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs index 3e3788a22a..970edb4e75 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. #if NETFRAMEWORK @@ -117,23 +117,19 @@ private async Task ExecuteInternalAsync(object?[]? arguments, Cancel // For any failure after this point, we must run TestCleanup _isTestContextSet = true; - // Intentionally not using ConfigureAwait(false) here to preserve SynchronizationContext. + // Intentionally using ConfigureAwait(true) here to preserve SynchronizationContext. // This ensures that the test method starts running on the same thread as the SynchronizationContext // that was active when ExecuteInternalAsync was called (e.g., the UI thread for WinUI tests). // Without this, an async TestInitialize would cause the test method to be invoked on a // thread pool thread instead of the UI thread. -#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task - Intentionally not using ConfigureAwait(false) to preserve SynchronizationContext - if (await RunTestInitializeMethodAsync(_classInstance!, result, timeoutTokenSource)) -#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task + if (await RunTestInitializeMethodAsync(_classInstance!, result, timeoutTokenSource).ConfigureAwait(true)) { if (_executionContext is null) { Task? invokeResult = MethodInfo.GetInvokeResultAsync(_classInstance, arguments); if (invokeResult is not null) { -#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task - Intentionally not using ConfigureAwait(false) to preserve SynchronizationContext - await invokeResult; -#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task + await invokeResult.ConfigureAwait(true); } } else diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index 13893b9c95..8dfbcb1b8f 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -679,36 +679,7 @@ public async Task TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationCon }; using var syncContext = new SingleThreadedSynchronizationContextForTesting(); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute - // dispatching the test to the UI thread. - syncContext.Post( - state => _ = testMethodInfo.InvokeAsync(null).ContinueWith( - t => - { - if (t.IsFaulted) - { - tcs.SetException(t.Exception!); - } - else if (t.IsCanceled) - { - tcs.SetCanceled(); - } - else - { - tcs.SetResult(t.Result); - } - }, - CancellationToken.None, - TaskContinuationOptions.None, - TaskScheduler.Default), - null); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - Task completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); - completed.Should().BeSameAs(tcs.Task, "Timed out waiting for InvokeAsync to complete."); - TestResult result = await tcs.Task; + TestResult result = await RunInvokeAsyncOnContextAsync(testMethodInfo, syncContext); // Assert: the SynchronizationContext should be preserved in the test method even after async TestInitialize result.Outcome.Should().Be(UnitTestOutcome.Passed); @@ -720,12 +691,14 @@ public async Task TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContex // Arrange SynchronizationContext? capturedContextInTestCleanup = null; + DummyTestClass.TestInitializeMethodBodyAsync = async _ => await Task.Delay(1); DummyTestClass.TestCleanupMethodBodyAsync = async _ => { capturedContextInTestCleanup = SynchronizationContext.Current; await Task.Delay(1); }; DummyTestClass.TestMethodBody = _ => { }; + _testClassInfo.TestInitializeMethod = typeof(DummyTestClass).GetMethod("DummyTestInitializeMethodAsync")!; _testClassInfo.TestCleanupMethod = typeof(DummyTestClass).GetMethod("DummyTestCleanupMethodAsync")!; // Create a TestMethodInfo without a timeout so we go through ExecuteInternalAsync directly @@ -736,36 +709,7 @@ public async Task TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContex }; using var syncContext = new SingleThreadedSynchronizationContextForTesting(); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - // Act: run InvokeAsync on the synchronization context's dedicated thread, simulating UITestMethodAttribute - // dispatching the test to the UI thread. - syncContext.Post( - state => _ = testMethodInfo.InvokeAsync(null).ContinueWith( - t => - { - if (t.IsFaulted) - { - tcs.SetException(t.Exception!); - } - else if (t.IsCanceled) - { - tcs.SetCanceled(); - } - else - { - tcs.SetResult(t.Result); - } - }, - CancellationToken.None, - TaskContinuationOptions.None, - TaskScheduler.Default), - null); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - Task completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); - completed.Should().BeSameAs(tcs.Task, "Timed out waiting for InvokeAsync to complete."); - TestResult result = await tcs.Task; + TestResult result = await RunInvokeAsyncOnContextAsync(testMethodInfo, syncContext); // Assert: the SynchronizationContext should be preserved at the start of TestCleanup result.Outcome.Should().Be(UnitTestOutcome.Passed); @@ -1737,6 +1681,40 @@ private static async Task RunWithTestablePlatformService(TestablePlatformService #endregion + private static async Task RunInvokeAsyncOnContextAsync( + TestMethodInfo info, + SingleThreadedSynchronizationContextForTesting ctx) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ctx.Post( + _ => _ = info.InvokeAsync(null).ContinueWith( + t => + { + if (t.IsFaulted) + { + tcs.SetException(t.Exception!); + } + else if (t.IsCanceled) + { + tcs.SetCanceled(); + } + else + { + tcs.SetResult(t.Result); + } + }, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default), + null); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + Task completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); + completed.Should().BeSameAs(tcs.Task, "Timed out waiting for InvokeAsync to complete."); + + return await tcs.Task; + } + #region Test data private sealed class SingleThreadedSynchronizationContextForTesting : SynchronizationContext, IDisposable @@ -1768,18 +1746,19 @@ public override void Post(SendOrPostCallback d, object? state) } catch (InvalidOperationException) { - // Collection completed (after Dispose called CompleteAdding) - } - catch (ObjectDisposedException) - { - // Collection disposed + // Collection completed (after Dispose called CompleteAdding) or disposed } } public void Dispose() { _queue.CompleteAdding(); - _thread.Join(); + if (!_thread.Join(TimeSpan.FromSeconds(5))) + { + // Thread is background, so it will be killed on process exit. + // Avoid hanging the test run indefinitely. + } + _queue.Dispose(); } } From 863563e853b3bf18e6d46e5a5c29a31d73e9243a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 12 May 2026 12:58:17 +0200 Subject: [PATCH 10/11] Update src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs Co-authored-by: Youssef Fahmy --- .../Execution/TestMethodInfo.Execution.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs index 970edb4e75..5514b9cd00 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs @@ -117,11 +117,8 @@ private async Task ExecuteInternalAsync(object?[]? arguments, Cancel // For any failure after this point, we must run TestCleanup _isTestContextSet = true; - // Intentionally using ConfigureAwait(true) here to preserve SynchronizationContext. - // This ensures that the test method starts running on the same thread as the SynchronizationContext - // that was active when ExecuteInternalAsync was called (e.g., the UI thread for WinUI tests). - // Without this, an async TestInitialize would cause the test method to be invoked on a - // thread pool thread instead of the UI thread. + // Intentionally using ConfigureAwait(true) here to ensure the continuation is posted to the synchronization context. + // In case of WinUI's default synchronization context, this will ensure that the test method runs on the UI thread. if (await RunTestInitializeMethodAsync(_classInstance!, result, timeoutTokenSource).ConfigureAwait(true)) { if (_executionContext is null) From cab91778babd491ac5e74ebcab413d1103f59366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 12 May 2026 16:23:14 +0200 Subject: [PATCH 11/11] Fix SA1316 tuple casing and move helper inside region --- .../Execution/TestMethodInfoTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs index 8dfbcb1b8f..0b09299c1d 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs @@ -1679,8 +1679,6 @@ private static async Task RunWithTestablePlatformService(TestablePlatformService } } - #endregion - private static async Task RunInvokeAsyncOnContextAsync( TestMethodInfo info, SingleThreadedSynchronizationContextForTesting ctx) @@ -1715,11 +1713,13 @@ private static async Task RunInvokeAsyncOnContextAsync( return await tcs.Task; } + #endregion + #region Test data private sealed class SingleThreadedSynchronizationContextForTesting : SynchronizationContext, IDisposable { - private readonly BlockingCollection<(SendOrPostCallback D, object? State)> _queue = []; + private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = []; private readonly Thread _thread; public SingleThreadedSynchronizationContextForTesting() @@ -1727,9 +1727,9 @@ public SingleThreadedSynchronizationContextForTesting() _thread = new Thread(() => { SynchronizationContext.SetSynchronizationContext(this); - foreach ((SendOrPostCallback d, object? state) in _queue.GetConsumingEnumerable()) + foreach ((SendOrPostCallback callback, object? state) in _queue.GetConsumingEnumerable()) { - d(state); + callback(state); } }) {