587 lines
21 KiB
C#
587 lines
21 KiB
C#
|
|
using System;
|
||
|
|
using System.Collections.Generic;
|
||
|
|
using System.Linq;
|
||
|
|
using System.Threading.Tasks;
|
||
|
|
using MCPForUnity.Editor.Helpers;
|
||
|
|
using Newtonsoft.Json;
|
||
|
|
using UnityEditor;
|
||
|
|
using UnityEditorInternal;
|
||
|
|
using UnityEditor.TestTools.TestRunner.Api;
|
||
|
|
|
||
|
|
namespace MCPForUnity.Editor.Services
|
||
|
|
{
|
||
|
|
internal enum TestJobStatus
|
||
|
|
{
|
||
|
|
Running,
|
||
|
|
Succeeded,
|
||
|
|
Failed
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed class TestJobFailure
|
||
|
|
{
|
||
|
|
public string FullName { get; set; }
|
||
|
|
public string Message { get; set; }
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed class TestJob
|
||
|
|
{
|
||
|
|
public string JobId { get; set; }
|
||
|
|
public TestJobStatus Status { get; set; }
|
||
|
|
public string Mode { get; set; }
|
||
|
|
public long StartedUnixMs { get; set; }
|
||
|
|
public long? FinishedUnixMs { get; set; }
|
||
|
|
public long LastUpdateUnixMs { get; set; }
|
||
|
|
public int? TotalTests { get; set; }
|
||
|
|
public int CompletedTests { get; set; }
|
||
|
|
public string CurrentTestFullName { get; set; }
|
||
|
|
public long? CurrentTestStartedUnixMs { get; set; }
|
||
|
|
public string LastFinishedTestFullName { get; set; }
|
||
|
|
public long? LastFinishedUnixMs { get; set; }
|
||
|
|
public List<TestJobFailure> FailuresSoFar { get; set; }
|
||
|
|
public string Error { get; set; }
|
||
|
|
public TestRunResult Result { get; set; }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Tracks async test jobs started via MCP tools. This is not intended to capture manual Test Runner UI runs.
|
||
|
|
/// </summary>
|
||
|
|
internal static class TestJobManager
|
||
|
|
{
|
||
|
|
// Keep this small to avoid ballooning payloads during polling.
|
||
|
|
private const int FailureCap = 25;
|
||
|
|
private const long StuckThresholdMs = 60_000;
|
||
|
|
private const int MaxJobsToKeep = 10;
|
||
|
|
private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead
|
||
|
|
|
||
|
|
// SessionState survives domain reloads within the same Unity Editor session.
|
||
|
|
private const string SessionKeyJobs = "MCPForUnity.TestJobsV1";
|
||
|
|
private const string SessionKeyCurrentJobId = "MCPForUnity.CurrentTestJobIdV1";
|
||
|
|
|
||
|
|
private static readonly object LockObj = new();
|
||
|
|
private static readonly Dictionary<string, TestJob> Jobs = new();
|
||
|
|
private static string _currentJobId;
|
||
|
|
private static long _lastPersistUnixMs;
|
||
|
|
|
||
|
|
static TestJobManager()
|
||
|
|
{
|
||
|
|
// Restore after domain reloads (e.g., compilation while a job is running).
|
||
|
|
TryRestoreFromSessionState();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static string CurrentJobId
|
||
|
|
{
|
||
|
|
get { lock (LockObj) return _currentJobId; }
|
||
|
|
}
|
||
|
|
|
||
|
|
public static bool HasRunningJob
|
||
|
|
{
|
||
|
|
get
|
||
|
|
{
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
return !string.IsNullOrEmpty(_currentJobId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class PersistedState
|
||
|
|
{
|
||
|
|
public string current_job_id { get; set; }
|
||
|
|
public List<PersistedJob> jobs { get; set; }
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class PersistedJob
|
||
|
|
{
|
||
|
|
public string job_id { get; set; }
|
||
|
|
public string status { get; set; }
|
||
|
|
public string mode { get; set; }
|
||
|
|
public long started_unix_ms { get; set; }
|
||
|
|
public long? finished_unix_ms { get; set; }
|
||
|
|
public long last_update_unix_ms { get; set; }
|
||
|
|
public int? total_tests { get; set; }
|
||
|
|
public int completed_tests { get; set; }
|
||
|
|
public string current_test_full_name { get; set; }
|
||
|
|
public long? current_test_started_unix_ms { get; set; }
|
||
|
|
public string last_finished_test_full_name { get; set; }
|
||
|
|
public long? last_finished_unix_ms { get; set; }
|
||
|
|
public List<TestJobFailure> failures_so_far { get; set; }
|
||
|
|
public string error { get; set; }
|
||
|
|
}
|
||
|
|
|
||
|
|
private static TestJobStatus ParseStatus(string status)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(status))
|
||
|
|
{
|
||
|
|
return TestJobStatus.Running;
|
||
|
|
}
|
||
|
|
|
||
|
|
string s = status.Trim().ToLowerInvariant();
|
||
|
|
return s switch
|
||
|
|
{
|
||
|
|
"succeeded" => TestJobStatus.Succeeded,
|
||
|
|
"failed" => TestJobStatus.Failed,
|
||
|
|
_ => TestJobStatus.Running
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void TryRestoreFromSessionState()
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
string json = SessionState.GetString(SessionKeyJobs, string.Empty);
|
||
|
|
if (string.IsNullOrWhiteSpace(json))
|
||
|
|
{
|
||
|
|
var legacy = SessionState.GetString(SessionKeyCurrentJobId, string.Empty);
|
||
|
|
_currentJobId = string.IsNullOrWhiteSpace(legacy) ? null : legacy;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var state = JsonConvert.DeserializeObject<PersistedState>(json);
|
||
|
|
if (state?.jobs == null)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
Jobs.Clear();
|
||
|
|
foreach (var pj in state.jobs)
|
||
|
|
{
|
||
|
|
if (pj == null || string.IsNullOrWhiteSpace(pj.job_id))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
Jobs[pj.job_id] = new TestJob
|
||
|
|
{
|
||
|
|
JobId = pj.job_id,
|
||
|
|
Status = ParseStatus(pj.status),
|
||
|
|
Mode = pj.mode,
|
||
|
|
StartedUnixMs = pj.started_unix_ms,
|
||
|
|
FinishedUnixMs = pj.finished_unix_ms,
|
||
|
|
LastUpdateUnixMs = pj.last_update_unix_ms,
|
||
|
|
TotalTests = pj.total_tests,
|
||
|
|
CompletedTests = pj.completed_tests,
|
||
|
|
CurrentTestFullName = pj.current_test_full_name,
|
||
|
|
CurrentTestStartedUnixMs = pj.current_test_started_unix_ms,
|
||
|
|
LastFinishedTestFullName = pj.last_finished_test_full_name,
|
||
|
|
LastFinishedUnixMs = pj.last_finished_unix_ms,
|
||
|
|
FailuresSoFar = pj.failures_so_far ?? new List<TestJobFailure>(),
|
||
|
|
Error = pj.error,
|
||
|
|
// Intentionally not persisted to avoid ballooning SessionState.
|
||
|
|
Result = null
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_currentJobId = string.IsNullOrWhiteSpace(state.current_job_id) ? null : state.current_job_id;
|
||
|
|
if (!string.IsNullOrEmpty(_currentJobId) && !Jobs.ContainsKey(_currentJobId))
|
||
|
|
{
|
||
|
|
_currentJobId = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
// Restoration is best-effort; never block editor load.
|
||
|
|
McpLog.Warn($"[TestJobManager] Failed to restore SessionState: {ex.Message}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void PersistToSessionState(bool force = false)
|
||
|
|
{
|
||
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
|
||
|
|
// Throttle non-critical updates to reduce overhead during large test runs
|
||
|
|
if (!force && (now - _lastPersistUnixMs) < MinPersistIntervalMs)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
PersistedState snapshot;
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
var jobs = Jobs.Values
|
||
|
|
.OrderByDescending(j => j.LastUpdateUnixMs)
|
||
|
|
.Take(MaxJobsToKeep)
|
||
|
|
.Select(j => new PersistedJob
|
||
|
|
{
|
||
|
|
job_id = j.JobId,
|
||
|
|
status = j.Status.ToString().ToLowerInvariant(),
|
||
|
|
mode = j.Mode,
|
||
|
|
started_unix_ms = j.StartedUnixMs,
|
||
|
|
finished_unix_ms = j.FinishedUnixMs,
|
||
|
|
last_update_unix_ms = j.LastUpdateUnixMs,
|
||
|
|
total_tests = j.TotalTests,
|
||
|
|
completed_tests = j.CompletedTests,
|
||
|
|
current_test_full_name = j.CurrentTestFullName,
|
||
|
|
current_test_started_unix_ms = j.CurrentTestStartedUnixMs,
|
||
|
|
last_finished_test_full_name = j.LastFinishedTestFullName,
|
||
|
|
last_finished_unix_ms = j.LastFinishedUnixMs,
|
||
|
|
failures_so_far = (j.FailuresSoFar ?? new List<TestJobFailure>()).Take(FailureCap).ToList(),
|
||
|
|
error = j.Error
|
||
|
|
})
|
||
|
|
.ToList();
|
||
|
|
|
||
|
|
snapshot = new PersistedState
|
||
|
|
{
|
||
|
|
current_job_id = _currentJobId,
|
||
|
|
jobs = jobs
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
SessionState.SetString(SessionKeyCurrentJobId, snapshot.current_job_id ?? string.Empty);
|
||
|
|
SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(snapshot));
|
||
|
|
_lastPersistUnixMs = now;
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
McpLog.Warn($"[TestJobManager] Failed to persist SessionState: {ex.Message}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null)
|
||
|
|
{
|
||
|
|
string jobId = Guid.NewGuid().ToString("N");
|
||
|
|
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
string modeStr = mode.ToString();
|
||
|
|
|
||
|
|
var job = new TestJob
|
||
|
|
{
|
||
|
|
JobId = jobId,
|
||
|
|
Status = TestJobStatus.Running,
|
||
|
|
Mode = modeStr,
|
||
|
|
StartedUnixMs = started,
|
||
|
|
FinishedUnixMs = null,
|
||
|
|
LastUpdateUnixMs = started,
|
||
|
|
TotalTests = null,
|
||
|
|
CompletedTests = 0,
|
||
|
|
CurrentTestFullName = null,
|
||
|
|
CurrentTestStartedUnixMs = null,
|
||
|
|
LastFinishedTestFullName = null,
|
||
|
|
LastFinishedUnixMs = null,
|
||
|
|
FailuresSoFar = new List<TestJobFailure>(),
|
||
|
|
Error = null,
|
||
|
|
Result = null
|
||
|
|
};
|
||
|
|
|
||
|
|
// Single lock scope for check-and-set to avoid TOCTOU race
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
if (!string.IsNullOrEmpty(_currentJobId))
|
||
|
|
{
|
||
|
|
throw new InvalidOperationException("A Unity test run is already in progress.");
|
||
|
|
}
|
||
|
|
Jobs[jobId] = job;
|
||
|
|
_currentJobId = jobId;
|
||
|
|
}
|
||
|
|
PersistToSessionState(force: true);
|
||
|
|
|
||
|
|
// Kick the run (must be called on main thread; our command handlers already run there).
|
||
|
|
Task<TestRunResult> task = MCPServiceLocator.Tests.RunTestsAsync(mode, filterOptions);
|
||
|
|
|
||
|
|
void FinalizeJob(Action finalize)
|
||
|
|
{
|
||
|
|
// Ensure state mutation happens on main thread to avoid Unity API surprises.
|
||
|
|
EditorApplication.delayCall += () =>
|
||
|
|
{
|
||
|
|
try { finalize(); }
|
||
|
|
catch (Exception ex) { McpLog.Error($"[TestJobManager] Finalize failed: {ex.Message}\n{ex.StackTrace}"); }
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
task.ContinueWith(t =>
|
||
|
|
{
|
||
|
|
// NOTE: We now finalize jobs deterministically from the TestRunnerService RunFinished callback.
|
||
|
|
// This continuation is retained as a safety net in case RunFinished is not delivered.
|
||
|
|
FinalizeJob(() => FinalizeFromTask(jobId, t));
|
||
|
|
}, TaskScheduler.Default);
|
||
|
|
|
||
|
|
return jobId;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static void FinalizeCurrentJobFromRunFinished(TestRunResult resultPayload)
|
||
|
|
{
|
||
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
job.LastUpdateUnixMs = now;
|
||
|
|
job.FinishedUnixMs = now;
|
||
|
|
job.Status = resultPayload != null && resultPayload.Failed > 0
|
||
|
|
? TestJobStatus.Failed
|
||
|
|
: TestJobStatus.Succeeded;
|
||
|
|
job.Error = null;
|
||
|
|
job.Result = resultPayload;
|
||
|
|
job.CurrentTestFullName = null;
|
||
|
|
_currentJobId = null;
|
||
|
|
}
|
||
|
|
PersistToSessionState(force: true);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static void OnRunStarted(int? totalTests)
|
||
|
|
{
|
||
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
job.LastUpdateUnixMs = now;
|
||
|
|
job.TotalTests = totalTests;
|
||
|
|
job.CompletedTests = 0;
|
||
|
|
job.CurrentTestFullName = null;
|
||
|
|
job.CurrentTestStartedUnixMs = null;
|
||
|
|
job.LastFinishedTestFullName = null;
|
||
|
|
job.LastFinishedUnixMs = null;
|
||
|
|
job.FailuresSoFar ??= new List<TestJobFailure>();
|
||
|
|
job.FailuresSoFar.Clear();
|
||
|
|
}
|
||
|
|
PersistToSessionState(force: true);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static void OnTestStarted(string testFullName)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(testFullName))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
job.LastUpdateUnixMs = now;
|
||
|
|
job.CurrentTestFullName = testFullName;
|
||
|
|
job.CurrentTestStartedUnixMs = now;
|
||
|
|
}
|
||
|
|
PersistToSessionState();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static void OnLeafTestFinished(string testFullName, bool isFailure, string message)
|
||
|
|
{
|
||
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
job.LastUpdateUnixMs = now;
|
||
|
|
job.CompletedTests = Math.Max(0, job.CompletedTests + 1);
|
||
|
|
job.LastFinishedTestFullName = testFullName;
|
||
|
|
job.LastFinishedUnixMs = now;
|
||
|
|
|
||
|
|
if (isFailure)
|
||
|
|
{
|
||
|
|
job.FailuresSoFar ??= new List<TestJobFailure>();
|
||
|
|
if (job.FailuresSoFar.Count < FailureCap)
|
||
|
|
{
|
||
|
|
job.FailuresSoFar.Add(new TestJobFailure
|
||
|
|
{
|
||
|
|
FullName = testFullName,
|
||
|
|
Message = string.IsNullOrWhiteSpace(message) ? "Test failed" : message
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
PersistToSessionState();
|
||
|
|
}
|
||
|
|
|
||
|
|
public static void OnRunFinished()
|
||
|
|
{
|
||
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
job.LastUpdateUnixMs = now;
|
||
|
|
job.CurrentTestFullName = null;
|
||
|
|
}
|
||
|
|
PersistToSessionState(force: true);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static TestJob GetJob(string jobId)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(jobId))
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
return Jobs.TryGetValue(jobId, out var job) ? job : null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests)
|
||
|
|
{
|
||
|
|
if (job == null)
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
object resultPayload = null;
|
||
|
|
if (job.Status == TestJobStatus.Succeeded && job.Result != null)
|
||
|
|
{
|
||
|
|
resultPayload = job.Result.ToSerializable(job.Mode, includeDetails, includeFailedTests);
|
||
|
|
}
|
||
|
|
|
||
|
|
return new
|
||
|
|
{
|
||
|
|
job_id = job.JobId,
|
||
|
|
status = job.Status.ToString().ToLowerInvariant(),
|
||
|
|
mode = job.Mode,
|
||
|
|
started_unix_ms = job.StartedUnixMs,
|
||
|
|
finished_unix_ms = job.FinishedUnixMs,
|
||
|
|
last_update_unix_ms = job.LastUpdateUnixMs,
|
||
|
|
progress = new
|
||
|
|
{
|
||
|
|
completed = job.CompletedTests,
|
||
|
|
total = job.TotalTests,
|
||
|
|
current_test_full_name = job.CurrentTestFullName,
|
||
|
|
current_test_started_unix_ms = job.CurrentTestStartedUnixMs,
|
||
|
|
last_finished_test_full_name = job.LastFinishedTestFullName,
|
||
|
|
last_finished_unix_ms = job.LastFinishedUnixMs,
|
||
|
|
stuck_suspected = IsStuck(job),
|
||
|
|
editor_is_focused = InternalEditorUtility.isApplicationActive,
|
||
|
|
blocked_reason = GetBlockedReason(job),
|
||
|
|
failures_so_far = BuildFailuresPayload(job.FailuresSoFar),
|
||
|
|
failures_capped = (job.FailuresSoFar != null && job.FailuresSoFar.Count >= FailureCap)
|
||
|
|
},
|
||
|
|
error = job.Error,
|
||
|
|
result = resultPayload
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string GetBlockedReason(TestJob job)
|
||
|
|
{
|
||
|
|
if (job == null || job.Status != TestJobStatus.Running)
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!IsStuck(job))
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// This matches the real-world symptom you observed: background Unity can get heavily throttled by OS/Editor.
|
||
|
|
if (!InternalEditorUtility.isApplicationActive)
|
||
|
|
{
|
||
|
|
return "editor_unfocused";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (EditorApplication.isCompiling)
|
||
|
|
{
|
||
|
|
return "compiling";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (EditorApplication.isUpdating)
|
||
|
|
{
|
||
|
|
return "asset_import";
|
||
|
|
}
|
||
|
|
|
||
|
|
return "unknown";
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool IsStuck(TestJob job)
|
||
|
|
{
|
||
|
|
if (job == null || job.Status != TestJobStatus.Running)
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (string.IsNullOrWhiteSpace(job.CurrentTestFullName) || !job.CurrentTestStartedUnixMs.HasValue)
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
return (now - job.CurrentTestStartedUnixMs.Value) > StuckThresholdMs;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static object[] BuildFailuresPayload(List<TestJobFailure> failures)
|
||
|
|
{
|
||
|
|
if (failures == null || failures.Count == 0)
|
||
|
|
{
|
||
|
|
return Array.Empty<object>();
|
||
|
|
}
|
||
|
|
|
||
|
|
var list = new object[failures.Count];
|
||
|
|
for (int i = 0; i < failures.Count; i++)
|
||
|
|
{
|
||
|
|
var f = failures[i];
|
||
|
|
list[i] = new { full_name = f?.FullName, message = f?.Message };
|
||
|
|
}
|
||
|
|
return list;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void FinalizeFromTask(string jobId, Task<TestRunResult> task)
|
||
|
|
{
|
||
|
|
lock (LockObj)
|
||
|
|
{
|
||
|
|
if (!Jobs.TryGetValue(jobId, out var existing))
|
||
|
|
{
|
||
|
|
if (_currentJobId == jobId) _currentJobId = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// If RunFinished already finalized the job, do nothing.
|
||
|
|
if (existing.Status != TestJobStatus.Running)
|
||
|
|
{
|
||
|
|
if (_currentJobId == jobId) _currentJobId = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
existing.LastUpdateUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
|
existing.FinishedUnixMs = existing.LastUpdateUnixMs;
|
||
|
|
|
||
|
|
if (task.IsFaulted)
|
||
|
|
{
|
||
|
|
existing.Status = TestJobStatus.Failed;
|
||
|
|
existing.Error = task.Exception?.GetBaseException()?.Message ?? "Unknown test job failure";
|
||
|
|
existing.Result = null;
|
||
|
|
}
|
||
|
|
else if (task.IsCanceled)
|
||
|
|
{
|
||
|
|
existing.Status = TestJobStatus.Failed;
|
||
|
|
existing.Error = "Test job canceled";
|
||
|
|
existing.Result = null;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
var result = task.Result;
|
||
|
|
existing.Status = result != null && result.Failed > 0
|
||
|
|
? TestJobStatus.Failed
|
||
|
|
: TestJobStatus.Succeeded;
|
||
|
|
existing.Error = null;
|
||
|
|
existing.Result = result;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (_currentJobId == jobId)
|
||
|
|
{
|
||
|
|
_currentJobId = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
PersistToSessionState(force: true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|