diff --git a/Assets/Tests/Demo/scripts/verify-replay-via-cli.sh b/Assets/Tests/Demo/scripts/verify-replay-via-cli.sh index c02239ba7..1e8329e4a 100755 --- a/Assets/Tests/Demo/scripts/verify-replay-via-cli.sh +++ b/Assets/Tests/Demo/scripts/verify-replay-via-cli.sh @@ -1,30 +1,88 @@ #!/bin/sh -# E2E verification: human plays freely, then CLI replays and verifies. +# E2E verification: record input, replay it through the CLI, and compare behavior. # -# Usage: sh verify-replay-via-cli.sh [--project-path ] +# Usage: sh verify-replay-via-cli.sh [--project-path ] [--automated-input] # # Prerequisites: -# - Unity Editor running with InputReplayVerificationScene loaded -# - PlayMode is NOT running (script starts it) +# - Unity Editor running for the target project +# - PlayMode is not running because this script starts it set -e PROJECT_PATH="" -if [ "$1" = "--project-path" ] && [ -n "$2" ]; then - PROJECT_PATH="$2" -fi +ULOOP_PATH="${ULOOP_BIN:-uloop}" +AUTOMATED_INPUT=false +SCENE_PATH="Assets/Scenes/InputReplayVerificationScene.unity" + +fail() { + echo "ERROR: $1" >&2 + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project-path) + [ "$#" -ge 2 ] || fail "--project-path requires a value" + PROJECT_PATH=$2 + shift 2 + ;; + --uloop-path) + [ "$#" -ge 2 ] || fail "--uloop-path requires a value" + ULOOP_PATH=$2 + shift 2 + ;; + --automated-input) + AUTOMATED_INPUT=true + shift + ;; + -h|--help) + echo "Usage: sh verify-replay-via-cli.sh [--project-path ] [--uloop-path ] [--automated-input]" + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done run_uloop() { if [ -n "$PROJECT_PATH" ]; then - uloop "$@" --project-path "$PROJECT_PATH" + "$ULOOP_PATH" --project-path "$PROJECT_PATH" "$@" else - uloop "$@" + "$ULOOP_PATH" "$@" fi } RECORDING_LOG=".uloop/outputs/InputRecordings/recording-event-log.txt" REPLAY_LOG=".uloop/outputs/InputRecordings/replay-event-log.txt" +if [ -n "$PROJECT_PATH" ]; then + RECORDING_LOG="$PROJECT_PATH/$RECORDING_LOG" + REPLAY_LOG="$PROJECT_PATH/$REPLAY_LOG" +fi + +run_uloop_json() { + output=$(run_uloop "$@" 2>&1) || { + printf '%s\n' "$output" >&2 + fail "uloop $* failed" + } + if printf '%s\n' "$output" | grep -Eq '"Success"[[:space:]]*:[[:space:]]*false'; then + printf '%s\n' "$output" >&2 + fail "uloop $* returned Success=false" + fi + + printf '%s\n' "$output" +} + +assert_json_result() { + json=$1 + expected=$2 + context=$3 + + actual=$(printf '%s\n' "$json" | sed -n 's/.*"Result"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + [ "$actual" = "$expected" ] || fail "$context: expected '$expected', got '$actual'" +} + wait_for_unity() { i=0 while [ $i -lt 15 ]; do @@ -39,30 +97,65 @@ wait_for_unity() { } activate_for_record() { - run_uloop execute-dynamic-code --code ' + json=$(run_uloop_json execute-dynamic-code --code ' var cube = GameObject.Find("VerificationCube"); if (cube == null) return "ERROR: VerificationCube not found"; cube.SendMessage("ActivateForExternalControl"); return "OK: activated for recording"; -' +') + assert_json_result "$json" "OK: activated for recording" "Activate recording controller" } activate_for_replay() { - run_uloop execute-dynamic-code --code ' + json=$(run_uloop_json execute-dynamic-code --code ' var cube = GameObject.Find("VerificationCube"); if (cube == null) return "ERROR: VerificationCube not found"; cube.SendMessage("ActivateForExternalReplay"); return "OK: activated for replay"; -' +') + assert_json_result "$json" "OK: activated for replay" "Activate replay controller" } save_log() { - run_uloop execute-dynamic-code --code " + unity_path=$1 + if command -v cygpath >/dev/null 2>&1; then + unity_path=$(cygpath -w "$1") + fi + escaped_path=$(printf '%s\n' "$unity_path" | sed 's/\\/\\\\/g; s/"/\\"/g') + rm -f "$1" + json=$(run_uloop_json execute-dynamic-code --code " var cube = GameObject.Find(\"VerificationCube\"); if (cube == null) return \"ERROR: VerificationCube not found\"; -cube.SendMessage(\"SaveLog\", \"$1\"); +cube.SendMessage(\"SaveLog\", \"$escaped_path\"); return \"OK: log saved\"; -" +") + assert_json_result "$json" "OK: log saved" "Save event log" + [ -f "$1" ] || fail "Save event log did not create $1" +} + +initialize_replay_scene() { + run_uloop control-play-mode --action Stop >/dev/null 2>&1 || true + + json=$(run_uloop_json execute-dynamic-code --code " +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; +string scenePath = \"$SCENE_PATH\"; +if (SceneManager.GetActiveScene().path != scenePath) +{ + EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); +} +return SceneManager.GetActiveScene().path; +") + assert_json_result "$json" "$SCENE_PATH" "Load replay verification scene" +} + +invoke_automated_input() { + run_uloop_json simulate-mouse-input --action SmoothDelta --delta-x 96 --delta-y 0 --duration 0.25 >/dev/null + sleep 1 + run_uloop_json simulate-mouse-input --action Click --x 400 --y 300 >/dev/null + sleep 1 + run_uloop_json simulate-mouse-input --action Scroll --scroll-y 120 >/dev/null + sleep 1 } echo "" @@ -73,58 +166,72 @@ echo "=========================================" # ---- Phase 1: Record human input ---- echo "" -echo "[1/8] Starting PlayMode..." -run_uloop control-play-mode --action Play +echo "[1/9] Loading replay verification scene..." +initialize_replay_scene + +echo "[2/9] Starting PlayMode..." +run_uloop_json control-play-mode --action Play >/dev/null echo " Waiting for Unity..." sleep 6 wait_for_unity -echo "[2/8] Activating controller..." +echo "[3/9] Activating controller..." activate_for_record -echo "[3/8] Starting recording via CLI..." -run_uloop record-input --action Start +echo "[4/9] Starting recording via CLI..." +if [ "$AUTOMATED_INPUT" = true ]; then + run_uloop_json record-input --action Start --delay-seconds 0 --no-show-overlay >/dev/null +else + run_uloop_json record-input --action Start >/dev/null +fi -echo "" -echo "=========================================" -echo " Recording is active!" -echo " Go to the Unity Game View and play." -echo "" -echo " WASD: move | Mouse: rotate" -echo " Left click: red | Right click: blue" -echo " Scroll: scale" -echo "" -echo " Press ENTER here when done." -echo "=========================================" -echo "" -read dummy +if [ "$AUTOMATED_INPUT" = true ]; then + echo " Running automated input sequence..." + sleep 1 + invoke_automated_input +else + echo "" + echo "=========================================" + echo " Recording is active!" + echo " Go to the Unity Game View and play." + echo "" + echo " WASD: move | Mouse: rotate" + echo " Left click: red | Right click: blue" + echo " Scroll: scale" + echo "" + echo " Press ENTER here when done." + echo "=========================================" + echo "" + read -r _ +fi -echo "[4/8] Saving event log + deactivating controller..." -run_uloop execute-dynamic-code --code ' -var cube = GameObject.Find("VerificationCube"); -if (cube == null) return "ERROR: VerificationCube not found"; -cube.SendMessage("SaveLog", ".uloop/outputs/InputRecordings/recording-event-log.txt"); -cube.SendMessage("ClearLog"); -return "OK: log saved, controller deactivated"; -' +echo "[5/9] Stopping recording via CLI..." +RECORD_STOP_RESULT=$(run_uloop_json record-input --action Stop) +echo " $RECORD_STOP_RESULT" +RECORDING_INPUT_PATH=$(printf '%s\n' "$RECORD_STOP_RESULT" | sed -n 's/.*"OutputPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') -echo " Stopping recording via CLI..." -run_uloop record-input --action Stop +echo "[6/9] Saving recording event log..." +save_log "$RECORDING_LOG" +[ -s "$RECORDING_LOG" ] || fail "Recording event log is empty" # ---- Phase 2: Replay via CLI ---- -echo "[5/8] Restarting PlayMode..." -run_uloop control-play-mode --action Stop +echo "[7/9] Restarting PlayMode..." +run_uloop_json control-play-mode --action Stop >/dev/null sleep 3 -run_uloop control-play-mode --action Play +run_uloop_json control-play-mode --action Play >/dev/null echo " Waiting for Unity..." sleep 6 wait_for_unity -echo "[6/8] Activating controller + starting replay via CLI..." +echo "[8/9] Activating controller + starting replay via CLI..." activate_for_replay echo " Starting replay..." -REPLAY_RESULT=$(run_uloop replay-input --action Start 2>&1) || true +if [ "$AUTOMATED_INPUT" = true ] && [ -n "$RECORDING_INPUT_PATH" ]; then + REPLAY_RESULT=$(run_uloop replay-input --action Start --input-path "$RECORDING_INPUT_PATH" --no-show-overlay 2>&1) || true +else + REPLAY_RESULT=$(run_uloop replay-input --action Start 2>&1) || true +fi echo " $REPLAY_RESULT" echo " Waiting for replay to finish..." @@ -153,13 +260,14 @@ if [ $waited -ge 60 ]; then fi sleep 1 -echo "[7/8] Saving replay event log..." -save_log ".uloop/outputs/InputRecordings/replay-event-log.txt" +echo "[9/9] Saving replay event log..." +save_log "$REPLAY_LOG" +[ -s "$REPLAY_LOG" ] || fail "Replay event log is empty" # ---- Phase 3: Compare ---- echo "" -echo "[8/8] Comparing logs..." +echo "[Final] Comparing logs..." echo "" # Normalize frame numbers to relative (first event = frame 0). diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index 88c3980a3..da4e7549d 100755 Binary files a/Packages/src/Cli~/dist/darwin-amd64/uloop and b/Packages/src/Cli~/dist/darwin-amd64/uloop differ diff --git a/Packages/src/Cli~/dist/darwin-arm64/uloop b/Packages/src/Cli~/dist/darwin-arm64/uloop index 0f4001611..7a3bc7774 100755 Binary files a/Packages/src/Cli~/dist/darwin-arm64/uloop and b/Packages/src/Cli~/dist/darwin-arm64/uloop differ diff --git a/Packages/src/Cli~/dist/windows-amd64/uloop.exe b/Packages/src/Cli~/dist/windows-amd64/uloop.exe index 2c087c21c..54e77d85f 100755 Binary files a/Packages/src/Cli~/dist/windows-amd64/uloop.exe and b/Packages/src/Cli~/dist/windows-amd64/uloop.exe differ diff --git a/Packages/src/Cli~/internal/cli/server_state.go b/Packages/src/Cli~/internal/cli/server_state.go index a0f983225..cec14897b 100644 --- a/Packages/src/Cli~/internal/cli/server_state.go +++ b/Packages/src/Cli~/internal/cli/server_state.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "time" ) const ( @@ -13,6 +14,12 @@ const ( serverStateCompletedTempSuffix = ".tmp" serverStateInProgressTempSuffix = ".tmp.write" serverStateBackupSuffix = ".bak" + serverStateReadRetryAttempts = 20 +) + +var ( + readServerStateFileContent = os.ReadFile + serverStateReadRetryDelay = 25 * time.Millisecond ) type serverState struct { @@ -48,23 +55,33 @@ func (err staleServerStateError) Error() string { func readServerState(projectRoot string) (serverState, bool, error) { statePath := filepath.Join(projectRoot, serverStateRelativePath) - content, ok, err := readServerStateFile(statePath) - if err != nil { - return serverState{}, false, err - } - if !ok { - return serverState{}, false, nil - } + var lastErr error + for attempt := 0; attempt <= serverStateReadRetryAttempts; attempt++ { + content, ok, err := readServerStateFile(statePath) + if err != nil { + return serverState{}, false, err + } + if !ok { + return serverState{}, false, nil + } - var state serverState - if err := json.Unmarshal(content, &state); err != nil { - return serverState{}, false, fmt.Errorf("server readiness state is unreadable: %w", err) + var state serverState + if err := json.Unmarshal(content, &state); err == nil { + return state, true, nil + } else { + lastErr = err + } + + if attempt < serverStateReadRetryAttempts { + // Unity can briefly expose a truncated readiness file while replacing it. + time.Sleep(serverStateReadRetryDelay) + } } - return state, true, nil + return serverState{}, false, fmt.Errorf("server readiness state is unreadable: %w", lastErr) } func readServerStateFile(statePath string) ([]byte, bool, error) { - content, err := os.ReadFile(statePath) + content, err := readServerStateFileWithRetry(statePath) if err != nil { if !os.IsNotExist(err) { return nil, false, err @@ -74,7 +91,7 @@ func readServerStateFile(statePath string) ([]byte, bool, error) { } for _, sidecarPath := range []string{statePath + serverStateCompletedTempSuffix, statePath + serverStateBackupSuffix} { - sidecarContent, sidecarErr := os.ReadFile(sidecarPath) + sidecarContent, sidecarErr := readServerStateFileWithRetry(sidecarPath) if sidecarErr == nil { return sidecarContent, true, nil } @@ -86,6 +103,23 @@ func readServerStateFile(statePath string) ([]byte, bool, error) { return nil, false, nil } +func readServerStateFileWithRetry(statePath string) ([]byte, error) { + var lastErr error + for attempt := 0; attempt <= serverStateReadRetryAttempts; attempt++ { + content, err := readServerStateFileContent(statePath) + if err == nil || os.IsNotExist(err) { + return content, err + } + + lastErr = err + if attempt < serverStateReadRetryAttempts { + // Unity can briefly publish the readiness file with an exclusive Windows handle. + time.Sleep(serverStateReadRetryDelay) + } + } + return nil, lastErr +} + func isServerStateBusy(state serverState) bool { switch state.Phase { case "starting", "compiling", "reloading", "recovering", "stopping": diff --git a/Packages/src/Cli~/internal/cli/server_state_test.go b/Packages/src/Cli~/internal/cli/server_state_test.go index 8d65051bb..c36de064a 100644 --- a/Packages/src/Cli~/internal/cli/server_state_test.go +++ b/Packages/src/Cli~/internal/cli/server_state_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "os" "path/filepath" "strings" @@ -119,6 +120,74 @@ func TestReadServerStateReadsSharedTempPath(t *testing.T) { } } +// Verifies that transient read failures are retried before reporting server state as unreadable. +func TestReadServerStateRetriesTransientReadError(t *testing.T) { + projectRoot := t.TempDir() + writeReadinessServerStateForTest(t, projectRoot, `{"phase":"ready","generationId":"gen"}`) + statePath := filepath.Join(projectRoot, serverStateRelativePath) + attempts := 0 + + originalReadFile := readServerStateFileContent + originalRetryDelay := serverStateReadRetryDelay + readServerStateFileContent = func(path string) ([]byte, error) { + if path == statePath && attempts < 2 { + attempts++ + return nil, errors.New("transient file lock") + } + return os.ReadFile(path) + } + serverStateReadRetryDelay = 0 + t.Cleanup(func() { + readServerStateFileContent = originalReadFile + serverStateReadRetryDelay = originalRetryDelay + }) + + state, ok, err := readServerState(projectRoot) + if err != nil { + t.Fatalf("readServerState failed: %v", err) + } + if !ok || state.Phase != "ready" { + t.Fatalf("server state mismatch: ok=%v state=%#v", ok, state) + } + if attempts != 2 { + t.Fatalf("retry count mismatch: %d", attempts) + } +} + +// Verifies that partially published server state JSON is retried before failing. +func TestReadServerStateRetriesPartialJSON(t *testing.T) { + projectRoot := t.TempDir() + writeReadinessServerStateForTest(t, projectRoot, `{"phase":"ready","generationId":"gen"}`) + statePath := filepath.Join(projectRoot, serverStateRelativePath) + attempts := 0 + + originalReadFile := readServerStateFileContent + originalRetryDelay := serverStateReadRetryDelay + readServerStateFileContent = func(path string) ([]byte, error) { + if path == statePath && attempts < 2 { + attempts++ + return []byte(`{"phase":`), nil + } + return os.ReadFile(path) + } + serverStateReadRetryDelay = 0 + t.Cleanup(func() { + readServerStateFileContent = originalReadFile + serverStateReadRetryDelay = originalRetryDelay + }) + + state, ok, err := readServerState(projectRoot) + if err != nil { + t.Fatalf("readServerState failed: %v", err) + } + if !ok || state.Phase != "ready" { + t.Fatalf("server state mismatch: ok=%v state=%#v", ok, state) + } + if attempts != 2 { + t.Fatalf("retry count mismatch: %d", attempts) + } +} + // Verifies that a completed temp sidecar is still visible to CLI waiters. func TestReadServerStateReadsCompletedTempSidecarWhenTargetIsMissing(t *testing.T) { projectRoot := t.TempDir() diff --git a/Packages/src/Cli~/internal/uninstall/command.go b/Packages/src/Cli~/internal/uninstall/command.go index f19d86067..e7531c805 100644 --- a/Packages/src/Cli~/internal/uninstall/command.go +++ b/Packages/src/Cli~/internal/uninstall/command.go @@ -4,7 +4,7 @@ import ( "encoding/base64" "errors" "fmt" - "path/filepath" + "path" "strings" "unicode/utf16" ) @@ -34,7 +34,7 @@ func CommandForOS(goos string, options Options) (Command, error) { switch goos { case "darwin": - targetPath := filepath.Join(options.InstallDir, PosixCommandName) + targetPath := path.Join(options.InstallDir, PosixCommandName) script := "rm -f " + shellQuote(targetPath) return Command{ Name: "sh", diff --git a/README.md b/README.md index 360e8c42c..b46c45074 100644 --- a/README.md +++ b/README.md @@ -473,18 +473,15 @@ Replay recorded keyboard and mouse input during PlayMode. Loads a JSON recording → replay-input (Action: Stop) ``` -Terminal-driven E2E helpers are available for both POSIX shells and Windows PowerShell: +Terminal-driven E2E coverage is available through one runner per shell family: ```bash +sh scripts/run-posix-e2e.sh --project-path /path/to/unity-project powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-windows-e2e.ps1 - -sh Assets/Tests/Demo/scripts/verify-replay-via-cli.sh -powershell -NoProfile -ExecutionPolicy Bypass -File .\Assets\Tests\Demo\scripts\verify-replay-via-cli.ps1 -AutomatedInput - -sh scripts/test-simulate-mouse-demo.sh -powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-simulate-mouse-demo.ps1 ``` +`run-posix-e2e.sh` uses the checked-in native CLI binary by default, passes an explicit `--project-path` to every `uloop` invocation, and runs CLI recovery/readiness, input record/replay, and simulate-mouse UI coverage in one sequence. + ## Unity CLI Loop Extension Development Unity CLI Loop enables efficient development of project-specific tools without requiring changes to the core package. The type-safe design allows for reliable custom tool implementation in minimal time. diff --git a/README_ja.md b/README_ja.md index 63f0fcff3..15feb07ee 100644 --- a/README_ja.md +++ b/README_ja.md @@ -474,18 +474,15 @@ PlayMode中のキーボード・マウス入力をフレーム単位でJSONフ → replay-input (Action: Stop) ``` -terminal から uloop コマンドを実行するE2Eヘルパーは、POSIXシェル向けとWindows PowerShell向けの両方があります: +terminal から uloop コマンドを実行するE2Eは、shell 系統ごとに1つのrunnerから実行します: ```bash +sh scripts/run-posix-e2e.sh --project-path /path/to/unity-project powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-windows-e2e.ps1 - -sh Assets/Tests/Demo/scripts/verify-replay-via-cli.sh -powershell -NoProfile -ExecutionPolicy Bypass -File .\Assets\Tests\Demo\scripts\verify-replay-via-cli.ps1 -AutomatedInput - -sh scripts/test-simulate-mouse-demo.sh -powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-simulate-mouse-demo.ps1 ``` +`run-posix-e2e.sh` は、デフォルトでチェックイン済みのネイティブCLIバイナリを使い、すべての `uloop` 呼び出しに明示的な `--project-path` を渡します。CLI recovery/readiness、input record/replay、simulate-mouse UI を1つの流れで検証します。 + ## Unity CLI Loop 拡張ツールの開発 Unity CLI Loopはコアパッケージへの変更を必要とせず、プロジェクト固有のツールを効率的に開発できます。 型安全な設計により、信頼性の高いカスタムツールを短時間で実装可能です。 diff --git a/scripts/run-posix-e2e.sh b/scripts/run-posix-e2e.sh new file mode 100755 index 000000000..4880d75e3 --- /dev/null +++ b/scripts/run-posix-e2e.sh @@ -0,0 +1,157 @@ +#!/bin/sh +# Runs the terminal-driven POSIX E2E coverage through one entrypoint. + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PROJECT_PATH="$ROOT_DIR" +ULOOP_PATH="${ULOOP_BIN:-}" +TIMEOUT_SECONDS=120 +LAUNCH_TIMEOUT_SECONDS=240 +SKIP_RECOVERY_READINESS=false +SKIP_INPUT_REPLAY=false +SKIP_SIMULATE_MOUSE=false + +fail() { + printf 'ERROR: %s\n' "$1" >&2 + exit 1 +} + +usage() { + cat < Unity project to test. Defaults to this repository. + --uloop-path uloop binary to execute. Defaults to the checked-in native binary. + --timeout Per-command smoke timeout. Default: 120. + --launch-timeout Launch/reuse smoke timeout. Default: 240. + --skip-recovery-readiness Skip recovery/readiness smoke. + --skip-input-replay Skip record/replay E2E. + --skip-simulate-mouse Skip simulate-mouse UI E2E. + -h, --help Show this help. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --project-path) + [ "$#" -ge 2 ] || fail "--project-path requires a value" + PROJECT_PATH=$2 + shift 2 + ;; + --uloop-path) + [ "$#" -ge 2 ] || fail "--uloop-path requires a value" + ULOOP_PATH=$2 + shift 2 + ;; + --timeout) + [ "$#" -ge 2 ] || fail "--timeout requires a value" + TIMEOUT_SECONDS=$2 + shift 2 + ;; + --launch-timeout) + [ "$#" -ge 2 ] || fail "--launch-timeout requires a value" + LAUNCH_TIMEOUT_SECONDS=$2 + shift 2 + ;; + --skip-recovery-readiness) + SKIP_RECOVERY_READINESS=true + shift + ;; + --skip-input-replay) + SKIP_INPUT_REPLAY=true + shift + ;; + --skip-simulate-mouse) + SKIP_SIMULATE_MOUSE=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +resolve_path() { + path=$1 + if [ -d "$path" ]; then + (cd "$path" && pwd) + return + fi + parent=$(dirname -- "$path") + leaf=$(basename -- "$path") + (cd "$parent" && printf '%s/%s\n' "$(pwd)" "$leaf") +} + +default_uloop_path() { + case "$(uname -s)" in + Darwin) + machine=$(uname -m) + if [ "$machine" = "arm64" ] || [ "$machine" = "aarch64" ]; then + printf '%s\n' "$ROOT_DIR/Packages/src/Cli~/dist/darwin-arm64/uloop" + else + printf '%s\n' "$ROOT_DIR/Packages/src/Cli~/dist/darwin-amd64/uloop" + fi + ;; + MINGW*|MSYS*|CYGWIN*) + printf '%s\n' "$ROOT_DIR/Packages/src/Cli~/dist/windows-amd64/uloop.exe" + ;; + *) + printf '%s\n' "" + ;; + esac +} + +run_step() { + name=$1 + shift + printf '\n=== %s ===\n' "$name" + "$@" +} + +PROJECT_PATH=$(resolve_path "$PROJECT_PATH") +if [ -z "$ULOOP_PATH" ]; then + ULOOP_PATH=$(default_uloop_path) +fi +[ -n "$ULOOP_PATH" ] || fail "no checked-in uloop binary for this platform; pass --uloop-path" +ULOOP_PATH=$(resolve_path "$ULOOP_PATH") +[ -x "$ULOOP_PATH" ] || fail "uloop binary is not executable: $ULOOP_PATH" +[ -d "$PROJECT_PATH/Assets" ] || fail "--project-path does not contain Assets: $PROJECT_PATH" +[ -d "$PROJECT_PATH/ProjectSettings" ] || fail "--project-path does not contain ProjectSettings: $PROJECT_PATH" + +export ULOOP_BIN="$ULOOP_PATH" +PATH="$(dirname -- "$ULOOP_PATH"):$PATH" +export PATH + +printf '=== POSIX terminal-driven E2E ===\n' +printf 'project_path=%s\n' "$PROJECT_PATH" +printf 'uloop_path=%s\n' "$ULOOP_PATH" + +if [ "$SKIP_RECOVERY_READINESS" = false ]; then + run_step "CLI recovery/readiness" \ + go run "$ROOT_DIR/scripts/smoke-cli-recovery-readiness.go" \ + --project-path "$PROJECT_PATH" \ + --uloop-path "$ULOOP_PATH" \ + --timeout "$TIMEOUT_SECONDS" \ + --launch-timeout "$LAUNCH_TIMEOUT_SECONDS" +fi + +if [ "$SKIP_INPUT_REPLAY" = false ]; then + run_step "Input record/replay" \ + sh "$ROOT_DIR/Assets/Tests/Demo/scripts/verify-replay-via-cli.sh" \ + --project-path "$PROJECT_PATH" \ + --automated-input +fi + +if [ "$SKIP_SIMULATE_MOUSE" = false ]; then + run_step "Simulate mouse UI" \ + sh "$ROOT_DIR/scripts/test-simulate-mouse-demo.sh" \ + --project-path "$PROJECT_PATH" +fi + +printf '\nAll POSIX terminal-driven E2E checks passed.\n' diff --git a/scripts/run-windows-e2e.ps1 b/scripts/run-windows-e2e.ps1 index 1312b8270..ad1f9aa1e 100644 --- a/scripts/run-windows-e2e.ps1 +++ b/scripts/run-windows-e2e.ps1 @@ -263,6 +263,30 @@ function Invoke-ScriptChecked { } } +function Invoke-GoScriptChecked { + param( + [string]$ScriptPath, + [string[]]$Arguments = @() + ) + + [string[]]$scriptArguments = @("run", $ScriptPath) + $Arguments + [string]$previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + [object[]]$output = & go @scriptArguments 2>&1 + [int]$exitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + + [string]$text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine + Write-Host $text + if ($exitCode -ne 0) { + throw "$ScriptPath failed with exit code $exitCode" + } +} + function Invoke-Step { param( [string]$Name, @@ -297,11 +321,21 @@ function Invoke-CoreToolSmoke { Invoke-UloopJsonChecked -CommandArguments @("hello-world") | Out-Null Invoke-UloopJsonChecked -CommandArguments @("focus-window") | Out-Null Invoke-UloopJsonChecked -CommandArguments @("clear-console") | Out-Null - Invoke-UloopJsonChecked -CommandArguments @("compile", "--wait-for-domain-reload") | Out-Null + Invoke-UloopJsonChecked -CommandArguments @("compile") | Out-Null Wait-UnityReady Invoke-UloopJsonChecked -CommandArguments @("get-logs", "--log-type", "All", "--max-count", "1") | Out-Null } +function Invoke-CliRecoveryReadinessSmoke { + [System.Management.Automation.CommandInfo]$uloopCommand = Get-Command uloop -CommandType Application -ErrorAction Stop + Invoke-GoScriptChecked -ScriptPath (Resolve-ProjectRelativePath -RelativePath "scripts\smoke-cli-recovery-readiness.go") -Arguments @( + "--project-path", + $ResolvedProjectPath, + "--uloop-path", + $uloopCommand.Source + ) +} + function Invoke-DiscoverySmoke { Open-Scene -ScenePath $DiscoveryScenePath Start-PlayMode @@ -327,7 +361,7 @@ function Invoke-RunTestsSmoke { function Invoke-CompileGetLogsStress { for ([int]$round = 1; $round -le $StressRounds; $round++) { Write-Host "stress round $round/$StressRounds" - Invoke-UloopJsonChecked -CommandArguments @("compile", "--wait-for-domain-reload") | Out-Null + Invoke-UloopJsonChecked -CommandArguments @("compile") | Out-Null Wait-UnityReady Invoke-UloopJsonChecked -CommandArguments @("get-logs", "--max-count", "1") | Out-Null Start-Sleep -Seconds 1 @@ -347,6 +381,7 @@ return cube.transform.position.z; try { Invoke-Step -Name "Launch Smoke" -Body { Invoke-LaunchSmoke } + Invoke-Step -Name "CLI Recovery Readiness Smoke" -Body { Invoke-CliRecoveryReadinessSmoke } Invoke-Step -Name "Core CLI Tool Smoke" -Body { Invoke-CoreToolSmoke } Invoke-Step -Name "Discovery and Screenshot Smoke" -Body { Invoke-DiscoverySmoke } Invoke-Step -Name "Run Tests Smoke" -Body { Invoke-RunTestsSmoke } diff --git a/scripts/smoke-cli-recovery-readiness.go b/scripts/smoke-cli-recovery-readiness.go new file mode 100644 index 000000000..5aa4885c2 --- /dev/null +++ b/scripts/smoke-cli-recovery-readiness.go @@ -0,0 +1,407 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + stateRelativePath = "Temp/UnityCliLoop/server-state.json" + expectedDynamicCodeResult = "cli-recovery-readiness-e2e" + e2eDynamicCode = `return "cli-recovery-readiness-e2e";` + timeoutExitCode = 124 +) + +type commandResult struct { + args []string + exitCode int + stdout string + stderr string + elapsed time.Duration + timedOut bool +} + +type options struct { + projectPath string + uloopPath string + timeout time.Duration + launchTimeout time.Duration +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } +} + +func run() error { + opts, err := parseOptions() + if err != nil { + return err + } + if err := validatePaths(opts.projectPath, opts.uloopPath); err != nil { + return err + } + + fmt.Println("=== CLI recovery/readiness smoke ===") + fmt.Printf("project_path=%s\n", opts.projectPath) + fmt.Printf("uloop_path=%s\n", opts.uloopPath) + + if err := runLiveRecoverySequence(opts); err != nil { + return err + } + if err := runStaleRecoveryStateSequence(opts.uloopPath, opts.timeout); err != nil { + return err + } + + fmt.Println("CLI recovery/readiness smoke passed") + return nil +} + +func parseOptions() (options, error) { + defaultUloopPath, err := defaultUloopPath() + if err != nil { + return options{}, err + } + + flagSet := flag.NewFlagSet(filepath.Base(os.Args[0]), flag.ContinueOnError) + projectPath := flagSet.String("project-path", "", "Unity project to test") + uloopPath := flagSet.String("uloop-path", defaultUloopPath, "uloop binary to execute") + timeoutSeconds := flagSet.Float64("timeout", 120, "per-command timeout in seconds") + launchTimeoutSeconds := flagSet.Float64("launch-timeout", 240, "launch timeout in seconds") + if err := flagSet.Parse(os.Args[1:]); err != nil { + return options{}, err + } + if strings.TrimSpace(*projectPath) == "" { + return options{}, errors.New("--project-path is required") + } + + resolvedProjectPath, err := filepath.Abs(*projectPath) + if err != nil { + return options{}, err + } + resolvedUloopPath := strings.TrimSpace(*uloopPath) + if resolvedUloopPath != "" { + resolvedUloopPath, err = filepath.Abs(resolvedUloopPath) + if err != nil { + return options{}, err + } + } + + return options{ + projectPath: resolvedProjectPath, + uloopPath: resolvedUloopPath, + timeout: secondsToDuration(*timeoutSeconds), + launchTimeout: secondsToDuration(*launchTimeoutSeconds), + }, nil +} + +func secondsToDuration(seconds float64) time.Duration { + return time.Duration(seconds * float64(time.Second)) +} + +func defaultUloopPath() (string, error) { + envPath := strings.TrimSpace(os.Getenv("ULOOP_BIN")) + if envPath != "" { + return envPath, nil + } + + repoRoot, err := repoRootFromSource() + if err != nil { + return "", err + } + + switch runtime.GOOS { + case "darwin": + arch := "darwin-amd64" + if runtime.GOARCH == "arm64" { + arch = "darwin-arm64" + } + return filepath.Join(repoRoot, "Packages", "src", "Cli~", "dist", arch, "uloop"), nil + case "windows": + return filepath.Join(repoRoot, "Packages", "src", "Cli~", "dist", "windows-amd64", "uloop.exe"), nil + default: + return "", nil + } +} + +func repoRootFromSource() (string, error) { + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("could not locate smoke source file") + } + return filepath.Dir(filepath.Dir(sourceFile)), nil +} + +func validatePaths(projectPath string, uloopPath string) error { + if !isDirectory(filepath.Join(projectPath, "Assets")) { + return fmt.Errorf("--project-path does not contain Assets: %s", projectPath) + } + if !isDirectory(filepath.Join(projectPath, "ProjectSettings")) { + return fmt.Errorf("--project-path does not contain ProjectSettings: %s", projectPath) + } + if strings.TrimSpace(uloopPath) == "" { + return errors.New("no checked-in uloop binary is available for this platform. Pass --uloop-path") + } + if !isFile(uloopPath) { + return fmt.Errorf("uloop binary not found: %s", uloopPath) + } + return nil +} + +func isDirectory(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func isFile(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func runLiveRecoverySequence(opts options) error { + if err := assertSuccess( + runUloop(opts.uloopPath, opts.projectPath, []string{"launch"}, opts.launchTimeout), + "launch or reuse Unity", + ); err != nil { + return err + } + if _, err := assertJSONResponse( + runUloop(opts.uloopPath, opts.projectPath, []string{"get-logs", "--max-count", "1"}, opts.timeout), + "initial get-logs readiness check", + ); err != nil { + return err + } + if _, err := assertJSONSuccess( + runUloop(opts.uloopPath, opts.projectPath, []string{"compile"}, opts.timeout), + "compile with domain reload wait", + ); err != nil { + return err + } + if _, err := assertJSONResponse( + runUloop(opts.uloopPath, opts.projectPath, []string{"get-logs", "--max-count", "1"}, opts.timeout), + "immediate get-logs after compile", + ); err != nil { + return err + } + + dynamicPayload, err := assertJSONSuccess( + runUloop( + opts.uloopPath, + opts.projectPath, + []string{"execute-dynamic-code", "--code", e2eDynamicCode}, + opts.timeout, + ), + "execute-dynamic-code after recovery", + ) + if err != nil { + return err + } + return assertDynamicCodeResult(dynamicPayload) +} + +func runStaleRecoveryStateSequence(uloopPath string, timeout time.Duration) error { + projectPath, err := os.MkdirTemp("", "uloop-stale-state-") + if err != nil { + return err + } + defer os.RemoveAll(projectPath) + + if err := createMinimalUnityProject(projectPath); err != nil { + return err + } + if err := writeStaleServerState(projectPath); err != nil { + return err + } + + fmt.Printf("stale_state_project=%s\n", projectPath) + staleResult := runUloop(uloopPath, projectPath, []string{"get-logs", "--max-count", "1"}, timeout) + if err := assertStaleRecoveryStateError(staleResult); err != nil { + return err + } + if err := assertSuccess( + runUloop(uloopPath, projectPath, []string{"fix"}, timeout), + "cleanup stale recovery state", + ); err != nil { + return err + } + + statePath := filepath.Join(projectPath, filepath.FromSlash(stateRelativePath)) + if isFile(statePath) { + return fmt.Errorf("stale recovery state was not removed: %s", statePath) + } + return nil +} + +func runUloop(uloopPath string, projectPath string, args []string, timeout time.Duration) commandResult { + command := append([]string{uloopPath, "--project-path", projectPath}, args...) + return runCommand(command, projectPath, timeout) +} + +func runCommand(args []string, cwd string, timeout time.Duration) commandResult { + startedAt := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = cwd + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + timedOut := ctx.Err() == context.DeadlineExceeded + exitCode := exitCodeFromError(err, timedOut) + + return commandResult{ + args: args, + exitCode: exitCode, + stdout: stdout.String(), + stderr: stderr.String(), + elapsed: time.Since(startedAt), + timedOut: timedOut, + } +} + +func exitCodeFromError(err error, timedOut bool) int { + if timedOut { + return timeoutExitCode + } + if err == nil { + return 0 + } + + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode() + } + return 1 +} + +func assertSuccess(result commandResult, label string) error { + if result.exitCode == 0 && !result.timedOut { + fmt.Printf("%s passed in %.1fs\n", label, result.elapsed.Seconds()) + return nil + } + + printCommandContext(label, result) + return errors.New(label) +} + +func assertJSONResponse(result commandResult, label string) (map[string]any, error) { + if err := assertSuccess(result, label); err != nil { + return nil, err + } + + var payload map[string]any + if err := json.Unmarshal([]byte(result.stdout), &payload); err != nil { + printCommandContext(label, result) + return nil, fmt.Errorf("%s did not return JSON: %w", label, err) + } + + return payload, nil +} + +func assertJSONSuccess(result commandResult, label string) (map[string]any, error) { + payload, err := assertJSONResponse(result, label) + if err != nil { + return nil, err + } + + if success, ok := payload["Success"].(bool); !ok || !success { + printCommandContext(label, result) + return nil, fmt.Errorf("%s returned invalid success payload: %v", label, payload) + } + + return payload, nil +} + +func assertDynamicCodeResult(payload map[string]any) error { + result, ok := payload["Result"].(string) + if ok && result == expectedDynamicCodeResult { + return nil + } + return fmt.Errorf("execute-dynamic-code result mismatch: %v", payload) +} + +func assertStaleRecoveryStateError(result commandResult) error { + if result.exitCode == 0 || result.timedOut { + printCommandContext("stale recovery-state check", result) + return errors.New("stale recovery-state check should fail without timing out") + } + + combinedOutput := result.stdout + result.stderr + requiredFragments := []string{ + "stale Unity CLI Loop recovery state file", + "Run `uloop fix` to remove stale recovery state files.", + } + for _, fragment := range requiredFragments { + if !strings.Contains(combinedOutput, fragment) { + printCommandContext("stale recovery-state check", result) + return fmt.Errorf("stale recovery-state output missing: %s", fragment) + } + } + + fmt.Printf("stale recovery-state check passed in %.1fs\n", result.elapsed.Seconds()) + return nil +} + +func printCommandContext(label string, result commandResult) { + fmt.Printf("%s failed\n", label) + fmt.Printf("command: %s\n", strings.Join(result.args, " ")) + fmt.Printf("exit_code: %d\n", result.exitCode) + fmt.Printf("elapsed: %.1fs\n", result.elapsed.Seconds()) + fmt.Printf("timed_out: %t\n", result.timedOut) + fmt.Println("--- stdout ---") + fmt.Print(result.stdout) + fmt.Println("--- stderr ---") + fmt.Print(result.stderr) +} + +func createMinimalUnityProject(projectPath string) error { + if err := os.MkdirAll(filepath.Join(projectPath, "Assets"), 0o755); err != nil { + return err + } + projectSettingsPath := filepath.Join(projectPath, "ProjectSettings") + if err := os.MkdirAll(projectSettingsPath, 0o755); err != nil { + return err + } + return os.WriteFile( + filepath.Join(projectSettingsPath, "ProjectVersion.txt"), + []byte("m_EditorVersion: 6000.0.0f1\n"), + 0o644, + ) +} + +func writeStaleServerState(projectPath string) error { + statePath := filepath.Join(projectPath, filepath.FromSlash(stateRelativePath)) + if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil { + return err + } + + state := map[string]string{ + "phase": "recovering", + "generationId": "stale-e2e", + "updatedAt": "1970-01-01T00:00:00Z", + "reason": "domain-reload-after", + "endpoint": "stale-e2e", + "lastError": "", + } + data, err := json.Marshal(state) + if err != nil { + return err + } + return os.WriteFile(statePath, data, 0o644) +} diff --git a/scripts/smoke-compile-get-logs.py b/scripts/smoke-compile-get-logs.py index 338668d57..57a768314 100755 --- a/scripts/smoke-compile-get-logs.py +++ b/scripts/smoke-compile-get-logs.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Smoke test compile --wait-for-domain-reload followed immediately by get-logs.""" +"""Smoke test compile followed immediately by get-logs after domain reload.""" import argparse import os @@ -51,10 +51,7 @@ def assert_ready(project_path: str, timeout: float) -> None: def assert_compile_then_get_logs(project_path: str, timeout: float, round_index: int) -> None: compile_started_at = time.time() compile_result = run_uloop( - [ - "compile", - "--wait-for-domain-reload", - ], + ["compile"], project_path, timeout, ) diff --git a/scripts/test-simulate-mouse-demo.sh b/scripts/test-simulate-mouse-demo.sh index 78b45a21a..de403ca01 100755 --- a/scripts/test-simulate-mouse-demo.sh +++ b/scripts/test-simulate-mouse-demo.sh @@ -7,6 +7,8 @@ SCENE_PATH="Assets/Scenes/SimulateMouseDemoScene.unity" TMP_DIR="${TMPDIR:-/tmp}/unity-cli-loop-simulate-mouse" ELEMENTS_JSON="$TMP_DIR/simulate-mouse-elements.json" ORIGINAL_GAME_VIEW_SIZE_INDEX="" +PROJECT_PATH="" +ULOOP_PATH="${ULOOP_BIN:-uloop}" fail() { printf 'ERROR: %s\n' "$1" >&2 @@ -14,19 +16,50 @@ fail() { } cleanup() { - uloop control-play-mode --action Stop >/dev/null 2>&1 || true + run_uloop control-play-mode --action Stop >/dev/null 2>&1 || true if [ -n "${ORIGINAL_GAME_VIEW_SIZE_INDEX:-}" ]; then restore_game_view_size_index "$ORIGINAL_GAME_VIEW_SIZE_INDEX" >/dev/null 2>&1 || true fi } trap cleanup EXIT INT TERM +while [ "$#" -gt 0 ]; do + case "$1" in + --project-path) + [ "$#" -ge 2 ] || fail "--project-path requires a value" + PROJECT_PATH=$2 + shift 2 + ;; + --uloop-path) + [ "$#" -ge 2 ] || fail "--uloop-path requires a value" + ULOOP_PATH=$2 + shift 2 + ;; + -h|--help) + printf 'Usage: sh scripts/test-simulate-mouse-demo.sh [--project-path ] [--uloop-path ]\n' + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + require_jq() { command -v jq >/dev/null 2>&1 || fail "jq is required to parse uloop JSON responses" } +run_uloop() { + if [ -n "$PROJECT_PATH" ]; then + "$ULOOP_PATH" --project-path "$PROJECT_PATH" "$@" + return + fi + + "$ULOOP_PATH" "$@" +} + run_uloop_json() { - if ! output=$(uloop "$@" 2>&1); then + if ! output=$(run_uloop "$@" 2>&1); then printf '%s\n' "$output" >&2 fail "uloop $* failed" fi @@ -53,7 +86,7 @@ assert_text_equals() { wait_unity_ready() { attempt=0 while [ "$attempt" -lt 15 ]; do - if uloop get-logs --max-count 1 >/dev/null 2>&1; then + if run_uloop get-logs --max-count 1 >/dev/null 2>&1; then return fi @@ -80,7 +113,7 @@ wait_play_mode() { } initialize_demo_scene() { - uloop control-play-mode --action Stop >/dev/null 2>&1 || true + run_uloop control-play-mode --action Stop >/dev/null 2>&1 || true code=" using UnityEditor.SceneManagement; diff --git a/scripts/test-smoke-cli-recovery-readiness.sh b/scripts/test-smoke-cli-recovery-readiness.sh new file mode 100755 index 000000000..24402dd2e --- /dev/null +++ b/scripts/test-smoke-cli-recovery-readiness.sh @@ -0,0 +1,141 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/uloop-recovery-e2e-test.XXXXXX") +PROJECT_PATH="$TMP_DIR/project" +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + FAKE_ULOOP="$TMP_DIR/uloop.exe" + ;; + *) + FAKE_ULOOP="$TMP_DIR/uloop" + ;; +esac +FAKE_ULOOP_SOURCE="$TMP_DIR/fake-uloop.go" +CALL_LOG="$TMP_DIR/calls.log" + +cleanup() { + rm -rf "$TMP_DIR" +} + +trap cleanup EXIT INT TERM + +mkdir -p "$PROJECT_PATH/Assets" "$PROJECT_PATH/ProjectSettings" + +cat > "$FAKE_ULOOP_SOURCE" <<'EOF' +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func main() { + if len(os.Args) < 4 || os.Args[1] != "--project-path" { + fmt.Fprintln(os.Stderr, "missing leading --project-path") + os.Exit(99) + } + + projectPath := os.Args[2] + commandName := os.Args[3] + commandArgs := os.Args[4:] + appendCallLog(projectPath, commandName, commandArgs) + + switch commandName { + case "launch": + fmt.Printf("Unity is already running for %s (PID: 1234)\n", projectPath) + case "get-logs": + if fileExists(filepath.Join(projectPath, "Temp", "UnityCliLoop", "server-state.json")) { + fmt.Fprintln(os.Stderr, "{\n \"success\": false,\n \"error\": {\n \"errorCode\": \"UNITY_NOT_REACHABLE\",\n \"message\": \"Unity is not running, but a stale Unity CLI Loop recovery state file says it is still busy.\",\n \"nextActions\": [\n \"Run `uloop fix` to remove stale recovery state files.\"\n ]\n }\n}") + os.Exit(1) + } + fmt.Println(`{"DisplayedCount":0,"Logs":[],"MaxCount":1,"TotalCount":0}`) + case "compile": + if os.Getenv("ULOOP_FAKE_COMPILE_WITHOUT_SUCCESS") == "1" { + fmt.Println(`{"ErrorCount":0,"WarningCount":0}`) + return + } + fmt.Println(`{"Success":true,"ErrorCount":0,"WarningCount":0}`) + case "execute-dynamic-code": + fmt.Println(`{"Success":true,"Result":"cli-recovery-readiness-e2e"}`) + case "fix": + removeRecoveryStateFiles(projectPath) + fmt.Println("Cleaned up 1 recovery state file(s).") + default: + fmt.Fprintf(os.Stderr, "unexpected command: %s\n", commandName) + os.Exit(98) + } +} + +func appendCallLog(projectPath string, commandName string, commandArgs []string) { + callLogPath := os.Getenv("CALL_LOG") + if callLogPath == "" { + fmt.Fprintln(os.Stderr, "CALL_LOG is required") + os.Exit(97) + } + + line := fmt.Sprintf("%s|%s|%s\n", projectPath, commandName, strings.Join(commandArgs, " ")) + file, err := os.OpenFile(callLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(96) + } + defer file.Close() + + if _, err := file.WriteString(line); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(95) + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func removeRecoveryStateFiles(projectPath string) { + stateDir := filepath.Join(projectPath, "Temp", "UnityCliLoop") + files := []string{ + "server-state.json", + "server-state.json.tmp", + "server-state.json.tmp.write", + "server-state.json.bak", + } + for _, file := range files { + _ = os.Remove(filepath.Join(stateDir, file)) + } +} +EOF + +go build -o "$FAKE_ULOOP" "$FAKE_ULOOP_SOURCE" + +if ! CALL_LOG="$CALL_LOG" go run "$ROOT_DIR/scripts/smoke-cli-recovery-readiness.go" \ + --project-path "$PROJECT_PATH" \ + --uloop-path "$FAKE_ULOOP" \ + --timeout 2 \ + --launch-timeout 2 > "$TMP_DIR/output.txt"; then + cat "$TMP_DIR/output.txt" + exit 1 +fi + +grep -F "launch" "$CALL_LOG" >/dev/null +grep -E '\|compile\|$' "$CALL_LOG" >/dev/null +grep -F "execute-dynamic-code" "$CALL_LOG" >/dev/null +grep -F "fix" "$CALL_LOG" >/dev/null +grep -F "stale recovery-state check passed" "$TMP_DIR/output.txt" >/dev/null + +if CALL_LOG="$CALL_LOG" ULOOP_FAKE_COMPILE_WITHOUT_SUCCESS=1 go run "$ROOT_DIR/scripts/smoke-cli-recovery-readiness.go" \ + --project-path "$PROJECT_PATH" \ + --uloop-path "$FAKE_ULOOP" \ + --timeout 2 \ + --launch-timeout 2 > "$TMP_DIR/missing-success-output.txt" 2>&1; then + cat "$TMP_DIR/missing-success-output.txt" + echo "expected compile payload without Success to fail" >&2 + exit 1 +fi +grep -F "compile with domain reload wait returned invalid success payload" "$TMP_DIR/missing-success-output.txt" >/dev/null + +echo "smoke-cli-recovery-readiness harness test passed" diff --git a/scripts/uloop-compile-get-logs-stress.sh b/scripts/uloop-compile-get-logs-stress.sh index 2229e471c..d2b1019b3 100755 --- a/scripts/uloop-compile-get-logs-stress.sh +++ b/scripts/uloop-compile-get-logs-stress.sh @@ -168,7 +168,7 @@ while :; do fi if ! run_with_logs "$LOG_DIR/${round}_compile.out" "$LOG_DIR/${round}_compile.err" \ - uloop_cmd compile --wait-for-domain-reload; then + uloop_cmd compile; then echo "compile failed at round $round" exit 1 fi