diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/EventHandlers/TestRunEventsHandler.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/EventHandlers/TestRunEventsHandler.cs index af4a23256c..8488323db5 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/EventHandlers/TestRunEventsHandler.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/EventHandlers/TestRunEventsHandler.cs @@ -15,7 +15,7 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.EventHandle /// /// The test run events handler. /// -public class TestRunEventsHandler : IInternalTestRunEventsHandler +public class TestRunEventsHandler : IInternalTestRunEventsHandler, ITestCaseLifecycleNotifier { private readonly ITestRequestHandler _requestHandler; @@ -104,4 +104,12 @@ public bool AttachDebuggerToProcess(AttachDebuggerInfo attachDebuggerInfo) EqtTrace.Info("Sending AttachDebuggerToProcess on additional test process with pid: {0}", attachDebuggerInfo.ProcessId); return _requestHandler.AttachDebuggerToProcess(attachDebuggerInfo); } + + /// + void ITestCaseLifecycleNotifier.SendTestCaseStarting(TestCase testCase) + => _requestHandler.SendTestCaseStarting(testCase); + + /// + void ITestCaseLifecycleNotifier.SendTestCaseFinished(TestCase testCase) + => _requestHandler.SendTestCaseFinished(testCase); } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/InFlightTestTracker.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/InFlightTestTracker.cs new file mode 100644 index 0000000000..032d03995b --- /dev/null +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/InFlightTestTracker.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel; + +namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities; + +/// +/// Tracks a set of in-flight test executions that share the same . +/// Uses inline slots to avoid queue allocation in the common case (unique Guid per test). +/// All mutation and enumeration is guarded by a lock because the message-receive thread +/// updates slots while the abort thread may read them concurrently via . +/// +internal sealed class InFlightTest +{ + private readonly object _lock = new(); + public TestCaseStartingPayload Slot0; + public DateTimeOffset StartTime0; + public TestCaseStartingPayload? Slot1; + public DateTimeOffset StartTime1; + public TestCaseStartingPayload? Slot2; + public DateTimeOffset StartTime2; + public TestCaseStartingPayload? Slot3; + public DateTimeOffset StartTime3; + public Queue<(TestCaseStartingPayload Payload, DateTimeOffset StartTime)>? Overflow; + + public InFlightTest(TestCaseStartingPayload payload, DateTimeOffset startTime) + { + Slot0 = payload; + StartTime0 = startTime; + } + + /// + /// Adds another in-flight execution for the same Guid. + /// + public void Add(TestCaseStartingPayload payload, DateTimeOffset startTime) + { + lock (_lock) + { + if (Slot1 is null) + { + Slot1 = payload; + StartTime1 = startTime; + } + else if (Slot2 is null) + { + Slot2 = payload; + StartTime2 = startTime; + } + else if (Slot3 is null) + { + Slot3 = payload; + StartTime3 = startTime; + } + else + { + Overflow ??= new Queue<(TestCaseStartingPayload, DateTimeOffset)>(); + Overflow.Enqueue((payload, startTime)); + } + } + } + + /// + /// Removes the oldest in-flight execution (FIFO). Returns true if there are still entries remaining. + /// + public bool RemoveOldest() + { + lock (_lock) + { + // Shift slots down + if (Slot1 is not null) + { + Slot0 = Slot1; + StartTime0 = StartTime1; + Slot1 = Slot2; + StartTime1 = StartTime2; + Slot2 = Slot3; + StartTime2 = StartTime3; + Slot3 = null; + StartTime3 = default; + + // Refill Slot3 from overflow if available + if (Overflow is { Count: > 0 }) + { + var (payload, startTime) = Overflow.Dequeue(); + Slot3 = payload; + StartTime3 = startTime; + } + + return true; + } + + // Slot0 was the only entry + return false; + } + } + + /// + /// Returns a snapshot of all in-flight entries with their display name and start time. + /// + public IReadOnlyList<(string? DisplayName, DateTimeOffset StartTime)> GetAll() + { + lock (_lock) + { + var result = new List<(string?, DateTimeOffset)>(); + result.Add((Slot0.DisplayName, StartTime0)); + + if (Slot1 is not null) + { + result.Add((Slot1.DisplayName, StartTime1)); + } + + if (Slot2 is not null) + { + result.Add((Slot2.DisplayName, StartTime2)); + } + + if (Slot3 is not null) + { + result.Add((Slot3.DisplayName, StartTime3)); + } + + if (Overflow is not null) + { + foreach (var (payload, startTime) in Overflow) + { + result.Add((payload.DisplayName, startTime)); + } + } + + return result; + } + } +} + +/// +/// Thread-safe tracker for tests currently executing in a testhost. +/// Used by vstest.console to report which tests were running when a testhost crashes. +/// +internal sealed class InFlightTestTracker +{ + private readonly ConcurrentDictionary _tests = new(); + + /// + /// Records that a test has started executing. + /// + public void TestStarting(TestCaseStartingPayload payload, DateTimeOffset startTime) + { + _tests.AddOrUpdate( + payload.Id, + _ => new InFlightTest(payload, startTime), + (_, existing) => + { + existing.Add(payload, startTime); + return existing; + }); + } + + /// + /// Records that a test has finished executing. Removes the oldest execution for the given Guid. + /// + public void TestFinished(Guid testId) + { + if (_tests.TryGetValue(testId, out var inFlight)) + { + if (!inFlight.RemoveOldest()) + { + _tests.TryRemove(testId, out _); + } + } + } + + /// + /// Gets all tests that are currently in-flight. Used when testhost crashes to report what was running. + /// + public IReadOnlyList<(string? DisplayName, DateTimeOffset StartTime)> GetInFlightTests() + { + var result = new List<(string?, DateTimeOffset)>(); + foreach (var kvp in _tests) + { + foreach (var entry in kvp.Value.GetAll()) + { + result.Add(entry); + } + } + + return result; + } + + /// + /// Returns true if there are any in-flight tests being tracked. + /// + public bool HasInFlightTests => !_tests.IsEmpty; +} diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Interfaces/ITestCaseLifecycleNotifier.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Interfaces/ITestCaseLifecycleNotifier.cs new file mode 100644 index 0000000000..f0ae730f14 --- /dev/null +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Interfaces/ITestCaseLifecycleNotifier.cs @@ -0,0 +1,17 @@ +// 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.VisualStudio.TestPlatform.ObjectModel; + +namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Interfaces; + +/// +/// Provides lightweight test case lifecycle notifications for real-time in-flight tracking. +/// Implemented by event handlers that can relay start/finish signals to the console. +/// +internal interface ITestCaseLifecycleNotifier +{ + void SendTestCaseStarting(TestCase testCase); + + void SendTestCaseFinished(TestCase testCase); +} diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Interfaces/ITestRequestHandler.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Interfaces/ITestRequestHandler.cs index f7f5ec0994..abb58a8774 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Interfaces/ITestRequestHandler.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Interfaces/ITestRequestHandler.cs @@ -94,4 +94,18 @@ public interface ITestRequestHandler : IDisposable /// Process ID and tfm of the process to which the debugger should be attached. /// if the debugger was successfully attached to the requested process, otherwise. bool AttachDebuggerToProcess(AttachDebuggerInfo attachDebuggerInfo); + + /// + /// Sends a lightweight notification that a test case has started executing. + /// Used for real-time in-flight test tracking on the console side. + /// + /// The test case that started. + void SendTestCaseStarting(TestCase testCase); + + /// + /// Sends a lightweight notification that a test case has finished executing. + /// Used for real-time in-flight test tracking on the console side. + /// + /// The test case that finished. + void SendTestCaseFinished(TestCase testCase); } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/MessageType.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/MessageType.cs index 29381d0f07..674d3e6c16 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/MessageType.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/MessageType.cs @@ -111,6 +111,18 @@ public static class MessageType /// public const string TestRunStatsChange = "TestExecution.StatsChange"; + /// + /// Lightweight notification that a test case has started executing. + /// Sent unbatched from testhost to console for real-time in-flight test tracking. + /// + public const string TestCaseStarting = "TestExecution.TestCaseStarting"; + + /// + /// Lightweight notification that a test case has finished executing. + /// Sent unbatched from testhost to console for real-time in-flight test tracking. + /// + public const string TestCaseFinished = "TestExecution.TestCaseFinished"; + /// /// The execution complete. /// diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/TestCaseFinishedPayload.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/TestCaseFinishedPayload.cs new file mode 100644 index 0000000000..3e4057ea02 --- /dev/null +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/TestCaseFinishedPayload.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 System; +using System.Runtime.Serialization; + +namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel; + +/// +/// Payload for the TestCaseFinished message, sent from testhost to console when a test completes execution. +/// +[DataContract] +public class TestCaseFinishedPayload +{ + /// + /// Gets or sets the test case ID. + /// + [DataMember] + public Guid Id { get; set; } +} diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/TestCaseStartingPayload.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/TestCaseStartingPayload.cs new file mode 100644 index 0000000000..652b687d9c --- /dev/null +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Messages/TestCaseStartingPayload.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 System; +using System.Runtime.Serialization; + +namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel; + +/// +/// Payload for the TestCaseStarting message, sent from testhost to console when a test begins execution. +/// +[DataContract] +public class TestCaseStartingPayload +{ + /// + /// Gets or sets the test case ID. + /// + [DataMember] + public Guid Id { get; set; } + + /// + /// Gets or sets the display name of the test case. + /// + [DataMember] + public string? DisplayName { get; set; } +} diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/PublicAPI/PublicAPI.Unshipped.txt b/src/Microsoft.TestPlatform.CommunicationUtilities/PublicAPI/PublicAPI.Unshipped.txt index 888f6721e5..2e90cee264 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/PublicAPI/PublicAPI.Unshipped.txt @@ -1,5 +1,19 @@ #nullable enable const Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.MessageType.TelemetryEventMessage = "TestPlatform.TelemetryEvent" -> string! +const Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.MessageType.TestCaseFinished = "TestExecution.TestCaseFinished" -> string! +const Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.MessageType.TestCaseStarting = "TestExecution.TestCaseStarting" -> string! +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Interfaces.ITestRequestHandler.SendTestCaseFinished(Microsoft.VisualStudio.TestPlatform.ObjectModel.TestCase! testCase) -> void +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Interfaces.ITestRequestHandler.SendTestCaseStarting(Microsoft.VisualStudio.TestPlatform.ObjectModel.TestCase! testCase) -> void +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseFinishedPayload +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseFinishedPayload.Id.get -> System.Guid +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseFinishedPayload.Id.set -> void +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseFinishedPayload.TestCaseFinishedPayload() -> void +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseStartingPayload +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseStartingPayload.DisplayName.get -> string? +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseStartingPayload.DisplayName.set -> void +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseStartingPayload.Id.get -> System.Guid +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseStartingPayload.Id.set -> void +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel.TestCaseStartingPayload.TestCaseStartingPayload() -> void ~static Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Resources.Resources.ConnectionTimeoutProcessDidNotStartErrorMessage.get -> string ~static Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Resources.Resources.ConnectionTimeoutProcessExitedErrorMessage.get -> string ~static Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Resources.Resources.ConnectionTimeoutWithDetailsErrorMessage.get -> string diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/TestRequestSender.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/TestRequestSender.cs index 89f7369dbd..fc6abc04c1 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/TestRequestSender.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/TestRequestSender.cs @@ -50,6 +50,7 @@ public class TestRequestSender : ITestRequestSender // that implies host is using version 1. private int _protocolVersion = 1; private bool _isDiscoveryAborted; + private readonly InFlightTestTracker _inFlightTests = new(); /// /// Initializes a new instance of the class. @@ -593,6 +594,24 @@ private void OnExecutionMessageReceived(MessageReceivedEventArgs messageReceived _channel.Send(resultMessage); break; + + case MessageType.TestCaseStarting: + var testCaseStarting = _dataSerializer.DeserializePayload(message); + if (testCaseStarting is not null) + { + EqtTrace.Info("TestRequestSender.OnExecutionMessageReceived: TestCaseStarting received for: {0}", testCaseStarting.DisplayName); + _inFlightTests.TestStarting(testCaseStarting, DateTimeOffset.UtcNow); + } + break; + + case MessageType.TestCaseFinished: + var testCaseFinished = _dataSerializer.DeserializePayload(message); + if (testCaseFinished is not null) + { + EqtTrace.Info("TestRequestSender.OnExecutionMessageReceived: TestCaseFinished received for: {0}", testCaseFinished.Id); + _inFlightTests.TestFinished(testCaseFinished.Id); + } + break; } } catch (Exception exception) @@ -748,6 +767,14 @@ private void OnDiscoveryAbort(ITestDiscoveryEventsHandler2 eventHandler, Excepti // TODO: this timeout is 10 seconds, make it also configurable like the other famous timeout that is 100ms if (_clientExited.Wait(_clientExitedWaitTime)) { + // Give a brief moment for any in-flight protocol messages (like TestCaseStarting) + // to be processed before we build the error. The message receive loop runs on a + // separate thread and may still be draining buffered messages. + if (!_inFlightTests.HasInFlightTests) + { + SpinWait.SpinUntil(() => _inFlightTests.HasInFlightTests, millisecondsTimeout: 50); + } + // Set a default message of test host process exited and additionally specify the error if we were able to get it // from error output of the process EqtTrace.Info("TestRequestSender.GetAbortErrorMessage: Received test host error message."); @@ -757,15 +784,47 @@ private void OnDiscoveryAbort(ITestDiscoveryEventsHandler2 eventHandler, Excepti reason = $"{reason} : {_clientExitErrorMessage}"; } + reason = AppendInFlightTestsInfo(reason); + return reason; } else { EqtTrace.Info("TestRequestSender.GetAbortErrorMessage: Timed out waiting for test host error message."); - return CommonResources.UnableToCommunicateToTestHost; + return AppendInFlightTestsInfo(CommonResources.UnableToCommunicateToTestHost); + } + } + + private string AppendInFlightTestsInfo(string reason) + { + var sb = new System.Text.StringBuilder(reason); + + if (_inFlightTests.HasInFlightTests) + { + var inFlight = _inFlightTests.GetInFlightTests(); + var now = DateTimeOffset.UtcNow; + sb.AppendLine(); + sb.AppendLine("Tests that were executing when the crash occurred:"); + foreach (var (displayName, startTime) in inFlight) + { + var elapsed = now - startTime; + sb.AppendLine($" {displayName ?? ""} (running for {FormatElapsed(elapsed)})"); + } } + + sb.AppendLine(); + sb.Append("Consider using --blame-crash to collect a crash dump for further diagnosis."); + + return sb.ToString(); } + private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalSeconds switch + { + < 1 => $"{elapsed.Milliseconds}ms", + < 60 => $"{elapsed.TotalSeconds:F0}s", + _ => $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s", + }; + private void LogErrorMessage(string message) { if (_messageEventHandler == null) diff --git a/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/FrameworkHandle.cs b/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/FrameworkHandle.cs index 8d8ee0be6c..bddb65fec2 100644 --- a/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/FrameworkHandle.cs +++ b/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/FrameworkHandle.cs @@ -45,9 +45,12 @@ internal class FrameworkHandle : TestExecutionRecorder, IFrameworkHandle2, IDisp /// The test run cache. /// The test execution context. /// TestRun Events Handler + /// Optional callback for real-time in-flight test tracking. + /// Optional callback for real-time in-flight test tracking. public FrameworkHandle(ITestCaseEventsHandler? testCaseEventsHandler, ITestRunCache testRunCache, - TestExecutionContext testExecutionContext, IInternalTestRunEventsHandler testRunEventsHandler) - : base(testCaseEventsHandler, testRunCache) + TestExecutionContext testExecutionContext, IInternalTestRunEventsHandler testRunEventsHandler, + Action? onTestCaseStarting = null, Action? onTestCaseFinished = null) + : base(testCaseEventsHandler, testRunCache, onTestCaseStarting, onTestCaseFinished) { _testExecutionContext = testExecutionContext; _testRunEventsHandler = testRunEventsHandler; diff --git a/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/TestExecutionRecorder.cs b/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/TestExecutionRecorder.cs index 6ae681e5d5..8d557c8180 100644 --- a/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/TestExecutionRecorder.cs +++ b/src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/TestExecutionRecorder.cs @@ -21,6 +21,8 @@ internal class TestExecutionRecorder : TestSessionMessageLogger, ITestExecutionR private readonly List _attachmentSets; private readonly ITestRunCache _testRunCache; private readonly ITestCaseEventsHandler? _testCaseEventsHandler; + private readonly Action? _onTestCaseStarting; + private readonly Action? _onTestCaseFinished; /// /// Contains TestCase Ids for test cases that are in progress @@ -35,10 +37,18 @@ internal class TestExecutionRecorder : TestSessionMessageLogger, ITestExecutionR /// /// The test Case Events Handler. /// The test run cache. - public TestExecutionRecorder(ITestCaseEventsHandler? testCaseEventsHandler, ITestRunCache testRunCache) + /// Optional callback invoked when a test case starts, for real-time in-flight tracking. + /// Optional callback invoked when a test case finishes, for real-time in-flight tracking. + public TestExecutionRecorder( + ITestCaseEventsHandler? testCaseEventsHandler, + ITestRunCache testRunCache, + Action? onTestCaseStarting = null, + Action? onTestCaseFinished = null) { _testRunCache = testRunCache; _testCaseEventsHandler = testCaseEventsHandler; + _onTestCaseStarting = onTestCaseStarting; + _onTestCaseFinished = onTestCaseFinished; _attachmentSets = new List(); // As a framework guideline, we should get events in this order: @@ -70,6 +80,7 @@ public void RecordStart(TestCase testCase) { EqtTrace.Verbose("TestExecutionRecorder.RecordStart: Starting test: {0}.", testCase.FullyQualifiedName); _testRunCache.OnTestStarted(testCase); + _onTestCaseStarting?.Invoke(testCase); if (_testCaseEventsHandler != null) { @@ -115,6 +126,7 @@ public void RecordEnd(TestCase testCase, TestOutcome outcome) { EqtTrace.Verbose("TestExecutionRecorder.RecordEnd: test: {0} execution completed.", testCase.FullyQualifiedName); _testRunCache.OnTestCompletion(testCase); + _onTestCaseFinished?.Invoke(testCase); SendTestCaseEnd(testCase, outcome); } diff --git a/src/Microsoft.TestPlatform.CrossPlatEngine/EventHandlers/TestRequestHandler.cs b/src/Microsoft.TestPlatform.CrossPlatEngine/EventHandlers/TestRequestHandler.cs index 0c9fb56ed2..6747be63b2 100644 --- a/src/Microsoft.TestPlatform.CrossPlatEngine/EventHandlers/TestRequestHandler.cs +++ b/src/Microsoft.TestPlatform.CrossPlatEngine/EventHandlers/TestRequestHandler.cs @@ -613,4 +613,21 @@ private void SendData(string data) EqtTrace.Verbose("TestRequestHandler.SendData: sending data from testhost: {0}", data); _channel?.Send(data); } + + /// + public void SendTestCaseStarting(TestCase testCase) + { + EqtTrace.Info("TestRequestHandler.SendTestCaseStarting: Sending test case starting for: {0}", testCase.DisplayName); + var payload = new TestCaseStartingPayload { Id = testCase.Id, DisplayName = testCase.DisplayName }; + var data = _dataSerializer.SerializePayload(MessageType.TestCaseStarting, payload, _protocolVersion); + SendData(data); + } + + /// + public void SendTestCaseFinished(TestCase testCase) + { + var payload = new TestCaseFinishedPayload { Id = testCase.Id }; + var data = _dataSerializer.SerializePayload(MessageType.TestCaseFinished, payload, _protocolVersion); + SendData(data); + } } diff --git a/src/Microsoft.TestPlatform.CrossPlatEngine/Execution/BaseRunTests.cs b/src/Microsoft.TestPlatform.CrossPlatEngine/Execution/BaseRunTests.cs index 2f9c975b7f..9defd0aac2 100644 --- a/src/Microsoft.TestPlatform.CrossPlatEngine/Execution/BaseRunTests.cs +++ b/src/Microsoft.TestPlatform.CrossPlatEngine/Execution/BaseRunTests.cs @@ -156,11 +156,14 @@ protected BaseRunTests( RunContext.SolutionDirectory = RunSettingsUtilities.GetSolutionDirectory(runConfig); _runConfiguration = runConfig; + var notifier = testRunEventsHandler as ITestCaseLifecycleNotifier; FrameworkHandle = new FrameworkHandle( _testCaseEventsHandler, TestRunCache, TestExecutionContext, - TestRunEventsHandler); + TestRunEventsHandler, + onTestCaseStarting: notifier is not null ? tc => notifier.SendTestCaseStarting(tc) : null, + onTestCaseFinished: notifier is not null ? tc => notifier.SendTestCaseFinished(tc) : null); FrameworkHandle.TestRunMessage += OnTestRunMessage; ExecutorUrisThatRanTests = new List(); diff --git a/src/Microsoft.TestPlatform.CrossPlatEngine/PublicAPI/PublicAPI.Unshipped.txt b/src/Microsoft.TestPlatform.CrossPlatEngine/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c58110..6190d4a803 100644 --- a/src/Microsoft.TestPlatform.CrossPlatEngine/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Microsoft.TestPlatform.CrossPlatEngine/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.TestRequestHandler.SendTestCaseFinished(Microsoft.VisualStudio.TestPlatform.ObjectModel.TestCase! testCase) -> void +Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.TestRequestHandler.SendTestCaseStarting(Microsoft.VisualStudio.TestPlatform.ObjectModel.TestCase! testCase) -> void diff --git a/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/ExecutionTests.cs b/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/ExecutionTests.cs index f19d76ad79..56285bc08d 100644 --- a/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/ExecutionTests.cs +++ b/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/ExecutionTests.cs @@ -228,6 +228,59 @@ public void UnhandleExceptionExceptionShouldBeLoggedToDiagLogFile(RunnerInfo run File.Delete(diagLogFilePath); } + [TestMethod] + [NetCoreTargetFrameworkDataSource] + public void WhenTestHostCrashesTheErrorSuggestsUsingBlameCrash(RunnerInfo runnerInfo) + { + SetTestEnvironment(_testEnvironment, runnerInfo); + + var assemblyPaths = GetAssetFullPath("SimpleTestProject3.dll"); + var arguments = PrepareArguments(assemblyPaths, GetTestAdapterPath(), string.Empty, FrameworkArgValue, runnerInfo.InIsolationValue, resultsDirectory: TempDirectory.Path); + arguments = string.Concat(arguments, " /testcasefilter:ExitwithUnhandleException"); + + InvokeVsTest(arguments); + + ExitCodeEquals(1); + // The error message should suggest using --blame-crash for further diagnosis. + StdErrorContains("--blame-crash"); + } + + [TestMethod] + [NetCoreTargetFrameworkDataSource] + public void WhenTestHostCrashesWithUnhandledExceptionTheErrorShowsInFlightTest(RunnerInfo runnerInfo) + { + SetTestEnvironment(_testEnvironment, runnerInfo); + + var assemblyPaths = GetAssetFullPath("SimpleTestProject3.dll"); + var arguments = PrepareArguments(assemblyPaths, GetTestAdapterPath(), string.Empty, FrameworkArgValue, runnerInfo.InIsolationValue, resultsDirectory: TempDirectory.Path); + arguments = string.Concat(arguments, " /testcasefilter:ExitwithUnhandleException"); + + InvokeVsTest(arguments); + + ExitCodeEquals(1); + StdErrorContains("Tests that were executing when the crash occurred:"); + StdErrorContains("ExitwithUnhandleException"); + } + + [TestMethod] + [NetCoreTargetFrameworkDataSource] + public void WhenTestHostCrashesWithStackOverflowTheErrorShowsInFlightTest(RunnerInfo runnerInfo) + { + SetTestEnvironment(_testEnvironment, runnerInfo); + + var assemblyPaths = GetAssetFullPath("SimpleTestProject3.dll"); + var arguments = PrepareArguments(assemblyPaths, GetTestAdapterPath(), string.Empty, FrameworkArgValue, runnerInfo.InIsolationValue, resultsDirectory: TempDirectory.Path); + arguments = string.Concat(arguments, " /testcasefilter:ExitWithStackoverFlow"); + + InvokeVsTest(arguments); + + ExitCodeEquals(1); + // Even for stack overflow, the error should suggest --blame-crash. + StdErrorContains("--blame-crash"); + // The test name should appear if the message was sent before the crash. + // Stack overflow may kill the process before the message arrives, so we only check blame-crash suggestion. + } + [TestMethod] [TestCategory("Windows-Review")] [NetFullTargetFrameworkDataSource] diff --git a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/TestRequestSenderTests.cs b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/TestRequestSenderTests.cs index f1df84a12d..18b4ce8f4c 100644 --- a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/TestRequestSenderTests.cs +++ b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/TestRequestSenderTests.cs @@ -212,7 +212,7 @@ public void OnClientProcessExitShouldSendErrorMessageIfStdErrIsEmpty(string stde var expectedErrorMessage = "Reason: Test host process crashed"; RaiseClientDisconnectedEvent(); - _mockDiscoveryEventsHandler.Verify(eh => eh.HandleLogMessage(TestMessageLevel.Error, It.Is(s => s.EndsWith(expectedErrorMessage))), Times.Once); + _mockDiscoveryEventsHandler.Verify(eh => eh.HandleLogMessage(TestMessageLevel.Error, It.Is(s => s.Contains(expectedErrorMessage))), Times.Once); } [TestMethod]