using System; using System.Collections.Generic; using System.Threading.Tasks; using UnityEditor.TestTools.TestRunner.Api; using UnityEngine; namespace Unity.Pipeline.Editor.Testing { /// /// Collects test results from Unity TestRunner API callbacks. /// Adapted from unity-tools implementation with sync/async mode support. /// public class TestResultCollector : ICallbacks { public bool IsComplete { get; private set; } public bool IsCancelled { get; set; } public List Results { get; } = new List(); public ITestResultAdaptor RootResult { get; private set; } // For async mode public Action OnRunFinished { get; set; } // For sync mode private TaskCompletionSource m_CompletionSource; public TestResultCollector() { m_CompletionSource = new TaskCompletionSource(); } /// /// Wait for test completion (sync mode) /// public Task WaitForCompletionAsync() { return m_CompletionSource.Task; } public void RunStarted(ITestAdaptor testsToRun) { if (IsCancelled) return; IsComplete = false; Results.Clear(); Debug.Log($"[TestResultCollector] Run started: {testsToRun.TestCaseCount} test(s)"); } public void TestStarted(ITestAdaptor test) { // No action needed for test start } public void TestFinished(ITestResultAdaptor result) { if (IsCancelled) return; if (result.Test.IsSuite) return; Results.Add(BuildTestResult(result)); } public void RunFinished(ITestResultAdaptor result) { if (IsCancelled) { Debug.Log("[TestResultCollector] Ignoring RunFinished from cancelled run"); return; } RootResult = result; IsComplete = true; // If we attached after the tests already ran (PlayMode re-registers a fresh collector // after each play-mode domain reload, so the incremental TestFinished callbacks were // delivered to a collector from a previous domain), rebuild from the authoritative // result tree. The normal in-domain path leaves Results already populated. if (Results.Count == 0) { CollectLeafResults(result); } var total = result.PassCount + result.FailCount + result.SkipCount + result.InconclusiveCount; Debug.Log($"[TestResultCollector] Run finished: {total} total, {result.PassCount} passed, {result.FailCount} failed"); // Notify async mode OnRunFinished?.Invoke(); // Notify sync mode m_CompletionSource.SetResult(result); } /// /// Cancel the test collection and notify sync waiters /// public void Cancel() { IsCancelled = true; if (!m_CompletionSource.Task.IsCompleted) { m_CompletionSource.SetCanceled(); } } /// /// Set error state and notify sync waiters /// public void SetError(Exception exception) { if (!m_CompletionSource.Task.IsCompleted) { m_CompletionSource.SetException(exception); } } /// /// Recursively collect leaf (non-suite) test results from the result tree. Used at /// RunFinished to rebuild Results when this collector was registered too late to receive /// the incremental TestFinished callbacks (PlayMode resume after a domain reload). /// private void CollectLeafResults(ITestResultAdaptor result) { if (result == null) return; if (!result.Test.IsSuite && !result.Test.HasChildren) { Results.Add(BuildTestResult(result)); return; } if (result.Children != null) { foreach (var child in result.Children) { CollectLeafResults(child); } } } private static TestResult BuildTestResult(ITestResultAdaptor result) { return new TestResult { FullName = result.Test.FullName, Status = result.TestStatus.ToString(), Duration = result.Duration, Message = Truncate(result.Message, 10000), StackTrace = Truncate(result.StackTrace, 10000) }; } private static string Truncate(string s, int maxLength) { if (s == null || s.Length <= maxLength) return s; return s.Substring(0, maxLength) + "\n... (truncated)"; } } }