Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Assets/Tests/Editor/JsonRpcHeartbeatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class JsonRpcHeartbeatTests
public void CreateDispatchAcceptedResponse_WhenHeartbeatNegotiated_AdvertisesInterval()
{
// Tests that the dispatch ack tells a heartbeat-capable CLI which interval to expect.
string response = JsonRpcRequestProcessor.CreateDispatchAcceptedResponse(1, 10);
string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 10);

JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
Expand All @@ -29,7 +29,7 @@ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_OmitsInterval()
{
// Tests that older CLIs that did not negotiate heartbeats get the legacy ack shape,
// because they would treat unexpected extra frames as the final response.
string response = JsonRpcRequestProcessor.CreateDispatchAcceptedResponse(1, 0);
string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 0);

JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
Expand All @@ -40,7 +40,7 @@ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_OmitsInterval()
public void CreateHeartbeatResponse_WhenSerialized_CarriesPhaseAndStallSeconds()
{
// Tests the heartbeat frame shape the CLI parses for freeze diagnosis.
string response = JsonRpcRequestProcessor.CreateHeartbeatResponse(7, 12.5);
string response = JsonRpcResponseFactory.CreateHeartbeatResponse(7, 12.5);

JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Heartbeat));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine.TestTools;

using io.github.hatayama.UnityCliLoop.Application;
using io.github.hatayama.UnityCliLoop.CompositionRoot;
using io.github.hatayama.UnityCliLoop.Domain;
using io.github.hatayama.UnityCliLoop.Infrastructure;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Freezes the exact serialized JSON string for every JSON-RPC response shape
/// (success / error / dispatch-accepted / heartbeat / protocol-mismatch) before the
/// R3-5 move to JsonRpcResponseFactory, so the move can be verified byte-equal.
/// </summary>
public sealed class JsonRpcResponseFactoryWireShapeCharacterizationTests
{
[Test]
public async Task ProcessRequest_WhenToolSucceeds_ProducesFrozenSuccessJson()
{
// Verifies the success response wire shape is byte-equal to the frozen baseline.
UnityCliLoopToolRegistrarService service = UnityCliLoopToolRegistrarTestFactory.Create(UnityCliLoopToolDiscovery.DiscoverTools);
service.RegisterCustomTool(new DeterministicSuccessTool());
JsonRpcRequestProcessor processor = CreateProcessor(service);

string response = await processor.ProcessRequest(BuildToolRequest(DeterministicSuccessTool.Name, 1), CancellationToken.None);

Assert.That(response, Is.EqualTo(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"Success\":true}}"));
}

[Test]
public async Task ProcessRequest_WhenToolIsDisabled_ProducesFrozenErrorJson()
{
// Verifies the internal_error response wire shape is byte-equal to the frozen baseline.
InMemoryToolSettingsPort toolSettingsPort = new();
toolSettingsPort.SetToolEnabled(DeterministicSuccessTool.Name, false);
UnityCliLoopToolRegistrarService service = new UnityCliLoopToolRegistrarService(
new EmptyInternalToolNameProvider(),
toolSettingsPort,
new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort()),
UnityCliLoopToolDiscovery.DiscoverTools);
service.RegisterCustomTool(new DeterministicSuccessTool());
JsonRpcRequestProcessor processor = CreateProcessor(service);

LogAssert.Expect(UnityEngine.LogType.Error, new System.Text.RegularExpressions.Regex(
@"\[JsonRpcRequestProcessor\] Error: Tool 'deterministic-success' is disabled"));
string response = await processor.ProcessRequest(BuildToolRequest(DeterministicSuccessTool.Name, 1), CancellationToken.None);

Assert.That(response, Is.EqualTo(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"Tool 'deterministic-success' is disabled. To enable it, go to Window > Unity CLI Loop > Settings\",\"data\":{\"type\":\"internal_error\",\"message\":\"This tool has been disabled in project settings.\"}}}"));
}

[Test]
public void CreateDispatchAcceptedResponse_WithHeartbeatNegotiated_ProducesFrozenJson()
{
// Verifies the dispatch-accepted-with-heartbeat wire shape is byte-equal to the frozen baseline.
string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 10);

Assert.That(response, Is.EqualTo(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"accepted\":true},\"uloop\":{\"phase\":\"accepted\",\"heartbeatIntervalSeconds\":10}}"));
}

[Test]
public void CreateDispatchAcceptedResponse_WithoutHeartbeat_ProducesFrozenJson()
{
// Verifies the dispatch-accepted-without-heartbeat wire shape is byte-equal to the frozen baseline.
string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 0);

Assert.That(response, Is.EqualTo(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"accepted\":true},\"uloop\":{\"phase\":\"accepted\"}}"));
}

[Test]
public void CreateHeartbeatResponse_WhenSerialized_ProducesFrozenJson()
{
// Verifies the heartbeat frame wire shape is byte-equal to the frozen baseline.
string response = JsonRpcResponseFactory.CreateHeartbeatResponse(7, 12.5);

Assert.That(response, Is.EqualTo(
"{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"alive\":true},\"uloop\":{\"phase\":\"heartbeat\",\"mainThreadStallSeconds\":12.5}}"));
}

[Test]
public async Task ProcessRequest_WhenProtocolVersionIsTooOld_ProducesFrozenMismatchJson()
{
// Verifies the CLI-update-required wire shape is byte-equal to the frozen baseline.
UnityCliLoopToolRegistrarService service = UnityCliLoopToolRegistrarTestFactory.Create(UnityCliLoopToolDiscovery.DiscoverTools);
JsonRpcRequestProcessor processor = CreateProcessor(service);

string request =
"{\"jsonrpc\":\"2.0\",\"method\":\"get-version\",\"params\":{},\"id\":1,\"uloop\":{\"protocolVersion\":" +
(CliConstants.REQUIRED_CLI_PROTOCOL_VERSION - 1) +
"}}";
string response = await processor.ProcessRequest(request, CancellationToken.None);

Assert.That(response, Is.EqualTo(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"The installed uloop CLI uses an IPC protocol that does not match this Unity package.\",\"data\":{\"type\":\"cli_update_required\",\"currentCliVersion\":null,\"currentProtocolVersion\":" +
(CliConstants.REQUIRED_CLI_PROTOCOL_VERSION - 1) +
",\"requiredProtocolVersion\":" +
CliConstants.REQUIRED_CLI_PROTOCOL_VERSION +
",\"updateCommand\":\"uloop update\",\"retryableAfterUpdate\":true,\"message\":\"Install matching uloop CLI and Unity package versions, then retry the original command.\"}}}"));
}

private static JsonRpcRequestProcessor CreateProcessor(UnityCliLoopToolRegistrarService service)
{
UnityCliLoopExecutionRouter executionRouter = new(service);
return new JsonRpcRequestProcessor(executionRouter);
}

private static string BuildToolRequest(string toolName, int id)
{
return
"{\"jsonrpc\":\"2.0\",\"method\":\"" +
toolName +
"\",\"params\":{},\"id\":" +
id +
",\"uloop\":{\"protocolVersion\":" +
CliConstants.REQUIRED_CLI_PROTOCOL_VERSION +
"}}";
}

private sealed class DeterministicSuccessResponse : UnityCliLoopToolResponse
{
public bool Success { get; set; } = true;
}

private sealed class DeterministicSuccessTool : IUnityCliLoopTool
{
public const string Name = "deterministic-success";

public string ToolName => Name;

public ToolParameterSchema ParameterSchema => new();

public Task<UnityCliLoopToolResponse> ExecuteAsync(JToken paramsToken, CancellationToken ct)
{
return Task.FromResult<UnityCliLoopToolResponse>(new DeterministicSuccessResponse());
}
}

private sealed class InMemoryToolSettingsPort : IToolSettingsPort
{
private readonly System.Collections.Generic.HashSet<string> _disabledTools = new();

public bool IsToolEnabled(string toolName)
{
return !_disabledTools.Contains(toolName);
}

public void SetToolEnabled(string toolName, bool enabled)
{
if (enabled)
{
_disabledTools.Remove(toolName);
return;
}

_disabledTools.Add(toolName);
}

public string[] GetDisabledTools()
{
string[] disabledTools = new string[_disabledTools.Count];
_disabledTools.CopyTo(disabledTools);
return disabledTools;
}

public void InvalidateCache()
{
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Newtonsoft.Json;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Carries protocol mismatch details plus optional CLI update instructions.
/// </summary>
public class CliUpdateRequiredErrorData : JsonRpcErrorData
{
public override string type => JsonRpcErrorTypes.CliUpdateRequired;

public string currentCliVersion { get; }

public int? currentProtocolVersion { get; }

public int requiredProtocolVersion { get; }

[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string updateCommand { get; }

public bool retryableAfterUpdate { get; }

public CliUpdateRequiredErrorData(
string currentCliVersion,
int? currentProtocolVersion,
int requiredProtocolVersion,
string updateCommand) : base("Install matching uloop CLI and Unity package versions, then retry the original command.")
{
this.currentCliVersion = string.IsNullOrWhiteSpace(currentCliVersion) ? null : currentCliVersion;
this.currentProtocolVersion = currentProtocolVersion;
this.requiredProtocolVersion = requiredProtocolVersion;
this.updateCommand = updateCommand;
retryableAfterUpdate = true;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Error data for internal errors
/// </summary>
public class InternalErrorData : JsonRpcErrorData
{
public override string type => JsonRpcErrorTypes.InternalError;

public InternalErrorData(string message) : base(message)
{
}
}
}
11 changes: 11 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// JSON-RPC error object
/// </summary>
public class JsonRpcError
{
public int code { get; }

public string message { get; }

public JsonRpcErrorData data { get; }

public JsonRpcError(int code, string message, JsonRpcErrorData data)
{
this.code = code;
this.message = message;
this.data = data;
}
}
}
11 changes: 11 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Base class for JSON-RPC error data
/// </summary>
public abstract class JsonRpcErrorData
{
public abstract string type { get; }

public string message { get; protected set; }

protected JsonRpcErrorData(string message)
{
this.message = message;
}
}
}
11 changes: 11 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// JSON-RPC error response
/// </summary>
public class JsonRpcErrorResponse
{
public string jsonrpc { get; }

public object id { get; }

public JsonRpcError error { get; }

public JsonRpcErrorResponse(string jsonRpc, object id, JsonRpcError error)
{
this.jsonrpc = jsonRpc;
this.id = id;
this.error = error;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading