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
98 changes: 98 additions & 0 deletions Assets/Tests/Editor/JsonRpcRequestProcessorCliVersionGateTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -277,6 +278,46 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows
}
}

[Test]
public async Task ProcessRequest_WhenToolIsDisabled_ReturnsInternalErrorShape()
{
// Verifies disabled tools keep the JSON-RPC internal_error data shape before PR3 folds gates together.
InMemoryToolSettingsPort toolSettingsPort = new InMemoryToolSettingsPort();
toolSettingsPort.SetToolEnabled(SingleFlightTestTool.Name, false);
UnityCliLoopToolRegistrarService service = CreateRegistrarServiceWithToolSettings(toolSettingsPort);
service.RegisterCustomTool(new SingleFlightTestTool());
JsonRpcRequestProcessor processor = CreateProcessor(service);

LogAssert.Expect(LogType.Error, new Regex(@"\[JsonRpcRequestProcessor\] Error: Tool 'single-flight-test' is disabled"));
string response = await processor.ProcessRequest(
BuildToolRequest(SingleFlightTestTool.Name, 1),
CancellationToken.None);
JObject error = ParseError(response);
JObject data = ParseErrorData(response);

Assert.That(error["message"]?.ToString(), Does.Contain(SingleFlightTestTool.Name));
Assert.That(data["type"]?.ToString(), Is.EqualTo("internal_error"));
}

[Test]
public async Task ProcessRequest_WhenToolRequiresBlockedSecuritySetting_ReturnsSecurityBlockedShape()
{
// Verifies security-blocked tools keep their machine-readable JSON-RPC error data.
UnityCliLoopToolRegistrarService service = CreateRegistrarService();
service.RegisterCustomTool(new SecurityBlockedTestTool());
JsonRpcRequestProcessor processor = CreateProcessor(service);

LogAssert.Expect(LogType.Error, new Regex(@"\[JsonRpcRequestProcessor\] Error: Tool 'security-blocked-test' is blocked by security settings"));
string response = await processor.ProcessRequest(
BuildToolRequest(SecurityBlockedTestTool.Name, 1),
CancellationToken.None);
JObject data = ParseErrorData(response);

Assert.That(data["type"]?.ToString(), Is.EqualTo("security_blocked"));
Assert.That(data["command"]?.ToString(), Is.EqualTo(SecurityBlockedTestTool.Name));
Assert.That(data["reason"]?.ToString(), Does.Contain("security settings"));
}

[Test]
public async Task ProcessRequest_WhenCompileWaitsForDomainReload_KeepsAcceptedRequestAliveAfterDisconnect()
{
Expand Down Expand Up @@ -553,6 +594,15 @@ private static UnityCliLoopToolRegistrarService CreateRegistrarService()
return UnityCliLoopToolRegistrarTestFactory.Create(UnityCliLoopToolDiscovery.DiscoverTools);
}

private static UnityCliLoopToolRegistrarService CreateRegistrarServiceWithToolSettings(IToolSettingsPort toolSettingsPort)
{
return new UnityCliLoopToolRegistrarService(
new EmptyInternalToolNameProvider(),
toolSettingsPort,
new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort()),
UnityCliLoopToolDiscovery.DiscoverTools);
}

private static JsonRpcRequestProcessor CreateProcessor(UnityCliLoopToolRegistrarService service)
{
UnityCliLoopExecutionRouter executionRouter = new(service);
Expand Down Expand Up @@ -756,6 +806,54 @@ public Task<UnityCliLoopToolResponse> ExecuteAsync(JToken paramsToken, Cancellat
}
}

// The enum currently has only None, so an undefined value is the only way to exercise security-blocked handling.
[UnityCliLoopTool(RequiredSecuritySetting = (UnityCliLoopSecuritySetting)999)]
private sealed class SecurityBlockedTestTool : IUnityCliLoopTool
{
public const string Name = "security-blocked-test";

public string ToolName => Name;

public ToolParameterSchema ParameterSchema => new();

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

private sealed class InMemoryToolSettingsPort : IToolSettingsPort
{
private readonly 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()
{
}
}

private sealed class SingleFlightTestResponse : UnityCliLoopToolResponse
{
public bool Success { get; set; } = true;
Expand Down
85 changes: 85 additions & 0 deletions Assets/Tests/Editor/ToolExecutionSessionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Domain;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
public sealed class ToolExecutionSessionTests
{
[Test]
public void TryEnter_WhenNoToolIsRunning_ShouldEnterAndAllowExitThenNextTool()
{
// Tests that an empty session admits a tool and releases the slot after exit.
ToolExecutionSession session = new ToolExecutionSession();

ToolExecutionSessionEnterResult firstResult = session.TryEnter("first-tool");
session.Exit();
ToolExecutionSessionEnterResult secondResult = session.TryEnter("second-tool");

Assert.That(firstResult.IsEntered, Is.True);
Assert.That(firstResult.RunningToolName, Is.Empty);
Assert.That(secondResult.IsEntered, Is.True);

session.Exit();
}

[Test]
public void TryEnter_WhenDifferentToolIsRunning_ShouldReturnBusyWithRunningToolName()
{
// Tests that the single-flight gate reports the already running tool for rejected requests.
ToolExecutionSession session = new ToolExecutionSession();

ToolExecutionSessionEnterResult firstResult = session.TryEnter("running-tool");
ToolExecutionSessionEnterResult busyResult = session.TryEnter("requested-tool");

Assert.That(firstResult.IsEntered, Is.True);
Assert.That(busyResult.IsEntered, Is.False);
Assert.That(busyResult.RunningToolName, Is.EqualTo("running-tool"));

session.Exit();
}

[Test]
public void TryEnter_WhenExecuteDynamicCodeIsAlreadyRunning_ShouldAllowSecondExecuteDynamicCode()
{
// Tests that execute-dynamic-code keeps its existing shared execution slot behavior.
ToolExecutionSession session = new ToolExecutionSession();

ToolExecutionSessionEnterResult firstResult =
session.TryEnter(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE);
ToolExecutionSessionEnterResult secondResult =
session.TryEnter(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE);

Assert.That(firstResult.IsEntered, Is.True);
Assert.That(secondResult.IsEntered, Is.True);

session.Exit();
session.Exit();
}

[Test]
public void Exit_WhenTwoSharedDynamicCodeExecutionsEntered_ShouldKeepSlotBusyUntilBothExit()
{
// Tests that shared execute-dynamic-code entries keep the session busy until every entry exits.
ToolExecutionSession session = new ToolExecutionSession();

session.TryEnter(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE);
session.TryEnter(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE);

ToolExecutionSessionEnterResult busyBeforeExit = session.TryEnter("other-tool");
session.Exit();
ToolExecutionSessionEnterResult busyAfterOneExit = session.TryEnter("other-tool");
session.Exit();
ToolExecutionSessionEnterResult enteredAfterBothExit = session.TryEnter("other-tool");

Assert.That(busyBeforeExit.IsEntered, Is.False);
Assert.That(busyBeforeExit.RunningToolName, Is.EqualTo(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE));
Assert.That(busyAfterOneExit.IsEntered, Is.False);
Assert.That(busyAfterOneExit.RunningToolName, Is.EqualTo(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE));
Assert.That(enteredAfterBothExit.IsEntered, Is.True);

session.Exit();
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/ToolExecutionSessionTests.cs.meta

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
Expand Up @@ -14,12 +14,8 @@ namespace io.github.hatayama.UnityCliLoop.Application
/// </summary>
internal sealed class UnityCliLoopToolExecutionService
{
private const string UnknownToolName = "unknown";

private readonly IEditorRuntimeStatePort _editorRuntimeStatePort;
private readonly object _executionStateLock = new();
private string _runningToolName;
private int _runningExecutionCount;
private readonly ToolExecutionSession _executionSession = new();

internal UnityCliLoopToolExecutionService(IEditorRuntimeStatePort editorRuntimeStatePort)
{
Expand Down Expand Up @@ -55,9 +51,10 @@ internal async Task<UnityCliLoopToolResponse> ExecuteToolAsync(
throw new UnityCliLoopSecurityException(toolName, "Tool is blocked by security settings");
}

if (!TryEnterExecution(toolName, out string runningToolName))
ToolExecutionSessionEnterResult enterResult = _executionSession.TryEnter(toolName);
if (!enterResult.IsEntered)
{
throw CreateBusyException(runningToolName, toolName, _editorRuntimeStatePort);
throw CreateBusyException(enterResult.RunningToolName, toolName, _editorRuntimeStatePort);
}

try
Expand All @@ -76,55 +73,10 @@ internal async Task<UnityCliLoopToolResponse> ExecuteToolAsync(
}
finally
{
ExitExecution();
_executionSession.Exit();
}
}

private bool TryEnterExecution(string toolName, out string runningToolName)
{
lock (_executionStateLock)
{
if (_runningExecutionCount == 0)
{
_runningToolName = toolName;
_runningExecutionCount = 1;
runningToolName = toolName;
return true;
}

if (CanShareExecutionSlot(_runningToolName, toolName))
{
_runningExecutionCount++;
runningToolName = _runningToolName;
return true;
}

runningToolName = GetRunningToolNameInsideLock();
return false;
}
}

private void ExitExecution()
{
lock (_executionStateLock)
{
Debug.Assert(_runningExecutionCount > 0, "running execution count must be positive before exit");
_runningExecutionCount--;
if (_runningExecutionCount > 0)
{
return;
}

_runningToolName = null;
}
}

private static bool CanShareExecutionSlot(string runningToolName, string requestedToolName)
{
return runningToolName == UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE
&& requestedToolName == UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE;
}

internal static UnityCliLoopToolBusyException CreateBusyException(
string runningToolName,
string requestedToolName,
Expand Down Expand Up @@ -157,11 +109,5 @@ internal static UnityCliLoopToolBusyException CreateBusyException(
return new UnityCliLoopToolBusyException(runningToolName, requestedToolName);
}

private string GetRunningToolNameInsideLock()
{
return string.IsNullOrWhiteSpace(_runningToolName)
? UnknownToolName
: _runningToolName;
}
}
}
Loading
Loading