diff --git a/Assets/Tests/Editor/CliInstallationDetectorCacheTests.cs b/Assets/Tests/Editor/CliInstallationDetectorCacheTests.cs
new file mode 100644
index 0000000000..5d2ebb743c
--- /dev/null
+++ b/Assets/Tests/Editor/CliInstallationDetectorCacheTests.cs
@@ -0,0 +1,348 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.Application;
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies how the CLI installation detector caches, refreshes, and invalidates detection results.
+ ///
+ public sealed class CliInstallationDetectorCacheTests
+ {
+ private const string DetectedVersion = "3.6.0";
+ private const string DetectedPath = "/bin/uloop";
+
+ ///
+ /// Verifies a refresh stores the detected version, path, and dispatcher flag and marks the check completed.
+ ///
+ [Test]
+ public async Task RefreshCliVersionAsync_WhenDetectionSucceeds_CachesDetection()
+ {
+ CountingDetection detection = new(new CliInstallationDetection(DetectedVersion, DetectedPath, true));
+ CliInstallationDetector detector = CreateDetector(detection);
+
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detector.IsCheckCompleted(), Is.True);
+ Assert.That(detector.IsCliInstalled(), Is.True);
+ Assert.That(detector.GetCachedCliVersion(), Is.EqualTo(DetectedVersion));
+ Assert.That(detector.GetCachedCliExecutablePath(), Is.EqualTo(DetectedPath));
+ Assert.That(detector.GetCachedCliIsDispatcher(), Is.True);
+ Assert.That(detection.CallCount, Is.EqualTo(1));
+ }
+
+ ///
+ /// Verifies a refresh hands the caller's token to the detection so cancelling it can stop the CLI process.
+ ///
+ [Test]
+ public async Task RefreshCliVersionAsync_PassesTheCallerTokenToDetection()
+ {
+ CountingDetection detection = new(new CliInstallationDetection(DetectedVersion, DetectedPath));
+ CliInstallationDetector detector = CreateDetector(detection);
+ using CancellationTokenSource cancellation = new();
+
+ await detector.RefreshCliVersionAsync(cancellation.Token);
+
+ Assert.That(detection.ReceivedTokens, Is.EqualTo(new[] { cancellation.Token }));
+ }
+
+ ///
+ /// Verifies a forced refresh hands the caller's token to the detection so cancelling it can stop the CLI process.
+ ///
+ [Test]
+ public async Task ForceRefreshCliVersionAsync_PassesTheCallerTokenToDetection()
+ {
+ CountingDetection detection = new(new CliInstallationDetection(DetectedVersion, DetectedPath));
+ CliInstallationDetector detector = CreateDetector(detection);
+ using CancellationTokenSource cancellation = new();
+
+ await detector.ForceRefreshCliVersionAsync(cancellation.Token);
+
+ Assert.That(detection.ReceivedTokens, Is.EqualTo(new[] { cancellation.Token }));
+ }
+
+ ///
+ /// Verifies a completed check without a detected version reports the CLI as not installed.
+ ///
+ [Test]
+ public async Task RefreshCliVersionAsync_WhenNoVersionIsDetected_ReportsNotInstalled()
+ {
+ CountingDetection detection = new(new CliInstallationDetection(null, DetectedPath));
+ CliInstallationDetector detector = CreateDetector(detection);
+
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detector.IsCheckCompleted(), Is.True);
+ Assert.That(detector.IsCliInstalled(), Is.False);
+ Assert.That(detector.GetCachedCliExecutablePath(), Is.EqualTo(DetectedPath));
+ Assert.That(detector.GetCachedCliIsDispatcher(), Is.False);
+ }
+
+ ///
+ /// Verifies a second refresh reuses the cached result instead of detecting again.
+ ///
+ [Test]
+ public async Task RefreshCliVersionAsync_WhenCacheIsInitialized_DoesNotDetectAgain()
+ {
+ CountingDetection detection = new(new CliInstallationDetection(DetectedVersion, DetectedPath));
+ CliInstallationDetector detector = CreateDetector(detection);
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detection.CallCount, Is.EqualTo(1));
+ }
+
+ ///
+ /// Verifies a refresh requested while another refresh is pending returns without starting a second detection.
+ ///
+ [Test]
+ public async Task RefreshCliVersionAsync_WhenRefreshIsPending_DoesNotStartSecondDetection()
+ {
+ PendingDetection detection = new();
+ CliInstallationDetector detector = CreateDetector(detection);
+ Task firstRefresh = detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Task secondRefresh = detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(secondRefresh.IsCompleted, Is.True);
+ Assert.That(detection.CallCount, Is.EqualTo(1));
+ Assert.That(detector.IsCheckCompleted(), Is.False);
+
+ detection.Complete(new CliInstallationDetection(DetectedVersion, DetectedPath));
+ await firstRefresh;
+
+ Assert.That(detector.GetCachedCliVersion(), Is.EqualTo(DetectedVersion));
+ }
+
+ ///
+ /// Verifies a failed detection clears the in-progress flag so the next refresh detects again.
+ ///
+ [Test]
+ public async Task RefreshCliVersionAsync_WhenDetectionThrows_AllowsNextRefresh()
+ {
+ ThrowOnceDetection detection = new(new CliInstallationDetection(DetectedVersion, DetectedPath));
+ CliInstallationDetector detector = CreateDetector(detection);
+
+ // Awaited in try / catch instead of Assert.ThrowsAsync, which blocks the main thread in this NUnit.
+ try
+ {
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+ Assert.Fail("Expected the detection failure to propagate.");
+ }
+ catch (InvalidOperationException exception)
+ {
+ Assert.That(exception.Message, Is.EqualTo(ThrowOnceDetection.ErrorMessage));
+ }
+
+ Assert.That(detector.IsCheckCompleted(), Is.False);
+
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detection.CallCount, Is.EqualTo(2));
+ Assert.That(detector.GetCachedCliVersion(), Is.EqualTo(DetectedVersion));
+ }
+
+ ///
+ /// Verifies a forced refresh detects again and replaces an already cached result.
+ ///
+ [Test]
+ public async Task ForceRefreshCliVersionAsync_WhenCacheIsInitialized_ReplacesCachedDetection()
+ {
+ SequenceDetection detection = new(
+ new CliInstallationDetection(DetectedVersion, DetectedPath, true),
+ new CliInstallationDetection("3.7.0", "/other/uloop"));
+ CliInstallationDetector detector = CreateDetector(detection);
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ await detector.ForceRefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detection.CallCount, Is.EqualTo(2));
+ Assert.That(detector.GetCachedCliVersion(), Is.EqualTo("3.7.0"));
+ Assert.That(detector.GetCachedCliExecutablePath(), Is.EqualTo("/other/uloop"));
+ Assert.That(detector.GetCachedCliIsDispatcher(), Is.False);
+ Assert.That(detector.IsCheckCompleted(), Is.True);
+ }
+
+ ///
+ /// Verifies a forced refresh fills the cache even when no refresh ran before.
+ ///
+ [Test]
+ public async Task ForceRefreshCliVersionAsync_WhenCacheIsEmpty_CachesDetection()
+ {
+ CountingDetection detection = new(new CliInstallationDetection(DetectedVersion, DetectedPath, true));
+ CliInstallationDetector detector = CreateDetector(detection);
+
+ await detector.ForceRefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detector.IsCheckCompleted(), Is.True);
+ Assert.That(detector.GetCachedCliVersion(), Is.EqualTo(DetectedVersion));
+ Assert.That(detector.GetCachedCliIsDispatcher(), Is.True);
+ }
+
+ ///
+ /// Verifies invalidating the cache hides the cached result and lets the next refresh detect again.
+ ///
+ [Test]
+ public async Task InvalidateCache_WhenCacheIsInitialized_ClearsResultAndAllowsRefresh()
+ {
+ CountingDetection detection = new(new CliInstallationDetection(DetectedVersion, DetectedPath, true));
+ CliInstallationDetector detector = CreateDetector(detection);
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ detector.InvalidateCache();
+
+ Assert.That(detector.IsCheckCompleted(), Is.False);
+ Assert.That(detector.IsCliInstalled(), Is.False);
+ Assert.That(detector.GetCachedCliVersion(), Is.Null);
+ Assert.That(detector.GetCachedCliExecutablePath(), Is.Null);
+ Assert.That(detector.GetCachedCliIsDispatcher(), Is.False);
+
+ await detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detection.CallCount, Is.EqualTo(2));
+ }
+
+ ///
+ /// Verifies invalidating the cache during a pending refresh lets a new refresh start its own detection.
+ ///
+ [Test]
+ public async Task InvalidateCache_WhenRefreshIsPending_AllowsNewRefresh()
+ {
+ PendingDetection detection = new();
+ CliInstallationDetector detector = CreateDetector(detection);
+ Task firstRefresh = detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ detector.InvalidateCache();
+ Task secondRefresh = detector.RefreshCliVersionAsync(CancellationToken.None);
+
+ Assert.That(detection.CallCount, Is.EqualTo(2));
+
+ detection.Complete(new CliInstallationDetection(DetectedVersion, DetectedPath));
+ await firstRefresh;
+ await secondRefresh;
+
+ Assert.That(detector.GetCachedCliVersion(), Is.EqualTo(DetectedVersion));
+ }
+
+ private static CliInstallationDetector CreateDetector(IFakeDetection detection)
+ {
+ CliInstallationDetector detector = new(new UnusedPinReader());
+ detector.SetDetectionForTesting(detection.DetectAsync);
+ return detector;
+ }
+
+ private interface IFakeDetection
+ {
+ Task DetectAsync(CancellationToken ct);
+ }
+
+ private sealed class CountingDetection : IFakeDetection
+ {
+ private readonly CliInstallationDetection _result;
+
+ public CountingDetection(CliInstallationDetection result)
+ {
+ _result = result;
+ }
+
+ public int CallCount { get; private set; }
+
+ public List ReceivedTokens { get; } = new();
+
+ public Task DetectAsync(CancellationToken ct)
+ {
+ CallCount++;
+ ReceivedTokens.Add(ct);
+ return Task.FromResult(_result);
+ }
+ }
+
+ private sealed class SequenceDetection : IFakeDetection
+ {
+ private readonly CliInstallationDetection[] _results;
+
+ public SequenceDetection(params CliInstallationDetection[] results)
+ {
+ _results = results;
+ }
+
+ public int CallCount { get; private set; }
+
+ public Task DetectAsync(CancellationToken ct)
+ {
+ CliInstallationDetection result = _results[CallCount];
+ CallCount++;
+ return Task.FromResult(result);
+ }
+ }
+
+ private sealed class ThrowOnceDetection : IFakeDetection
+ {
+ public const string ErrorMessage = "detection failed in this test";
+
+ private readonly CliInstallationDetection _result;
+
+ public ThrowOnceDetection(CliInstallationDetection result)
+ {
+ _result = result;
+ }
+
+ public int CallCount { get; private set; }
+
+ public Task DetectAsync(CancellationToken ct)
+ {
+ CallCount++;
+ if (CallCount == 1)
+ {
+ return Task.FromException(new InvalidOperationException(ErrorMessage));
+ }
+
+ return Task.FromResult(_result);
+ }
+ }
+
+ // Every call shares one task that the test completes itself, so nothing outlives the test.
+ private sealed class PendingDetection : IFakeDetection
+ {
+ private readonly TaskCompletionSource _completion = new();
+
+ public int CallCount { get; private set; }
+
+ public Task DetectAsync(CancellationToken ct)
+ {
+ CallCount++;
+ return _completion.Task;
+ }
+
+ public void Complete(CliInstallationDetection result)
+ {
+ _completion.SetResult(result);
+ }
+ }
+
+ private sealed class UnusedPinReader : ICliPinReader
+ {
+ public CliPinLoadResult LoadPackagePin()
+ {
+ throw new InvalidOperationException("The cache tests never read the pin.");
+ }
+
+ public DispatcherBootstrapPinLoadResult LoadDispatcherBootstrapPin()
+ {
+ throw new InvalidOperationException("The cache tests never read the pin.");
+ }
+
+ public string LoadMinimumDispatcherVersionOrThrow()
+ {
+ throw new InvalidOperationException("The cache tests never read the pin.");
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/CliInstallationDetectorCacheTests.cs.meta b/Assets/Tests/Editor/CliInstallationDetectorCacheTests.cs.meta
new file mode 100644
index 0000000000..537452009b
--- /dev/null
+++ b/Assets/Tests/Editor/CliInstallationDetectorCacheTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c99b2772b0a054948bd907ecc799de9d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/CliInstallationDetectorVersionCommandTests.cs b/Assets/Tests/Editor/CliInstallationDetectorVersionCommandTests.cs
new file mode 100644
index 0000000000..19125c4ffb
--- /dev/null
+++ b/Assets/Tests/Editor/CliInstallationDetectorVersionCommandTests.cs
@@ -0,0 +1,305 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using System.Threading;
+
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+using io.github.hatayama.UnityCliLoop.Domain;
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies how the CLI installation detector runs and interprets the CLI version commands.
+ ///
+ public sealed class CliInstallationDetectorVersionCommandTests
+ {
+ private const string ExecutablePath = "/bin/uloop";
+ private const string ContractArguments = CliConstants.VERSION_FLAG + " " + CliConstants.JSON_FLAG;
+
+ private int _warningCount;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _warningCount = 0;
+ UnityEngine.Application.logMessageReceived += CountWarning;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ UnityEngine.Application.logMessageReceived -= CountWarning;
+ }
+
+ ///
+ /// Verifies a version read from the JSON contract output is used without running the plain version command.
+ ///
+ [Test]
+ public void DetectCliInstallationAtExecutablePath_WhenContractReportsVersion_UsesContractVersion()
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(ContractArguments, new CliDetectionCommandResult(new[] { "{\"DispatcherVersion\":\"3.6.2\"}" }, 0));
+
+ CliInstallationDetection detection = CliInstallationDetector.DetectCliInstallationAtExecutablePath(
+ ExecutablePath,
+ CancellationToken.None,
+ runner.Execute);
+
+ Assert.That(detection.Version, Is.EqualTo("3.6.2"));
+ Assert.That(detection.IsDispatcher, Is.True);
+ Assert.That(detection.ExecutablePath, Is.EqualTo(ExecutablePath));
+ Assert.That(runner.Arguments, Is.EqualTo(new[] { ContractArguments }));
+ Assert.That(runner.FileNames, Is.EqualTo(new[] { ExecutablePath }));
+ }
+
+ ///
+ /// Verifies the plain version output is used when the JSON contract output has no version.
+ ///
+ [Test]
+ public void DetectCliInstallationAtExecutablePath_WhenContractHasNoVersion_FallsBackToPlainVersion()
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(ContractArguments, new CliDetectionCommandResult(new[] { "not json" }, 0));
+ runner.Add(CliConstants.VERSION_FLAG, new CliDetectionCommandResult(new[] { "2.9.0" }, 0));
+
+ CliInstallationDetection detection = CliInstallationDetector.DetectCliInstallationAtExecutablePath(
+ ExecutablePath,
+ CancellationToken.None,
+ runner.Execute);
+
+ Assert.That(detection.Version, Is.EqualTo("2.9.0"));
+ Assert.That(detection.IsDispatcher, Is.False);
+ Assert.That(detection.ExecutablePath, Is.EqualTo(ExecutablePath));
+ Assert.That(runner.Arguments, Is.EqualTo(new[] { ContractArguments, CliConstants.VERSION_FLAG }));
+ }
+
+ ///
+ /// Verifies both version commands receive the caller's token so cancelling it can stop the CLI process.
+ ///
+ [Test]
+ public void DetectCliInstallationAtExecutablePath_PassesTheCallerTokenToEveryCommand()
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(ContractArguments, new CliDetectionCommandResult(Array.Empty(), 1));
+ runner.Add(CliConstants.VERSION_FLAG, new CliDetectionCommandResult(new[] { "2.9.0" }, 0));
+ using CancellationTokenSource cancellation = new();
+
+ CliInstallationDetector.DetectCliInstallationAtExecutablePath(
+ ExecutablePath,
+ cancellation.Token,
+ runner.Execute);
+
+ Assert.That(runner.Tokens, Is.EqualTo(new[] { cancellation.Token, cancellation.Token }));
+ }
+
+ ///
+ /// Verifies the version command receives the caller's token so cancelling it can stop the CLI process.
+ ///
+ [Test]
+ public void ExecuteCliVersionCommand_PassesTheCallerTokenToTheCommand()
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(CliConstants.VERSION_FLAG, new CliDetectionCommandResult(new[] { "3.6.0" }, 0));
+ using CancellationTokenSource cancellation = new();
+
+ CliInstallationDetector.ExecuteCliVersionCommand(
+ ExecutablePath,
+ CliConstants.VERSION_FLAG,
+ cancellation.Token,
+ runner.Execute);
+
+ Assert.That(runner.Tokens, Is.EqualTo(new[] { cancellation.Token }));
+ }
+
+ ///
+ /// Verifies both version commands failing reports no version while keeping the executable path.
+ ///
+ [Test]
+ public void DetectCliInstallationAtExecutablePath_WhenBothCommandsFail_ReportsNoVersion()
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(ContractArguments, new CliDetectionCommandResult(Array.Empty(), 1));
+ runner.Add(CliConstants.VERSION_FLAG, new CliDetectionCommandResult(new[] { "2.9.0" }, 1));
+
+ CliInstallationDetection detection = CliInstallationDetector.DetectCliInstallationAtExecutablePath(
+ ExecutablePath,
+ CancellationToken.None,
+ runner.Execute);
+
+ Assert.That(detection.Version, Is.Null);
+ Assert.That(detection.ExecutablePath, Is.EqualTo(ExecutablePath));
+ }
+
+ ///
+ /// Verifies a missing executable path runs the commands through the bare executable name.
+ ///
+ [Test]
+ public void DetectCliInstallationAtExecutablePath_WhenPathIsNull_RunsExecutableName()
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(ContractArguments, new CliDetectionCommandResult(new[] { "{\"ProjectRunnerVersion\":\"3.6.0\"}" }, 0));
+
+ CliInstallationDetection detection = CliInstallationDetector.DetectCliInstallationAtExecutablePath(
+ null,
+ CancellationToken.None,
+ runner.Execute);
+
+ Assert.That(detection.Version, Is.EqualTo("3.6.0"));
+ Assert.That(detection.ExecutablePath, Is.Null);
+ Assert.That(runner.FileNames, Is.EqualTo(new[] { CliConstants.EXECUTABLE_NAME }));
+ }
+
+ ///
+ /// Verifies a successful command returns its output lines joined and trimmed, and runs with redirected output.
+ ///
+ [Test]
+ public void ExecuteCliVersionCommand_WhenCommandSucceeds_ReturnsTrimmedOutput()
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(CliConstants.VERSION_FLAG, new CliDetectionCommandResult(new[] { " 3.6", ".0 " }, 0));
+
+ string output = CliInstallationDetector.ExecuteCliVersionCommand(
+ ExecutablePath,
+ CliConstants.VERSION_FLAG,
+ CancellationToken.None,
+ runner.Execute);
+
+ Assert.That(output, Is.EqualTo("3.6.0"));
+ ProcessStartInfo startInfo = runner.StartInfos[0];
+ Assert.That(startInfo.UseShellExecute, Is.False);
+ Assert.That(startInfo.RedirectStandardOutput, Is.True);
+ Assert.That(startInfo.RedirectStandardError, Is.True);
+ Assert.That(startInfo.CreateNoWindow, Is.True);
+ }
+
+ ///
+ /// Verifies a command that could not run, exited with an error, or printed nothing yields no output without warning.
+ ///
+ [TestCase(false, 0, "3.6.0")]
+ [TestCase(true, 1, "3.6.0")]
+ [TestCase(true, 0, " ")]
+ public void ExecuteCliVersionCommand_WhenCommandDoesNotSucceed_ReturnsNull(
+ bool hasResult,
+ int exitCode,
+ string outputLine)
+ {
+ RecordingCommandRunner runner = new();
+ runner.Add(
+ CliConstants.VERSION_FLAG,
+ hasResult ? new CliDetectionCommandResult(new[] { outputLine }, exitCode) : null);
+
+ string output = CliInstallationDetector.ExecuteCliVersionCommand(
+ ExecutablePath,
+ CliConstants.VERSION_FLAG,
+ CancellationToken.None,
+ runner.Execute);
+
+ Assert.That(output, Is.Null);
+ Assert.That(_warningCount, Is.EqualTo(0));
+ }
+
+ ///
+ /// Verifies a command that throws yields no output and logs a warning.
+ ///
+ [Test]
+ public void ExecuteCliVersionCommand_WhenCommandThrows_ReturnsNullAndWarns()
+ {
+ LogAssert.Expect(LogType.Warning, new Regex("Failed to detect CLI version: command failed in this test"));
+
+ string output = CliInstallationDetector.ExecuteCliVersionCommand(
+ ExecutablePath,
+ CliConstants.VERSION_FLAG,
+ CancellationToken.None,
+ ThrowingRunner);
+
+ Assert.That(output, Is.Null);
+ Assert.That(_warningCount, Is.EqualTo(1));
+ }
+
+ ///
+ /// Verifies a command that throws after cancellation yields no output without logging a warning.
+ ///
+ [Test]
+ public void ExecuteCliVersionCommand_WhenCommandThrowsAfterCancellation_ReturnsNullSilently()
+ {
+ using CancellationTokenSource cancellation = new();
+ cancellation.Cancel();
+
+ string output = CliInstallationDetector.ExecuteCliVersionCommand(
+ ExecutablePath,
+ CliConstants.VERSION_FLAG,
+ cancellation.Token,
+ ThrowingRunner);
+
+ Assert.That(output, Is.Null);
+ Assert.That(_warningCount, Is.EqualTo(0));
+ }
+
+ private void CountWarning(string condition, string stackTrace, LogType type)
+ {
+ if (type == LogType.Warning)
+ {
+ _warningCount++;
+ }
+ }
+
+ private static CliDetectionCommandResult ThrowingRunner(ProcessStartInfo startInfo, CancellationToken ct)
+ {
+ throw new InvalidOperationException("command failed in this test");
+ }
+
+ private sealed class RecordingCommandRunner
+ {
+ private readonly Dictionary _resultsByArguments = new();
+
+ public List StartInfos { get; } = new();
+
+ public List Tokens { get; } = new();
+
+ public List Arguments
+ {
+ get
+ {
+ List arguments = new();
+ foreach (ProcessStartInfo startInfo in StartInfos)
+ {
+ arguments.Add(startInfo.Arguments);
+ }
+
+ return arguments;
+ }
+ }
+
+ public List FileNames
+ {
+ get
+ {
+ List fileNames = new();
+ foreach (ProcessStartInfo startInfo in StartInfos)
+ {
+ fileNames.Add(startInfo.FileName);
+ }
+
+ return fileNames;
+ }
+ }
+
+ public void Add(string arguments, CliDetectionCommandResult result)
+ {
+ _resultsByArguments.Add(arguments, result);
+ }
+
+ public CliDetectionCommandResult Execute(ProcessStartInfo startInfo, CancellationToken ct)
+ {
+ StartInfos.Add(startInfo);
+ Tokens.Add(ct);
+ return _resultsByArguments[startInfo.Arguments];
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/CliInstallationDetectorVersionCommandTests.cs.meta b/Assets/Tests/Editor/CliInstallationDetectorVersionCommandTests.cs.meta
new file mode 100644
index 0000000000..1e4b46aa41
--- /dev/null
+++ b/Assets/Tests/Editor/CliInstallationDetectorVersionCommandTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 58d44273ca58c4549a0f7cd6956df8f4
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/CliPlayModeRunInBackgroundServiceTests.cs b/Assets/Tests/Editor/CliPlayModeRunInBackgroundServiceTests.cs
new file mode 100644
index 0000000000..61b329e9d1
--- /dev/null
+++ b/Assets/Tests/Editor/CliPlayModeRunInBackgroundServiceTests.cs
@@ -0,0 +1,150 @@
+#nullable enable
+using NUnit.Framework;
+using UnityEditor;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Verifies the service applies the controller's runInBackground decisions on CLI Play start and Play Mode exit,
+ /// using an in-memory store and a recorded runInBackground value instead of SessionState and the real Application setting.
+ ///
+ public sealed class CliPlayModeRunInBackgroundServiceTests
+ {
+ private InMemoryStore _store = new();
+ private bool _runInBackground;
+ private int _setCount;
+ private CliPlayModeRunInBackgroundService _service = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _store = new InMemoryStore();
+ _runInBackground = false;
+ _setCount = 0;
+ _service = new CliPlayModeRunInBackgroundService(
+ new CliPlayModeRunInBackgroundController(_store),
+ value =>
+ {
+ _setCount++;
+ _runInBackground = value;
+ },
+ () => _runInBackground);
+ }
+
+ ///
+ /// Verifies a CLI Play start records the current value as the original and turns runInBackground on.
+ ///
+ [Test]
+ public void EnableForCliPlayStart_RecordsOriginalAndEnablesRunInBackground()
+ {
+ _service.EnableForCliPlayStart();
+
+ Assert.That(_runInBackground, Is.True);
+ Assert.That(_store.IsActive, Is.True);
+ Assert.That(_store.OriginalRunInBackground, Is.False);
+ }
+
+ ///
+ /// Verifies exiting Play Mode re-applies the original value but keeps the override active until Edit Mode is stable.
+ ///
+ [Test]
+ public void OnPlayModeStateChanged_ExitingPlayModeAfterCliStart_ReappliesOriginalAndKeepsOverride()
+ {
+ _service.EnableForCliPlayStart();
+
+ _service.OnPlayModeStateChanged(PlayModeStateChange.ExitingPlayMode);
+
+ Assert.That(_runInBackground, Is.False);
+ Assert.That(_store.IsActive, Is.True);
+ }
+
+ ///
+ /// Verifies entering Edit Mode restores the original value and clears the override.
+ ///
+ [Test]
+ public void OnPlayModeStateChanged_EnteredEditModeAfterCliStart_RestoresOriginalAndClearsOverride()
+ {
+ _service.EnableForCliPlayStart();
+
+ _service.OnPlayModeStateChanged(PlayModeStateChange.EnteredEditMode);
+
+ Assert.That(_runInBackground, Is.False);
+ Assert.That(_store.IsActive, Is.False);
+ }
+
+ ///
+ /// Verifies Play Mode exit after a manual Play start never writes runInBackground.
+ ///
+ [Test]
+ public void OnPlayModeStateChanged_WithoutCliStart_DoesNotWriteRunInBackground()
+ {
+ _service.OnPlayModeStateChanged(PlayModeStateChange.ExitingPlayMode);
+ _service.OnPlayModeStateChanged(PlayModeStateChange.EnteredEditMode);
+
+ Assert.That(_setCount, Is.EqualTo(0));
+ }
+
+ ///
+ /// Verifies transitions other than exiting Play Mode and entering Edit Mode leave runInBackground and the override alone.
+ ///
+ [Test]
+ public void OnPlayModeStateChanged_EnteredPlayMode_LeavesOverrideUntouched()
+ {
+ _service.EnableForCliPlayStart();
+ int setCountAfterStart = _setCount;
+
+ _service.OnPlayModeStateChanged(PlayModeStateChange.EnteredPlayMode);
+ _service.OnPlayModeStateChanged(PlayModeStateChange.ExitingEditMode);
+
+ Assert.That(_setCount, Is.EqualTo(setCountAfterStart));
+ Assert.That(_runInBackground, Is.True);
+ Assert.That(_store.IsActive, Is.True);
+ }
+
+ ///
+ /// Verifies a project that already had runInBackground on gets it back after CLI Play, even when Unity
+ /// overwrote the value during the transition, instead of having it turned off.
+ ///
+ [Test]
+ public void OnPlayModeStateChanged_WhenOriginalWasOn_RestoresOnAfterPlayModeExit()
+ {
+ _runInBackground = true;
+
+ _service.EnableForCliPlayStart();
+
+ Assert.That(_store.OriginalRunInBackground, Is.True);
+
+ _runInBackground = false;
+ _service.OnPlayModeStateChanged(PlayModeStateChange.ExitingPlayMode);
+
+ Assert.That(_runInBackground, Is.True);
+
+ _runInBackground = false;
+ _service.OnPlayModeStateChanged(PlayModeStateChange.EnteredEditMode);
+
+ Assert.That(_runInBackground, Is.True);
+ Assert.That(_store.IsActive, Is.False);
+ }
+
+ private sealed class InMemoryStore : ICliPlayModeRunInBackgroundStore
+ {
+ public bool IsActive { get; private set; }
+
+ public bool OriginalRunInBackground { get; private set; }
+
+ public void Activate(bool originalRunInBackground)
+ {
+ IsActive = true;
+ OriginalRunInBackground = originalRunInBackground;
+ }
+
+ public void Clear()
+ {
+ IsActive = false;
+ OriginalRunInBackground = false;
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/CliPlayModeRunInBackgroundServiceTests.cs.meta b/Assets/Tests/Editor/CliPlayModeRunInBackgroundServiceTests.cs.meta
new file mode 100644
index 0000000000..b53c09876e
--- /dev/null
+++ b/Assets/Tests/Editor/CliPlayModeRunInBackgroundServiceTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0d69f29e6d5f344a69830390c5a0ea45
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/CompileControllerPipelineTests.cs b/Assets/Tests/Editor/CompileControllerPipelineTests.cs
new file mode 100644
index 0000000000..70da971f47
--- /dev/null
+++ b/Assets/Tests/Editor/CompileControllerPipelineTests.cs
@@ -0,0 +1,372 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using UnityEditor.Compilation;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Verifies CompileController drives the compile request through its pipeline port:
+ /// event registration, completion, the wait-for-existing-task path, and cleanup.
+ ///
+ [TestFixture]
+ public sealed class CompileControllerPipelineTests
+ {
+ private UnityCliLoopEditorSessionStateSnapshot _originalSnapshot;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalSnapshot = UnityCliLoopEditorSessionStateTestFactory.CaptureSnapshot();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ CompileApiUpdaterConsentState.EndCliCompile();
+ _originalSnapshot.Restore();
+ }
+
+ ///
+ /// What: a normal compile refreshes assets, registers both callbacks, requests a non-clean
+ /// compile, starts the watchdog, and completes with the messages the assembly callback reported.
+ ///
+ [Test]
+ public async Task TryCompileAsync_WhenPipelineFinishes_ReturnsReportedMessagesAndClearsState()
+ {
+ FakeCompilePipelinePort pipeline = new()
+ {
+ CompleteOnRequest = true,
+ ReportedMessages = new[] { CreateMessage(CompilerMessageType.Warning, "sample warning") }
+ };
+ using CompileController controller = CreateController(pipeline);
+ List startedMessages = new();
+ List compiledAssemblies = new();
+ controller.OnCompileStarted += startedMessages.Add;
+ controller.OnAssemblyCompiled += (assemblyName, _) => compiledAssemblies.Add(assemblyName);
+
+ CompileResult result = await UncanceledAwaits.AwaitValueAsync(controller.TryCompileAsync(
+ forceRecompile: false,
+ playModeStopWarning: null,
+ CancellationToken.None));
+
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.WarningCount, Is.EqualTo(1));
+ Assert.That(pipeline.RefreshCount, Is.EqualTo(1));
+ Assert.That(pipeline.SubscribeCount, Is.EqualTo(1));
+ Assert.That(pipeline.UnsubscribeCount, Is.EqualTo(1));
+ Assert.That(pipeline.MismatchedUnsubscribeCount, Is.EqualTo(0));
+ Assert.That(pipeline.RequestedCleanBuildCache, Is.EqualTo(new[] { false }));
+ Assert.That(pipeline.WatchdogStartCount, Is.EqualTo(1));
+ Assert.That(startedMessages, Is.EqualTo(new[] { "Compilation started after asset refresh..." }));
+ Assert.That(compiledAssemblies, Is.EqualTo(new[] { "Sample.dll" }));
+ Assert.That(controller.IsCompiling, Is.False);
+ Assert.That(CompileApiUpdaterConsentState.IsCliCompileInFlight, Is.False);
+ }
+
+ ///
+ /// What: a forced recompile requests a clean build cache and returns an indeterminate result.
+ ///
+ [Test]
+ public async Task TryCompileAsync_WhenForced_RequestsCleanBuildCacheAndReturnsIndeterminateResult()
+ {
+ FakeCompilePipelinePort pipeline = new() { CompleteOnRequest = true };
+ using CompileController controller = CreateController(pipeline);
+ List startedMessages = new();
+ controller.OnCompileStarted += startedMessages.Add;
+
+ CompileResult result = await UncanceledAwaits.AwaitValueAsync(controller.TryCompileAsync(
+ forceRecompile: true,
+ playModeStopWarning: null,
+ CancellationToken.None));
+
+ Assert.That(result.IsIndeterminate, Is.True);
+ Assert.That(pipeline.RequestedCleanBuildCache, Is.EqualTo(new[] { true }));
+ Assert.That(startedMessages, Is.EqualTo(new[] { "Forced recompile started after asset refresh..." }));
+ }
+
+ ///
+ /// What: Assembly Definition errors found after the refresh stop the compile before any callback
+ /// is registered or compile is requested, and the controller returns to idle.
+ ///
+ [Test]
+ public async Task TryCompileAsync_WhenAssemblyDefinitionErrorsExist_ReturnsFailureWithoutRequestingCompile()
+ {
+ // Why CompleteOnRequest: if the early return regresses, the request still finishes
+ // and the assertions fail instead of the test hanging on a compile that never ends.
+ FakeCompilePipelinePort pipeline = new()
+ {
+ CompleteOnRequest = true,
+ AssemblyDefinitionErrors = new AssemblyDefinitionConsoleErrorResult(new[]
+ {
+ new AssemblyDefinitionConsoleError("broken asmdef", "Assets/Broken.asmdef", 1)
+ })
+ };
+ using CompileController controller = CreateController(pipeline);
+
+ CompileResult result = await UncanceledAwaits.AwaitValueAsync(controller.TryCompileAsync(
+ forceRecompile: false,
+ playModeStopWarning: null,
+ CancellationToken.None));
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(pipeline.RefreshCount, Is.EqualTo(1));
+ Assert.That(pipeline.SubscribeCount, Is.EqualTo(0));
+ Assert.That(pipeline.RequestedCleanBuildCache, Is.Empty);
+ Assert.That(pipeline.WatchdogStartCount, Is.EqualTo(0));
+ Assert.That(controller.IsCompiling, Is.False);
+ Assert.That(CompileApiUpdaterConsentState.IsCliCompileInFlight, Is.False);
+ }
+
+ ///
+ /// What: a second TryCompileAsync during an in-flight compile waits for the existing request
+ /// instead of starting another one, and both callers receive the same result.
+ ///
+ [Test]
+ public async Task TryCompileAsync_WhenAlreadyCompiling_WaitsForExistingRequest()
+ {
+ FakeCompilePipelinePort pipeline = new();
+ using CompileController controller = CreateController(pipeline);
+
+ Task firstCompile = controller.TryCompileAsync(
+ forceRecompile: false,
+ playModeStopWarning: null,
+ CancellationToken.None);
+ Task secondCompile = controller.TryCompileAsync(
+ forceRecompile: true,
+ playModeStopWarning: null,
+ CancellationToken.None);
+
+ // Why before completing: if the second call started its own compile, the first request
+ // would never finish, so the check must fail before awaiting it.
+ Assert.That(pipeline.RefreshCount, Is.EqualTo(1));
+ Assert.That(pipeline.RequestedCleanBuildCache, Is.EqualTo(new[] { false }));
+ Assert.That(controller.IsCompiling, Is.True);
+ Assert.That(secondCompile.IsCompleted, Is.False);
+ pipeline.CompilationFinished(null);
+ CompileResult firstResult = await UncanceledAwaits.AwaitValueAsync(firstCompile);
+ CompileResult secondResult = await UncanceledAwaits.AwaitValueAsync(secondCompile);
+
+ Assert.That(secondResult, Is.SameAs(firstResult));
+ }
+
+ ///
+ /// What: when requesting compile throws after callbacks were registered, the controller
+ /// unregisters them, clears the in-flight state, and lets the exception reach the caller.
+ ///
+ [Test]
+ public async Task TryCompileAsync_WhenRequestThrows_UnregistersCallbacksAndClearsState()
+ {
+ FakeCompilePipelinePort pipeline = new()
+ {
+ RequestException = new InvalidOperationException("request failed")
+ };
+ using CompileController controller = CreateController(pipeline);
+
+ // Why not Assert.ThrowsAsync: it blocks the main thread synchronously in this NUnit version.
+ try
+ {
+ await UncanceledAwaits.AwaitValueAsync(controller.TryCompileAsync(
+ forceRecompile: false,
+ playModeStopWarning: null,
+ CancellationToken.None));
+ Assert.Fail("TryCompileAsync should rethrow the request failure.");
+ }
+ catch (InvalidOperationException e)
+ {
+ Assert.That(e.Message, Is.EqualTo("request failed"));
+ }
+
+ Assert.That(pipeline.UnsubscribeCount, Is.EqualTo(1));
+ Assert.That(pipeline.MismatchedUnsubscribeCount, Is.EqualTo(0));
+ Assert.That(pipeline.WatchdogStartCount, Is.EqualTo(0));
+ Assert.That(controller.IsCompiling, Is.False);
+ Assert.That(CompileApiUpdaterConsentState.IsCliCompileInFlight, Is.False);
+ }
+
+ ///
+ /// What: Cleanup unregisters the callbacks through the port and cancels a pending compile request.
+ ///
+ [Test]
+ public async Task Cleanup_WhenCompileIsPending_UnregistersCallbacksAndCancelsRequest()
+ {
+ FakeCompilePipelinePort pipeline = new();
+ using CompileController controller = CreateController(pipeline);
+ Task pendingCompile = controller.TryCompileAsync(
+ forceRecompile: false,
+ playModeStopWarning: null,
+ CancellationToken.None);
+
+ controller.Cleanup();
+
+ Assert.That(pipeline.UnsubscribeCount, Is.EqualTo(1));
+ Assert.That(pipeline.MismatchedUnsubscribeCount, Is.EqualTo(0));
+ Assert.That(controller.IsCompiling, Is.False);
+ Assert.That(CompileApiUpdaterConsentState.IsCliCompileInFlight, Is.False);
+ try
+ {
+ await pendingCompile;
+ Assert.Fail("The pending compile should be canceled by Cleanup.");
+ }
+ catch (TaskCanceledException)
+ {
+ }
+ }
+
+ ///
+ /// What: replacing the pipeline during a pending compile is rejected, so Cleanup still unsubscribes from the original port.
+ ///
+ [Test]
+ public async Task SetCompilePipelineForTesting_WhenCompileIsPending_RejectsReplacementAndKeepsOriginalPort()
+ {
+ FakeCompilePipelinePort pipeline = new();
+ FakeCompilePipelinePort replacement = new();
+ using CompileController controller = CreateController(pipeline);
+ Task pendingCompile = controller.TryCompileAsync(
+ forceRecompile: false,
+ playModeStopWarning: null,
+ CancellationToken.None);
+
+ try
+ {
+ controller.SetCompilePipelineForTesting(replacement);
+ Assert.Fail("Replacing the pipeline during a pending compile should be rejected.");
+ }
+ catch (InvalidOperationException)
+ {
+ }
+
+ controller.Cleanup();
+
+ Assert.That(pipeline.UnsubscribeCount, Is.EqualTo(1));
+ Assert.That(pipeline.MismatchedUnsubscribeCount, Is.EqualTo(0));
+ Assert.That(replacement.UnsubscribeCount + replacement.MismatchedUnsubscribeCount, Is.EqualTo(0));
+ try
+ {
+ await pendingCompile;
+ Assert.Fail("The pending compile should be canceled by Cleanup.");
+ }
+ catch (TaskCanceledException)
+ {
+ }
+ }
+
+ private static CompileController CreateController(FakeCompilePipelinePort pipeline)
+ {
+ CompileController controller = new(
+ UnityCliLoopEditorSessionStateTestFactory.CreateCompileResultSessionRepository(),
+ UnityCliLoopEditorSessionStateTestFactory.CreatePendingCompileSessionRepository());
+ // Why: the real resolver subscribes to Scene and Play Mode events and reads the open Scenes.
+ controller.SetExternalSceneChangeResolutionForTesting(_ => (true, null, Array.Empty()));
+ controller.SetCompilePipelineForTesting(pipeline);
+ return controller;
+ }
+
+ private static CompilerMessage CreateMessage(CompilerMessageType type, string message)
+ {
+ return new CompilerMessage
+ {
+ type = type,
+ message = message,
+ file = "Assets/Sample.cs",
+ line = 1,
+ column = 1
+ };
+ }
+
+ ///
+ /// Records pipeline calls and optionally finishes the compile synchronously on request.
+ ///
+ private sealed class FakeCompilePipelinePort : ICompilePipelinePort
+ {
+ private Action