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
14 changes: 14 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version-file: cli/.go-version
cache: false

# Windows-only code paths (named pipe transport, winio deadlines, typed error
# classification) are otherwise never executed by CI. Scoped to the transport
# package: the wider test suite still has Windows-incompatible tests (path
# separators, missing .exe suffixes, TCP-only fixtures) that predate this job.
- name: Test native Go CLI transport on Windows
shell: bash
run: cd cli && go test ./internal/unityipc/

- name: Test install release filter in Git Bash
shell: bash
run: sh scripts/test-install-release-filter.sh
Expand Down
108 changes: 108 additions & 0 deletions Assets/Tests/Editor/BridgeTransportListenerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System;
using System.IO;
using System.Threading;
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Test fixture that verifies the Unix domain socket listener's stop behavior.
/// </summary>
public class BridgeTransportListenerTests
{
private string _tempProjectRoot;
private BridgeTransportEndpoint _endpoint;

[SetUp]
public void SetUp()
{
_tempProjectRoot = Path.Combine(Path.GetTempPath(), "uloop-listener-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempProjectRoot);
_endpoint = BridgeTransportEndpoint.CreateProjectIpc(_tempProjectRoot);
if (_endpoint.Kind != BridgeTransportKind.UnixDomainSocket)
{
Assert.Ignore("Unix domain socket listener tests only run on macOS/Linux.");
}
}

[TearDown]
public void TearDown()
{
if (_endpoint != null && File.Exists(_endpoint.Path))
{
File.Delete(_endpoint.Path);
}

if (Directory.Exists(_tempProjectRoot))
{
Directory.Delete(_tempProjectRoot, recursive: true);
}
}

[Test]
public void Stop_WhenCalledTwice_DoesNotThrowAndRemovesSocketFile()
{
// Tests that Stop is idempotent: Dispose() also calls Stop, so double stops happen
// routinely during shutdown and must not throw on the already-closed socket.
UnixDomainSocketBridgeTransportListener listener = new(_endpoint);
listener.Start();
Assert.That(File.Exists(_endpoint.Path), Is.True);

listener.Stop();
Assert.DoesNotThrow(() => listener.Stop());
Assert.That(File.Exists(_endpoint.Path), Is.False);
}

[Test]
public void AcceptClient_AfterStop_ThrowsObjectDisposedException()
{
// Tests that accepting on a stopped listener surfaces as disposal, which the server
// loop treats as an exit-and-recover signal, instead of NullReferenceException
// which would make the accept loop spin.
UnixDomainSocketBridgeTransportListener listener = new(_endpoint);
listener.Start();
listener.Stop();

Assert.Throws<ObjectDisposedException>(
() => listener.AcceptClient(CancellationToken.None));
}
}

/// <summary>
/// Test fixture that verifies the Windows named pipe listener's stopped-state behavior.
/// These paths run before any pipe is created, so they are platform-independent.
/// </summary>
public class WindowsNamedPipeBridgeTransportListenerTests
{
[Test]
public void Stop_WhenCalledTwice_DoesNotThrow()
{
// Tests that Stop is idempotent: Dispose() also calls Stop, so double stops happen
// routinely during shutdown and only one caller may dispose the active pipe.
BridgeTransportEndpoint endpoint = BridgeTransportEndpoint.CreateProjectIpc(
System.IO.Path.GetTempPath());
WindowsNamedPipeBridgeTransportListener listener = new(endpoint);
listener.Start();

listener.Stop();
Assert.DoesNotThrow(() => listener.Stop());
}

[Test]
public void AcceptClient_AfterStop_ThrowsObjectDisposedException()
{
// Tests that a stopped listener refuses to accept instead of creating a fresh
// pipe nobody can wake, so the server loop exits and recovers.
BridgeTransportEndpoint endpoint = BridgeTransportEndpoint.CreateProjectIpc(
System.IO.Path.GetTempPath());
WindowsNamedPipeBridgeTransportListener listener = new(endpoint);
listener.Start();
listener.Stop();

Assert.Throws<ObjectDisposedException>(
() => listener.AcceptClient(CancellationToken.None));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/BridgeTransportListenerTests.cs.meta

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

106 changes: 106 additions & 0 deletions Assets/Tests/Editor/JsonRpcHeartbeatTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Test fixture that verifies heartbeat negotiation and frame emission for the bridge server.
/// </summary>
public class JsonRpcHeartbeatTests
{
[Test]
public void CreateDispatchAcceptedResponse_WhenHeartbeatNegotiated_AdvertisesInterval()
{
// Tests that the dispatch ack tells a heartbeat-capable CLI which interval to expect.
string response = JsonRpcProcessor.CreateDispatchAcceptedResponse(1, 10);

JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
Assert.That(parsed["uloop"]["heartbeatIntervalSeconds"].Value<int>(), Is.EqualTo(10));
}

[Test]
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 = JsonRpcProcessor.CreateDispatchAcceptedResponse(1, 0);

JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
Assert.That(parsed["uloop"]["heartbeatIntervalSeconds"], Is.Null);
}

[Test]
public void CreateHeartbeatResponse_WhenSerialized_CarriesPhaseAndStallSeconds()
{
// Tests the heartbeat frame shape the CLI parses for freeze diagnosis.
string response = JsonRpcProcessor.CreateHeartbeatResponse(7, 12.5);

JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Heartbeat));
Assert.That(parsed["uloop"]["mainThreadStallSeconds"].Value<double>(), Is.EqualTo(12.5));
Assert.That(parsed["id"].Value<int>(), Is.EqualTo(7));
}

[Test]
public async Task SendHeartbeatsAsync_WhenRunning_WritesFramesUntilCancelled()
{
// Tests that the heartbeat loop emits frames on the interval and stops on cancellation
// without leaving background work behind.
int writtenFrameCount = 0;
using CancellationTokenSource cancellationSource = new();

Task heartbeatTask = UnityCliLoopBridgeServer.SendHeartbeatsAsync(
() => "{}",
_ =>
{
Interlocked.Increment(ref writtenFrameCount);
return Task.CompletedTask;
},
TimeSpan.FromMilliseconds(10),
cancellationSource.Token);

await Task.Delay(100);
cancellationSource.Cancel();
await heartbeatTask;

Assert.That(Volatile.Read(ref writtenFrameCount), Is.GreaterThanOrEqualTo(1));
}

[Test]
public async Task SendHeartbeatsAsync_WhenWriteThrowsIOException_StopsWithoutFaulting()
{
// Tests that a broken connection ends the heartbeat loop silently; teardown is
// owned by the read loop, not the heartbeat writer.
using CancellationTokenSource cancellationSource = new();

Task heartbeatTask = UnityCliLoopBridgeServer.SendHeartbeatsAsync(
() => "{}",
_ => throw new System.IO.IOException("broken pipe"),
TimeSpan.FromMilliseconds(1),
cancellationSource.Token);

await heartbeatTask;

Assert.That(heartbeatTask.IsCompletedSuccessfully, Is.True);
}

[Test]
public void EditorMainThreadLivenessTracker_AfterRegistration_ReportsSmallStall()
{
// Tests that the tracker reports near-zero stall right after a recorded tick.
EditorMainThreadLivenessTracker.RegisterForEditorStartup();

double stallSeconds = EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick();

Assert.That(stallSeconds, Is.GreaterThanOrEqualTo(0));
Assert.That(stallSeconds, Is.LessThan(60));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/JsonRpcHeartbeatTests.cs.meta

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

28 changes: 14 additions & 14 deletions Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,14 @@ public async Task ProcessRequest_WhenFirstToolWaitsForMainThread_ReturnsServerBu
firstResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 1),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));

secondResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 2),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

string secondResponse = await AwaitWithTimeout(secondResponseTask, TimeSpan.FromMilliseconds(200));
JObject error = ParseError(secondResponse);
Expand Down Expand Up @@ -163,22 +163,22 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows
firstDynamicCodeTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE, 1),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));

secondDynamicCodeTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE, 2),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(2));
Assert.That(secondDynamicCodeTask.IsCompleted, Is.False);

otherToolTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 3),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

string otherToolResponse = await AwaitWithTimeout(otherToolTask, TimeSpan.FromMilliseconds(200));
JObject error = ParseError(otherToolResponse);
Expand Down Expand Up @@ -227,7 +227,7 @@ public async Task ProcessRequest_WhenCompileWaitsForDomainReload_KeepsAcceptedRe
"{\"WaitForDomainReload\":true}",
1),
CancellationToken.None,
(_, shouldCancelOnClientDisconnect) =>
(_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
Expand Down Expand Up @@ -263,7 +263,7 @@ public async Task ProcessRequest_WhenCompileOmitsReloadWait_KeepsAcceptedRequest
string response = await JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_COMPILE, 1),
CancellationToken.None,
(_, shouldCancelOnClientDisconnect) =>
(_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
Expand Down Expand Up @@ -302,7 +302,7 @@ public async Task ProcessRequest_WhenCompileDoesNotWaitForDomainReload_CancelsOn
"{\"WaitForDomainReload\":false}",
1),
CancellationToken.None,
(_, shouldCancelOnClientDisconnect) =>
(_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
Expand Down Expand Up @@ -341,7 +341,7 @@ public async Task ProcessRequest_WhenCompileUsesCamelCaseNoReloadWait_CancelsOnC
"{\"waitForDomainReload\":false}",
1),
CancellationToken.None,
(_, shouldCancelOnClientDisconnect) =>
(_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
Expand Down Expand Up @@ -384,7 +384,7 @@ public async Task ProcessRequest_AfterGetHierarchyReturns_AllowsImmediateGetLogs
"{\"MaxDepth\":0,\"IncludeComponents\":false}",
1),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
dispatcher.RunContinuations();
Expand All @@ -400,7 +400,7 @@ public async Task ProcessRequest_AfterGetHierarchyReturns_AllowsImmediateGetLogs
"{\"MaxCount\":0}",
2),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
dispatcher.RunContinuations();
Expand Down Expand Up @@ -434,7 +434,7 @@ public async Task ProcessRequest_WhenInternalBridgeCommandRuns_SwitchesToMainThr
responseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.COMMAND_NAME_GET_VERSION, 1),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
Assert.That(responseTask.IsCompleted, Is.False);
Expand Down Expand Up @@ -478,7 +478,7 @@ public async Task ProcessRequest_WhenMainThreadSwitchIsCanceled_ReleasesExecutio
canceledResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 1),
cancellationSource.Token,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));

Expand All @@ -488,7 +488,7 @@ public async Task ProcessRequest_WhenMainThreadSwitchIsCanceled_ReleasesExecutio
secondResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 2),
CancellationToken.None,
(_, _) => Task.CompletedTask);
(_, _, _) => Task.CompletedTask);

Assert.That(secondResponseTask.IsCompleted, Is.False);

Expand Down
Loading
Loading