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: 9 additions & 5 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,16 @@ jobs:
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
# classification, process management) are otherwise never executed by CI.
# Runs every go.work module: the formerly Windows-incompatible tests (POSIX-only
# fake executables, TCP-only fixtures, shell-script git/gh mocks) now install
# portable fixtures on Windows.
- name: Test native Go CLI on Windows
shell: bash
run: cd cli/common && go test ./unityipc/
run: |
for module in common dispatcher project-runner release-automation; do
(cd "cli/$module" && go test ./...)
done

- name: Test install release filter in Git Bash
shell: bash
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/native-cli-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,17 @@ jobs:
exit 1
fi
release_sha="${TARGET_SHA}"
# Why publishing is blocked here: the attestation certificate binds
# SourceRepositoryDigest to the workflow run head (GITHUB_SHA), never
# to the checked-out build commit, so assets published for an older
# release commit can never pass the dispatcher's tag-to-certificate
# verification. Recovery dispatch may only validate builds.
if [ "${should_publish}" = "true" ] && [ "${release_sha}" != "${GITHUB_SHA}" ]; then
echo "Recovery-target dispatch cannot publish release assets: the release commit ${release_sha} differs from the run head ${GITHUB_SHA}, so the attestation certificate digest would never match the release tag." >&2
echo "Rerun the original failed workflow run whose head commit is the release commit instead." >&2
echo "See docs/release-recovery-runbook.md for the recovery procedure." >&2
exit 1
fi
else
if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then
echo "Build SHA and release target must match the approved event commit." >&2
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#nullable enable
using System;

using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
Expand Down Expand Up @@ -29,6 +31,12 @@ public static InputSimulationRunInBackgroundScope Enable()

public void Dispose()
{
// Precondition: Application.runInBackground is main-thread-only, so callers resuming
// from ConfigureAwait(false) continuations must switch back to the main thread first.
UnityEngine.Debug.Assert(
MainThreadSwitcher.IsMainThread,
"InputSimulationRunInBackgroundScope.Dispose must be called on the main thread.");

if (_isDisposed)
{
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"name": "UnityCLILoop.FirstPartyTools.Common.InputSimulation.Editor",
"rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools",
"references": [],
"references": [
"GUID:fc3fd32eddbee40e39c2d76dc184957b"
],
"includePlatforms": [
"Editor"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,49 +104,59 @@ public async Task<SimulateKeyboardResponse> ExecuteAsync(
correlationId: correlationId
);

using InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();

EnsureOverlayExists();
// Why not `using`: the executor awaits below use ConfigureAwait(false), so this method
// can resume on a thread-pool thread. Application.runInBackground is main-thread-only,
// so the scope must be disposed after switching back to the main thread.
InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();
try
{
EnsureOverlayExists();

SimulateKeyboardResponse response;
SimulateKeyboardResponse response;

switch (parameters.Action)
switch (parameters.Action)
{
case UnityCliLoopKeyboardAction.Press:
response = await KeyboardInputActionExecutor.ExecutePress(
keyboard,
key,
parameters.Duration,
ct).ConfigureAwait(false);
break;

case UnityCliLoopKeyboardAction.KeyDown:
response = await KeyboardInputActionExecutor.ExecuteKeyDown(keyboard, key, ct).ConfigureAwait(false);
break;

case UnityCliLoopKeyboardAction.KeyUp:
response = await KeyboardInputActionExecutor.ExecuteKeyUp(keyboard, key, ct).ConfigureAwait(false);
break;

default:
// Only reachable when an out-of-range enum value is cast from an integer;
// surface as a Success=false response so the CLI treats it as a normal validation failure.
return new SimulateKeyboardResponse
{
Success = false,
Message = $"Unknown keyboard action: {parameters.Action}",
Action = parameters.Action.ToString()
};
}

VibeLogger.LogInfo(
"simulate_keyboard_complete",
$"Keyboard simulation completed: {response.Message}",
new { Action = parameters.Action.ToString(), Success = response.Success },
correlationId: correlationId
);

return response;
}
finally
{
case UnityCliLoopKeyboardAction.Press:
response = await KeyboardInputActionExecutor.ExecutePress(
keyboard,
key,
parameters.Duration,
ct).ConfigureAwait(false);
break;

case UnityCliLoopKeyboardAction.KeyDown:
response = await KeyboardInputActionExecutor.ExecuteKeyDown(keyboard, key, ct).ConfigureAwait(false);
break;

case UnityCliLoopKeyboardAction.KeyUp:
response = await KeyboardInputActionExecutor.ExecuteKeyUp(keyboard, key, ct).ConfigureAwait(false);
break;

default:
// Only reachable when an out-of-range enum value is cast from an integer;
// surface as a Success=false response so the CLI treats it as a normal validation failure.
return new SimulateKeyboardResponse
{
Success = false,
Message = $"Unknown keyboard action: {parameters.Action}",
Action = parameters.Action.ToString()
};
await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None);
runInBackgroundScope.Dispose();
}

VibeLogger.LogInfo(
"simulate_keyboard_complete",
$"Keyboard simulation completed: {response.Message}",
new { Action = parameters.Action.ToString(), Success = response.Success },
correlationId: correlationId
);

return response;
#endif
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,46 +97,56 @@ public async Task<SimulateMouseInputResponse> ExecuteAsync(
correlationId: correlationId
);

using InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();

EnsureOverlayExists();

SimulateMouseInputResponse response;

switch (parameters.Action)
// Why not `using`: the executor awaits below use ConfigureAwait(false), so this method
// can resume on a thread-pool thread. Application.runInBackground is main-thread-only,
// so the scope must be disposed after switching back to the main thread.
InputSimulationRunInBackgroundScope runInBackgroundScope = InputSimulationRunInBackgroundScope.Enable();
try
{
case UnityCliLoopMouseInputAction.Click:
response = await MouseInputPressActionExecutor.ExecuteClick(mouse, parameters, ct).ConfigureAwait(false);
break;

case UnityCliLoopMouseInputAction.LongPress:
response = await MouseInputPressActionExecutor.ExecuteLongPress(mouse, parameters, ct).ConfigureAwait(false);
break;
EnsureOverlayExists();

case UnityCliLoopMouseInputAction.MoveDelta:
response = await MouseInputMotionActionExecutor.ExecuteMoveDelta(mouse, parameters, ct).ConfigureAwait(false);
break;
SimulateMouseInputResponse response;

case UnityCliLoopMouseInputAction.Scroll:
response = await MouseInputMotionActionExecutor.ExecuteScroll(mouse, parameters, ct).ConfigureAwait(false);
break;

case UnityCliLoopMouseInputAction.SmoothDelta:
response = await MouseInputMotionActionExecutor.ExecuteSmoothDelta(mouse, parameters, ct).ConfigureAwait(false);
break;

default:
throw new ArgumentException($"Unknown mouse input action: {parameters.Action}");
switch (parameters.Action)
{
case UnityCliLoopMouseInputAction.Click:
response = await MouseInputPressActionExecutor.ExecuteClick(mouse, parameters, ct).ConfigureAwait(false);
break;

case UnityCliLoopMouseInputAction.LongPress:
response = await MouseInputPressActionExecutor.ExecuteLongPress(mouse, parameters, ct).ConfigureAwait(false);
break;

case UnityCliLoopMouseInputAction.MoveDelta:
response = await MouseInputMotionActionExecutor.ExecuteMoveDelta(mouse, parameters, ct).ConfigureAwait(false);
break;

case UnityCliLoopMouseInputAction.Scroll:
response = await MouseInputMotionActionExecutor.ExecuteScroll(mouse, parameters, ct).ConfigureAwait(false);
break;

case UnityCliLoopMouseInputAction.SmoothDelta:
response = await MouseInputMotionActionExecutor.ExecuteSmoothDelta(mouse, parameters, ct).ConfigureAwait(false);
break;

default:
throw new ArgumentException($"Unknown mouse input action: {parameters.Action}");
}

VibeLogger.LogInfo(
"simulate_mouse_input_complete",
$"Mouse input simulation completed: {response.Message}",
new { Action = parameters.Action.ToString(), Success = response.Success },
correlationId: correlationId
);

return response;
}
finally
{
await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None);
runInBackgroundScope.Dispose();
}

VibeLogger.LogInfo(
"simulate_mouse_input_complete",
$"Mouse input simulation completed: {response.Message}",
new { Action = parameters.Action.ToString(), Success = response.Success },
correlationId: correlationId
);

return response;
#endif
}

Expand Down
19 changes: 19 additions & 0 deletions cli/common/unityprocess/command_error.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package unityprocess

import (
"context"
"errors"
"fmt"
"strings"
)
Expand All @@ -15,3 +17,20 @@ func commandErrorWithStderr(err error, stderr string) error {
}
return fmt.Errorf("%w: %s", err, trimmedStderr)
}

// focusCommandError converts a focus script failure into an actionable error.
// Why: a timeout kills the script before it can write anything to stderr, so without this
// mapping the caller only sees a bare "exit status 1" with no hint about the stalled Editor.
func focusCommandError(contextErr error, runErr error, stderr string) error {
if runErr == nil {
return nil
}
if errors.Is(contextErr, context.DeadlineExceeded) {
return fmt.Errorf(
"focusing the Unity window timed out after %s; the Unity Editor may be busy (for example during a domain reload), retry once it is responsive: %w",
FocusCommandTimeout,
runErr,
)
}
return commandErrorWithStderr(runErr, stderr)
}
29 changes: 29 additions & 0 deletions cli/common/unityprocess/command_error_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package unityprocess

import (
"context"
"errors"
"strings"
"testing"
Expand All @@ -25,3 +26,31 @@ func TestCommandErrorWithStderrKeepsOriginalErrorWithoutStderr(t *testing.T) {
t.Fatalf("expected original error, got %v", actual)
}
}

// Verifies a timed-out focus script is reported as a busy-Editor timeout instead of a bare exit status.
func TestFocusCommandErrorReportsTimeoutWhenContextDeadlineExceeded(t *testing.T) {
err := focusCommandError(context.DeadlineExceeded, errors.New("exit status 1"), "")

if err == nil || !strings.Contains(err.Error(), "timed out") || !strings.Contains(err.Error(), "domain reload") {
t.Fatalf("expected timeout explanation, got %v", err)
}
}

// Verifies a focus script throw keeps the stderr text without the timeout explanation.
func TestFocusCommandErrorKeepsStderrForScriptFailures(t *testing.T) {
err := focusCommandError(nil, errors.New("exit status 1"), "Windows refused to bring the Unity window\r\n")

if err == nil || !strings.Contains(err.Error(), "Windows refused to bring the Unity window") {
t.Fatalf("expected stderr in error, got %v", err)
}
if strings.Contains(err.Error(), "timed out") {
t.Fatalf("expected no timeout explanation, got %v", err)
}
}

// Verifies a nil run error yields no focus error.
func TestFocusCommandErrorReturnsNilWithoutRunError(t *testing.T) {
if err := focusCommandError(context.DeadlineExceeded, nil, ""); err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
Loading
Loading