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
118 changes: 118 additions & 0 deletions Assets/Tests/Editor/CliDetectionCommandRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Diagnostics;
using System.Threading;

using NUnit.Framework;
using UnityEngine;

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Test fixture that verifies CLI detection command execution behavior.
/// </summary>
public class CliDetectionCommandRunnerTests
{
/// <summary>
/// Verifies that command execution preserves stdout line order and the exit code.
/// </summary>
[Test]
public void Execute_WhenCommandWritesMultipleLines_ReturnsOrderedLinesAndExitCode()
{
ProcessStartInfo startInfo = BuildOutputProcessStartInfo(0);

CliDetectionCommandResult result = CliDetectionCommandRunner.Execute(
startInfo,
CancellationToken.None);

Assert.That(result, Is.Not.Null);
Assert.That(result.StandardOutputLines, Is.EqualTo(new[] { "first", "second" }));
Assert.That(result.ExitCode, Is.Zero);
}

/// <summary>
/// Verifies that command execution returns captured output for the detection policy to reject.
/// </summary>
[Test]
public void Execute_WhenCommandExitsWithFailure_ReturnsOutputAndExitCode()
{
ProcessStartInfo startInfo = BuildOutputProcessStartInfo(7);

CliDetectionCommandResult result = CliDetectionCommandRunner.Execute(
startInfo,
CancellationToken.None);

Assert.That(result, Is.Not.Null);
Assert.That(result.StandardOutputLines, Is.EqualTo(new[] { "first", "second" }));
Assert.That(result.ExitCode, Is.EqualTo(7));
}

/// <summary>
/// Verifies that a missing executable is represented by an absent execution result.
/// </summary>
[Test]
public void Execute_WhenExecutableDoesNotExist_ReturnsNull()
{
ProcessStartInfo startInfo = CreateStartInfo(
"__uloop_missing_cli_detection_test_executable__",
string.Empty);

CliDetectionCommandResult result = CliDetectionCommandRunner.Execute(
startInfo,
CancellationToken.None);

Assert.That(result, Is.Null);
}

/// <summary>
/// Verifies that process cleanup tolerates the child exiting before Kill.
/// </summary>
[Test]
public void KillProcessIfRunning_WhenProcessAlreadyExited_DoesNotThrow()
{
ProcessStartInfo startInfo = BuildImmediateExitProcessStartInfo();

using Process process = Process.Start(startInfo);
process.WaitForExit();

Assert.DoesNotThrow(() => CliDetectionCommandRunner.KillProcessIfRunning(process));
}

private static ProcessStartInfo BuildOutputProcessStartInfo(int exitCode)
{
if (UnityEngine.Application.platform == RuntimePlatform.WindowsEditor)
{
return CreateStartInfo(
"cmd.exe",
$"/d /s /c \"echo first&&echo second&&exit /b {exitCode}\"");
}

return CreateStartInfo(
"/bin/sh",
$"-c \"printf 'first\\nsecond\\n'; exit {exitCode}\"");
}

private static ProcessStartInfo BuildImmediateExitProcessStartInfo()
{
if (UnityEngine.Application.platform == RuntimePlatform.WindowsEditor)
{
return CreateStartInfo("cmd.exe", "/c exit 0");
}

return CreateStartInfo("/bin/sh", "-c \"exit 0\"");
}

private static ProcessStartInfo CreateStartInfo(string fileName, string arguments)
{
return new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/CliDetectionCommandRunnerTests.cs.meta

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

39 changes: 0 additions & 39 deletions Assets/Tests/Editor/CliInstallationDetectorTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Diagnostics;

using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Infrastructure;
Expand Down Expand Up @@ -304,42 +302,5 @@ public void ParseShellCliInstallationOutput_WhenVersionCommandFails_ReturnsPathW
Assert.That(detection.ExecutablePath, Is.EqualTo("/Users/ExampleUser/.npm-global/bin/uloop"));
}

[Test]
public void KillProcessIfRunning_WhenProcessAlreadyExited_DoesNotThrow()
{
// Verifies that process cleanup tolerates the race where the child exits before Kill.
ProcessStartInfo startInfo = BuildImmediateExitProcessStartInfo();

using Process process = Process.Start(startInfo);
process.WaitForExit();

Assert.DoesNotThrow(() => CliInstallationDetector.KillProcessIfRunning(process));
}

private static ProcessStartInfo BuildImmediateExitProcessStartInfo()
{
if (UnityEngine.Application.platform == UnityEngine.RuntimePlatform.WindowsEditor)
{
return new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = "/c exit 0",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}

return new ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-c \"exit 0\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;

using UnityEngine;

using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Describes the observable result of a completed CLI detection command.
/// </summary>
internal sealed class CliDetectionCommandResult
{
internal CliDetectionCommandResult(IReadOnlyList<string> standardOutputLines, int exitCode)
{
UnityEngine.Debug.Assert(standardOutputLines != null, "standardOutputLines must not be null");

StandardOutputLines = standardOutputLines;
ExitCode = exitCode;
}

internal IReadOnlyList<string> StandardOutputLines { get; }
internal int ExitCode { get; }
}

/// <summary>
/// Runs CLI detection commands and captures their process-level results.
/// </summary>
internal static class CliDetectionCommandRunner
{
private const int PROCESS_TIMEOUT_MS = 5000;

internal static CliDetectionCommandResult Execute(ProcessStartInfo startInfo, CancellationToken ct)
{
UnityEngine.Debug.Assert(startInfo != null, "startInfo must not be null");
UnityEngine.Debug.Assert(startInfo.RedirectStandardOutput, "RedirectStandardOutput must be true");
UnityEngine.Debug.Assert(startInfo.RedirectStandardError, "RedirectStandardError must be true");

Process process = ProcessStartHelper.TryStart(startInfo);
if (process == null)
{
return null;
}

using (process)
{
List<string> standardOutputLines = new();
process.OutputDataReceived += (sender, e) =>
{
if (e.Data != null)
{
standardOutputLines.Add(e.Data);
}
};
process.ErrorDataReceived += (sender, e) => { };

process.BeginOutputReadLine();
process.BeginErrorReadLine();

using CancellationTokenRegistration registration = ct.Register(() => KillProcessIfRunning(process));
bool exited = process.WaitForExit(PROCESS_TIMEOUT_MS);
if (!exited)
{
KillProcessIfRunning(process);
return null;
}

// Parameterless WaitForExit flushes async output buffers.
process.WaitForExit();
return new CliDetectionCommandResult(standardOutputLines.ToArray(), process.ExitCode);
}
}

internal static void KillProcessIfRunning(Process process)
{
UnityEngine.Debug.Assert(process != null, "process must not be null");

try
{
process.Kill();
}
catch (System.InvalidOperationException)
{
// Process exit can race with cancellation or timeout cleanup.
}
}
}
}

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

Loading
Loading