diff --git a/Docs~/ExtendingCommands.md b/Docs~/ExtendingCommands.md
index 5308654..1309a5a 100644
--- a/Docs~/ExtendingCommands.md
+++ b/Docs~/ExtendingCommands.md
@@ -112,10 +112,18 @@ Handler parameters are bound automatically from JSON args by name. No DTO classe
editorOnly: false, // true = unavailable on Player builds
runOnMainThread: true,// default true — the framework dispatches to the Unity main thread
// Set false only when the handler self-dispatches internally
- summary: "" // Human-readable description
+ summary: "", // Human-readable description
+ requiresProtectedInvocation: false,
+ // true = require a durable protocol-v2 direct /command invocation
+ allowInBatch: true // false = reject this command before batch handler execution
)]
```
+Use `requiresProtectedInvocation: true` only for a non-repeatable workflow that
+needs the server-accepted invocation UUID as durable identity. Such a command is
+unavailable to unprotected clients. Pair it with `allowInBatch: false`: batch
+items deliberately do not inherit the parent batch invocation UUID.
+
## Injected Parameters
Declare a `CommandInvocation` parameter to receive request metadata. The framework injects it automatically and does not expose it in the command catalog or bind it from args:
@@ -134,7 +142,12 @@ public static class MyCommands
}
```
-`CommandInvocation` exposes `commandNamespace`, `action`, `sessionId`, and the raw `argsJson`.
+`CommandInvocation` exposes `commandNamespace`, `action`, `sessionId`, the raw
+`argsJson`, and `protectedInvocationId`. The last field is a normalized UUID
+only for a protected direct `/command` request; it is empty for unprotected
+requests and every batch item. A handler that requires it must also declare
+`requiresProtectedInvocation: true` so the dispatcher rejects a missing id
+before parameter binding, main-thread switching, or handler execution.
## Editor Helper Utilities
@@ -220,3 +233,7 @@ The `/batch` endpoint executes multiple commands in a single HTTP request, reduc
```
Commands execute sequentially. When `stopOnError` is `true`, execution halts on the first failure and remaining commands are skipped. The response includes per-command results. The `total` field reflects the number of commands actually executed (not the number submitted), so `succeeded + failed == total` always holds.
+
+A command declared with `allowInBatch: false` is rejected before its handler
+runs. The protected invocation UUID of the batch request is never exposed as a
+child command's `protectedInvocationId`.
diff --git a/Docs~/ExtendingCommands_zh.md b/Docs~/ExtendingCommands_zh.md
index c19dc9e..9eab2d9 100644
--- a/Docs~/ExtendingCommands_zh.md
+++ b/Docs~/ExtendingCommands_zh.md
@@ -112,10 +112,18 @@ Handler 参数从 JSON args 按名称自动绑定,不需要 DTO 类。
editorOnly: false, // true = Player 构建中不可用
runOnMainThread: true,// 默认 true — 框架自动调度到 Unity 主线程
// 仅当 handler 自行管理主线程调度时才设为 false
- summary: "" // 人类可读的描述
+ summary: "", // 人类可读的描述
+ requiresProtectedInvocation: false,
+ // true = 必须使用 protocol-v2 保护的直连 /command invocation
+ allowInBatch: true // false = 在 batch handler 执行前拒绝该命令
)]
```
+只有不可重复、需要把服务端接受的 invocation UUID 作为 durable identity 的工作流,
+才应设置 `requiresProtectedInvocation: true`。这类命令不能由未受保护的客户端调用,
+并应同时设置 `allowInBatch: false`;batch 子命令不会继承父 batch 的 invocation
+UUID。
+
## 注入参数
声明 `CommandInvocation` 参数可获取请求元数据。框架自动注入,不将其暴露到命令目录中,也不从 args 中绑定:
@@ -134,7 +142,11 @@ public static class MyCommands
}
```
-`CommandInvocation` 包含 `commandNamespace`、`action`、`sessionId` 以及原始 `argsJson`。
+`CommandInvocation` 包含 `commandNamespace`、`action`、`sessionId`、原始
+`argsJson` 以及 `protectedInvocationId`。最后一个字段只会在受保护的直连
+`/command` 请求中保存规范化 UUID;未受保护请求和所有 batch 子命令中均为空。
+需要该字段的 handler 还必须声明 `requiresProtectedInvocation: true`,这样
+dispatcher 会在参数绑定、主线程切换和 handler 执行前拒绝缺失 id 的请求。
## Editor 辅助工具
@@ -220,3 +232,6 @@ Zh1Zh1.CSharpConsole.RuntimeInitializer.ConsoleInitialize();
```
命令按顺序执行。当 `stopOnError` 为 `true` 时,首次失败后停止执行,剩余命令被跳过。响应包含每个命令的结果。`total` 字段反映实际执行的命令数(而非提交数),因此 `succeeded + failed == total` 始终成立。
+
+声明为 `allowInBatch: false` 的命令会在 handler 运行前被拒绝。batch 请求自身受
+保护的 invocation UUID 绝不会作为子命令的 `protectedInvocationId` 暴露。
diff --git a/Docs~/UnityCliPluginSync_zh.md b/Docs~/UnityCliPluginSync_zh.md
index 07e79e2..20946e8 100644
--- a/Docs~/UnityCliPluginSync_zh.md
+++ b/Docs~/UnityCliPluginSync_zh.md
@@ -1,5 +1,110 @@
# unity-cli-plugin 协议同步记录
+## 2026-07-24:Unity Tests 有界异步状态机
+
+新增两个 Editor-only 内置命令,wire namespace 独立为 `tests`:
+
+```text
+tests/run(mode, testNames?)
+tests/status(runId, waitSeconds=0)
+```
+
+`tests/run` 的 `mode` 必须是 `edit` 或 `play`。省略 `testNames` 表示运行该模式
+下的全部测试;若提供,则必须包含 1–32 个非空的精确测试全名,每项最多 512 个
+UTF-16 代码单元(与 C# `string.Length` 一致)。它只能作为受 at-most-once
+保护的直连 `/command` 请求执行,不能放进
+`/batch`;缺少受保护 invocation id 时,dispatcher 会在参数绑定、主线程切换和
+handler 执行之前拒绝。`runId` 直接取受保护 invocation UUID 的 32 位十六进制
+`N` 格式;当前或保留历史中已有同一 `runId` 时不会再次调用
+`TestRunnerApi.Execute`。
+
+执行前还会检查所有已加载 scene;只要存在 dirty scene 就返回
+`validation_error`,要求先保存或丢弃修改,避免非交互执行触发保存确认框。
+成功响应只返回紧凑 acceptance:
+
+```json
+{
+ "runId": "32 位十六进制 id",
+ "phase": "requested",
+ "mode": "edit",
+ "accepted": true
+}
+```
+
+调用方必须保存 `runId`。Test Framework 运行 GUID 只保存在内部 durable
+state,用于把全局 callback 归属到唯一运行,不能作为公开 selector。
+
+`tests/status` 必须传入上述 `runId`,它本身不要求受保护 invocation。
+`waitSeconds` 范围为 0–20;大于 0 时,
+若当前请求不在 Editor 主线程,会短暂等待 terminal state,以减少复杂工作流的
+轮询次数和重复上下文。状态结果不返回测试树、passing test 列表、完整 Output 或
+XML,只返回:
+
+- `phase`: `requested`、`running`、`completed` 或 `interrupted`;
+- `outcome`: terminal 时为 `passed`、`failed`、`skipped`、
+ `inconclusive`、`no_tests` 或 `unknown`;
+- total/completed/passed/failed/skipped/inconclusive 的扁平计数;
+- 当前测试、根结果、耗时、时间戳和操作级 message;
+- 有界 `failureDetails`、`returnedFailureCount` 与
+ `failuresTruncated`。
+
+`status.resultJson` 的 UTF-8 大小硬限制为 16 KiB。失败详情最多保留 20 条,
+单条测试名、message 与 stack 都有独立上限;超过状态文件或 response 预算时从
+尾部省略并显式设置 `failuresTruncated=true`。完成裁剪后仍无法满足 16 KiB 时
+返回 `system_error`,不会发送超限 payload。
+
+### durable ownership 与 reload
+
+内部状态位于
+`Library/CSharpConsole/TestRuns/v1/state.json`,采用 flush + 同卷原子替换,
+最大 64 KiB。当前终态会在下一次 run acceptance 前原子归档到 `history/`;
+`tests/status` 可查询当前 run 或最近 16 个已归档终态。超出保留数量的详细记录
+会连同对应的 `seen/` marker 一起裁剪;marker 总量因此只覆盖 current + 16 条
+历史,不形成无界 machine-local state。
+历史文件损坏、文件名与内容中的 `runId` 不一致或归档失败时均 fail closed,且
+等待 run A 的请求在唤醒后会按 A 的 `runId` 重新取当前/历史记录,绝不会误返回
+随后启动的 run B。
+
+执行顺序固定为:
+
+```text
+持久化 requested + seen marker → TestRunnerApi.Execute 恰好一次
+→ 持久化 Test Framework run id → callback 推进 running/completed
+```
+
+Test Framework callback 不携带 run id、属于全局订阅,并会在 domain reload 后
+丢失。因此集成放在独立 Editor assembly 中,每次 reload 重新注册 callback;一个
+版本隔离的内部 probe 会在 dispatch 后证明唯一活动 run,并把 ownership proof
+持久化。进入 Play Mode 后,Test Framework 可能先移除 editor-side job,再发送
+remote callback;此时只在 proof 已持久化且没有其他活动 framework job 时接受该
+callback stream。出现冲突 run、probe 不可用、离开 Play Mode 后超过短暂宽限仍
+没有 terminal callback、状态文件损坏或关键写入失败时,状态一律 fail closed 为
+`interrupted`,不会推断成功,也不会再次调用 `Execute`。
+
+测试断言失败属于 `phase=completed` + `outcome=failed`,与基础设施证据中断分开。
+完成状态优先使用根结果的 `TestStatus` / `ResultState` 判定;cancel、setup error、
+teardown error 或其他根失败不会被误报为 `passed` / `no_tests`,空根结果也不会
+生成 `completed`。suite/setup/teardown 失败与 leaf failure 共用同一套 20 条和
+字节预算。
+`IErrorCallbacks` 本身无法可靠归属到已被移除的 run,因此只作为
+`interrupted` 诊断保存。
+
+live command descriptor 新增 `requiresProtectedInvocation` 与 `allowInBatch`。
+Editor `/health` 新增精确 capability `test_runs_v1`;Player 不声明该 capability。
+
+推荐调用方使用:
+
+```text
+tests/run → tests/status(runId, waitSeconds=10)
+```
+
+如果 Play Mode 或 assembly reload 使连接暂时断开,先用 `wait-ready` 恢复同一
+Unity 2022 target,再继续查询同一个测试 `runId`;不得通过新的
+`tests/run` 重试结果未明的运行。
+服务端只保证 health 公告的 dedupe window;该窗口之外的长期“不重发”由 CLI
+machine-local outbox 保证。调用方不得把已发送过的旧 invocation UUID 作为新意图
+再次 dispatch。
+
## 2026-07-24:`editor/console.get` 有界诊断读取
新增只读内置命令 `editor/console.get`。接口只包含一个可选参数:
@@ -141,7 +246,8 @@ envelope。状态查询也会即时检查 24 小时窗口;过期 record 会在
使用各自独立的 target ledger;
- `serviceEpoch`:每次 service 初始化生成;domain reload 后会变化;
- `capabilities`:`invocation_headers`、`invocation_receipts`、
- `invocation_status`、`at_most_once`;
+ `invocation_status`、`at_most_once`;Editor 还会在测试状态机可用时声明精确的
+ `test_runs_v1`,Player 不声明;
- `journalWritable`
- `dedupeWindowSeconds`
- `isUpdating`
diff --git a/Editor/TestIntegration.meta b/Editor/TestIntegration.meta
new file mode 100644
index 0000000..80a536c
--- /dev/null
+++ b/Editor/TestIntegration.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 2b84e4f0529e4c37a0be8ddf5e301f01
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/TestIntegration/TestCommandActions.cs b/Editor/TestIntegration/TestCommandActions.cs
new file mode 100644
index 0000000..e0d94d6
--- /dev/null
+++ b/Editor/TestIntegration/TestCommandActions.cs
@@ -0,0 +1,32 @@
+using Zh1Zh1.CSharpConsole.Service.Commands.Core;
+using Zh1Zh1.CSharpConsole.Service.Commands.Routing;
+
+namespace Zh1Zh1.CSharpConsole.Editor.TestIntegration
+{
+ internal static class TestCommandActions
+ {
+ [CommandAction(
+ "tests",
+ "run",
+ editorOnly: true,
+ requiresProtectedInvocation: true,
+ allowInBatch: false,
+ summary: "Start one tracked Unity Test Framework run")]
+ private static CommandResponse Run(
+ CommandInvocation invocation,
+ string mode,
+ string[] testNames = null) =>
+ UnityTestRunWorkflow.Run(invocation, mode, testNames);
+
+ [CommandAction(
+ "tests",
+ "status",
+ editorOnly: true,
+ runOnMainThread: false,
+ summary: "Read or briefly wait for one tracked Unity test run")]
+ private static CommandResponse Status(
+ string runId,
+ int waitSeconds = 0) =>
+ UnityTestRunWorkflow.Status(runId, waitSeconds);
+ }
+}
diff --git a/Editor/TestIntegration/TestCommandActions.cs.meta b/Editor/TestIntegration/TestCommandActions.cs.meta
new file mode 100644
index 0000000..2aaeda3
--- /dev/null
+++ b/Editor/TestIntegration/TestCommandActions.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 72fc6d394e354eb28a85afc7e44cb3df
diff --git a/Editor/TestIntegration/UnityTestFrameworkRunProbe.cs b/Editor/TestIntegration/UnityTestFrameworkRunProbe.cs
new file mode 100644
index 0000000..d795ba1
--- /dev/null
+++ b/Editor/TestIntegration/UnityTestFrameworkRunProbe.cs
@@ -0,0 +1,153 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Reflection;
+using UnityEditor.TestTools.TestRunner.Api;
+
+namespace Zh1Zh1.CSharpConsole.Editor.TestIntegration
+{
+ ///
+ /// Isolates the version-pinned Test Framework implementation detail needed
+ /// to correlate global callbacks with the GUID returned by Execute().
+ /// The workflow fails closed whenever this seam cannot prove one owner.
+ ///
+ internal static class UnityTestFrameworkRunProbe
+ {
+ internal sealed class Snapshot
+ {
+ internal bool available;
+ internal string error = "";
+ internal string[] activeRunIds = Array.Empty();
+ }
+
+ private const string HolderTypeName =
+ "UnityEditor.TestTools.TestRunner.TestRun.TestJobDataHolder";
+
+ private static bool s_Resolved;
+ private static string s_ResolutionError = "";
+ private static PropertyInfo s_InstanceProperty;
+ private static FieldInfo s_TestRunsField;
+ private static FieldInfo s_GuidField;
+ private static FieldInfo s_IsRunningField;
+
+ internal static Snapshot Capture()
+ {
+ try
+ {
+ Resolve();
+ if (!string.IsNullOrEmpty(s_ResolutionError))
+ {
+ return Unavailable(s_ResolutionError);
+ }
+
+ var holder = s_InstanceProperty.GetValue(null);
+ if (holder == null)
+ {
+ return Unavailable("Test Framework run holder is unavailable");
+ }
+
+ if (!(s_TestRunsField.GetValue(holder) is IEnumerable runs))
+ {
+ return Unavailable("Test Framework run collection is unavailable");
+ }
+
+ var activeIds = new List();
+ foreach (var run in runs)
+ {
+ if (run == null)
+ {
+ continue;
+ }
+
+ var isRunningValue = s_IsRunningField.GetValue(run);
+ if (!(isRunningValue is bool isRunning) || !isRunning)
+ {
+ continue;
+ }
+
+ var rawGuid = s_GuidField.GetValue(run) as string;
+ if (!Guid.TryParse(rawGuid, out var parsedGuid))
+ {
+ return Unavailable(
+ "Test Framework exposed an active run with an invalid id");
+ }
+
+ activeIds.Add(parsedGuid.ToString("D"));
+ }
+
+ activeIds.Sort(StringComparer.OrdinalIgnoreCase);
+ return new Snapshot
+ {
+ available = true,
+ activeRunIds = activeIds.ToArray()
+ };
+ }
+ catch (Exception e)
+ {
+ return Unavailable(
+ $"Test Framework run probe failed: {e.GetType().Name}: {e.Message}");
+ }
+ }
+
+ private static void Resolve()
+ {
+ if (s_Resolved)
+ {
+ return;
+ }
+
+ s_Resolved = true;
+ var assembly = typeof(TestRunnerApi).Assembly;
+ var holderType = assembly.GetType(HolderTypeName, throwOnError: false);
+ if (holderType == null)
+ {
+ s_ResolutionError = "Test Framework run holder type was not found";
+ return;
+ }
+
+ s_InstanceProperty = holderType.GetProperty(
+ "instance",
+ BindingFlags.Public
+ | BindingFlags.NonPublic
+ | BindingFlags.Static
+ | BindingFlags.FlattenHierarchy);
+ s_TestRunsField = holderType.GetField(
+ "TestRuns",
+ BindingFlags.Public
+ | BindingFlags.NonPublic
+ | BindingFlags.Instance);
+
+ var runType = s_TestRunsField?.FieldType.IsGenericType == true
+ ? s_TestRunsField.FieldType.GetGenericArguments()[0]
+ : null;
+ s_GuidField = runType?.GetField(
+ "guid",
+ BindingFlags.Public
+ | BindingFlags.NonPublic
+ | BindingFlags.Instance);
+ s_IsRunningField = runType?.GetField(
+ "isRunning",
+ BindingFlags.Public
+ | BindingFlags.NonPublic
+ | BindingFlags.Instance);
+
+ if (s_InstanceProperty == null
+ || s_TestRunsField == null
+ || s_GuidField == null
+ || s_IsRunningField == null)
+ {
+ s_ResolutionError =
+ "Test Framework run holder shape does not match the supported Unity 2022 integration";
+ }
+ }
+
+ private static Snapshot Unavailable(string error)
+ {
+ return new Snapshot
+ {
+ available = false,
+ error = error ?? "Test Framework run probe is unavailable"
+ };
+ }
+ }
+}
diff --git a/Editor/TestIntegration/UnityTestFrameworkRunProbe.cs.meta b/Editor/TestIntegration/UnityTestFrameworkRunProbe.cs.meta
new file mode 100644
index 0000000..c01f636
--- /dev/null
+++ b/Editor/TestIntegration/UnityTestFrameworkRunProbe.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 36aa2b2324534ade9cf0a54a0fe6f49f
diff --git a/Editor/TestIntegration/UnityTestRunWorkflow.cs b/Editor/TestIntegration/UnityTestRunWorkflow.cs
new file mode 100644
index 0000000..5272bc6
--- /dev/null
+++ b/Editor/TestIntegration/UnityTestRunWorkflow.cs
@@ -0,0 +1,2179 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using UnityEditor;
+using UnityEditor.TestTools.TestRunner.Api;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using Zh1Zh1.CSharpConsole.Service.Commands.Core;
+
+namespace Zh1Zh1.CSharpConsole.Editor.TestIntegration
+{
+ ///
+ /// Deep module for starting one Unity test run and tracking bounded,
+ /// durable evidence across Play Mode and assembly reloads.
+ ///
+ internal static class UnityTestRunWorkflow
+ {
+ private const int SchemaVersion = 1;
+ private const int MaxStateBytes = 64 * 1024;
+ private const int MaxStatusJsonBytes = 16 * 1024;
+ private const int MaxTestNames = 32;
+ private const int MaxTestNameChars = 512;
+ private const int MaxFailureDetails = 20;
+ private const int MaxOperationMessageChars = 1024;
+ private const int MaxFailureMessageChars = 1024;
+ private const int MaxFailureStackChars = 2048;
+ private const int MaxResultStateChars = 128;
+ private const int MaxOutcomeChars = 32;
+ private const int MaxHistoryRuns = 16;
+ private const int ProgressPersistEvery = 25;
+ private const double ProgressPersistSeconds = 1.0;
+ private const double ReconcileIntervalSeconds = 0.5;
+ private const double PlayModeOrphanGraceSeconds = 5.0;
+
+ private const string RequestedPhase = "requested";
+ private const string RunningPhase = "running";
+ private const string CompletedPhase = "completed";
+ private const string InterruptedPhase = "interrupted";
+
+ private static readonly object s_Gate = new object();
+
+ [Serializable]
+ private sealed class FailureDetail
+ {
+ public string testName = "";
+ public string resultState = "";
+ public string message = "";
+ public string stackTrace = "";
+ }
+
+ [Serializable]
+ private sealed class TestRunState
+ {
+ public int schemaVersion = SchemaVersion;
+ public int revision;
+ public string runId = "";
+ public string frameworkRunId = "";
+ public bool ownershipConfirmed;
+ public string phase = "";
+ public string outcome = "";
+ public string mode = "";
+ public string[] testNames = Array.Empty();
+ public string requestedAtUtc = "";
+ public string startedAtUtc = "";
+ public string finishedAtUtc = "";
+ public string updatedAtUtc = "";
+ public int totalCount;
+ public int completedCount;
+ public int passedCount;
+ public int failedCount;
+ public int skippedCount;
+ public int inconclusiveCount;
+ public string currentTest = "";
+ public string resultState = "";
+ public double durationSeconds;
+ public string message = "";
+ public FailureDetail[] failureDetails = Array.Empty();
+ public bool failuresTruncated;
+ }
+
+ [Serializable]
+ private sealed class AcceptedResult
+ {
+ public string runId = "";
+ public string phase = RequestedPhase;
+ public string mode = "";
+ public bool accepted;
+ }
+
+ [Serializable]
+ private sealed class StatusResult
+ {
+ public string runId = "";
+ public string phase = "";
+ public string outcome = "";
+ public string mode = "";
+ public int totalCount;
+ public int completedCount;
+ public int passedCount;
+ public int failedCount;
+ public int skippedCount;
+ public int inconclusiveCount;
+ public string currentTest = "";
+ public string resultState = "";
+ public double durationSeconds;
+ public string requestedAtUtc = "";
+ public string startedAtUtc = "";
+ public string finishedAtUtc = "";
+ public string message = "";
+ public FailureDetail[] failureDetails = Array.Empty();
+ public int returnedFailureCount;
+ public bool failuresTruncated;
+ }
+
+ private sealed class TestCallbacks : IErrorCallbacks
+ {
+ public void RunStarted(ITestAdaptor testsToRun) =>
+ HandleRunStarted(testsToRun);
+
+ public void RunFinished(ITestResultAdaptor result) =>
+ HandleRunFinished(result);
+
+ public void TestStarted(ITestAdaptor test) =>
+ HandleTestStarted(test);
+
+ public void TestFinished(ITestResultAdaptor result) =>
+ HandleTestFinished(result);
+
+ public void OnError(string message) =>
+ HandleFrameworkError(message);
+ }
+
+ private static bool s_Initialized;
+ private static bool s_PersistenceBlocked;
+ private static int s_MainThreadId;
+ private static string s_StatePath = "";
+ private static string s_HistoryDirectory = "";
+ private static string s_SeenDirectory = "";
+ private static string s_LoadError = "";
+ private static TestRunState s_State;
+ private static TestCallbacks s_Callbacks;
+ private static TestRunnerApi s_TestRunnerApi;
+ private static double s_NextReconcileAt;
+ private static double s_LastProgressPersistAt;
+ private static double s_PlayModeOrphanedOutsidePlayAt = -1.0;
+
+ [InitializeOnLoadMethod]
+ private static void InitializeOnLoad()
+ {
+ if (AssetDatabase.IsAssetImportWorkerProcess())
+ {
+ return;
+ }
+
+ InitializeOnMainThread();
+ }
+
+ internal static CommandResponse Run(
+ CommandInvocation invocation,
+ string mode,
+ string[] testNames)
+ {
+ var normalizedRunId = NormalizeProtectedRunId(invocation);
+ if (string.IsNullOrEmpty(normalizedRunId))
+ {
+ return CommandResponseFactory.ValidationError(
+ "tests/run requires a protected direct invocation id");
+ }
+
+ if (!TryNormalizeRequest(
+ mode,
+ testNames,
+ out var normalizedMode,
+ out var normalizedTestNames,
+ out var validationError))
+ {
+ return CommandResponseFactory.ValidationError(validationError);
+ }
+
+ if (HasDirtyLoadedScene())
+ {
+ return CommandResponseFactory.ValidationError(
+ "Save or discard all modified scenes before tests/run; non-interactive test execution cannot open the save prompt.");
+ }
+
+ if (!s_Initialized)
+ {
+ InitializeOnMainThread();
+ }
+
+ lock (s_Gate)
+ {
+ var availabilityError = GetAvailabilityErrorLocked();
+ if (!string.IsNullOrEmpty(availabilityError))
+ {
+ return SystemError(availabilityError);
+ }
+
+ if (IsActive(s_State))
+ {
+ ReconcileActiveStateLocked("before accepting a new test run");
+ if (IsActive(s_State))
+ {
+ return CommandResponseFactory.ValidationError(
+ $"Unity tests run '{s_State.runId}' is still {s_State.phase}");
+ }
+
+ return CommandResponseFactory.ValidationError(
+ $"Previous Unity tests run '{s_State.runId}' became interrupted; inspect it with tests/status before starting a new run");
+ }
+
+ if (HasRunIdentityLocked(normalizedRunId))
+ {
+ return CommandResponseFactory.ValidationError(
+ $"Unity tests run '{normalizedRunId}' was already accepted; query tests/status with the same runId");
+ }
+
+ var activeSnapshot = UnityTestFrameworkRunProbe.Capture();
+ if (!activeSnapshot.available)
+ {
+ return SystemError(activeSnapshot.error);
+ }
+ if (activeSnapshot.activeRunIds.Length > 0)
+ {
+ return CommandResponseFactory.ValidationError(
+ "Another Unity Test Framework run is already active");
+ }
+
+ if (!TryArchiveTerminalCurrentLocked(out var archiveError))
+ {
+ return SystemError(
+ $"Unity tests were not started because the previous run could not be archived safely: {archiveError}");
+ }
+
+ var now = UtcNow();
+ var operation = new TestRunState
+ {
+ runId = normalizedRunId,
+ phase = RequestedPhase,
+ mode = normalizedMode,
+ testNames = normalizedTestNames,
+ requestedAtUtc = now,
+ updatedAtUtc = now
+ };
+
+ if (!TryPublishStateLocked(operation, out var acceptanceError))
+ {
+ return SystemError(
+ $"Unity tests were not started because durable acceptance failed: {acceptanceError}");
+ }
+
+ if (!TryCreateSeenMarkerLocked(normalizedRunId, out var markerError))
+ {
+ InterruptLocked(
+ s_State,
+ $"The durable run identity marker could not be recorded: {markerError}");
+ return SystemError(
+ "Unity tests were not started because their runId could not be protected against redispatch");
+ }
+
+ if (!TryPruneHistoryLocked(out var pruneError))
+ {
+ InterruptLocked(
+ s_State,
+ $"Retained Unity test history could not be bounded safely: {pruneError}");
+ return SystemError(
+ "Unity tests were not started because retained history could not be maintained safely");
+ }
+
+ string rawFrameworkRunId;
+ try
+ {
+ var filter = new Filter
+ {
+ testMode = string.Equals(
+ normalizedMode,
+ "edit",
+ StringComparison.Ordinal)
+ ? TestMode.EditMode
+ : TestMode.PlayMode
+ };
+ if (normalizedTestNames.Length > 0)
+ {
+ filter.testNames = (string[])normalizedTestNames.Clone();
+ }
+
+ rawFrameworkRunId = s_TestRunnerApi.Execute(
+ new ExecutionSettings(filter)
+ {
+ runSynchronously = false
+ });
+ }
+ catch (Exception e)
+ {
+ InterruptLocked(
+ s_State,
+ $"Test Framework dispatch outcome is unknown: {e.GetType().Name}: {e.Message}");
+ return OutcomeUnknown(
+ s_State,
+ "Unity tests may have started, but Test Framework dispatch could not be confirmed");
+ }
+
+ if (!Guid.TryParse(rawFrameworkRunId, out var parsedFrameworkRunId))
+ {
+ InterruptLocked(
+ s_State,
+ "Test Framework returned an invalid run id after dispatch");
+ return OutcomeUnknown(
+ s_State,
+ "Unity tests may have started, but the Test Framework run id is invalid");
+ }
+
+ var scheduled = CloneState(s_State);
+ scheduled.frameworkRunId = parsedFrameworkRunId.ToString("D");
+ if (!TryPublishStateLocked(scheduled, out var schedulePersistError))
+ {
+ InterruptLocked(
+ scheduled,
+ $"Test Framework run id could not be persisted after dispatch: {schedulePersistError}");
+ return OutcomeUnknown(
+ s_State,
+ "Unity tests may have started, but their durable run identity could not be recorded");
+ }
+
+ var scheduledSnapshot = UnityTestFrameworkRunProbe.Capture();
+ if (!OwnsOnlyActiveRun(s_State, scheduledSnapshot, out var ownershipError))
+ {
+ InterruptLocked(s_State, ownershipError);
+ return OutcomeUnknown(
+ s_State,
+ "Unity tests were dispatched, but exclusive ownership of the run could not be proven");
+ }
+
+ var owned = CloneState(s_State);
+ owned.ownershipConfirmed = true;
+ if (!TryPublishStateLocked(owned, out var ownershipPersistError))
+ {
+ InterruptLocked(
+ owned,
+ $"Test Framework ownership proof could not be persisted: {ownershipPersistError}");
+ return OutcomeUnknown(
+ s_State,
+ "Unity tests were dispatched, but their durable ownership proof could not be recorded");
+ }
+
+ s_PlayModeOrphanedOutsidePlayAt = -1.0;
+ var accepted = new AcceptedResult
+ {
+ runId = s_State.runId,
+ phase = s_State.phase,
+ mode = s_State.mode,
+ accepted = true
+ };
+ return CommandResponseFactory.Ok(
+ "Unity tests accepted",
+ JsonUtility.ToJson(accepted));
+ }
+ }
+
+ internal static CommandResponse Status(string runId, int waitSeconds)
+ {
+ var normalizedRunId = (runId ?? "").Trim();
+ if (!Guid.TryParseExact(normalizedRunId, "N", out var parsedRunId))
+ {
+ return CommandResponseFactory.ValidationError(
+ "runId must be the 32-character hexadecimal id returned by tests/run");
+ }
+ normalizedRunId = parsedRunId.ToString("N");
+ if (waitSeconds < 0 || waitSeconds > 20)
+ {
+ return CommandResponseFactory.ValidationError(
+ "waitSeconds must be between 0 and 20");
+ }
+ if (!s_Initialized)
+ {
+ return SystemError(
+ "Unity test tracking is not initialized in this Editor domain");
+ }
+
+ TestRunState snapshot;
+ lock (s_Gate)
+ {
+ if (!string.IsNullOrEmpty(s_LoadError))
+ {
+ return SystemError(s_LoadError);
+ }
+ if (waitSeconds > 0
+ && IsActive(s_State)
+ && RunIdsEqual(s_State?.runId, normalizedRunId)
+ && Thread.CurrentThread.ManagedThreadId != s_MainThreadId)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(waitSeconds);
+ while (IsActive(s_State)
+ && RunIdsEqual(s_State?.runId, normalizedRunId))
+ {
+ var remaining = deadline - DateTime.UtcNow;
+ if (remaining <= TimeSpan.Zero)
+ {
+ break;
+ }
+
+ Monitor.Wait(
+ s_Gate,
+ Math.Max(1, (int)Math.Min(
+ int.MaxValue,
+ remaining.TotalMilliseconds)));
+ }
+ }
+
+ snapshot = LoadRetainedRunLocked(
+ normalizedRunId,
+ out var retainedError);
+ if (!string.IsNullOrEmpty(retainedError))
+ {
+ return SystemError(retainedError);
+ }
+ if (snapshot == null)
+ {
+ return CommandResponseFactory.ValidationError(
+ $"Unity tests run '{normalizedRunId}' is not retained");
+ }
+ }
+
+ return BuildStatusResponse(snapshot);
+ }
+
+ private static void InitializeOnMainThread()
+ {
+ lock (s_Gate)
+ {
+ if (s_Initialized)
+ {
+ return;
+ }
+
+ s_MainThreadId = Thread.CurrentThread.ManagedThreadId;
+ s_StatePath = ResolveStatePath();
+ var stateDirectory = Path.GetDirectoryName(s_StatePath) ?? "";
+ s_HistoryDirectory = Path.Combine(
+ stateDirectory,
+ "history");
+ s_SeenDirectory = Path.Combine(
+ stateDirectory,
+ "seen");
+ s_State = LoadState(s_StatePath, out s_LoadError);
+ s_PersistenceBlocked = !string.IsNullOrEmpty(s_LoadError);
+ s_Callbacks = new TestCallbacks();
+ s_TestRunnerApi = ScriptableObject.CreateInstance();
+ s_TestRunnerApi.hideFlags = HideFlags.HideAndDontSave;
+ s_TestRunnerApi.RegisterCallbacks(s_Callbacks, priority: 1000);
+ s_NextReconcileAt =
+ EditorApplication.timeSinceStartup + ReconcileIntervalSeconds;
+ s_LastProgressPersistAt = EditorApplication.timeSinceStartup;
+ s_Initialized = true;
+
+ if (IsActive(s_State)
+ && (
+ string.IsNullOrEmpty(s_State.frameworkRunId)
+ || !s_State.ownershipConfirmed
+ ))
+ {
+ InterruptLocked(
+ s_State,
+ "Unity Editor reloaded before the Test Framework run identity and ownership proof were both durable");
+ }
+ }
+
+ EditorApplication.update -= ReconcileOnEditorUpdate;
+ EditorApplication.update += ReconcileOnEditorUpdate;
+ }
+
+ private static void ReconcileOnEditorUpdate()
+ {
+ if (!s_Initialized
+ || EditorApplication.timeSinceStartup < s_NextReconcileAt)
+ {
+ return;
+ }
+
+ s_NextReconcileAt =
+ EditorApplication.timeSinceStartup + ReconcileIntervalSeconds;
+ lock (s_Gate)
+ {
+ if (IsActive(s_State))
+ {
+ ReconcileActiveStateLocked("while tracking the active test run");
+ }
+ }
+ }
+
+ private static void ReconcileActiveStateLocked(string context)
+ {
+ if (!IsActive(s_State))
+ {
+ return;
+ }
+ if (string.IsNullOrEmpty(s_State.frameworkRunId))
+ {
+ InterruptLocked(
+ s_State,
+ $"Unity test evidence is incomplete {context}: no durable Test Framework run id");
+ return;
+ }
+
+ var snapshot = UnityTestFrameworkRunProbe.Capture();
+ if (OwnsOnlyActiveRun(s_State, snapshot, out _))
+ {
+ s_PlayModeOrphanedOutsidePlayAt = -1.0;
+ return;
+ }
+
+ if (CanUseConfirmedPlayModeOwnership(
+ s_State,
+ snapshot,
+ out var ownershipError))
+ {
+ if (EditorApplication.isPlayingOrWillChangePlaymode)
+ {
+ s_PlayModeOrphanedOutsidePlayAt = -1.0;
+ return;
+ }
+
+ if (s_PlayModeOrphanedOutsidePlayAt < 0.0)
+ {
+ s_PlayModeOrphanedOutsidePlayAt =
+ EditorApplication.timeSinceStartup;
+ return;
+ }
+ if (EditorApplication.timeSinceStartup
+ - s_PlayModeOrphanedOutsidePlayAt
+ < PlayModeOrphanGraceSeconds)
+ {
+ return;
+ }
+
+ ownershipError =
+ "The confirmed Play Mode callback stream ended without terminal evidence";
+ }
+
+ if (!string.IsNullOrEmpty(ownershipError))
+ {
+ InterruptLocked(s_State, $"{ownershipError} ({context})");
+ }
+ }
+
+ private static void HandleRunStarted(ITestAdaptor testsToRun)
+ {
+ lock (s_Gate)
+ {
+ if (!CanAcceptCallbackLocked("run-start callback"))
+ {
+ return;
+ }
+
+ var next = CloneState(s_State);
+ next.phase = RunningPhase;
+ next.startedAtUtc = string.IsNullOrEmpty(next.startedAtUtc)
+ ? UtcNow()
+ : next.startedAtUtc;
+ var expectedTotal = next.testNames.Length > 0
+ ? next.testNames.Length
+ : CountLeafTests(testsToRun);
+ next.totalCount = Math.Max(
+ next.totalCount,
+ expectedTotal);
+ next.currentTest = "";
+ if (!TryPublishStateLocked(next, out var persistError))
+ {
+ InterruptLocked(
+ next,
+ $"Could not persist the Unity test run-start evidence: {persistError}");
+ }
+ }
+ }
+
+ private static void HandleTestStarted(ITestAdaptor test)
+ {
+ if (test == null || test.HasChildren)
+ {
+ return;
+ }
+
+ lock (s_Gate)
+ {
+ if (!CanAcceptCallbackLocked("test-start callback"))
+ {
+ return;
+ }
+
+ var next = CloneState(s_State);
+ next.phase = RunningPhase;
+ next.startedAtUtc = string.IsNullOrEmpty(next.startedAtUtc)
+ ? UtcNow()
+ : next.startedAtUtc;
+ next.currentTest = Truncate(
+ string.IsNullOrEmpty(test.FullName)
+ ? test.UniqueName
+ : test.FullName,
+ MaxTestNameChars);
+ PublishVolatileLocked(next);
+ }
+ }
+
+ private static int CountLeafTests(ITestAdaptor root)
+ {
+ if (root == null)
+ {
+ return 0;
+ }
+
+ var count = 0;
+ var pending = new Stack();
+ pending.Push(root);
+ while (pending.Count > 0)
+ {
+ var current = pending.Pop();
+ if (current == null)
+ {
+ continue;
+ }
+ if (!current.HasChildren)
+ {
+ if (!current.IsSuite)
+ {
+ count++;
+ }
+ continue;
+ }
+ if (current.Children == null)
+ {
+ continue;
+ }
+
+ foreach (var child in current.Children)
+ {
+ if (child != null)
+ {
+ pending.Push(child);
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private static void HandleTestFinished(ITestResultAdaptor result)
+ {
+ if (result == null || result.HasChildren)
+ {
+ return;
+ }
+
+ lock (s_Gate)
+ {
+ if (!CanAcceptCallbackLocked("test-finish callback"))
+ {
+ return;
+ }
+
+ var next = CloneState(s_State);
+ next.phase = RunningPhase;
+ next.startedAtUtc = string.IsNullOrEmpty(next.startedAtUtc)
+ ? UtcNow()
+ : next.startedAtUtc;
+ next.currentTest = "";
+
+ var status = result.TestStatus.ToString();
+ switch (status)
+ {
+ case "Passed":
+ next.passedCount++;
+ break;
+ case "Failed":
+ next.failedCount++;
+ AddFailureDetail(next, result);
+ break;
+ case "Skipped":
+ next.skippedCount++;
+ break;
+ default:
+ next.inconclusiveCount++;
+ break;
+ }
+
+ var observedCompleted =
+ next.passedCount
+ + next.failedCount
+ + next.skippedCount
+ + next.inconclusiveCount;
+ next.completedCount = next.totalCount > 0
+ ? Math.Min(next.totalCount, observedCompleted)
+ : observedCompleted;
+
+ var shouldPersist =
+ string.Equals(status, "Failed", StringComparison.Ordinal)
+ || next.completedCount % ProgressPersistEvery == 0
+ || (
+ EditorApplication.timeSinceStartup
+ - s_LastProgressPersistAt
+ ) >= ProgressPersistSeconds;
+ if (!shouldPersist)
+ {
+ PublishVolatileLocked(next);
+ return;
+ }
+
+ if (!TryPublishStateLocked(next, out var persistError))
+ {
+ InterruptLocked(
+ next,
+ $"Could not persist Unity test progress evidence: {persistError}");
+ return;
+ }
+
+ s_LastProgressPersistAt = EditorApplication.timeSinceStartup;
+ }
+ }
+
+ private static void HandleRunFinished(ITestResultAdaptor result)
+ {
+ lock (s_Gate)
+ {
+ if (!CanAcceptCallbackLocked("run-finish callback"))
+ {
+ return;
+ }
+
+ if (result == null)
+ {
+ InterruptLocked(
+ s_State,
+ "Test Framework returned no root result; terminal evidence could not be proven");
+ return;
+ }
+
+ var next = CloneState(s_State);
+ next.phase = CompletedPhase;
+ next.finishedAtUtc = UtcNow();
+ next.currentTest = "";
+ next.resultState = Truncate(
+ result.ResultState,
+ MaxResultStateChars);
+ next.durationSeconds = Math.Max(0.0, result.Duration);
+ next.passedCount = Math.Max(0, result.PassCount);
+ next.failedCount = Math.Max(0, result.FailCount);
+ next.skippedCount = Math.Max(0, result.SkipCount);
+ next.inconclusiveCount = Math.Max(
+ 0,
+ result.InconclusiveCount);
+ next.completedCount =
+ next.passedCount
+ + next.failedCount
+ + next.skippedCount
+ + next.inconclusiveCount;
+ next.totalCount = next.completedCount;
+ CollectFailureDetails(next, result, 0);
+ next.outcome = DetermineOutcome(next, result);
+ next.message = Truncate(
+ result.Message,
+ MaxOperationMessageChars);
+ next.failuresTruncated =
+ next.failuresTruncated
+ || next.failedCount > (next.failureDetails?.Length ?? 0);
+
+ if (!TryPublishStateLocked(next, out var persistError))
+ {
+ InterruptLocked(
+ next,
+ $"Could not persist terminal Unity test evidence: {persistError}");
+ }
+ }
+ }
+
+ private static void HandleFrameworkError(string message)
+ {
+ lock (s_Gate)
+ {
+ if (!IsActive(s_State))
+ {
+ return;
+ }
+
+ // IErrorCallbacks carries no run id, and the failing framework
+ // job may already have been removed before this callback.
+ // Preserve the diagnostic but never attribute it as a proven
+ // terminal result.
+ InterruptLocked(
+ s_State,
+ $"Test Framework reported an uncorrelated error: {Truncate(message, MaxOperationMessageChars)}");
+ }
+ }
+
+ private static bool CanAcceptCallbackLocked(string callbackName)
+ {
+ if (!IsActive(s_State))
+ {
+ return false;
+ }
+
+ var snapshot = UnityTestFrameworkRunProbe.Capture();
+ if (OwnsOnlyActiveRun(s_State, snapshot, out var ownershipError)
+ || CanUseConfirmedPlayModeOwnership(
+ s_State,
+ snapshot,
+ out ownershipError))
+ {
+ return true;
+ }
+
+ InterruptLocked(
+ s_State,
+ $"{ownershipError}; ignored {callbackName}");
+ return false;
+ }
+
+ private static bool OwnsOnlyActiveRun(
+ TestRunState state,
+ UnityTestFrameworkRunProbe.Snapshot snapshot,
+ out string error)
+ {
+ error = "";
+ if (snapshot == null || !snapshot.available)
+ {
+ error = snapshot?.error
+ ?? "Test Framework run ownership probe is unavailable";
+ return false;
+ }
+ if (state == null
+ || !Guid.TryParse(state.frameworkRunId, out var expectedRunId))
+ {
+ error = "Durable Test Framework run id is unavailable";
+ return false;
+ }
+ if (snapshot.activeRunIds.Length != 1)
+ {
+ error = snapshot.activeRunIds.Length == 0
+ ? "The tracked Test Framework run is no longer active and no terminal callback was proven"
+ : "Multiple Test Framework runs are active, so callback ownership is ambiguous";
+ return false;
+ }
+ if (!Guid.TryParse(snapshot.activeRunIds[0], out var activeRunId)
+ || activeRunId != expectedRunId)
+ {
+ error =
+ "The active Test Framework run does not match the durable run id";
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool CanUseConfirmedPlayModeOwnership(
+ TestRunState state,
+ UnityTestFrameworkRunProbe.Snapshot snapshot,
+ out string error)
+ {
+ error = "";
+ if (snapshot == null || !snapshot.available)
+ {
+ error = snapshot?.error
+ ?? "Test Framework run ownership probe is unavailable";
+ return false;
+ }
+ if (state == null
+ || !state.ownershipConfirmed
+ || !string.Equals(state.mode, "play", StringComparison.Ordinal))
+ {
+ error = "The tracked Test Framework run is no longer active and no terminal callback was proven";
+ return false;
+ }
+ if (snapshot.activeRunIds.Length > 0)
+ {
+ error =
+ "A different Test Framework run is active, so callback ownership is ambiguous";
+ return false;
+ }
+
+ // Entering Play Mode can end the editor-side TestJobData before
+ // remote callbacks arrive. Initial exclusive ownership was already
+ // proven and persisted; accept the callback stream only while no
+ // conflicting framework job exists.
+ return true;
+ }
+
+ private static void AddFailureDetail(
+ TestRunState state,
+ ITestResultAdaptor result)
+ {
+ var details = state.failureDetails ?? Array.Empty();
+ var testName = Truncate(
+ string.IsNullOrEmpty(result.FullName)
+ ? result.Test?.UniqueName
+ : result.FullName,
+ MaxTestNameChars);
+ var resultState = Truncate(
+ result.ResultState,
+ MaxResultStateChars);
+ if (details.Any(detail =>
+ string.Equals(
+ detail?.testName,
+ testName,
+ StringComparison.Ordinal)
+ && string.Equals(
+ detail?.resultState,
+ resultState,
+ StringComparison.Ordinal)))
+ {
+ return;
+ }
+ if (details.Length >= MaxFailureDetails)
+ {
+ state.failuresTruncated = true;
+ return;
+ }
+
+ var candidate = new FailureDetail[details.Length + 1];
+ Array.Copy(details, candidate, details.Length);
+ candidate[candidate.Length - 1] = new FailureDetail
+ {
+ testName = testName,
+ resultState = resultState,
+ message = Truncate(
+ result.Message,
+ MaxFailureMessageChars),
+ stackTrace = Truncate(
+ result.StackTrace,
+ MaxFailureStackChars)
+ };
+ state.failureDetails = candidate;
+
+ if (Encoding.UTF8.GetByteCount(JsonUtility.ToJson(state))
+ > MaxStateBytes - 4096)
+ {
+ state.failureDetails = details;
+ state.failuresTruncated = true;
+ }
+ }
+
+ private static void CollectFailureDetails(
+ TestRunState state,
+ ITestResultAdaptor result,
+ int depth)
+ {
+ if (state == null || result == null)
+ {
+ return;
+ }
+ if (depth > 256)
+ {
+ state.failuresTruncated = true;
+ return;
+ }
+
+ var resultState = result.ResultState ?? "";
+ var failed =
+ string.Equals(
+ result.TestStatus.ToString(),
+ "Failed",
+ StringComparison.Ordinal)
+ || resultState.StartsWith(
+ "Failed",
+ StringComparison.Ordinal);
+ var aggregateOnly =
+ result.HasChildren
+ && string.Equals(
+ resultState,
+ "Failed(Child)",
+ StringComparison.Ordinal)
+ && string.IsNullOrWhiteSpace(result.StackTrace);
+ if (failed && !aggregateOnly)
+ {
+ AddFailureDetail(state, result);
+ }
+
+ if (!result.HasChildren || result.Children == null)
+ {
+ return;
+ }
+
+ foreach (var child in result.Children)
+ {
+ CollectFailureDetails(state, child, depth + 1);
+ }
+ }
+
+ private static CommandResponse BuildStatusResponse(TestRunState state)
+ {
+ var result = new StatusResult
+ {
+ runId = state.runId,
+ phase = state.phase,
+ outcome = state.outcome,
+ mode = state.mode,
+ totalCount = state.totalCount,
+ completedCount = state.completedCount,
+ passedCount = state.passedCount,
+ failedCount = state.failedCount,
+ skippedCount = state.skippedCount,
+ inconclusiveCount = state.inconclusiveCount,
+ currentTest = state.currentTest,
+ resultState = state.resultState,
+ durationSeconds = state.durationSeconds,
+ requestedAtUtc = state.requestedAtUtc,
+ startedAtUtc = state.startedAtUtc,
+ finishedAtUtc = state.finishedAtUtc,
+ message = state.message,
+ failureDetails = CloneFailures(state.failureDetails),
+ failuresTruncated = state.failuresTruncated
+ };
+
+ var originalFailureCount = result.failureDetails.Length;
+ var json = JsonUtility.ToJson(result);
+ while (Encoding.UTF8.GetByteCount(json) > MaxStatusJsonBytes
+ && result.failureDetails.Length > 0)
+ {
+ Array.Resize(
+ ref result.failureDetails,
+ result.failureDetails.Length - 1);
+ result.failuresTruncated = true;
+ json = JsonUtility.ToJson(result);
+ }
+
+ result.returnedFailureCount = result.failureDetails.Length;
+ result.failuresTruncated =
+ result.failuresTruncated
+ || result.failureDetails.Length < originalFailureCount;
+ json = JsonUtility.ToJson(result);
+ if (Encoding.UTF8.GetByteCount(json) > MaxStatusJsonBytes)
+ {
+ result.currentTest = Truncate(result.currentTest, 128);
+ result.message = Truncate(result.message, 256);
+ result.failureDetails = Array.Empty();
+ result.returnedFailureCount = 0;
+ result.failuresTruncated = true;
+ json = JsonUtility.ToJson(result);
+ }
+ if (Encoding.UTF8.GetByteCount(json) > MaxStatusJsonBytes)
+ {
+ return SystemError(
+ "Unity test status exceeded the 16 KiB response limit and was withheld");
+ }
+
+ var summary = state.phase switch
+ {
+ CompletedPhase =>
+ $"Unity tests completed with {state.failedCount} failed",
+ InterruptedPhase =>
+ "Unity test evidence is interrupted",
+ RunningPhase =>
+ $"Unity tests running: {state.completedCount}/{state.totalCount}",
+ _ => "Unity tests requested"
+ };
+ return CommandResponseFactory.Ok(summary, json);
+ }
+
+ private static string DetermineOutcome(
+ TestRunState state,
+ ITestResultAdaptor rootResult)
+ {
+ var rootStatus = rootResult?.TestStatus.ToString() ?? "";
+ var rootResultState = rootResult?.ResultState ?? "";
+ if (string.Equals(
+ rootStatus,
+ "Failed",
+ StringComparison.Ordinal)
+ || rootResultState.StartsWith(
+ "Failed",
+ StringComparison.Ordinal))
+ {
+ return "failed";
+ }
+ if (string.Equals(
+ rootStatus,
+ "Inconclusive",
+ StringComparison.Ordinal))
+ {
+ return "inconclusive";
+ }
+ if (string.Equals(
+ rootStatus,
+ "Skipped",
+ StringComparison.Ordinal))
+ {
+ return "skipped";
+ }
+ if (!string.Equals(
+ rootStatus,
+ "Passed",
+ StringComparison.Ordinal))
+ {
+ return "unknown";
+ }
+
+ if (state.failedCount > 0)
+ {
+ return "failed";
+ }
+ if (state.inconclusiveCount > 0)
+ {
+ return "inconclusive";
+ }
+ if (state.totalCount == 0 && state.completedCount == 0)
+ {
+ return "no_tests";
+ }
+ if (state.passedCount > 0)
+ {
+ return "passed";
+ }
+ if (state.skippedCount > 0)
+ {
+ return "skipped";
+ }
+
+ return "unknown";
+ }
+
+ private static bool TryNormalizeRequest(
+ string mode,
+ string[] testNames,
+ out string normalizedMode,
+ out string[] normalizedTestNames,
+ out string error)
+ {
+ normalizedMode = (mode ?? "").Trim().ToLowerInvariant();
+ normalizedTestNames = Array.Empty();
+ error = "";
+ if (normalizedMode != "edit" && normalizedMode != "play")
+ {
+ error = "mode must be 'edit' or 'play'";
+ return false;
+ }
+ if (testNames == null)
+ {
+ return true;
+ }
+ if (testNames.Length == 0)
+ {
+ error = "testNames must be omitted or contain at least one exact test name";
+ return false;
+ }
+ if (testNames.Length > MaxTestNames)
+ {
+ error = $"testNames must not contain more than {MaxTestNames} entries";
+ return false;
+ }
+
+ var normalized = new List(testNames.Length);
+ var seen = new HashSet(StringComparer.Ordinal);
+ for (var index = 0; index < testNames.Length; index++)
+ {
+ var name = (testNames[index] ?? "").Trim();
+ if (string.IsNullOrEmpty(name))
+ {
+ error = $"testNames[{index}] must be non-empty";
+ return false;
+ }
+ if (name.Length > MaxTestNameChars)
+ {
+ error =
+ $"testNames[{index}] must not exceed {MaxTestNameChars} characters";
+ return false;
+ }
+ if (seen.Add(name))
+ {
+ normalized.Add(name);
+ }
+ }
+
+ normalizedTestNames = normalized.ToArray();
+ return true;
+ }
+
+ private static string GetAvailabilityErrorLocked()
+ {
+ if (!s_Initialized)
+ {
+ return "Unity test tracking is not initialized";
+ }
+ if (!string.IsNullOrEmpty(s_LoadError))
+ {
+ return s_LoadError;
+ }
+ if (s_PersistenceBlocked)
+ {
+ return "Unity test state persistence is unavailable; no new run can be accepted safely";
+ }
+ if (s_TestRunnerApi == null)
+ {
+ return "Unity Test Framework API is unavailable";
+ }
+
+ return "";
+ }
+
+ private static string NormalizeProtectedRunId(
+ CommandInvocation invocation)
+ {
+ return Guid.TryParse(
+ invocation?.protectedInvocationId?.Trim(),
+ out var parsed)
+ ? parsed.ToString("N")
+ : "";
+ }
+
+ private static bool HasDirtyLoadedScene()
+ {
+ for (var index = 0; index < SceneManager.sceneCount; index++)
+ {
+ if (SceneManager.GetSceneAt(index).isDirty)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasRunIdentityLocked(string runId)
+ {
+ if (RunIdsEqual(s_State?.runId, runId))
+ {
+ return true;
+ }
+
+ return File.Exists(GetHistoryPath(runId))
+ || File.Exists(GetSeenPath(runId));
+ }
+
+ private static bool TryArchiveTerminalCurrentLocked(out string error)
+ {
+ error = "";
+ if (s_State == null)
+ {
+ return true;
+ }
+ if (!IsTerminal(s_State.phase))
+ {
+ error = "the current run is not terminal";
+ return false;
+ }
+ if (!TryCreateSeenMarkerLocked(s_State.runId, out error))
+ {
+ return false;
+ }
+
+ try
+ {
+ Directory.CreateDirectory(s_HistoryDirectory);
+ var path = GetHistoryPath(s_State.runId);
+ if (File.Exists(path))
+ {
+ var existing = TryReadStateDocument(
+ path,
+ out var existingError);
+ if (existing == null
+ || !RunIdsEqual(existing.runId, s_State.runId)
+ || !IsTerminal(existing.phase))
+ {
+ error =
+ "an existing history record is unreadable or does not match its runId: "
+ + existingError;
+ return false;
+ }
+
+ return true;
+ }
+
+ WriteStateDurably(
+ path,
+ JsonUtility.ToJson(CloneState(s_State)));
+ var archived = TryReadStateDocument(
+ path,
+ out var archiveReadError);
+ if (archived == null
+ || !RunIdsEqual(archived.runId, s_State.runId)
+ || !IsTerminal(archived.phase))
+ {
+ error =
+ "the archived history record could not be verified: "
+ + archiveReadError;
+ return false;
+ }
+
+ return true;
+ }
+ catch (Exception e)
+ {
+ error = e.Message;
+ return false;
+ }
+ }
+
+ private static bool TryCreateSeenMarkerLocked(
+ string runId,
+ out string error)
+ {
+ error = "";
+ if (!Guid.TryParseExact(runId, "N", out _))
+ {
+ error = "runId is invalid";
+ return false;
+ }
+
+ try
+ {
+ Directory.CreateDirectory(s_SeenDirectory);
+ var path = GetSeenPath(runId);
+ if (File.Exists(path))
+ {
+ return true;
+ }
+
+ var bytes = new UTF8Encoding(false).GetBytes(runId);
+ using var stream = new FileStream(
+ path,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.Read);
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ return true;
+ }
+ catch (IOException)
+ {
+ if (File.Exists(GetSeenPath(runId)))
+ {
+ return true;
+ }
+
+ error = "runId marker could not be created";
+ return false;
+ }
+ catch (Exception e)
+ {
+ error = e.Message;
+ return false;
+ }
+ }
+
+ private static bool TryPruneHistoryLocked(out string error)
+ {
+ error = "";
+ try
+ {
+ if (!Directory.Exists(s_HistoryDirectory))
+ {
+ return true;
+ }
+
+ var paths = Directory
+ .GetFiles(s_HistoryDirectory, "*.json")
+ .OrderByDescending(File.GetLastWriteTimeUtc)
+ .ThenBy(path => path, StringComparer.Ordinal)
+ .ToArray();
+ var retainedRunIds = new HashSet(
+ StringComparer.OrdinalIgnoreCase);
+ if (!string.IsNullOrEmpty(s_State?.runId))
+ {
+ retainedRunIds.Add(s_State.runId);
+ }
+ for (var index = 0;
+ index < Math.Min(MaxHistoryRuns, paths.Length);
+ index++)
+ {
+ var retainedName =
+ Path.GetFileNameWithoutExtension(paths[index]);
+ if (Guid.TryParseExact(retainedName, "N", out _))
+ {
+ retainedRunIds.Add(retainedName);
+ }
+ }
+
+ for (var index = MaxHistoryRuns; index < paths.Length; index++)
+ {
+ var path = paths[index];
+ var state = TryReadStateDocument(path, out var readError);
+ if (state == null
+ || !IsTerminal(state.phase)
+ || !string.Equals(
+ Path.GetFileName(path),
+ state.runId + ".json",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ error =
+ $"history record '{Path.GetFileName(path)}' is unreadable or mismatched: {readError}";
+ return false;
+ }
+ if (!TryCreateSeenMarkerLocked(state.runId, out error))
+ {
+ return false;
+ }
+
+ var markerPath = GetSeenPath(state.runId);
+ if (File.Exists(markerPath))
+ {
+ File.Delete(markerPath);
+ }
+ File.Delete(path);
+ DeleteBackup(path);
+ DeleteStaleTemps(path);
+ }
+
+ if (Directory.Exists(s_SeenDirectory))
+ {
+ foreach (var markerPath in Directory.GetFiles(
+ s_SeenDirectory,
+ "*.seen"))
+ {
+ var markerRunId =
+ Path.GetFileNameWithoutExtension(markerPath);
+ if (!retainedRunIds.Contains(markerRunId))
+ {
+ File.Delete(markerPath);
+ }
+ }
+ }
+
+ return true;
+ }
+ catch (Exception e)
+ {
+ error = e.Message;
+ return false;
+ }
+ }
+
+ private static TestRunState LoadRetainedRunLocked(
+ string runId,
+ out string error)
+ {
+ error = "";
+ if (RunIdsEqual(s_State?.runId, runId))
+ {
+ return CloneState(s_State);
+ }
+
+ var path = GetHistoryPath(runId);
+ var hasRecord =
+ File.Exists(path)
+ || File.Exists(path + ".backup");
+ if (!hasRecord)
+ {
+ return null;
+ }
+
+ var state = LoadState(path, out error);
+ if (state == null)
+ {
+ if (string.IsNullOrEmpty(error))
+ {
+ error =
+ $"Unity test history for run '{runId}' disappeared while it was being read";
+ }
+ return null;
+ }
+ if (!RunIdsEqual(state.runId, runId)
+ || !IsTerminal(state.phase))
+ {
+ error =
+ $"Unity test history for run '{runId}' is mismatched or non-terminal";
+ return null;
+ }
+
+ return state;
+ }
+
+ private static string GetHistoryPath(string runId)
+ {
+ return Path.Combine(
+ s_HistoryDirectory ?? "",
+ (runId ?? "") + ".json");
+ }
+
+ private static string GetSeenPath(string runId)
+ {
+ return Path.Combine(
+ s_SeenDirectory ?? "",
+ (runId ?? "") + ".seen");
+ }
+
+ private static bool RunIdsEqual(string left, string right)
+ {
+ return string.Equals(
+ left ?? "",
+ right ?? "",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool TryPublishStateLocked(
+ TestRunState state,
+ out string error)
+ {
+ error = "";
+ var persisted = CloneState(state);
+ persisted.schemaVersion = SchemaVersion;
+ persisted.revision = Math.Max(
+ persisted.revision,
+ s_State?.revision ?? 0) + 1;
+ persisted.updatedAtUtc = UtcNow();
+
+ try
+ {
+ WriteStateDurably(s_StatePath, JsonUtility.ToJson(persisted));
+ s_State = CloneState(persisted);
+ s_PersistenceBlocked = false;
+ Monitor.PulseAll(s_Gate);
+ return true;
+ }
+ catch (Exception e)
+ {
+ error = e.Message;
+ return false;
+ }
+ }
+
+ private static void PublishVolatileLocked(TestRunState state)
+ {
+ state.updatedAtUtc = UtcNow();
+ s_State = CloneState(state);
+ Monitor.PulseAll(s_Gate);
+ }
+
+ private static void InterruptLocked(TestRunState basis, string message)
+ {
+ var interrupted = CloneState(basis ?? s_State ?? new TestRunState());
+ interrupted.phase = InterruptedPhase;
+ interrupted.outcome = "unknown";
+ interrupted.finishedAtUtc = UtcNow();
+ interrupted.currentTest = "";
+ interrupted.message = Truncate(
+ message,
+ MaxOperationMessageChars);
+ interrupted.updatedAtUtc = UtcNow();
+ interrupted.revision = Math.Max(
+ interrupted.revision,
+ s_State?.revision ?? 0) + 1;
+
+ try
+ {
+ WriteStateDurably(
+ s_StatePath,
+ JsonUtility.ToJson(interrupted));
+ s_PersistenceBlocked = false;
+ }
+ catch
+ {
+ s_PersistenceBlocked = true;
+ }
+
+ s_State = CloneState(interrupted);
+ Monitor.PulseAll(s_Gate);
+ }
+
+ private static CommandResponse OutcomeUnknown(
+ TestRunState state,
+ string summary)
+ {
+ return new CommandResponse
+ {
+ ok = false,
+ type = "outcome_unknown",
+ summary = summary ?? "Unity test outcome is unknown",
+ resultJson = JsonUtility.ToJson(new AcceptedResult
+ {
+ runId = state?.runId ?? "",
+ phase = state?.phase ?? InterruptedPhase,
+ mode = state?.mode ?? "",
+ accepted = false
+ })
+ };
+ }
+
+ private static CommandResponse SystemError(string summary)
+ {
+ return new CommandResponse
+ {
+ ok = false,
+ type = "system_error",
+ summary = summary ?? "Unity test state is unavailable",
+ resultJson = "{}"
+ };
+ }
+
+ private static bool IsActive(TestRunState state)
+ {
+ return state != null
+ && (
+ string.Equals(
+ state.phase,
+ RequestedPhase,
+ StringComparison.Ordinal)
+ || string.Equals(
+ state.phase,
+ RunningPhase,
+ StringComparison.Ordinal)
+ );
+ }
+
+ private static bool IsTerminal(string phase)
+ {
+ return string.Equals(
+ phase,
+ CompletedPhase,
+ StringComparison.Ordinal)
+ || string.Equals(
+ phase,
+ InterruptedPhase,
+ StringComparison.Ordinal);
+ }
+
+ private static TestRunState LoadState(
+ string path,
+ out string error)
+ {
+ error = "";
+ try
+ {
+ RecoverMissingCanonical(path);
+ if (!File.Exists(path))
+ {
+ DeleteStaleTemps(path);
+ return null;
+ }
+
+ var canonical = TryReadStateDocument(path, out var canonicalError);
+ if (canonical != null)
+ {
+ DeleteBackup(path);
+ DeleteStaleTemps(path);
+ return canonical;
+ }
+
+ var backupPath = path + ".backup";
+ var backup = File.Exists(backupPath)
+ ? TryReadStateDocument(backupPath, out _)
+ : null;
+ if (backup != null)
+ {
+ WriteStateDurably(path, JsonUtility.ToJson(backup));
+ DeleteBackup(path);
+ DeleteStaleTemps(path);
+ return backup;
+ }
+
+ error =
+ "Unity test state is unreadable; its previous outcome is unknown: "
+ + canonicalError;
+ return null;
+ }
+ catch (Exception e)
+ {
+ error =
+ $"Unity test state could not be read; its previous outcome is unknown: {e.Message}";
+ return null;
+ }
+ }
+
+ private static TestRunState TryReadStateDocument(
+ string path,
+ out string error)
+ {
+ error = "";
+ try
+ {
+ if (new FileInfo(path).Length > MaxStateBytes)
+ {
+ error = "state file exceeds the 64 KiB safety limit";
+ return null;
+ }
+
+ var json = File.ReadAllText(path, Encoding.UTF8);
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ error = "state file is empty";
+ return null;
+ }
+
+ var state = JsonUtility.FromJson(json);
+ if (!TryValidateStateDocument(json, state, out error))
+ {
+ return null;
+ }
+
+ return CloneState(state);
+ }
+ catch (Exception e)
+ {
+ error = e.Message;
+ return null;
+ }
+ }
+
+ private static bool TryValidateStateDocument(
+ string json,
+ TestRunState state,
+ out string error)
+ {
+ error = "";
+ var requiredFields = new[]
+ {
+ "schemaVersion",
+ "revision",
+ "runId",
+ "frameworkRunId",
+ "ownershipConfirmed",
+ "phase",
+ "outcome",
+ "mode",
+ "testNames",
+ "requestedAtUtc",
+ "startedAtUtc",
+ "finishedAtUtc",
+ "updatedAtUtc",
+ "totalCount",
+ "completedCount",
+ "passedCount",
+ "failedCount",
+ "skippedCount",
+ "inconclusiveCount",
+ "currentTest",
+ "resultState",
+ "durationSeconds",
+ "message",
+ "failureDetails",
+ "failuresTruncated"
+ };
+ foreach (var field in requiredFields)
+ {
+ if (!ContainsJsonProperty(json, field))
+ {
+ error = $"required field '{field}' is missing";
+ return false;
+ }
+ }
+
+ if (state == null || state.schemaVersion != SchemaVersion)
+ {
+ error = "schemaVersion is unsupported";
+ return false;
+ }
+ if (state.revision <= 0)
+ {
+ error = "revision must be positive";
+ return false;
+ }
+ if (!Guid.TryParseExact(state.runId, "N", out _))
+ {
+ error = "runId is invalid";
+ return false;
+ }
+ if (!string.IsNullOrEmpty(state.frameworkRunId)
+ && !Guid.TryParse(state.frameworkRunId, out _))
+ {
+ error = "frameworkRunId is invalid";
+ return false;
+ }
+ if (!string.Equals(state.phase, RequestedPhase, StringComparison.Ordinal)
+ && !string.Equals(state.phase, RunningPhase, StringComparison.Ordinal)
+ && !string.Equals(state.phase, CompletedPhase, StringComparison.Ordinal)
+ && !string.Equals(state.phase, InterruptedPhase, StringComparison.Ordinal))
+ {
+ error = $"phase '{state.phase}' is not recognized";
+ return false;
+ }
+ if (state.mode != "edit" && state.mode != "play")
+ {
+ error = "mode is invalid";
+ return false;
+ }
+ if ((state.outcome?.Length ?? 0) > MaxOutcomeChars
+ || (
+ !string.IsNullOrEmpty(state.outcome)
+ && state.outcome != "passed"
+ && state.outcome != "failed"
+ && state.outcome != "skipped"
+ && state.outcome != "inconclusive"
+ && state.outcome != "no_tests"
+ && state.outcome != "unknown"
+ ))
+ {
+ error = "outcome is invalid";
+ return false;
+ }
+ if (IsActive(state) && !string.IsNullOrEmpty(state.outcome))
+ {
+ error = "active state must not have a terminal outcome";
+ return false;
+ }
+ if (string.Equals(
+ state.phase,
+ CompletedPhase,
+ StringComparison.Ordinal)
+ && string.IsNullOrEmpty(state.outcome))
+ {
+ error = "completed state requires an outcome";
+ return false;
+ }
+ if (string.Equals(
+ state.phase,
+ InterruptedPhase,
+ StringComparison.Ordinal)
+ && !string.Equals(
+ state.outcome,
+ "unknown",
+ StringComparison.Ordinal))
+ {
+ error = "interrupted state requires outcome 'unknown'";
+ return false;
+ }
+ if (state.testNames == null
+ || state.testNames.Length > MaxTestNames
+ || state.testNames.Any(name =>
+ string.IsNullOrWhiteSpace(name)
+ || name.Length > MaxTestNameChars))
+ {
+ error = "testNames is invalid";
+ return false;
+ }
+ if (!IsIsoTimestamp(state.requestedAtUtc)
+ || !IsIsoTimestamp(state.updatedAtUtc)
+ || (
+ !string.IsNullOrEmpty(state.startedAtUtc)
+ && !IsIsoTimestamp(state.startedAtUtc)
+ )
+ || (
+ !string.IsNullOrEmpty(state.finishedAtUtc)
+ && !IsIsoTimestamp(state.finishedAtUtc)
+ ))
+ {
+ error = "one or more timestamps are invalid";
+ return false;
+ }
+ if (string.Equals(state.phase, RunningPhase, StringComparison.Ordinal)
+ && string.IsNullOrEmpty(state.frameworkRunId))
+ {
+ error = "running state requires frameworkRunId";
+ return false;
+ }
+ if (string.Equals(state.phase, RunningPhase, StringComparison.Ordinal)
+ && !state.ownershipConfirmed)
+ {
+ error = "running state requires durable ownership proof";
+ return false;
+ }
+ if (IsTerminal(state.phase)
+ && string.IsNullOrEmpty(state.finishedAtUtc))
+ {
+ error = "terminal state requires finishedAtUtc";
+ return false;
+ }
+ if (state.totalCount < 0
+ || state.completedCount < 0
+ || state.passedCount < 0
+ || state.failedCount < 0
+ || state.skippedCount < 0
+ || state.inconclusiveCount < 0
+ || state.durationSeconds < 0)
+ {
+ error = "counts and duration must be non-negative";
+ return false;
+ }
+ if (state.failureDetails == null
+ || state.failureDetails.Length > MaxFailureDetails
+ || state.failureDetails.Any(detail =>
+ detail == null
+ || (detail.testName?.Length ?? 0) > MaxTestNameChars
+ || (detail.resultState?.Length ?? 0) > MaxResultStateChars
+ || (detail.message?.Length ?? 0) > MaxFailureMessageChars
+ || (detail.stackTrace?.Length ?? 0) > MaxFailureStackChars))
+ {
+ error = "failureDetails is invalid";
+ return false;
+ }
+ if ((state.message?.Length ?? 0) > MaxOperationMessageChars
+ || (state.currentTest?.Length ?? 0) > MaxTestNameChars
+ || (state.resultState?.Length ?? 0) > MaxResultStateChars)
+ {
+ error = "bounded state strings exceed their limits";
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void WriteStateDurably(string path, string json)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new IOException("Unity test state path is unavailable");
+ }
+
+ var bytes = new UTF8Encoding(false).GetBytes(json ?? "");
+ if (bytes.Length > MaxStateBytes)
+ {
+ throw new IOException("Unity test state exceeds the 64 KiB safety limit");
+ }
+
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var tempPath = path + $".tmp.{Guid.NewGuid():N}";
+ try
+ {
+ using (var stream = new FileStream(
+ tempPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None))
+ {
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ }
+
+ if (File.Exists(path))
+ {
+ try
+ {
+ File.Replace(tempPath, path, null);
+ }
+ catch (PlatformNotSupportedException)
+ {
+ ReplaceWithBackup(path, tempPath);
+ }
+ catch (NotSupportedException)
+ {
+ ReplaceWithBackup(path, tempPath);
+ }
+ }
+ else
+ {
+ File.Move(tempPath, path);
+ }
+ }
+ finally
+ {
+ if (File.Exists(tempPath))
+ {
+ try
+ {
+ File.Delete(tempPath);
+ }
+ catch
+ {
+ // Canonical state is either durable or the caller fails
+ // closed. Startup removes leftover temp files.
+ }
+ }
+ }
+ }
+
+ private static void ReplaceWithBackup(
+ string path,
+ string tempPath)
+ {
+ var backupPath = path + ".backup";
+ if (File.Exists(backupPath))
+ {
+ File.Delete(backupPath);
+ }
+
+ File.Move(path, backupPath);
+ try
+ {
+ File.Move(tempPath, path);
+ }
+ catch
+ {
+ if (!File.Exists(path) && File.Exists(backupPath))
+ {
+ File.Move(backupPath, path);
+ }
+ throw;
+ }
+
+ try
+ {
+ File.Delete(backupPath);
+ }
+ catch
+ {
+ // The new canonical is complete. Startup discards the older
+ // backup after validating the canonical document.
+ }
+ }
+
+ private static void RecoverMissingCanonical(string path)
+ {
+ var backupPath = path + ".backup";
+ if (!File.Exists(path) && File.Exists(backupPath))
+ {
+ File.Move(backupPath, path);
+ }
+ }
+
+ private static void DeleteBackup(string path)
+ {
+ var backupPath = path + ".backup";
+ if (File.Exists(backupPath))
+ {
+ try
+ {
+ File.Delete(backupPath);
+ }
+ catch
+ {
+ // A valid canonical remains authoritative.
+ }
+ }
+ }
+
+ private static void DeleteStaleTemps(string path)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
+ {
+ return;
+ }
+
+ foreach (var stalePath in Directory.GetFiles(
+ directory,
+ Path.GetFileName(path) + ".tmp.*"))
+ {
+ try
+ {
+ File.Delete(stalePath);
+ }
+ catch
+ {
+ // Best effort only; temp files are never authoritative.
+ }
+ }
+ }
+
+ private static string ResolveStatePath()
+ {
+ return Path.GetFullPath(Path.Combine(
+ Application.dataPath,
+ "..",
+ "Library",
+ "CSharpConsole",
+ "TestRuns",
+ "v1",
+ "state.json"));
+ }
+
+ private static TestRunState CloneState(TestRunState source)
+ {
+ if (source == null)
+ {
+ return null;
+ }
+
+ return new TestRunState
+ {
+ schemaVersion = source.schemaVersion,
+ revision = source.revision,
+ runId = source.runId ?? "",
+ frameworkRunId = source.frameworkRunId ?? "",
+ ownershipConfirmed = source.ownershipConfirmed,
+ phase = source.phase ?? "",
+ outcome = source.outcome ?? "",
+ mode = source.mode ?? "",
+ testNames = source.testNames == null
+ ? Array.Empty()
+ : (string[])source.testNames.Clone(),
+ requestedAtUtc = source.requestedAtUtc ?? "",
+ startedAtUtc = source.startedAtUtc ?? "",
+ finishedAtUtc = source.finishedAtUtc ?? "",
+ updatedAtUtc = source.updatedAtUtc ?? "",
+ totalCount = source.totalCount,
+ completedCount = source.completedCount,
+ passedCount = source.passedCount,
+ failedCount = source.failedCount,
+ skippedCount = source.skippedCount,
+ inconclusiveCount = source.inconclusiveCount,
+ currentTest = source.currentTest ?? "",
+ resultState = source.resultState ?? "",
+ durationSeconds = source.durationSeconds,
+ message = source.message ?? "",
+ failureDetails = CloneFailures(source.failureDetails),
+ failuresTruncated = source.failuresTruncated
+ };
+ }
+
+ private static FailureDetail[] CloneFailures(
+ FailureDetail[] failures)
+ {
+ if (failures == null || failures.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ var clone = new FailureDetail[failures.Length];
+ for (var index = 0; index < failures.Length; index++)
+ {
+ var source = failures[index] ?? new FailureDetail();
+ clone[index] = new FailureDetail
+ {
+ testName = source.testName ?? "",
+ resultState = source.resultState ?? "",
+ message = source.message ?? "",
+ stackTrace = source.stackTrace ?? ""
+ };
+ }
+
+ return clone;
+ }
+
+ private static bool ContainsJsonProperty(
+ string json,
+ string propertyName)
+ {
+ var marker = $"\"{propertyName}\"";
+ var index = 0;
+ while ((index = json.IndexOf(
+ marker,
+ index,
+ StringComparison.Ordinal)) >= 0)
+ {
+ var cursor = index + marker.Length;
+ while (cursor < json.Length && char.IsWhiteSpace(json[cursor]))
+ {
+ cursor++;
+ }
+ if (cursor < json.Length && json[cursor] == ':')
+ {
+ return true;
+ }
+ index = cursor;
+ }
+
+ return false;
+ }
+
+ private static bool IsIsoTimestamp(string value)
+ {
+ return DateTimeOffset.TryParseExact(
+ value,
+ "O",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind,
+ out _);
+ }
+
+ private static string UtcNow()
+ {
+ return DateTime.UtcNow.ToString(
+ "O",
+ CultureInfo.InvariantCulture);
+ }
+
+ private static string Truncate(string value, int maxChars)
+ {
+ var text = value ?? "";
+ if (text.Length <= maxChars)
+ {
+ return text;
+ }
+
+ var length = maxChars;
+ if (length > 0
+ && length < text.Length
+ && char.IsHighSurrogate(text[length - 1])
+ && char.IsLowSurrogate(text[length]))
+ {
+ length--;
+ }
+ return text.Substring(0, Math.Max(0, length));
+ }
+ }
+}
diff --git a/Editor/TestIntegration/UnityTestRunWorkflow.cs.meta b/Editor/TestIntegration/UnityTestRunWorkflow.cs.meta
new file mode 100644
index 0000000..7ce931e
--- /dev/null
+++ b/Editor/TestIntegration/UnityTestRunWorkflow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8e9f25b86ad34c5f84432637d95b7741
diff --git a/Editor/TestIntegration/Zh1Zh1.CSharpConsole.Editor.TestIntegration.asmdef b/Editor/TestIntegration/Zh1Zh1.CSharpConsole.Editor.TestIntegration.asmdef
new file mode 100644
index 0000000..bd80705
--- /dev/null
+++ b/Editor/TestIntegration/Zh1Zh1.CSharpConsole.Editor.TestIntegration.asmdef
@@ -0,0 +1,19 @@
+{
+ "name": "Zh1Zh1.CSharpConsole.Editor.TestIntegration",
+ "rootNamespace": "Zh1Zh1.CSharpConsole",
+ "references": [
+ "Zh1Zh1.CSharpConsole.Runtime",
+ "UnityEditor.TestRunner"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": false,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Editor/TestIntegration/Zh1Zh1.CSharpConsole.Editor.TestIntegration.asmdef.meta b/Editor/TestIntegration/Zh1Zh1.CSharpConsole.Editor.TestIntegration.asmdef.meta
new file mode 100644
index 0000000..2693bd7
--- /dev/null
+++ b/Editor/TestIntegration/Zh1Zh1.CSharpConsole.Editor.TestIntegration.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 810b3de60f9d4ec484c8a7233d021ec0
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/README.md b/README.md
index c688a3f..c7454d7 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
**Interactive C# REPL for Unity — powered by Roslyn**
[](LICENSE)
-[](https://unity.com/)
+[](https://unity.com/)
[](https://claude.ai/code)
[](package.json)
@@ -206,7 +206,7 @@ Tab completion works for both command names and argument names.
## 📋 Built-in Actions
-60 built-in commands across 13 namespaces, covering editor control, scene manipulation, asset management, and more.
+62 built-in commands across 14 namespaces, covering editor control, scene manipulation, asset management, test runs, and more.
| Namespace | Action | Description |
|-----------|--------|-------------|
@@ -245,6 +245,8 @@ Tab completion works for both command names and argument names.
| | `console.clear` | Clear the editor console |
| | `console.mark` | Write a searchable marker into the editor log and return the log file path |
| | `console.get` | Read bounded Editor log output, optionally after a console marker |
+| **tests** | `run` | Start one protected, direct-only Edit Mode or Play Mode test run |
+| | `status` | Read or briefly wait for a retained test run by `runId` |
| **project** | `scene.list` | List all scenes in the project |
| | `scene.open` | Open a scene by path |
| | `scene.save` | Save the current scene |
@@ -274,7 +276,7 @@ See the full guide: **[Extending Commands](Docs~/ExtendingCommands.md)**
| Dependency | Version |
|------------|---------|
-| Unity | 2020.3+ |
+| Unity | 2022 |
| Python | 3.7+ (on system `PATH`) |
| Windows Terminal | Optional (falls back to Python directly) |
diff --git a/README_zh.md b/README_zh.md
index b72ddb7..3648582 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -5,7 +5,7 @@
**Unity 交互式 C# REPL — 基于 Roslyn**
[](LICENSE)
-[](https://unity.com/)
+[](https://unity.com/)
[](https://claude.ai/code)
[](package.json)
@@ -206,7 +206,7 @@ Tab 补全支持命令名和参数名。
## 📋 内置 Action
-13 个命名空间,60 个内置命令,覆盖编辑器控制、场景操作、资产管理等常见场景。
+14 个命名空间,62 个内置命令,覆盖编辑器控制、场景操作、资产管理、测试运行等常见场景。
| 命名空间 | Action | 说明 |
|----------|--------|------|
@@ -245,6 +245,8 @@ Tab 补全支持命令名和参数名。
| | `console.clear` | 清空编辑器控制台 |
| | `console.mark` | 向编辑器日志写入可搜索标记并返回日志文件路径 |
| | `console.get` | 读取有界的编辑器日志,可选从控制台标记之后开始 |
+| **tests** | `run` | 通过受保护的直连请求启动一次 Edit Mode 或 Play Mode 测试 |
+| | `status` | 按 `runId` 读取或短暂等待一次保留的测试运行 |
| **project** | `scene.list` | 列出项目中所有场景 |
| | `scene.open` | 通过路径打开场景 |
| | `scene.save` | 保存当前场景 |
@@ -274,7 +276,7 @@ Tab 补全支持命令名和参数名。
| 依赖 | 版本 |
|------|------|
-| Unity | 2020.3+ |
+| Unity | 2022 |
| Python | 3.7+(系统 PATH 可访问) |
| Windows Terminal | 可选(不可用时回退到直接启动 Python) |
diff --git a/Runtime/Service/Commands/Core/CommandDescriptor.cs b/Runtime/Service/Commands/Core/CommandDescriptor.cs
index b460d75..b2d60bf 100644
--- a/Runtime/Service/Commands/Core/CommandDescriptor.cs
+++ b/Runtime/Service/Commands/Core/CommandDescriptor.cs
@@ -11,6 +11,8 @@ internal sealed class CommandDescriptor
public string summary = "";
public bool editorOnly;
public bool runOnMainThread;
+ public bool requiresProtectedInvocation;
+ public bool allowInBatch = true;
public string declaringType = "";
public string methodName = "";
public string commandType = "builtin";
diff --git a/Runtime/Service/Commands/Core/CommandDispatchContext.cs b/Runtime/Service/Commands/Core/CommandDispatchContext.cs
new file mode 100644
index 0000000..03fd240
--- /dev/null
+++ b/Runtime/Service/Commands/Core/CommandDispatchContext.cs
@@ -0,0 +1,38 @@
+using System;
+
+namespace Zh1Zh1.CSharpConsole.Service.Commands.Core
+{
+ internal sealed class CommandDispatchContext
+ {
+ internal bool IsBatch { get; private set; }
+ internal string ProtectedInvocationId { get; private set; } = "";
+
+ internal static CommandDispatchContext Direct(string protectedInvocationId)
+ {
+ var normalized = Guid.TryParse(
+ protectedInvocationId?.Trim(),
+ out var parsed)
+ ? parsed.ToString("D")
+ : "";
+ return new CommandDispatchContext
+ {
+ IsBatch = false,
+ ProtectedInvocationId = normalized
+ };
+ }
+
+ internal static CommandDispatchContext Batch()
+ {
+ return new CommandDispatchContext
+ {
+ IsBatch = true,
+ ProtectedInvocationId = ""
+ };
+ }
+
+ internal static CommandDispatchContext Unprotected()
+ {
+ return new CommandDispatchContext();
+ }
+ }
+}
diff --git a/Runtime/Service/Commands/Core/CommandDispatchContext.cs.meta b/Runtime/Service/Commands/Core/CommandDispatchContext.cs.meta
new file mode 100644
index 0000000..03476f3
--- /dev/null
+++ b/Runtime/Service/Commands/Core/CommandDispatchContext.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ce24bd59095d40f5b99a454c2ad1c277
diff --git a/Runtime/Service/Commands/Core/CommandDispatcher.cs b/Runtime/Service/Commands/Core/CommandDispatcher.cs
index 6d36451..804d182 100644
--- a/Runtime/Service/Commands/Core/CommandDispatcher.cs
+++ b/Runtime/Service/Commands/Core/CommandDispatcher.cs
@@ -21,10 +21,16 @@ internal CommandDispatcher(Func, CommandResponse> mainThre
_mainThreadRunner = mainThreadRunner;
}
- public CommandResponse Dispatch(CommandRegistry registry, CommandInvocation invocation)
+ public CommandResponse Dispatch(
+ CommandRegistry registry,
+ CommandInvocation invocation,
+ CommandDispatchContext dispatchContext = null)
{
registry ??= new CommandRegistry();
invocation ??= new CommandInvocation();
+ dispatchContext ??= CommandDispatchContext.Unprotected();
+ invocation.protectedInvocationId =
+ dispatchContext.ProtectedInvocationId ?? "";
if (!registry.TryGet(invocation.commandNamespace, invocation.action, out var route))
{
@@ -38,6 +44,24 @@ public CommandResponse Dispatch(CommandRegistry registry, CommandInvocation invo
}
#endif
+ if (route.descriptor != null
+ && !route.descriptor.allowInBatch
+ && dispatchContext.IsBatch)
+ {
+ return CommandResponseFactory.ValidationError(
+ invocation,
+ $"Command cannot run in a batch: {invocation.commandNamespace}/{invocation.action}");
+ }
+
+ if (route.descriptor != null
+ && route.descriptor.requiresProtectedInvocation
+ && string.IsNullOrEmpty(invocation.protectedInvocationId))
+ {
+ return CommandResponseFactory.ValidationError(
+ invocation,
+ $"Command requires a protected direct invocation: {invocation.commandNamespace}/{invocation.action}");
+ }
+
try
{
if (route.descriptor != null && route.descriptor.runOnMainThread && _mainThreadRunner != null)
diff --git a/Runtime/Service/Commands/Core/CommandInvocation.cs b/Runtime/Service/Commands/Core/CommandInvocation.cs
index 88fea06..22bf3d2 100644
--- a/Runtime/Service/Commands/Core/CommandInvocation.cs
+++ b/Runtime/Service/Commands/Core/CommandInvocation.cs
@@ -9,6 +9,7 @@ public sealed class CommandInvocation
public string action = "";
public string sessionId = "";
public string argsJson = "{}";
+ public string protectedInvocationId { get; internal set; } = "";
public static CommandInvocation FromRequest(CommandRequest request)
{
diff --git a/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs b/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs
index 987ebe5..f1f79e2 100644
--- a/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs
+++ b/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs
@@ -120,7 +120,9 @@ private static BatchResponse ExecuteBatch(BatchRequest request)
}
};
- var response = CommandRouter.Dispatch(commandRequest)
+ var response = CommandRouter.Dispatch(
+ commandRequest,
+ CommandDispatchContext.Batch())
?? CommandResponseFactory.SystemError(
CommandInvocation.FromRequest(commandRequest),
"Failed to process command");
diff --git a/Runtime/Service/Commands/Routing/CommandActionAttribute.cs b/Runtime/Service/Commands/Routing/CommandActionAttribute.cs
index 29d5c91..e331bf4 100644
--- a/Runtime/Service/Commands/Routing/CommandActionAttribute.cs
+++ b/Runtime/Service/Commands/Routing/CommandActionAttribute.cs
@@ -10,6 +10,8 @@ public sealed class CommandActionAttribute : Attribute
public string action { get; }
public bool editorOnly { get; }
public bool runOnMainThread { get; }
+ public bool requiresProtectedInvocation { get; }
+ public bool allowInBatch { get; }
public string summary { get; }
public CommandType commandType { get; }
@@ -20,12 +22,16 @@ public CommandActionAttribute(
bool editorOnly = false,
bool runOnMainThread = true,
string summary = "",
- CommandType commandType = CommandType.Builtin)
+ CommandType commandType = CommandType.Builtin,
+ bool requiresProtectedInvocation = false,
+ bool allowInBatch = true)
{
this.commandNamespace = commandNamespace ?? "";
this.action = action ?? "";
this.editorOnly = editorOnly;
this.runOnMainThread = runOnMainThread;
+ this.requiresProtectedInvocation = requiresProtectedInvocation;
+ this.allowInBatch = allowInBatch;
this.summary = summary ?? "";
this.commandType = commandType;
}
diff --git a/Runtime/Service/Commands/Routing/CommandEndpointHandler.cs b/Runtime/Service/Commands/Routing/CommandEndpointHandler.cs
index 2266618..0d4bd3d 100644
--- a/Runtime/Service/Commands/Routing/CommandEndpointHandler.cs
+++ b/Runtime/Service/Commands/Routing/CommandEndpointHandler.cs
@@ -45,9 +45,18 @@ public async Task Handle(HttpListenerContext context)
else
{
var request = JsonUtility.FromJson(message);
+ var claim =
+ ConsoleHttpServiceDependencies.GetInvocationClaim(context);
+ var dispatchContext = CommandDispatchContext.Direct(
+ claim?.Disposition == InvocationClaimDisposition.Execute
+ ? claim.InvocationId
+ : "");
response = request == null
? CommandResponseFactory.InvalidRequestBody()
- : (CommandRouter.Dispatch(request) ?? CommandResponseFactory.SystemError(CommandInvocation.FromRequest(request), "Failed to process command"));
+ : (CommandRouter.Dispatch(request, dispatchContext)
+ ?? CommandResponseFactory.SystemError(
+ CommandInvocation.FromRequest(request),
+ "Failed to process command"));
}
}
catch (Exception e)
diff --git a/Runtime/Service/Commands/Routing/CommandRouter.cs b/Runtime/Service/Commands/Routing/CommandRouter.cs
index b90d976..888277f 100644
--- a/Runtime/Service/Commands/Routing/CommandRouter.cs
+++ b/Runtime/Service/Commands/Routing/CommandRouter.cs
@@ -24,9 +24,13 @@ private CommandRouter(Func, CommandResponse> mainThreadRun
m_Dispatcher = new CommandDispatcher(mainThreadRunner);
}
- internal static CommandResponse Dispatch(CommandRequest request)
+ internal static CommandResponse Dispatch(
+ CommandRequest request,
+ CommandDispatchContext dispatchContext = null)
{
- return GetOrCreate().DispatchInternal(request ?? new CommandRequest());
+ return GetOrCreate().DispatchInternal(
+ request ?? new CommandRequest(),
+ dispatchContext ?? CommandDispatchContext.Unprotected());
}
internal static CommandDescriptor[] ListDescriptors()
@@ -174,10 +178,15 @@ private void RegisterAttributedHandlersFromType(Type ownerType)
}
}
- private CommandResponse DispatchInternal(CommandRequest request)
+ private CommandResponse DispatchInternal(
+ CommandRequest request,
+ CommandDispatchContext dispatchContext)
{
var invocation = CommandInvocation.FromRequest(request);
- return m_Dispatcher.Dispatch(m_Registry, invocation);
+ return m_Dispatcher.Dispatch(
+ m_Registry,
+ invocation,
+ dispatchContext);
}
private static CommandDescriptor BuildDescriptor(Type ownerType, MethodInfo method, CommandActionAttribute attribute, CommandArgumentDescriptor[] arguments)
@@ -190,6 +199,9 @@ private static CommandDescriptor BuildDescriptor(Type ownerType, MethodInfo meth
summary = attribute.summary ?? "",
editorOnly = attribute.editorOnly,
runOnMainThread = attribute.runOnMainThread,
+ requiresProtectedInvocation =
+ attribute.requiresProtectedInvocation,
+ allowInBatch = attribute.allowInBatch,
declaringType = ownerType?.FullName ?? "",
methodName = method?.Name ?? "",
commandType = attribute.commandType == CommandType.Custom ? "custom" : "builtin",
diff --git a/Runtime/Service/ConsoleHttpService.cs b/Runtime/Service/ConsoleHttpService.cs
index ada0662..6130c23 100644
--- a/Runtime/Service/ConsoleHttpService.cs
+++ b/Runtime/Service/ConsoleHttpService.cs
@@ -771,7 +771,10 @@ internal static HealthResponse BuildHealthResponseSnapshot()
"invocation_headers",
"invocation_receipts",
"invocation_status",
- "at_most_once"
+ "at_most_once",
+#if UNITY_EDITOR
+ "test_runs_v1"
+#endif
},
journalWritable = s_InvocationCoordinator.JournalWritable,
dedupeWindowSeconds = InvocationCoordinator.DedupeWindowSeconds,
diff --git a/package.json b/package.json
index 6f723fb..530b403 100644
--- a/package.json
+++ b/package.json
@@ -20,5 +20,8 @@
],
"bugs": {
"url": "https://github.com/zh1zh1/com.zh1zh1.csharpconsole/issues"
+ },
+ "dependencies": {
+ "com.unity.test-framework": "1.1.33"
}
}