Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
@@ -0,0 +1,175 @@
using System.Threading.Tasks;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Security;
namespace Unity.Pipeline.Tests.Editor.ServerLifecycle
{
class DialogGateTests
{
sealed class DialogGateTestServer : EditorPipelineServer
{
/// <summary>true (the default) simulates a settled server; false the post-cold-start
/// settle window, for the settling-vs-dialog priority tests below.</summary>
public bool Settled { get; set; } = true;
protected override bool WritesDescriptor => false;
protected override (int basePort, int maxPort) GetPortRange() => (7850, 7899);
protected override string GetToken() => SecurityTokenManager.GetOrCreateToken();
protected override bool IsSettled => Settled;
// WritesDescriptor is false, so the instance descriptor is never created; this
// server is genuinely running once Start() returns, so /api/status's ordinary case
// should still be "ready", not "error" — needed for the ApiStatus_* tests below.
protected override bool HasInstanceDescriptor => true;
}
DialogGateTestServer m_Server;
Unity.Pipeline.Tests.Runtime.PipelineClient m_Client;
[SetUp]
public void SetUp()
{
m_Server = new DialogGateTestServer();
m_Server.Start();
m_Client = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
}
[TearDown]
public void TearDown()
{
m_Client?.Dispose();
m_Server?.Stop();
}
[Test]
public async Task ApiExec_MainThreadCommand_WhileDialogOpen_Returns503BusyEnvelopeWithDialogInfo()
{
m_Server.Dialogs.OnShown(7, "native", "Save changes?", "msg", new[] { "Save", "Cancel" }, "Warning", System.DateTime.UtcNow);
var response = await m_Client.ExecuteCommandAsync("log_editor", new { message = "should not run while a dialog is open" });
Assert.AreEqual(503, response.StatusCode);
var json = response.JsonResponse;
Assert.IsFalse(json["success"].ToObject<bool>());
Assert.AreEqual("busy", json["status"]?.ToString());
Assert.AreEqual("blocked_by_dialog", json["busyReason"]?.ToString());
Assert.IsTrue(json["retryable"].ToObject<bool>());
var dialogs = (JArray)json["dialogs"];
Assert.AreEqual(7, dialogs[0]["id"].ToObject<int>());
}
// EditorDialogStateMirror.OnDialogWillShow always calls SnapshotStatusForDialogGate()
// immediately before Dialogs.OnShown(...), so in a live Editor a snapshot exists by the
// time any request can observe a dialog as open — editor_status then gets served from
// that snapshot (200) instead of the generic busy envelope. The no-snapshot case (503)
// can only occur before the mirror has ever snapshotted a dialog this session; here it's
// reproduced by driving Dialogs.OnShown directly, bypassing the mirror.
[TestCase(false, 503, TestName = "ApiExec_EditorStatus_WhileDialogOpen_NoSnapshotTaken_Returns503BusyEnvelope")]
[TestCase(true, 200, TestName = "ApiExec_EditorStatus_WhileDialogOpen_SnapshotTakenFirst_Returns200WithBlockedStatus")]
public async Task ApiExec_EditorStatus_WhileDialogOpen(bool snapshotTakenFirst, int expectedStatusCode)
{
if (snapshotTakenFirst)
m_Server.SnapshotStatusForDialogGate();
m_Server.Dialogs.OnShown(7, "native", "Title", "msg", new[] { "OK" }, "Info", System.DateTime.UtcNow);
var response = await m_Client.ExecuteCommandAsync("editor_status", new { });
Assert.AreEqual(expectedStatusCode, response.StatusCode);
if (snapshotTakenFirst)
{
Assert.AreEqual("blocked_by_dialog", response.JsonResponse["result"]?["status"]?.ToString());
Assert.AreEqual(7, response.JsonResponse["result"]?["dialog"]?["id"]?.ToObject<int>());
}
}
[Test]
public async Task ApiExec_BackgroundCommand_WhileDialogOpen_ExecutesNormally()
{
m_Server.Dialogs.OnShown(7, "native", "Title", "msg", new[] { "OK" }, "Info", System.DateTime.UtcNow);
var response = await m_Client.ExecuteCommandAsync("recompile_status", new { });
Assert.IsTrue(response.IsSuccess,
"MainThreadRequired=false commands must stay servable while a dialog is open");
}
[Test]
public async Task ApiExec_AfterDialogDismissed_PreviouslyGatedCommandSucceeds()
{
m_Server.Dialogs.OnShown(7, "native", "Title", "msg", new[] { "OK" }, "Info", System.DateTime.UtcNow);
m_Server.Dialogs.OnDismissed(7, System.DateTime.UtcNow);
var response = await m_Client.ExecuteCommandAsync("log_editor", new { message = "runs after dismissal" });
Assert.IsTrue(response.IsSuccess);
}
[Test]
public async Task ApiStatus_WhileDialogOpen_ReportsBlockedByDialogWithDetails()
{
// /api/status is the cheapest, most-likely-to-be-polled-first probe — it must not
// report "ready" while every MainThreadRequired command is actually gated.
m_Server.Dialogs.OnShown(7, "native", "Save changes?", "msg", new[] { "Save", "Cancel" }, "Warning", System.DateTime.UtcNow);
var http = await m_Client.GetHttpAsync("/api/status");
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
Assert.AreEqual("blocked_by_dialog", json["status"]?.ToString());
Assert.AreEqual(7, json["dialog"]?["id"]?.ToObject<int>());
Assert.AreEqual("Save changes?", json["dialog"]?["title"]?.ToString());
Assert.AreEqual("Warning", json["dialog"]?["level"]?.ToString());
}
[Test]
public async Task ApiStatus_WhileSettlingAndDialogOpen_ReportsBlockedByDialogNotSettling()
{
// A dialog blocks the main thread regardless of settle state, and needs a human —
// settling resolves on its own. blocked_by_dialog must win over settling.
m_Server.Settled = false;
m_Server.Dialogs.OnShown(7, "native", "Title", "msg", new[] { "OK" }, "Info", System.DateTime.UtcNow);
var http = await m_Client.GetHttpAsync("/api/status");
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
Assert.AreEqual("blocked_by_dialog", json["status"]?.ToString());
Assert.AreEqual(7, json["dialog"]?["id"]?.ToObject<int>());
}
[Test]
public async Task ApiExec_MainThreadCommand_WhileSettlingAndDialogOpen_ReportsBlockedByDialogNotSettling()
{
m_Server.Settled = false;
m_Server.Dialogs.OnShown(7, "native", "Title", "msg", new[] { "OK" }, "Info", System.DateTime.UtcNow);
var response = await m_Client.ExecuteCommandAsync("log_editor", new { message = "should be gated by the dialog, not settling" });
Assert.AreEqual(503, response.StatusCode);
Assert.AreEqual("blocked_by_dialog", response.JsonResponse["busyReason"]?.ToString());
}
[Test]
public async Task ApiStatus_NoDialogOpen_ReportsReadyWithNoDialogKey()
{
var http = await m_Client.GetHttpAsync("/api/status");
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
Assert.AreEqual("ready", json["status"]?.ToString());
Assert.IsNull(json["dialog"], "The common case must not carry a \"dialog\" key at all, not even null.");
}
[Test]
public async Task ApiStatus_AfterDialogDismissed_ReportsReadyAgain()
{
m_Server.Dialogs.OnShown(7, "native", "Title", "msg", new[] { "OK" }, "Info", System.DateTime.UtcNow);
m_Server.Dialogs.OnDismissed(7, System.DateTime.UtcNow);
var http = await m_Client.GetHttpAsync("/api/status");
var json = JObject.Parse(await http.Content.ReadAsStringAsync());
Assert.AreEqual("ready", json["status"]?.ToString());
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 2ca2f891dc6641478f17125ef1f1327b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1
@@ -0,0 +1,86 @@
#if UNITY_6000_7_OR_NEWER
using System;
using System.Reflection;
using NUnit.Framework;
using Unity.Pipeline.Editor;
using UnityEditor;
namespace Unity.Pipeline.Tests.Editor.ServerLifecycle
{
/// <summary>
/// Tests that <see cref="EditorDialogStateMirror"/> resubscribes to
/// <see cref="EditorDialogEvents"/> after its static subscription is lost (e.g. domain
/// reload), by manually stripping and rearming the event delegates.
///
/// Marked [Explicit]: raises a "live" dialog-will-show event directly on the actual live
/// pipeline server (<see cref="PipelineServerStartup.Server"/>), not an isolated test server - the
/// mirror only targets the live server by design. Conflicts with the live editor server. Run
/// deliberately.
/// </summary>
[Explicit("Raises a dialog on the live pipeline server's dialog gate; conflicts with the live editor server. Run deliberately.")]
[Category("ServerLifecycle")]
public class DialogStateMirrorResubscribeTests
{
[TearDown]
public void TearDown()
{
Rearm();
}
[Test]
public void MirrorSurvivesLostSubscription_WhenRearmed()
{
Strip();
RaiseDialogWillShow("dead");
var server = PipelineServerStartup.Server;
Assume.That(server, Is.Not.Null, "PRECONDITION: the live pipeline server must be running for this test");
Assert.IsFalse(server.Dialogs.IsDialogOpen,
"Stripping the subscription should stop the mirror from observing new dialogs (sanity check on the strip step)");
Rearm();
var liveId = RaiseDialogWillShow("live");
try
{
Assert.IsTrue(server.Dialogs.IsDialogOpen,
"Re-arming the subscription must restore mirroring");
}
finally
{
// Always dismiss the live dialog, even if the assertion above fails - otherwise
// the live server's dialog busy-gate stays tripped for the rest of the Editor
// session (every MainThreadRequired command 503s forever), including poisoning
// the next run of this same test.
RaiseDialogDismissed(liveId);
}
}
static void Strip()
{
var willShowField = typeof(EditorDialogEvents).GetField("dialogWillShow", BindingFlags.NonPublic | BindingFlags.Static);
var dismissedField = typeof(EditorDialogEvents).GetField("dialogDismissed", BindingFlags.NonPublic | BindingFlags.Static);
willShowField.SetValue(null, null);
dismissedField.SetValue(null, null);
}
static void Rearm()
{
var ctor = typeof(EditorDialogStateMirror).TypeInitializer;
ctor.Invoke(null, null);
}
static int RaiseDialogWillShow(string title)
{
var method = typeof(EditorDialogEvents).GetMethod("Internal_DialogWillShow", BindingFlags.NonPublic | BindingFlags.Static);
return (int)method.Invoke(null, new object[] { title, title, new[] { "OK" }, (int)DialogIconType.Info });
}
static void RaiseDialogDismissed(int id)
{
var method = typeof(EditorDialogEvents).GetMethod("Internal_DialogDismissed", BindingFlags.NonPublic | BindingFlags.Static);
method.Invoke(null, new object[] { id });
}
}
}
#endif
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: b612fd87814b401586f653f4b7fbc910
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1
@@ -0,0 +1,194 @@
using System.Collections;
using NUnit.Framework;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Tests.Runtime;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor.ServerLifecyle
{
/// <summary>
/// Tests for simultaneous operation of Editor and Runtime servers.
/// Validates port range separation and dual server functionality.
///
/// Marked [Explicit]: starts a real EditorPipelineServer + RuntimePipelineDriver, which
/// conflict with the live editor server present in every editor session. Excluded from the
/// normal/dogfood run; run deliberately (e.g. Test Runner window). See [[GlobalServerStateGuard]].
/// </summary>
[Explicit("Server-lifecycle test; conflicts with the live editor server. Run deliberately.")]
[Category("ServerLifecycle")]
class DualServerTests
{
private GameObject m_TestGameObject;
private RuntimePipelineDriver m_RuntimeManager;
private Unity.Pipeline.Config.RuntimePipelineConfig m_RuntimeConfig;
private EditorPipelineServer m_EditorServer;
[SetUp]
public void SetUp()
{
// RuntimePipelineDriver + EditorPipelineServer here mutate global state shared with
// the live editor server (descriptor file, command discovery).
GlobalServerStateGuard.Capture();
// Create test GameObject with RuntimePipelineDriver
m_RuntimeConfig = ScriptableObject.CreateInstance<Unity.Pipeline.Config.RuntimePipelineConfig>();
m_RuntimeConfig.enableInBuilds = true;
m_TestGameObject = new GameObject("TestRuntimePipelineDriver");
m_RuntimeManager = m_TestGameObject.AddComponent<RuntimePipelineDriver>();
m_RuntimeManager.Initialize(m_RuntimeConfig, null);
// Create Editor server for dual-server tests
m_EditorServer = new EditorPipelineServer();
}
[TearDown]
public void TearDown()
{
// Stop servers
if (m_RuntimeManager != null && m_RuntimeManager.IsServerRunning)
{
m_RuntimeManager.StopServer();
}
if (m_EditorServer != null && m_EditorServer.IsRunning)
{
m_EditorServer.Stop();
}
// Clean up GameObjects
if (m_TestGameObject != null)
{
Object.DestroyImmediate(m_TestGameObject);
}
if (m_RuntimeConfig != null)
{
Object.DestroyImmediate(m_RuntimeConfig);
}
// Restore global state the servers/manager mutated (runs last, after our own cleanup).
GlobalServerStateGuard.Restore();
}
[UnityTest]
public IEnumerator DualServers_EditorAndRuntime_UsesDifferentPortRanges()
{
// Act - Start Editor server first
m_EditorServer.Start();
yield return null;
// Start Runtime server
m_RuntimeManager.StartServer();
yield return null;
// Assert - Both servers should be running on different port ranges
Assert.IsTrue(m_EditorServer.IsRunning, "Editor server should be running");
Assert.IsTrue(m_RuntimeManager.IsServerRunning, "Runtime server should be running");
// Editor should use 7800-7899
Assert.GreaterOrEqual(m_EditorServer.Port, 7800, "Editor server should use port >= 7800");
Assert.LessOrEqual(m_EditorServer.Port, 7899, "Editor server should use port <= 7899");
// Runtime should use 7900-7999
Assert.GreaterOrEqual(m_RuntimeManager.ActualPort, 7900, "Runtime server should use port >= 7900");
Assert.LessOrEqual(m_RuntimeManager.ActualPort, 7999, "Runtime server should use port <= 7999");
// Ports should not conflict
Assert.AreNotEqual(m_EditorServer.Port, m_RuntimeManager.ActualPort, "Editor and Runtime servers should use different ports");
}
[UnityTest]
public IEnumerator DualServers_BothStatusEndpoints_ReturnValidResponses()
{
// Arrange - Start both servers
m_EditorServer.Start();
m_RuntimeManager.StartServer();
yield return null;
if (!m_EditorServer.IsRunning || !m_RuntimeManager.IsServerRunning)
{
Assert.Fail("Both servers should be running for this test");
yield break;
}
// Test Editor server status endpoint
PipelineResponse editorResponse = null;
using (var editorClient = new PipelineClient(m_EditorServer))
yield return editorClient.GetAsync("/api/status", r => editorResponse = r);
Assert.IsTrue(editorResponse.IsSuccess, $"Editor status endpoint should work: {editorResponse.Error}");
Assert.IsNotEmpty(editorResponse.RawResponse, "Editor status response should not be empty");
// Test Runtime server status endpoint
PipelineResponse runtimeResponse = null;
using (var runtimeClient = new PipelineClient(m_RuntimeManager))
yield return runtimeClient.GetAsync("/api/status", r => runtimeResponse = r);
Assert.IsTrue(runtimeResponse.IsSuccess, $"Runtime status endpoint should work: {runtimeResponse.Error}");
Assert.IsNotEmpty(runtimeResponse.RawResponse, "Runtime status response should not be empty");
}
[UnityTest]
public IEnumerator DualServers_Authentication_EachRequiresOwnToken()
{
// Arrange - Start both servers
m_EditorServer.Start();
m_RuntimeManager.StartServer();
yield return null;
if (!m_EditorServer.IsRunning || !m_RuntimeManager.IsServerRunning)
{
Assert.Fail("Both servers should be running for this test");
yield break;
}
// The runtime client authenticates with the server's own token; a request should be
// accepted (not 401). (Rejection of missing/invalid tokens is covered by ServerAuthTests.)
PipelineResponse runtimeResponse = null;
using (var runtimeClient = new PipelineClient(m_RuntimeManager))
yield return runtimeClient.GetAsync("/api/status", r => runtimeResponse = r);
Assert.AreNotEqual(401, runtimeResponse.StatusCode, "Runtime server should accept its own token");
}
[UnityTest]
public IEnumerator DualServers_StartStopSequence_WorksCorrectly()
{
// Test various start/stop sequences to ensure no interference
// Sequence 1: Start Editor first
m_EditorServer.Start();
yield return new WaitForSeconds(0.1f);
m_RuntimeManager.StartServer();
yield return new WaitForSeconds(0.1f);
Assert.IsTrue(m_EditorServer.IsRunning, "Editor should be running");
Assert.IsTrue(m_RuntimeManager.IsServerRunning, "Runtime should be running");
// Stop both
m_EditorServer.Stop();
m_RuntimeManager.StopServer();
yield return new WaitForSeconds(0.1f);
Assert.IsFalse(m_EditorServer.IsRunning, "Editor should be stopped");
Assert.IsFalse(m_RuntimeManager.IsServerRunning, "Runtime should be stopped");
// Sequence 2: Start Runtime first
m_RuntimeManager.StartServer();
yield return new WaitForSeconds(0.1f);
m_EditorServer.Start();
yield return new WaitForSeconds(0.1f);
Assert.IsTrue(m_EditorServer.IsRunning, "Editor should be running");
Assert.IsTrue(m_RuntimeManager.IsServerRunning, "Runtime should be running");
// Should still use correct port ranges regardless of start order
Assert.GreaterOrEqual(m_EditorServer.Port, 7800, "Editor should use Editor range");
Assert.LessOrEqual(m_EditorServer.Port, 7899, "Editor should use Editor range");
Assert.GreaterOrEqual(m_RuntimeManager.ActualPort, 7900, "Runtime should use Runtime range");
Assert.LessOrEqual(m_RuntimeManager.ActualPort, 7999, "Runtime should use Runtime range");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fb4e86ff6994ad141ae2cc0f129206e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,124 @@
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Models;
using Newtonsoft.Json.Linq;
using UnityEngine;
using System.IO;
using Unity.Pipeline.Tests.Editor;
using Unity.Pipeline.Tests.Runtime;
namespace Unity.Pipeline.Tests.Editor.ServerLifecyle
{
/// <summary>
/// End-to-end tests demonstrating complete Pipeline workflow.
/// These test the full roundtrip: discovery → connection → command execution
/// that simulates real CLI usage patterns.
///
/// Marked [Explicit]: starts its own EditorPipelineServer (and manages the shared descriptor),
/// which conflicts with the live editor server. Excluded from the normal/dogfood run.
/// TODO: to rejoin the dogfood run, refactor to drive an isolated TestEditorPipelineServer
/// (ports 7850+, WritesDescriptor=false) instead of starting a real server.
/// </summary>
[Explicit("Starts its own server; conflicts with the live editor server. Run deliberately.")]
[Category("ServerLifecycle")]
class EndToEndTests
{
[Test]
public async Task CompleteWorkflow_DiscoverConnectExecute_WorksEndToEnd()
{
// Setup
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
// Step 1: Start Pipeline Server (simulates Editor startup)
var server = new EditorPipelineServer();
server.Start();
try
{
var projectPath = Path.GetDirectoryName(Application.dataPath);
// Step 2: CLI Discovery - Find running instances
var instances = MockCliDiscovery.DiscoverInstances(new[] { projectPath });
var runningInstances = instances.Where(i => i.IsRunning).ToList();
Assert.Greater(runningInstances.Count, 0, "Should discover at least one running instance");
var targetInstance = runningInstances.First();
// Give server a moment to fully start
await Task.Delay(100);
// Step 3: CLI Connection Validation
var isReachable = await MockCliDiscovery.ValidateConnection(targetInstance.Descriptor);
Assert.IsTrue(isReachable, "Discovered instance should be reachable");
// Step 4: CLI Command Discovery - Get available commands
using (var client = new PipelineClient(server))
{
var commandsResponse = await client.GetAsync("/api/commands");
Assert.IsTrue(commandsResponse.IsSuccess, "Should be able to get commands list");
var commands = commandsResponse.JsonResponse["commands"] as JArray;
Assert.Greater(commands.Count, 0, "Should have available commands");
// Step 5: CLI Command Execution - Execute log_editor command
var execResponse = await client.ExecuteCommandAsync(
"log_editor", new { message = "Hello from end-to-end test!" });
Assert.IsTrue(execResponse.IsSuccess,
$"Command execution should succeed. Response: {execResponse.RawResponse}");
var execData = execResponse.JsonResponse;
Assert.IsTrue(execData["success"].ToObject<bool>(), "Command should execute successfully");
// Lean envelope omits the command echo (AUTHAPI-21); success is the signal.
}
}
finally
{
server.Stop();
}
}
[Test]
public async Task RealWorldScenario_MultipleCommands_ExecutesSequentially()
{
// Setup
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
var server = new EditorPipelineServer();
server.Start();
try
{
using (var client = new PipelineClient(server))
{
// Simulate CLI batch execution of multiple commands
var commands = new CommandExecutionRequest[]
{
new CommandExecutionRequest("log_editor") { Parameters = { ["message"] = "Starting batch execution" } },
new CommandExecutionRequest("test_types") { Parameters = { ["count"] = 5, ["enabled"] = true, ["factor"] = 2.5f } },
new CommandExecutionRequest("log_editor") { Parameters = { ["message"] = "Batch execution completed" } }
};
foreach (var cmd in commands)
{
var response = await client.ExecuteCommandAsync(cmd.Command, cmd.Parameters);
Assert.IsTrue(response.IsSuccess,
$"Command '{cmd.Command}' should execute successfully. Response: {response.RawResponse}");
Assert.IsTrue(response.JsonResponse["success"].ToObject<bool>(),
$"Command '{cmd.Command}' should succeed");
}
}
}
finally
{
server.Stop();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a97f33bd666ad6340965b07f15094045
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,59 @@
using System.IO;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Models;
using UnityEngine;
#if UNITY_6000_5_OR_NEWER
using Unity.Scripting.LifecycleManagement;
#endif
namespace Unity.Pipeline.Tests.Editor.ServerLifecyle
{
/// <summary>
/// Isolates server-lifecycle tests from the live editor pipeline server, which shares some global
/// state in-process. Call Capture() in [SetUp] and Restore() in [TearDown].
///
/// It records whether a live server was advertised before the test and restores command discovery
/// (RuntimePipelineDriver.Awake switches it to reflection). The main-thread dispatcher is
/// per-server now, so there is nothing global to swap.
/// </summary>
#if UNITY_6000_5_OR_NEWER
[NoAutoStaticsCleanup]
#endif
static class GlobalServerStateGuard
{
static string s_DescriptorPath;
static bool s_HadLiveServer;
public static void Capture()
{
var projectPath = Path.GetDirectoryName(Application.dataPath);
s_DescriptorPath = InstanceDescriptor.GetDescriptorFilePath(projectPath);
// Key off the live server OBJECT, not the descriptor file: a prior test may have left the
// live server running while the shared descriptor was already deleted (object/file desync).
// Keying off the file would then wrongly skip the restart below.
s_HadLiveServer = PipelineServerStartup.Server != null;
}
public static void Restore()
{
// A server-lifecycle test starts/stops its own EditorPipelineServer on the live port,
// which tears down the live listener and deletes the shared `.unity-pipeline-port`
// descriptor. Rewriting the descriptor file is NOT enough — it would advertise a port
// nothing listens on (the symptom: menu shows "running" but the server is unreachable).
// Fully restart the live server so the listener and descriptor are consistent (this also
// re-arms the watchdog and auto-tick). Mirrors RuntimePipelineServerTests.TearDown.
try
{
if (s_HadLiveServer)
PipelineServerStartup.RestartServer();
else if (s_DescriptorPath != null && File.Exists(s_DescriptorPath))
File.Delete(s_DescriptorPath); // No live server before the test — clear any stray descriptor.
}
catch { /* best effort */ }
// RuntimePipelineDriver.Awake switches discovery to reflection; restore TypeCache.
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a715663d547625346836df81c2aee9a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,71 @@
using System.IO;
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Models;
using Unity.Pipeline.Tests.Runtime;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.ServerLifecyle
{
/// <summary>
/// Regression test for the full-suite run leaving the live editor server unreachable.
///
/// <see cref="GlobalServerStateGuard.Restore"/> is the safety net that server-lifecycle tests
/// rely on (in [TearDown]) to hand the live editor server back intact. Its contract is not "the
/// descriptor file is present" but "the live server is actually running and reachable". A guard
/// that only rewrites the descriptor file leaves it advertising a port nothing listens on —
/// which is exactly how the suite used to finish with the server "running" in the menu but
/// unreachable.
/// </summary>
[Explicit("Manipulates the live editor server; run deliberately (e.g. from the Test Runner window).")]
[Category("ServerLifecycle")]
class GlobalServerStateGuardTests
{
[TearDown]
public void TearDown()
{
// Safety net: never leave the live server down for the next test/dogfood command, even if
// an assertion above failed before the guard could restore it.
PipelineServerStartup.EnsureServerStarted();
}
[Test]
public async Task Restore_AfterLiveListenerTornDown_LeavesServerReachable()
{
// Arrange: a live server is running and advertised (as in every interactive editor).
PipelineServerStartup.EnsureServerStarted();
Assert.IsNotNull(PipelineServerStartup.Server, "Precondition: a live server should exist");
Assert.IsTrue(PipelineServerStartup.Server.IsRunning, "Precondition: live server should be running");
GlobalServerStateGuard.Capture();
// Simulate a server-lifecycle test that tears down the live listener and deletes the
// shared descriptor (what new EditorPipelineServer().Start()/Stop() does on the live port).
PipelineServerStartup.StopServer();
var projectRoot = Path.GetDirectoryName(Application.dataPath);
Assert.IsNull(InstanceDescriptor.ReadFromProjectRoot(projectRoot),
"Sanity: descriptor should be gone after the live server stopped");
// Act
GlobalServerStateGuard.Restore();
// Assert: the guard must leave a live, reachable server — not just a descriptor file.
var server = PipelineServerStartup.Server;
Assert.IsNotNull(server, "Restore() must leave a live server running");
Assert.IsTrue(server.IsRunning, "Restore() must leave the live server running");
var descriptor = InstanceDescriptor.ReadFromProjectRoot(projectRoot);
Assert.IsNotNull(descriptor, "Restore() must leave a discovery descriptor");
Assert.AreEqual(server.Port, descriptor.Port,
"Descriptor port must match the running server's port");
using (var client = new PipelineClient($"http://localhost:{descriptor.Port}", descriptor.EvalToken))
{
var status = await client.GetStatusAsync();
Assert.IsTrue(status.IsSuccess,
"Live server must answer /api/status after Restore() — the descriptor must point at a live listener");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c16e03783027c2a48bf19735c2b8823d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,131 @@
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using System.IO;
using System.Threading.Tasks;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Models;
using Unity.Pipeline.Tests.Runtime;
using UnityEngine;
namespace Unity.Pipeline.Tests.Editor.ServerLifecyle
{
/// <summary>
/// Tests for PipelineServer lifecycle and basic HTTP functionality.
/// These test the public interface behaviors that CLI tools will rely on.
///
/// Marked [Explicit]: they start/stop a real EditorPipelineServer and manage the shared
/// `.unity-pipeline-port` descriptor, which conflicts with the live editor server that exists
/// in every editor session. They are excluded from the normal/dogfood run and meant to be run
/// deliberately (e.g. from the Test Runner window). See [[GlobalServerStateGuard]].
/// </summary>
[Explicit("Server-lifecycle test; conflicts with the live editor server. Run deliberately.")]
[Category("ServerLifecycle")]
class PipelineServerTests
{
// These tests start/stop their own EditorPipelineServer, which writes/deletes the shared
// `.unity-pipeline-port` descriptor and would corrupt the live editor server. Guard it.
[SetUp]
public void SetUp() => GlobalServerStateGuard.Capture();
[TearDown]
public void TearDown() => GlobalServerStateGuard.Restore();
[Test]
public void ServerLifecycle_StartAndStop_ManagesRunningState()
{
// Arrange
var server = new EditorPipelineServer();
// Assert initial state
Assert.IsFalse(server.IsRunning, "Server should not be running initially");
Assert.AreEqual(0, server.Port, "Port should be 0 when not running");
// Act - Start server
server.Start();
// Assert - Started state
Assert.IsTrue(server.IsRunning, "Server should be running after Start()");
Assert.Greater(server.Port, 0, "Port should be assigned after Start()");
Assert.GreaterOrEqual(server.Port, 7800, "Port should be in range 7800-7899");
Assert.LessOrEqual(server.Port, 7899, "Port should be in range 7800-7899");
// Act - Stop server
server.Stop();
// Assert - Stopped state
Assert.IsFalse(server.IsRunning, "Server should not be running after Stop()");
// Note: Port may retain value after stop for diagnostic purposes
}
[Test]
public async Task StatusEndpoint_GetApiStatus_ReturnsBasicServerInfo()
{
// Arrange
var server = new EditorPipelineServer();
server.Start();
try
{
using (var client = new PipelineClient(server))
{
// Act - Call /api/status endpoint (basic server health)
var response = await client.GetHttpAsync("/api/status");
var jsonContent = await response.Content.ReadAsStringAsync();
// Assert - Response structure
Assert.IsTrue(response.IsSuccessStatusCode,
$"Basic status endpoint should return success, got: {response.StatusCode}");
Assert.AreEqual("application/json", response.Content.Headers.ContentType.MediaType,
"Status endpoint should return JSON content type");
// Assert - Basic server status JSON structure (not StatusResponse)
var statusJson = JObject.Parse(jsonContent);
Assert.IsNotNull(statusJson, "Should be able to parse basic status JSON");
// Verify basic server fields
Assert.AreEqual("ready", statusJson["status"]?.ToString(), "Server status should be ready");
}
}
finally
{
server.Stop();
}
}
[Test]
public void InstanceDescriptor_ServerStartStop_ManagesDescriptorFile()
{
// Arrange
var server = new EditorPipelineServer();
var projectPath = Path.GetDirectoryName(Application.dataPath);
var descriptorFilePath = InstanceDescriptor.GetDescriptorFilePath(projectPath);
// Ensure no existing descriptor file
if (File.Exists(descriptorFilePath))
{
File.Delete(descriptorFilePath);
}
// Act - Start server
server.Start();
// Assert - Descriptor file created
Assert.IsTrue(File.Exists(descriptorFilePath),
"Instance descriptor file should be created when server starts");
// Read and verify descriptor content
var descriptor = InstanceDescriptor.ReadFromProjectRoot(projectPath);
Assert.IsNotNull(descriptor, "Should be able to read descriptor file");
Assert.AreEqual(server.Port, descriptor.Port, "Descriptor should contain correct port");
Assert.Greater(descriptor.Pid, 0, "Descriptor should contain valid PID");
Assert.IsNotEmpty(descriptor.ProjectPath, "Descriptor should contain project path");
// Act - Stop server
server.Stop();
// Assert - Descriptor file removed
Assert.IsFalse(File.Exists(descriptorFilePath),
"Instance descriptor file should be removed when server stops");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 42319be0b075849479314f25fe300c69
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,255 @@
using System.Threading.Tasks;
using NUnit.Framework;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor;
using Unity.Pipeline.Security;
using Newtonsoft.Json.Linq;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for the post-startup settle gate (AUTHAPI-35): while the Editor is settling after a
/// cold start (still importing/compiling when the server comes up), main-thread commands must
/// be rejected with a distinguishable, retryable "busy" signal (HTTP 503) instead of executing
/// into a half-ready Editor and failing with an opaque null-data envelope. Status surfaces
/// (background commands, editor_status, /api/status, /api/editor_status) must stay servable so
/// callers can observe the settling state and know when to retry.
/// </summary>
class SettleGateTests
{
/// <summary>
/// Isolated editor server (test port range, no descriptor — same isolation as
/// <see cref="TestEditorPipelineServer"/>) whose settle latch is pinned via
/// <see cref="Settled"/>, since an EditMode test cannot put the real Editor into its
/// cold-import busy state.
/// </summary>
private sealed class SettleGateTestServer : EditorPipelineServer
{
/// <summary>false simulates the post-cold-start settle window; true a settled server.</summary>
public bool Settled { get; set; }
protected override bool WritesDescriptor => false;
protected override (int basePort, int maxPort) GetPortRange() => (7850, 7899);
protected override string GetToken() => SecurityTokenManager.GetOrCreateToken();
protected override bool IsSettled => Settled;
}
/// <summary>
/// Isolated editor server exposing the REAL settle latch, to verify a server started after
/// the session has settled (mid-session restarts and test servers — every server except
/// the cold-import one) settles immediately and never gates anything, even if the Editor
/// happens to be importing/compiling at Start() (as CI fixtures that create scripts do).
/// </summary>
private sealed class LatchProbeServer : EditorPipelineServer
{
public bool RealSettled => IsSettled;
/// <summary>The session-settled SessionState marker, surfaced so the latch test can
/// assert its precondition explicitly instead of assuming it.</summary>
public static bool SessionSettled => IsSessionSettled;
protected override bool WritesDescriptor => false;
protected override (int basePort, int maxPort) GetPortRange() => (7850, 7899);
protected override string GetToken() => SecurityTokenManager.GetOrCreateToken();
}
private SettleGateTestServer m_Server;
private Unity.Pipeline.Tests.Runtime.PipelineClient m_PipelineClient;
[SetUp]
public void SetUp()
{
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
m_Server = new SettleGateTestServer { Settled = false };
m_Server.Start();
m_PipelineClient = new Unity.Pipeline.Tests.Runtime.PipelineClient(m_Server);
}
[TearDown]
public void TearDown()
{
m_PipelineClient?.Dispose();
m_Server?.Stop();
}
[Test]
public async Task ApiExec_MainThreadCommand_WhileSettling_Returns503BusyEnvelope()
{
// Act - log_editor is MainThreadRequired (the attribute default), like create_scene /
// instantiate_prefab from the report. Parameters are valid so only the gate can reject.
// Sent verbose: the busy reply is a standard exec envelope, so the lean default drops
// the command/executedAt metadata this test echoes back (AUTHAPI-21); a lean request
// is asserted separately below.
var response = await m_PipelineClient.PostJsonAsync("/api/exec", new
{
command = "log_editor",
parameters = new { message = "should not run while settling" },
verbose = true
});
// Assert - rejected with 503 and the distinguishable, retryable busy envelope
Assert.AreEqual(503, response.StatusCode,
$"Main-thread command should be rejected with 503 while settling. Response: {response.RawResponse}");
Assert.IsTrue(response.HasValidJson, "Busy response should have valid JSON");
var json = response.JsonResponse;
Assert.IsFalse(json["success"].ToObject<bool>(), "Busy response should have success=false");
Assert.AreEqual("log_editor", json["command"]?.ToString(), "Verbose busy response should echo the command");
Assert.AreEqual("Server Busy", json["error"]?.ToString(), "Busy response should carry the busy error");
Assert.AreEqual("busy", json["status"]?.ToString(), "Busy response should carry the machine-readable status marker");
Assert.AreEqual("settling", json["busyReason"]?.ToString(), "Busy response should carry the specific busy reason");
Assert.IsTrue(json["retryable"].ToObject<bool>(), "Busy response should be marked retryable");
StringAssert.Contains("settling", json["errorDetails"]?.ToString(),
"Busy details should explain the Editor is settling");
// The lean default carries the same machine-readable busy signal, minus the metadata.
var lean = await m_PipelineClient.ExecuteCommandAsync("log_editor",
new { message = "should not run while settling" });
Assert.AreEqual(503, lean.StatusCode, "Lean busy reply should still be a 503");
Assert.AreEqual("busy", lean.JsonResponse["status"]?.ToString(),
"Lean busy reply should keep the status marker");
Assert.IsTrue(lean.JsonResponse["retryable"].ToObject<bool>(),
"Lean busy reply should keep the retryable marker");
Assert.IsNull(lean.JsonResponse["command"],
"Lean busy reply drops the command echo like every lean envelope (AUTHAPI-21)");
}
[Test]
public async Task ApiExec_DetachedJobSubmission_WhileSettling_Returns503AndCreatesNoJob()
{
// Act - the gate must apply BEFORE a detached job is created: a queued job would
// otherwise run into the half-ready Editor in the background (the contract called
// out in the changelog and connectivity.md).
var response = await m_PipelineClient.PostJsonAsync("/api/exec", new
{
command = "log_editor",
parameters = new { message = "should not be queued while settling" },
job = true
});
// Assert - same busy envelope as the sync path, and no job handle was handed out
Assert.AreEqual(503, response.StatusCode,
$"Detached job submission should be rejected with 503 while settling. Response: {response.RawResponse}");
Assert.IsTrue(response.HasValidJson, "Busy response should have valid JSON");
var json = response.JsonResponse;
Assert.IsFalse(json["success"].ToObject<bool>(), "Busy response should have success=false");
Assert.AreEqual("Server Busy", json["error"]?.ToString(), "Busy response should carry the busy error");
Assert.AreEqual("busy", json["status"]?.ToString(), "Busy response should carry the status marker");
Assert.AreEqual("settling", json["busyReason"]?.ToString(), "Busy response should carry the specific busy reason");
Assert.IsTrue(json["retryable"].ToObject<bool>(), "Busy response should be marked retryable");
Assert.IsNull(json.SelectToken("result.jobId"),
"No job may be created while settling — the busy reply must carry no job handle");
}
[Test]
public async Task ApiExec_BackgroundCommand_WhileSettling_ExecutesNormally()
{
// Act - recompile_status is MainThreadRequired=false: exactly the polling clients rely
// on while the Editor is busy, so it must never be gated.
var response = await m_PipelineClient.ExecuteCommandAsync("recompile_status", new { });
// Assert
Assert.IsTrue(response.IsSuccess,
$"Background command should execute while settling, got: {response.StatusCode}. Response: {response.RawResponse}");
Assert.IsTrue(response.IsCommandSuccess, "Background command should succeed while settling");
}
[Test]
public async Task ApiExec_EditorStatus_WhileSettling_RemainsServiceable()
{
// Act - editor_status is main-thread but explicitly exempt: it is the status surface
// callers poll to observe the settling state itself.
var response = await m_PipelineClient.ExecuteCommandAsync("editor_status", new { });
// Assert
Assert.IsTrue(response.IsSuccess,
$"editor_status should stay servable while settling, got: {response.StatusCode}. Response: {response.RawResponse}");
Assert.IsTrue(response.IsCommandSuccess, "editor_status should succeed while settling");
}
[Test]
public async Task ApiStatus_WhileSettling_ReportsSettlingInsteadOfReady()
{
// Act
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/status");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - ready is withheld until the Editor is actually serviceable
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"/api/status should respond while settling, got: {httpResponse.StatusCode}");
var statusJson = JObject.Parse(jsonContent);
Assert.AreEqual("settling", statusJson["status"]?.ToString(),
"/api/status should report 'settling' until the Editor is first seen idle");
}
[Test]
public async Task ApiEditorStatus_Endpoint_WhileSettling_BypassesBusyGate()
{
// Act - the dedicated status endpoint must keep working so the busy state is observable
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/editor_status");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"/api/editor_status should respond while settling, got: {httpResponse.StatusCode}. Response: {jsonContent}");
var statusJson = JObject.Parse(jsonContent);
Assert.IsNotNull(statusJson["status"], "/api/editor_status should report the editor state");
}
[Test]
public async Task ApiExec_AfterSettling_PreviouslyGatedCommandSucceeds()
{
// Arrange - the settle window ends (one-way latch releases)
m_Server.Settled = true;
// Act - the same command that was gated in the settling tests
var response = await m_PipelineClient.ExecuteCommandAsync("log_editor",
new { message = "runs after settling" });
// Assert
Assert.IsTrue(response.IsSuccess,
$"Main-thread command should execute once settled, got: {response.StatusCode}. Response: {response.RawResponse}");
Assert.IsTrue(response.IsCommandSuccess, "Main-thread command should succeed once settled");
}
[Test]
public void ServerStartedInSettledSession_SettlesImmediately()
{
// Precondition, asserted explicitly rather than assumed: this test probes the
// "session already settled ⇒ immediate settle" path, which is only meaningful once
// the session-settled marker is set. It always is by the time tests run (the test
// runner doesn't execute while the initial import/compile is still pending, and the
// live server settles then), but if a test-runner change ever violates that, fail
// loudly on the precondition instead of confusingly on the latch assertion below.
Assert.IsTrue(LatchProbeServer.SessionSettled,
"PRECONDITION: the Editor session should have settled before EditMode tests run — " +
"if this fails, the test ran before the session's first idle moment and the assertion below would be meaningless");
// Act - the session-scoped latch must release during Start() even if the Editor
// happens to be importing/compiling at that instant (as it is on CI when fixtures
// create scripts). Mid-session servers must never gate anything (AUTHAPI-35 gates the
// cold-import window only).
var server = new LatchProbeServer();
try
{
server.Start();
// Assert
Assert.IsTrue(server.RealSettled,
"A server started after the session has settled should settle immediately (no behavior change outside the cold-import window)");
}
finally
{
server.Stop();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 71e9ba6b04394f29ac36340eea461e2f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: