// 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
}