// MIT License - Copyright (c) 2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Utils { #if UNITY_EDITOR using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using UnityEditor; using UnityEditor.TestTools.TestRunner.Api; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Extension; using WallstopStudios.UnityHelpers.Editor.Settings; /// /// Hooks into the Unity Test Runner API to capture failed tests and export /// their details to a timestamped text file in the project root. /// /// /// Registration is gated by via the /// check. When disabled, no callbacks are registered /// and no resources are allocated. Call after /// changing settings to apply the new state without a domain reload. /// [Serializable] [InitializeOnLoad] internal sealed class FailedTestsExporter : ScriptableObject, ICallbacks { private static FailedTestsExporter _instance; private static TestRunnerApi _api; [SerializeField] private List _failures = new(); static FailedTestsExporter() { Initialize(); } /// /// Schedules callback registration via /// so that settings are available when registration occurs. /// [InitializeOnLoadMethod] private static void Initialize() { CleanupPreviousInstance(); EditorApplication.delayCall -= RegisterCallbacks; EditorApplication.delayCall += RegisterCallbacks; } /// /// Re-initializes the exporter, allowing settings changes to take effect /// without requiring a full domain reload. /// internal static void Reinitialize() { Initialize(); } /// /// Checks whether the failed tests exporter is enabled in the project settings. /// /// /// true if the exporter is enabled; false if disabled or if /// settings are unavailable. /// public static bool IsEnabled() { try { return UnityHelpersSettings.GetFailedTestsExporterEnabled(); } catch { return false; } } private static void CleanupPreviousInstance() { if (_api != null) { DestroyImmediate(_api); _api = null; } if (_instance != null) { DestroyImmediate(_instance); _instance = null; } } private static void RegisterCallbacks() { EditorApplication.delayCall -= RegisterCallbacks; if (!IsEnabled()) { return; } if (_instance != null) { return; } _instance = CreateInstance(); _instance.hideFlags = HideFlags.HideAndDontSave; _api = CreateInstance(); _api.hideFlags = HideFlags.HideAndDontSave; _api.RegisterCallbacks(_instance); } /// /// Called by the Test Runner when a test run begins. Clears any previously /// recorded failures. /// /// The test tree that will be executed. void ICallbacks.RunStarted(ITestAdaptor testsToRun) { _failures.Clear(); } /// /// Called by the Test Runner when a test run finishes. Writes any recorded /// failures to a file. /// /// The aggregate result of the test run. void ICallbacks.RunFinished(ITestResultAdaptor result) { if (_failures.Count == 0) { this.Log($"Test run completed with no failures."); return; } string outputPath = WriteFailuresToFile(); if (outputPath == null) { return; } this.Log($"Wrote {_failures.Count} failure(s) to: {outputPath}"); } /// /// Called by the Test Runner when an individual test begins. No action is taken. /// /// The test that is starting. void ICallbacks.TestStarted(ITestAdaptor test) { } /// /// Called by the Test Runner when an individual test finishes. Records the /// test details if it failed. /// /// The result of the completed test. void ICallbacks.TestFinished(ITestResultAdaptor result) { if (result.TestStatus != TestStatus.Failed) { return; } if (result.HasChildren) { return; } _failures.Add( new FailedTestInfo( result.FullName, result.Message ?? string.Empty, result.StackTrace ?? string.Empty ) ); } /// /// Menu item that exports the currently recorded failed tests to a file. /// [MenuItem("Tools/Wallstop Studios/Unity Helpers/Export Failed Tests", priority = 100)] private static void ExportFailedTestsMenuItem() { if (!HasValidFailures()) { Debug.Log("[FailedTestsExporter] No failed tests to export."); return; } string outputPath = _instance.WriteFailuresToFile(); if (outputPath == null) { return; } Debug.Log( $"[FailedTestsExporter] Exported {_instance._failures.Count} failure(s) to: {outputPath}" ); } /// /// Validation function for the Export Failed Tests menu item. /// /// true if there are failed tests available to export. [MenuItem("Tools/Wallstop Studios/Unity Helpers/Export Failed Tests", validate = true)] private static bool ExportFailedTestsMenuItemValidate() { return HasValidFailures(); } /// /// Menu item that clears all currently recorded failed tests. /// [MenuItem("Tools/Wallstop Studios/Unity Helpers/Clear Failed Tests", priority = 101)] private static void ClearFailedTestsMenuItem() { if (_instance != null) { _instance._failures.Clear(); } Debug.Log("[FailedTestsExporter] Cleared failed tests."); } /// /// Validation function for the Clear Failed Tests menu item. /// /// true if there are failed tests available to clear. [MenuItem("Tools/Wallstop Studios/Unity Helpers/Clear Failed Tests", validate = true)] private static bool ClearFailedTestsMenuItemValidate() { return HasValidFailures(); } private static bool HasValidFailures() { return _instance != null && _instance._failures.Count > 0; } private string WriteFailuresToFile() { try { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); string outputDirectory = UnityHelpersSettings.GetFailedTestsOutputDirectory(); string targetDirectory = string.IsNullOrEmpty(outputDirectory) ? projectRoot : Path.GetFullPath(Path.Combine(projectRoot, outputDirectory)); // Defense-in-depth: directory may have been removed since validation if (!Directory.Exists(targetDirectory)) { targetDirectory = projectRoot; } string timestamp = DateTime.Now.ToString( "yyyy-MM-dd-HHmmss", CultureInfo.InvariantCulture ); string fileName = $"failed-tests-{timestamp}.txt"; string outputPath = Path.Combine(targetDirectory, fileName); StringBuilder builder = new(_failures.Count * 512); for (int i = 0; i < _failures.Count; i++) { FailedTestInfo failure = _failures[i]; builder.Append("TEST_FAILURE_"); builder.AppendLine((i + 1).ToString()); builder.Append("Name: "); builder.AppendLine(failure.name); builder.Append("Message: "); builder.AppendLine( string.IsNullOrEmpty(failure.message) ? "(no message)" : failure.message ); builder.AppendLine("Stack Trace:"); builder.AppendLine( string.IsNullOrEmpty(failure.stackTrace) ? "(no stack trace)" : failure.stackTrace ); if (i < _failures.Count - 1) { builder.AppendLine(); builder.AppendLine("---"); builder.AppendLine(); } } File.WriteAllText(outputPath, builder.ToString()); return outputPath; } catch (Exception e) { this.LogError($"Failed to write file.", e); return null; } } /// /// Gets the list of recorded test failures from the most recent test run. /// public IReadOnlyList Failures => _failures; /// /// Gets the current singleton instance of the exporter, or null if /// the exporter is not initialized or is disabled. /// public static FailedTestsExporter Instance => _instance; /// /// Contains the details of a single failed test captured by the /// . /// [Serializable] internal readonly struct FailedTestInfo { /// /// The fully qualified name of the failed test. /// public readonly string name; /// /// The failure message reported by the test runner. /// public readonly string message; /// /// The stack trace at the point of failure. /// public readonly string stackTrace; /// /// Creates a new with the specified values. /// /// The fully qualified name of the failed test. /// The failure message reported by the test runner. /// The stack trace at the point of failure. internal FailedTestInfo(string name, string message, string stackTrace) { this.name = name; this.message = message; this.stackTrace = stackTrace; } } } #endif }