Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+175
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ca2f891dc6641478f17125ef1f1327b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
+86
@@ -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
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b612fd87814b401586f653f4b7fbc910
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
+194
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a97f33bd666ad6340965b07f15094045
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+59
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a715663d547625346836df81c2aee9a0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+71
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c16e03783027c2a48bf19735c2b8823d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+131
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 42319be0b075849479314f25fe300c69
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+255
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 71e9ba6b04394f29ac36340eea461e2f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user