Files

1229 lines
65 KiB
C#

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.TestTools;
using System.Text.RegularExpressions;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Tests for HTTP API endpoints that CLI tools will consume.
/// These test the complete server API surface for remote command execution.
/// </summary>
class ApiEndpointsTests
{
private EditorPipelineServer m_Server;
private Unity.Pipeline.Tests.Runtime.PipelineClient m_PipelineClient;
[SetUp]
public void SetUp()
{
// Setup command discovery for tests
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
// Start an ISOLATED test server (ports 7850-7899, writes no descriptor) for endpoint
// testing, so we never bind the live server's port (7800) or clobber its descriptor.
m_Server = new TestEditorPipelineServer();
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 ApiCommands_GetEndpoint_ReturnsCommandList()
{
// Act - Call /api/commands endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - Response structure
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"Commands endpoint should return success, got: {httpResponse.StatusCode}");
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
"Commands endpoint should return JSON content type");
// Assert - JSON parsing
var responseJson = JObject.Parse(jsonContent);
Assert.IsNotNull(responseJson, "Should be able to parse commands JSON");
// Verify response structure
Assert.IsNotNull(responseJson["commands"], "Response should have commands array");
Assert.IsNotNull(responseJson["count"], "Response should have count field");
Assert.IsNotNull(responseJson["server"], "Response should have server info");
// Verify commands array contains discovered commands
var commands = responseJson["commands"] as JArray;
Assert.Greater(commands.Count, 0, "Should have at least one discovered command");
// Verify a specific test command is included
var testCommand = commands.Cast<JObject>()
.FirstOrDefault(cmd => cmd["name"]?.ToString() == "log_editor");
Assert.IsNotNull(testCommand, "Should include log_editor test command");
Assert.AreEqual("Log a message to Unity Editor console", testCommand["description"]?.ToString());
}
[Test]
public async Task ApiCommands_OnEditorServer_ExcludesRuntimeOnlyCommands()
{
// Act - List commands from the Editor server
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
var responseJson = JObject.Parse(jsonContent);
var commandNames = (responseJson["commands"] as JArray)
.Cast<JObject>()
.Select(cmd => cmd["name"]?.ToString())
.ToList();
// Assert - editor commands are listed, runtime-only commands are hidden
Assert.Contains("editor_status", commandNames, "Editor command should be listed");
CollectionAssert.DoesNotContain(commandNames, "runtime_status", "Runtime-only eval should be hidden on the Editor server");
CollectionAssert.DoesNotContain(commandNames, "set_target_framerate", "Runtime-only commands should be hidden on the Editor server");
}
[Test]
public async Task ApiCommands_DefaultDetail_ReturnsFullMetadata()
{
// Act - no detail parameter: full is the default (back-compat with pre-detail clients)
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"Commands endpoint should return success, got: {httpResponse.StatusCode}");
var responseJson = JObject.Parse(jsonContent);
var commands = responseJson["commands"] as JArray;
Assert.Greater(commands.Count, 0, "Should have at least one discovered command");
var logEditor = commands.Cast<JObject>()
.First(cmd => cmd["name"]?.ToString() == "log_editor");
Assert.IsNotNull(logEditor["parameters"], "default detail should include parameters");
Assert.IsNotNull(logEditor["schema"], "default detail should include schema");
Assert.IsNotNull(logEditor["tags"], "default detail should include tags");
Assert.IsNotNull(logEditor["package"], "default detail should include package");
}
[Test]
public async Task ApiCommands_DetailCompact_ReturnsLightweightIndex()
{
// Act - detail=compact opts into the lightweight browse/discovery projection
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"detail=compact should return success, got: {httpResponse.StatusCode}");
var commands = JObject.Parse(jsonContent)["commands"] as JArray;
Assert.Greater(commands.Count, 0, "Should have at least one discovered command");
foreach (var cmd in commands.Cast<JObject>())
{
Assert.IsNotNull(cmd["name"], "compact entry should have name");
Assert.IsNotNull(cmd["description"], "compact entry should have description");
Assert.IsNotNull(cmd["tags"], "compact entry should have tags");
Assert.IsNotNull(cmd["package"], "compact entry should have package");
Assert.IsNull(cmd["parameters"], $"compact entry '{cmd["name"]}' should omit parameters");
Assert.IsNull(cmd["schema"], $"compact entry '{cmd["name"]}' should omit schema");
}
}
[Test]
public async Task ApiCommands_QueryFilter_MatchesTagCaseInsensitively()
{
// Act - 'REGISTRATION' only appears in test_tagged's 'test/registration' tag
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&query=REGISTRATION");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"query filter should return success, got: {httpResponse.StatusCode}");
var responseJson = JObject.Parse(jsonContent);
var names = (responseJson["commands"] as JArray)
.Cast<JObject>()
.Select(cmd => cmd["name"]?.ToString())
.ToList();
Assert.Contains("test_tagged", names, "query should match a command via its tag, case-insensitively");
CollectionAssert.DoesNotContain(names, "log_editor", "commands matching neither name, description nor tag should be filtered out");
}
[Test]
public async Task ApiCommands_QueryFilter_MatchesNameByPrefix()
{
// Act - 'log_edi' is a strict prefix of the 'log_editor' command name
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&query=log_edi");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"query filter should return success, got: {httpResponse.StatusCode}");
var names = (JObject.Parse(jsonContent)["commands"] as JArray)
.Cast<JObject>()
.Select(cmd => cmd["name"]?.ToString())
.ToList();
Assert.Contains("log_editor", names, "a query that is a prefix of a command name should match that command");
}
[Test]
public async Task ApiCommands_QueryFilter_NoMatch_ReturnsEmptyNotError()
{
// Act
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?query=zzz_definitely_no_match_zzz");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - empty result, not an error
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"a no-match query should still return success, got: {httpResponse.StatusCode}");
var responseJson = JObject.Parse(jsonContent);
Assert.AreEqual(0, (responseJson["commands"] as JArray).Count, "no-match query should return an empty commands array");
Assert.AreEqual(0, responseJson["total"]?.ToObject<int>(), "no-match query should report total 0");
Assert.AreEqual(0, responseJson["count"]?.ToObject<int>(), "no-match query should report count 0");
}
[Test]
public async Task ApiCommands_TagFilter_MatchesSubtreeBySegmentPrefix()
{
// Act / Assert - 'test' matches the exact 'test' tag and the 'test/registration' subtree
var jsonContent = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&tag=test"))
.Content.ReadAsStringAsync();
var names = (JObject.Parse(jsonContent)["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
Assert.Contains("test_tagged", names, "tag=test should match the tagged test command");
CollectionAssert.DoesNotContain(names, "log_editor", "untagged commands should be filtered out");
// Drilling into a subtag still matches
jsonContent = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&tag=test/registration"))
.Content.ReadAsStringAsync();
names = (JObject.Parse(jsonContent)["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
Assert.Contains("test_tagged", names, "tag=test/registration should match the subtag directly");
// Segment-aware: 'tes' is not a whole-segment prefix of 'test'
jsonContent = await (await m_PipelineClient.GetHttpAsync("/api/commands?tag=tes"))
.Content.ReadAsStringAsync();
Assert.AreEqual(0, JObject.Parse(jsonContent)["total"]?.ToObject<int>(),
"tag matching should respect '/' segment boundaries, not raw string prefixes");
}
[Test]
public async Task ApiCommands_CombinedFilters_AreAnded()
{
// Act - log_editor matches the query but carries no tags, so the tag filter excludes it
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?query=log_editor&tag=test");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"combined filters should return success, got: {httpResponse.StatusCode}");
Assert.AreEqual(0, JObject.Parse(jsonContent)["total"]?.ToObject<int>(),
"filters should combine with AND");
}
[Test]
public async Task ApiCommands_GroupByPackage_GroupsCommandsByOriginatingAssembly()
{
// Act
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&group_by=package&query=log_editor");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - grouped responses carry 'groups' instead of 'commands'
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"group_by=package should return success, got: {httpResponse.StatusCode}");
var responseJson = JObject.Parse(jsonContent);
Assert.IsNull(responseJson["commands"], "grouped response should not have a flat commands array");
var groups = responseJson["groups"] as JArray;
Assert.IsNotNull(groups, "grouped response should have a groups array");
var testGroup = groups.Cast<JObject>()
.FirstOrDefault(g => g["package"]?.ToString() == "Unity.Pipeline.Tests.Editor");
Assert.IsNotNull(testGroup, "log_editor's assembly should appear as a package group");
var groupNames = (testGroup["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
Assert.Contains("log_editor", groupNames, "the group should contain the matching command");
}
[Test]
public async Task ApiCommands_GroupByTag_ReturnsNestedTagTree()
{
// Act
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&group_by=tag&tag=test");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - top-level 'test' node with a nested 'test/registration' child
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"group_by=tag should return success, got: {httpResponse.StatusCode}");
var groups = JObject.Parse(jsonContent)["groups"] as JArray;
Assert.IsNotNull(groups, "grouped response should have a groups array");
var testNode = groups.Cast<JObject>().FirstOrDefault(g => g["tag"]?.ToString() == "test");
Assert.IsNotNull(testNode, "should have a top-level 'test' tag node");
var directNames = (testNode["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
Assert.Contains("test_tagged", directNames, "test_tagged carries the exact 'test' tag");
var childNode = (testNode["children"] as JArray)?.Cast<JObject>()
.FirstOrDefault(g => g["tag"]?.ToString() == "test/registration");
Assert.IsNotNull(childNode, "'test/registration' should nest under 'test'");
var childNames = (childNode["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
Assert.Contains("test_tagged", childNames, "test_tagged also carries the 'test/registration' tag");
}
[Test]
public async Task ApiCommands_GroupByInvalid_ReturnsBadRequestListingAcceptedValues()
{
// Act
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?group_by=namespace");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.AreEqual(400, (int)httpResponse.StatusCode,
$"Invalid group_by value should be rejected with 400. Response: {jsonContent}");
StringAssert.Contains("flat", jsonContent, "error should list accepted value 'flat'");
StringAssert.Contains("package", jsonContent, "error should list accepted value 'package'");
StringAssert.Contains("tag", jsonContent, "error should list accepted value 'tag'");
}
[Test]
public async Task ApiCommands_Pagination_SlicesNameSortedListDeterministically()
{
// Arrange - the unpaginated listing (name-sorted for deterministic pages)
var allJson = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact"))
.Content.ReadAsStringAsync();
var allNames = (JObject.Parse(allJson)["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
CollectionAssert.AreEqual(allNames.OrderBy(n => n, System.StringComparer.Ordinal).ToList(), allNames,
"commands should be name-sorted so pagination windows are deterministic");
// Act
var pageJson = await (await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&offset=1&limit=2"))
.Content.ReadAsStringAsync();
var pageResponse = JObject.Parse(pageJson);
var pageNames = (pageResponse["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
// Assert
CollectionAssert.AreEqual(allNames.Skip(1).Take(2).ToList(), pageNames,
"offset/limit should slice the same ordering the unpaginated listing uses");
Assert.AreEqual(2, pageResponse["count"]?.ToObject<int>(), "count should be the returned page size");
Assert.AreEqual(allNames.Count, pageResponse["total"]?.ToObject<int>(), "total should be the match count before pagination");
Assert.AreEqual(1, pageResponse["offset"]?.ToObject<int>(), "offset should be echoed");
Assert.AreEqual(2, pageResponse["limit"]?.ToObject<int>(), "limit should be echoed");
}
[Test]
public async Task ApiCommands_PaginationInvalid_ReturnsBadRequest()
{
// Act / Assert - negative offset
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?offset=-1");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
Assert.AreEqual(400, (int)httpResponse.StatusCode,
$"negative offset should be rejected with 400. Response: {jsonContent}");
StringAssert.Contains("offset", jsonContent, "error should name the offending parameter");
// Non-numeric limit
httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?limit=abc");
jsonContent = await httpResponse.Content.ReadAsStringAsync();
Assert.AreEqual(400, (int)httpResponse.StatusCode,
$"non-numeric limit should be rejected with 400. Response: {jsonContent}");
StringAssert.Contains("limit", jsonContent, "error should name the offending parameter");
}
[Test]
public async Task ApiCommands_SortByPackage_OrdersByPackageThenName()
{
// Act
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&sort=package");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - ordered by originating package, ties broken by name
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"sort=package should return success, got: {httpResponse.StatusCode}");
var pairs = (JObject.Parse(jsonContent)["commands"] as JArray)
.Cast<JObject>()
.Select(cmd => (Package: cmd["package"]?.ToString(), Name: cmd["name"]?.ToString()))
.ToList();
var expected = pairs
.OrderBy(p => p.Package, System.StringComparer.Ordinal)
.ThenBy(p => p.Name, System.StringComparer.Ordinal)
.ToList();
CollectionAssert.AreEqual(expected, pairs,
"sort=package should order by package with name as tiebreak");
}
[Test]
public async Task ApiCommands_OrderDesc_ReversesNameSort()
{
// Act
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=compact&order=desc");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"order=desc should return success, got: {httpResponse.StatusCode}");
var names = (JObject.Parse(jsonContent)["commands"] as JArray)
.Cast<JObject>().Select(cmd => cmd["name"]?.ToString()).ToList();
CollectionAssert.AreEqual(names.OrderByDescending(n => n, System.StringComparer.Ordinal).ToList(), names,
"order=desc should reverse the default name sort");
}
[Test]
public async Task ApiCommands_SortInvalid_ReturnsBadRequestListingAcceptedValues()
{
// Act / Assert - unknown sort key
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?sort=alphabetical");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
Assert.AreEqual(400, (int)httpResponse.StatusCode,
$"Invalid sort value should be rejected with 400. Response: {jsonContent}");
StringAssert.Contains("name", jsonContent, "error should list accepted value 'name'");
StringAssert.Contains("package", jsonContent, "error should list accepted value 'package'");
// Unknown order direction
httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?order=upside_down");
jsonContent = await httpResponse.Content.ReadAsStringAsync();
Assert.AreEqual(400, (int)httpResponse.StatusCode,
$"Invalid order value should be rejected with 400. Response: {jsonContent}");
StringAssert.Contains("asc", jsonContent, "error should list accepted value 'asc'");
StringAssert.Contains("desc", jsonContent, "error should list accepted value 'desc'");
}
[Test]
public async Task ApiCommands_DetailFull_ReturnsFullMetadataWithTagsAndPackage()
{
// Act - detail=full opts into the complete per-command metadata
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=full");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"detail=full should return success, got: {httpResponse.StatusCode}");
var commands = JObject.Parse(jsonContent)["commands"] as JArray;
var logEditor = commands.Cast<JObject>()
.First(cmd => cmd["name"]?.ToString() == "log_editor");
Assert.IsNotNull(logEditor["parameters"], "full detail should include parameters");
Assert.IsNotNull(logEditor["schema"], "full detail should include schema");
Assert.IsNotNull(logEditor["tags"], "full detail should include tags");
Assert.AreEqual("Unity.Pipeline.Tests.Editor", logEditor["package"]?.ToString(),
"full detail should include the originating package");
}
[Test]
public async Task ApiCommands_InvalidDetail_ReturnsBadRequestListingAcceptedValues()
{
// Act - an unsupported detail value
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands?detail=verbose");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - rejected with a clear error naming the accepted values
Assert.AreEqual(400, (int)httpResponse.StatusCode,
$"Invalid detail value should be rejected with 400. Response: {jsonContent}");
var responseJson = JObject.Parse(jsonContent);
Assert.IsNotNull(responseJson["error"], "400 response should have error field");
StringAssert.Contains("compact", jsonContent, "error should list accepted value 'compact'");
StringAssert.Contains("full", jsonContent, "error should list accepted value 'full'");
}
[Test]
public async Task ApiCommands_CommandStructure_ContainsRequiredFields()
{
// Act - Call /api/commands endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/commands");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
var responseJson = JObject.Parse(jsonContent);
// Assert - Command structure validation
var commands = responseJson["commands"] as JArray;
var firstCommand = commands[0] as JObject;
// Required command fields
Assert.IsNotNull(firstCommand["name"], "Command should have name field");
Assert.IsNotNull(firstCommand["description"], "Command should have description field");
Assert.IsNotNull(firstCommand["parameters"], "Command should have parameters array");
Assert.IsNotNull(firstCommand["schema"], "Command should have JSON schema");
Assert.IsNotNull(firstCommand["mainThreadRequired"], "Command should have mainThreadRequired field");
// Verify schema is valid JSON
var schema = firstCommand["schema"]?.ToString();
var schemaJson = JObject.Parse(schema);
Assert.AreEqual(firstCommand["name"]?.ToString(), schemaJson["title"]?.ToString(),
"Schema title should match command name");
}
[Test]
public async Task ApiStatus_GetBasicStatus_ReturnsServerInfo()
{
// Act - Call basic /api/status endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/status");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - Response structure
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"Basic status endpoint should return success, got: {httpResponse.StatusCode}");
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
"Basic status endpoint should return JSON content type");
// Assert - JSON structure (basic server info only, no Editor APIs)
var statusJson = JObject.Parse(jsonContent);
Assert.IsNotNull(statusJson["status"], "Should have status field");
// Verify basic values
Assert.AreEqual("ready", statusJson["status"]?.ToString());
}
[Test]
public async Task ApiEditorStatus_GetDetailedStatus_ReturnsEditorInfo()
{
// Act - Call rich /api/editor_status endpoint using unified Pipeline client
var httpResponse = await m_PipelineClient.GetHttpAsync("/api/editor_status");
var jsonContent = await httpResponse.Content.ReadAsStringAsync();
// Assert - Response structure
Assert.IsTrue(httpResponse.IsSuccessStatusCode,
$"Editor status endpoint should return success, got: {httpResponse.StatusCode}. Response: {jsonContent}");
Assert.AreEqual("application/json", httpResponse.Content.Headers.ContentType.MediaType,
"Editor status endpoint should return JSON content type");
// Assert - Rich Editor status structure
var statusJson = JObject.Parse(jsonContent);
Assert.IsNotNull(statusJson["status"], "Should have overall status");
Assert.IsNotNull(statusJson["compiling"], "Should have compiling state");
Assert.IsNotNull(statusJson["domainReloadInProgress"], "Should have domain reload state");
Assert.IsNotNull(statusJson["playMode"], "Should have play mode state");
Assert.IsNotNull(statusJson["unityVersion"], "Should have Unity version");
// Verify Editor-specific data is present
Assert.Contains(statusJson["status"]?.ToString(), new[] { "ready", "compiling", "playing", "reloading" });
Assert.Contains(statusJson["playMode"]?.ToString(), new[] { "stopped", "playing", "paused" });
Assert.IsInstanceOf<bool>(statusJson["compiling"]?.ToObject<bool>());
}
[Test]
public async Task ApiExec_PostCommand_ExecutesSuccessfully()
{
// Arrange
var commandRequest = new CommandExecutionRequest("log_editor");
commandRequest.Parameters["message"] = "Test message from CLI";
// Act - Execute command via /api/exec endpoint using unified Pipeline client
var response = await m_PipelineClient.PostJsonAsync("/api/exec", commandRequest);
var responseContent = response.RawResponse;
// Assert - Response structure
Assert.IsTrue(response.IsSuccess,
$"Exec endpoint should return success, got: {response.StatusCode}. Response: {responseContent}");
// Assert - JSON parsing and structure
Assert.IsTrue(response.HasValidJson, "Response should have valid JSON");
var responseJson = response.JsonResponse;
// Lean default (AUTHAPI-21): a success is just success + result. The always-on metadata
// (command, executedAt, executionTimeMs) is dropped unless the request opts into verbose.
Assert.IsNotNull(responseJson["success"], "Response should have success field");
Assert.IsTrue(responseJson["success"].ToObject<bool>(), "Command should execute successfully");
Assert.IsNull(responseJson["command"], "Lean envelope should omit the command echo");
Assert.IsNull(responseJson["executedAt"], "Lean envelope should omit executedAt");
Assert.IsNull(responseJson["executionTimeMs"], "Lean envelope should omit executionTimeMs");
}
[Test]
public async Task ApiExec_VerboseFlag_IncludesEnvelopeMetadata()
{
// Arrange - opt into the full envelope via the request's verbose flag.
var commandRequest = new CommandExecutionRequest("log_editor") { Verbose = true };
commandRequest.Parameters["message"] = "Test message from CLI";
// Act
var response = await m_PipelineClient.PostJsonAsync("/api/exec", commandRequest);
// Assert - the metadata the lean envelope drops is present again.
Assert.IsTrue(response.IsSuccess,
$"Exec endpoint should return success, got: {response.StatusCode}. Response: {response.RawResponse}");
var responseJson = response.JsonResponse;
Assert.IsTrue(responseJson["success"].ToObject<bool>(), "Command should execute successfully");
Assert.IsNotNull(responseJson["command"], "Verbose envelope should include the command");
Assert.AreEqual("log_editor", responseJson["command"]?.ToString());
Assert.IsNotNull(responseJson["executedAt"], "Verbose envelope should include executedAt");
}
[Test]
public async Task ApiExec_OmitNullsFlag_AcceptedEndToEnd()
{
// The payload-null semantics themselves are pinned at the serializer level
// (ResponseEnvelopeSizeTests); this covers the HTTP wiring: the request flag
// deserializes and the reply still parses.
var request = new CommandExecutionRequest("log_editor") { OmitNulls = true };
request.Parameters["message"] = "omitNulls round-trip";
var response = await m_PipelineClient.PostJsonAsync("/api/exec", request);
Assert.IsTrue(response.IsSuccess,
$"Exec with omitNulls should succeed, got: {response.StatusCode}. Response: {response.RawResponse}");
Assert.IsTrue(response.JsonResponse["success"].ToObject<bool>());
}
[Test]
public async Task ApiExec_ValidationFailure_HonorsVerboseFlag()
{
// A post-parse validation failure (structurally valid JSON, invalid content) can and
// must honor the request's verbose flag — that's precisely when a debugging client
// wants the full envelope. Pre-parse failures (unparseable JSON) can't, by definition.
var verboseResponse = await m_PipelineClient.PostJsonAsync("/api/exec",
new { command = "log_editor", timeout = -1, verbose = true });
var leanResponse = await m_PipelineClient.PostJsonAsync("/api/exec",
new { command = "log_editor", timeout = -1 });
Assert.AreEqual(400, verboseResponse.StatusCode, "Invalid timeout should be rejected");
Assert.IsNotNull(verboseResponse.JsonResponse["executedAt"],
"A verbose request's validation failure should carry the full envelope");
Assert.AreEqual(400, leanResponse.StatusCode, "Invalid timeout should be rejected");
Assert.IsNull(leanResponse.JsonResponse["executedAt"],
"A lean request's validation failure should stay lean");
}
[Test]
public async Task ApiExec_NullJsonBody_Returns400InvalidRequest()
{
// A body of literal JSON "null" parses without a JsonException but deserializes to a
// null request — it must be rejected cleanly, not NRE inside the handler.
var response = await m_PipelineClient.PostJsonAsync("/api/exec", null);
Assert.AreEqual(400, response.StatusCode,
$"A null JSON body should be rejected with 400. Response: {response.RawResponse}");
Assert.IsTrue(response.HasValidJson, "The rejection should be a structured JSON reply");
Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString());
StringAssert.Contains("JSON object", response.JsonResponse["errorDetails"]?.ToString());
}
[Test]
public async Task ApiExec_InvalidCommand_ReturnsError()
{
// Arrange
var invalidRequest = new CommandExecutionRequest("nonexistent_command");
// Act - Execute invalid command via /api/exec endpoint using unified Pipeline client
var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest);
var responseContent = response.RawResponse;
LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named"));
// Assert - Should return error
Assert.IsFalse(response.IsSuccess, "Should return error for invalid command");
Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON");
var responseJson = response.JsonResponse;
Assert.IsNotNull(responseJson["error"], "Error response should have error field");
// A failure has no `message` (that's for success), so the lean envelope omits it; assert on
// the fields a failure actually carries instead. (AUTHAPI-21)
Assert.IsFalse(responseJson["success"].ToObject<bool>(), "Error response should report success=false");
}
[Test]
public async Task ApiExec_MissingRequiredParameter_ReturnsValidationError()
{
// Arrange - Try to execute log_editor without required message parameter
var invalidRequest = new CommandExecutionRequest("log_editor");
// Intentionally not setting the 'message' parameter to test validation
// Act - Execute command with missing parameter via /api/exec endpoint using unified Pipeline client
var response = await m_PipelineClient.PostJsonAsync("/api/exec", invalidRequest);
var responseContent = response.RawResponse;
LogAssert.Expect("ExecuteCommandByName: Parameter validation failed: Required parameter 'message' is missing or empty");
// Assert - Should return validation error
Assert.IsFalse(response.IsSuccess, "Should return error for missing required parameter");
Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON");
var responseJson = response.JsonResponse;
Assert.IsNotNull(responseJson["error"], "Should have error field");
Assert.That(responseJson["errorDetails"]?.ToString(),
Contains.Substring("message").IgnoreCase,
"Error should mention missing message parameter");
}
[Test]
public async Task ApiExec_OversizedBody_ReturnsPayloadTooLarge()
{
// Arrange - a request whose body exceeds the 1 MiB cap (Content-Length will advertise it).
var oversized = new CommandExecutionRequest("log_editor");
oversized.Parameters["message"] = new string('a', (1 * 1024 * 1024) + 1024);
// Act
var response = await m_PipelineClient.PostJsonAsync("/api/exec", oversized);
// Assert - rejected with 413 before the command ever runs.
Assert.AreEqual(413, response.StatusCode,
$"Oversized body should be rejected with 413. Response: {response.RawResponse}");
Assert.IsTrue(response.HasValidJson, "413 response should have valid JSON");
Assert.That(response.JsonResponse["error"]?.ToString(),
Contains.Substring("Payload Too Large"),
"413 response should identify the payload-too-large error");
}
[Test]
public async Task ApiExec_UnconvertibleParameter_ReturnsErrorInsteadOfSubstitutingDefault()
{
// Arrange - 'tail' is an int defaulting to 100. Falling back to that default would
// hand the caller far more entries than it asked for, with no way to detect it.
var request = new CommandExecutionRequest("console");
request.Parameters["tail"] = "not-a-number";
// Act
var response = await m_PipelineClient.PostJsonAsync("/api/exec", request);
LogAssert.Expect(new Regex("^ExecuteCommandByName: Parameter conversion failed: Parameter 'tail'"));
// Assert - pin the HTTP contract, not just "some failure": without the status and
// category, a regression to the command-execution handler would still pass.
Assert.IsFalse(response.IsSuccess, "Unconvertible argument should not report success");
Assert.AreEqual(400, response.StatusCode,
$"Unconvertible argument should be a 400, not another failure class. Response: {response.RawResponse}");
Assert.IsTrue(response.HasValidJson, "Error response should have valid JSON");
var responseJson = response.JsonResponse;
Assert.AreEqual("Parameter Validation Failed", responseJson["error"]?.ToString(),
"Should be classified as a parameter validation failure");
Assert.That(responseJson["errorDetails"]?.ToString(),
Contains.Substring("tail"),
"Error should name the offending parameter");
Assert.That(responseJson["errorDetails"]?.ToString(),
Contains.Substring("Int32"),
"Error should name the type the value could not be converted to");
}
[Test]
public async Task ApiExec_NumericStringParameter_IsConvertedAndApplied()
{
// Arrange - a numeric *string* is what MCP clients send today, since the tool schema
// advertises numeric params as strings. Seed more entries than the limit asked for:
// asserting success alone would pass even if "5" were dropped for the default of 100,
// so the count is what proves the value was applied.
for (var i = 0; i < 12; i++)
{
UnityEngine.Debug.Log($"CLI978_numeric_string_seed_{i}");
}
var request = new CommandExecutionRequest("console");
request.Parameters["tail"] = "5";
// Act
var response = await m_PipelineClient.PostJsonAsync("/api/exec", request);
// Assert
Assert.IsTrue(response.IsSuccess,
$"A parseable numeric string should still be accepted. Response: {response.RawResponse}");
Assert.AreEqual(5, ReadReturnedCount(response.JsonResponse),
"tail=\"5\" must be honoured, not replaced by the command's default");
}
/// <summary>
/// Reads <c>result.returned</c> from an /api/exec envelope. The envelope carries `result`
/// either as a nested object or as a JSON string depending on the command, so handle both.
/// </summary>
private static int ReadReturnedCount(JObject responseJson)
{
var result = responseJson["result"];
if (result is JValue { Type: JTokenType.String } stringResult)
{
result = JToken.Parse(stringResult.Value<string>());
}
var returned = result?["returned"];
Assert.IsNotNull(returned, $"Response should carry result.returned: {responseJson}");
return returned.ToObject<int>();
}
[Test]
public async Task ApiExec_ConverterDeclinedToken_IsRejectedNotTreatedAsOmitted()
{
// ObjectRefConverter returns null for an unsupported token kind instead of throwing, so
// {"parent": true} used to read as an omitted argument and create_gameobject ran with
// parent == null — creating the object at the scene root rather than rejecting the
// malformed handle. A declined token must be a conversion error, not a silent default.
var request = new CommandExecutionRequest("create_gameobject");
request.Parameters["name"] = "CLI978_should_not_be_created";
request.Parameters["parent"] = true;
var response = await m_PipelineClient.PostJsonAsync("/api/exec", request);
LogAssert.Expect(new Regex("^ExecuteCommandByName: Parameter conversion failed: Parameter 'parent'"));
Assert.IsFalse(response.IsSuccess, "A declined token should not succeed");
Assert.AreEqual(400, response.StatusCode,
$"Should be a parameter-validation 400. Response: {response.RawResponse}");
Assert.AreEqual("Parameter Validation Failed", response.JsonResponse["error"]?.ToString());
Assert.That(response.JsonResponse["errorDetails"]?.ToString(),
Contains.Substring("parent"), "Error should name the offending parameter");
Assert.That(response.JsonResponse["errorDetails"]?.ToString(),
Contains.Substring("ObjectRef"), "Error should name the target type");
}
[Test]
public async Task ApiExec_ExplicitJsonNull_IsStillAcceptedAsAValue()
{
// The guard above must not catch a legitimately null optional argument: an explicit JSON
// null is the one value allowed to convert to null, and it falls back to the default.
var request = new CommandExecutionRequest("console");
request.Parameters["level"] = null;
request.Parameters["tail"] = 5;
var response = await m_PipelineClient.PostJsonAsync("/api/exec", request);
Assert.IsTrue(response.IsSuccess,
$"An explicit JSON null should remain acceptable. Response: {response.RawResponse}");
Assert.IsTrue(response.JsonResponse["success"].ToObject<bool>(), "Command should succeed");
}
[Test]
public async Task ApiExec_EmptyObjectRefString_IsAcceptedNotRejectedAsUnconvertible()
{
// An empty ObjectRef string is a documented value, not a malformed one:
// ObjectRefConverter.FromString returns null for it and set_parent advertises
// "Omit (or empty) to move the object to the scene root". The declined-token guard must
// not reject it. Uses a target that cannot resolve, so this asserts parameter BINDING
// without reparenting anything: the call still fails, but not as a validation failure.
var request = new CommandExecutionRequest("set_parent");
request.Parameters["target"] = "/CLI978_no_such_object";
request.Parameters["parent"] = "";
var response = await m_PipelineClient.PostJsonAsync("/api/exec", request);
// The command runs and fails at target resolution — which is itself the proof that
// `parent` bound. That failure logs an error, so it has to be declared.
LogAssert.Expect(new Regex("Could not resolve 'target'"));
// The error CATEGORY is not the discriminator here: set_parent's own resolve failure
// also throws ArgumentException, so it lands in "Parameter Validation Failed" too. What
// separates binding from execution is the detail text.
Assert.IsFalse(response.IsSuccess, "An unresolvable target should still fail");
Assert.That(response.JsonResponse["errorDetails"]?.ToString() ?? string.Empty,
Does.Not.Contain("could not be converted"),
$"An empty ObjectRef string must bind, not be rejected as unconvertible. Response: {response.RawResponse}");
Assert.That(response.JsonResponse["errorDetails"]?.ToString() ?? string.Empty,
Contains.Substring("Could not resolve 'target'"),
"The failure must come from the command body, proving 'parent' bound");
}
// ------------------------------------------------------------- raw command-line forms
// {"argv":[...]} and {"commandLine":"..."}. The server
// tokenizes/binds, so a client needs no schema knowledge and spends one request
// instead of two.
[Test]
public async Task ApiExec_ArgvForm_ExecutesTheCommand()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "test_types", "--count", "5" } });
Assert.AreEqual(200, response.StatusCode, $"Response: {response.RawResponse}");
Assert.IsTrue(response.IsCommandSuccess, $"Response: {response.RawResponse}");
}
[Test]
public async Task ApiExec_CommandLineForm_ExecutesTheCommand()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = "test_types --count 5" });
Assert.AreEqual(200, response.StatusCode, $"Response: {response.RawResponse}");
Assert.IsTrue(response.IsCommandSuccess, $"Response: {response.RawResponse}");
}
[Test]
public async Task ApiExec_RawForm_EchoesTheBoundParameters()
{
// The CLI renders this in four output formats; without the echo it has nothing
// to show, because it no longer binds client-side.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "test_types", "--count", "5" } });
var echoed = response.JsonResponse["parameters"] as JObject;
Assert.IsNotNull(echoed, $"argv requests must echo bound parameters. Response: {response.RawResponse}");
Assert.AreEqual("5", echoed["count"]?.ToString());
}
[Test]
public async Task ApiExec_StructuredForm_DoesNotEchoParameters()
{
// Back-compat: the structured path's envelope must not grow a key.
var request = new CommandExecutionRequest("test_types");
request.Parameters["count"] = 5;
var response = await m_PipelineClient.PostJsonAsync("/api/exec", request);
Assert.IsNull(response.JsonResponse["parameters"],
$"structured requests must be byte-identical to before. Response: {response.RawResponse}");
}
[Test]
public async Task ApiExec_ArgvWithSpacesInAToken_IsNotRetokenized()
{
// argv is already split; re-splitting it would corrupt any value containing a space.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor", "hello world" } });
var echoed = response.JsonResponse["parameters"] as JObject;
Assert.IsNotNull(echoed, $"Response: {response.RawResponse}");
Assert.AreEqual("hello world", echoed["message"]?.ToString());
}
[Test]
public async Task ApiExec_CommandLineQuoting_GroupsIntoOneToken()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = "log_editor \"hello world\"" });
var echoed = response.JsonResponse["parameters"] as JObject;
Assert.IsNotNull(echoed, $"Response: {response.RawResponse}");
Assert.AreEqual("hello world", echoed["message"]?.ToString());
}
// ------------------------------------------------------------------ mutual exclusion
[Test]
public async Task ApiExec_CommandAndArgvTogether_IsRejected()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { command = "test_types", argv = new[] { "test_types" } });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
}
[Test]
public async Task ApiExec_ArgvAndCommandLineTogether_IsRejected()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "test_types" }, commandLine = "test_types" });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
}
[Test]
public async Task ApiExec_ParametersAlongsideArgv_IsRejectedNotIgnored()
{
// Silently dropping the payload would recreate exactly the absent-vs-null
// ambiguity this codebase legislates against in the lean envelope.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "test_types" }, parameters = new { count = 5 } });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
}
[Test]
public async Task ApiExec_EmptyArgv_IsRejected()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new string[0] });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
}
[Test]
public async Task ApiExec_BlankCommandLine_IsRejected()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = " " });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
}
// ------------------------------------------------------------------ argument errors
[Test]
public async Task ApiExec_UnknownFlag_ReturnsInvalidCommandArgsWithProblemsAndSchema()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor", "--mesage", "hi" } });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
var json = response.JsonResponse;
Assert.AreEqual("INVALID_COMMAND_ARGS", json["errorCode"]?.ToString(),
"errorCode is the discriminator; every /api/exec failure is already 400");
var problems = json["argProblems"] as JArray;
Assert.IsNotNull(problems, $"Response: {response.RawResponse}");
Assert.AreEqual(1, problems.Count);
Assert.AreEqual("unknownName", problems[0]["kind"]?.ToString(),
"kind is camelCase on the wire even though C# spells it PascalCase");
Assert.AreEqual("mesage", problems[0]["name"]?.ToString());
Assert.AreEqual("message", problems[0]["suggestion"]?.ToString());
// Replaces the retired per-exec schema fetch: shaped like a /api/commands entry
// so the CLI's existing renderer consumes it with no new code.
var schema = json["commandSchema"] as JObject;
Assert.IsNotNull(schema, "an argument error must carry the command's schema");
Assert.AreEqual("log_editor", schema["name"]?.ToString());
Assert.IsNotNull(schema["parameters"] as JArray);
}
[Test]
public async Task ApiExec_TypeMismatch_ReturnsTypeMismatchProblem()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "test_types", "--count", "abc" } });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
var problems = response.JsonResponse["argProblems"] as JArray;
Assert.IsNotNull(problems, $"Response: {response.RawResponse}");
Assert.AreEqual("typeMismatch", problems[0]["kind"]?.ToString());
Assert.AreEqual("Int32", problems[0]["expectedType"]?.ToString());
}
[Test]
public async Task ApiExec_UnbalancedQuoteInCommandLine_IsRejected()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = "log_editor \"unbalanced" });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.That(response.JsonResponse["errorDetails"]?.ToString(),
Contains.Substring("quote").IgnoreCase);
}
[Test]
public async Task ApiExec_ATokenizerFailure_IsAnInvalidRequestWithNoErrorCode()
{
// A command line that will not tokenize never reached a command, so it is a malformed
// request shape - the same family as two command forms at once. Reporting it as
// INVALID_COMMAND_ARGS promised an argument diagnosis the reply cannot carry: there is
// no commandSchema and no argProblems, because there is no command. A client keying on
// errorCode to render usage found nothing to render.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = "log_editor \"unbalanced" });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString());
Assert.IsNull(response.JsonResponse["errorCode"],
"no command was resolved, so the argument-error discriminator must be absent");
Assert.IsNull(response.JsonResponse["commandSchema"]);
Assert.IsNull(response.JsonResponse["argProblems"]);
}
[Test]
public async Task ApiExec_ARequestWithNoCommandForm_IsAnInvalidRequest()
{
// Relaxing `command` from Required.Always (so the raw forms may omit it) moved this
// case out of JSON.NET's deserializer and into Validate(). Both answer 400, but the
// `error` value changed from "Invalid JSON" to "Invalid Request" - pinned here so the
// wording is a decision rather than a side effect.
var response = await m_PipelineClient.PostJsonAsync("/api/exec", new { });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString());
Assert.AreEqual("Command name is required",
response.JsonResponse["errorDetails"]?.ToString());
Assert.IsNull(response.JsonResponse["errorCode"]);
}
[Test]
public async Task ApiExec_ArgumentErrorSchema_MatchesTheCatalogEntryWithoutTheGeneratedSchema()
{
// commandSchema is the /api/commands entry minus the generated JSON schema, and it is
// built without generating that schema at all. Pinning both halves keeps the shape
// from drifting when the wasteful generate-then-discard is removed.
var error = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor", "--mesage", "hi" } });
Assert.AreEqual(400, error.StatusCode, $"Response: {error.RawResponse}");
var entry = error.JsonResponse["commandSchema"] as JObject;
Assert.IsNotNull(entry, $"Response: {error.RawResponse}");
Assert.IsNull(entry["schema"], "the generated schema must not ride along on an error");
var catalogHttp = await m_PipelineClient.GetHttpAsync("/api/commands");
var catalog = JObject.Parse(await catalogHttp.Content.ReadAsStringAsync());
var served = (catalog["commands"] as JArray)
?.FirstOrDefault(c => c["name"]?.ToString() == "log_editor") as JObject;
Assert.IsNotNull(served, "log_editor must be in the catalog");
Assert.IsNotNull(served["schema"], "/api/commands still carries the generated schema");
foreach (var property in served.Properties())
{
if (property.Name == "schema")
continue;
Assert.IsTrue(JToken.DeepEquals(property.Value, entry[property.Name]),
$"commandSchema.{property.Name} must match the catalog entry verbatim");
}
Assert.AreEqual(served.Properties().Count() - 1, entry.Properties().Count(),
"commandSchema must carry every catalog property except the generated schema");
}
[Test]
public async Task ApiExec_UnknownCommandViaArgv_MatchesTheStructuredEnvelope()
{
// Both calls log the same registry error; expect one per call.
LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named"));
LogAssert.Expect(new Regex("^ExecuteCommandByName: No command named"));
var viaArgv = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "no_such_command_xyz" } });
var viaStructured = await m_PipelineClient.PostJsonAsync("/api/exec",
new CommandExecutionRequest("no_such_command_xyz"));
Assert.AreEqual(viaStructured.StatusCode, viaArgv.StatusCode);
Assert.AreEqual(viaStructured.JsonResponse["error"]?.ToString(),
viaArgv.JsonResponse["error"]?.ToString(),
"an unknown raw command name must produce the identical Command Not Found envelope");
}
[Test]
public async Task ApiExec_MissingRequiredParameterViaArgv_IsRejectedBeforeExecution()
{
// A raw form is validated up front, so it never reaches ExecuteCommandByName and
// therefore logs no execution error. Message parity with the structured form is
// asserted directly by
// ApiExec_RawFormMissingRequiredParameter_ReportsTheSameMessageInBothForms, which is
// a stronger check than the log line this used to expect.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor" } });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.AreEqual("Parameter Validation Failed", response.JsonResponse["error"]?.ToString());
Assert.That(response.JsonResponse["errorDetails"]?.ToString(),
Contains.Substring("message").IgnoreCase);
}
[Test]
public async Task ApiExec_RawFormFailure_HonorsVerboseFlag()
{
// Reply-shape flags are captured before the raw-form branch, so a malformed
// command line still honours them.
var lean = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor", "--mesage", "hi" } });
var verbose = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor", "--mesage", "hi" }, verbose = true });
Assert.AreEqual(400, lean.StatusCode);
Assert.AreEqual(400, verbose.StatusCode);
Assert.IsNull(lean.JsonResponse["executedAt"], "lean mode drops envelope metadata");
Assert.IsNotNull(verbose.JsonResponse["executedAt"], "verbose mode restores it");
}
[Test]
public async Task ApiExec_JobWithCommandLine_ReturnsAJobId()
{
// RunJobDetached re-reads the request object, so normalizing in place means the
// detached path needs no changes of its own.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = "test_types --count 5", job = true });
Assert.AreEqual(200, response.StatusCode, $"Response: {response.RawResponse}");
var result = response.JsonResponse["result"] as JObject;
Assert.IsNotNull(result?["jobId"] ?? result?["id"],
$"detached submission should return a job id. Response: {response.RawResponse}");
}
[Test]
public async Task ApiStatus_AdvertisesTheRawFormCapabilities()
{
// Capability negotiation exists so a CLI can tell "this server predates argv" from
// "your request was malformed" WITHOUT a probe request -- an old server answers an
// argv request with a bare Invalid JSON, which the CLI would otherwise render as
// exit 6 plus "Run `unity bug`", i.e. version skew reading as a CLI defect.
var response = await m_PipelineClient.GetAsync("/api/status");
var capabilities = response.JsonResponse?["capabilities"] as JArray;
Assert.IsNotNull(capabilities, $"/api/status must advertise capabilities. Response: {response.RawResponse}");
var tokens = capabilities.Select(t => t.ToString()).ToList();
CollectionAssert.Contains(tokens, "exec.argv");
CollectionAssert.Contains(tokens, "exec.commandLine");
}
[Test]
public void ServerCapabilities_AreASingleSharedSource()
{
// The descriptor and /api/status must not be able to drift: one array feeds both.
CollectionAssert.Contains(BasePipelineServer.Capabilities, "exec.argv");
CollectionAssert.Contains(BasePipelineServer.Capabilities, "exec.commandLine");
}
[Test]
public async Task ApiExec_RawFormMissingRequiredParameter_NeverCreatesAJob()
{
// A deterministically invalid submission must not be acknowledged with a job id.
// Binding SUCCEEDS here -- a missing required parameter is not a binder problem, it is
// ValidateCommandParameters' job -- so without a preflight this reached job creation,
// returned 200 plus an id, and only failed later inside the detached run. A client
// polling that id learns minutes later what was knowable up front.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor" }, job = true });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.IsNull(response.JsonResponse["result"], "no job id for a known-invalid submission");
Assert.That(response.JsonResponse["errorDetails"]?.ToString(),
Contains.Substring("message").IgnoreCase);
}
[Test]
public async Task ApiExec_RawFormMissingRequiredParameter_ReportsTheSameMessageInBothForms()
{
// The preflight must reproduce ValidateCommandParameters' wording exactly, or the two
// request forms would diagnose the same mistake differently.
LogAssert.Expect("ExecuteCommandByName: Parameter validation failed: Required parameter 'message' is missing or empty");
var viaArgv = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor" } });
var viaStructured = await m_PipelineClient.PostJsonAsync("/api/exec",
new CommandExecutionRequest("log_editor"));
Assert.AreEqual(400, viaArgv.StatusCode);
Assert.AreEqual(400, viaStructured.StatusCode);
Assert.AreEqual(
viaStructured.JsonResponse["error"]?.ToString(),
viaArgv.JsonResponse["error"]?.ToString());
Assert.AreEqual(
viaStructured.JsonResponse["errorDetails"]?.ToString(),
viaArgv.JsonResponse["errorDetails"]?.ToString(),
"both forms must report the identical validation message");
}
[Test]
public async Task ApiExec_CommandLineWithAnEmptyFirstToken_IsAnInvalidRequest()
{
// `"" --message hi` is a NONBLANK source string, so Validate() passes, but it
// tokenizes to an empty first token. Left unchecked it became a Command Not Found,
// telling the caller an empty command is merely unavailable. The argv equivalent is
// already rejected as a bad request, so the two raw forms must agree.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = "\"\" --message hi" });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString(),
"an empty command name is a malformed request, not a missing command");
}
[Test]
public async Task ApiExec_CommandLineWithAWhitespaceFirstToken_IsAlsoRejected()
{
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { commandLine = "\" \" --message hi" });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.AreEqual("Invalid Request", response.JsonResponse["error"]?.ToString());
}
[Test]
public async Task ApiExec_MalformedArgumentsNeverCreateAJob()
{
// A mistyped parameter must not return a job id and exit 0.
var response = await m_PipelineClient.PostJsonAsync("/api/exec",
new { argv = new[] { "log_editor", "--mesage", "hi" }, job = true });
Assert.AreEqual(400, response.StatusCode, $"Response: {response.RawResponse}");
Assert.IsNull(response.JsonResponse["result"], "no job id for a malformed command line");
}
}
}