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://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -[![Unity](https://img.shields.io/badge/Unity-2020.3%2B-black.svg?logo=unity)](https://unity.com/) +[![Unity](https://img.shields.io/badge/Unity-2022-black.svg?logo=unity)](https://unity.com/) [![Claude Code](https://img.shields.io/badge/Claude_Code-blueviolet.svg?logo=anthropic)](https://claude.ai/code) [![UPM](https://img.shields.io/badge/UPM-Package-brightgreen.svg)](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://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -[![Unity](https://img.shields.io/badge/Unity-2020.3%2B-black.svg?logo=unity)](https://unity.com/) +[![Unity](https://img.shields.io/badge/Unity-2022-black.svg?logo=unity)](https://unity.com/) [![Claude Code](https://img.shields.io/badge/Claude_Code-blueviolet.svg?logo=anthropic)](https://claude.ai/code) [![UPM](https://img.shields.io/badge/UPM-Package-brightgreen.svg)](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" } }