diff --git a/cli/project-runner/internal/projectrunner/list_output.go b/cli/project-runner/internal/projectrunner/list_output.go index 5a0665aea..12525a2fb 100644 --- a/cli/project-runner/internal/projectrunner/list_output.go +++ b/cli/project-runner/internal/projectrunner/list_output.go @@ -183,6 +183,16 @@ func appendPausePointEnableAwaitListOptions(tool clicore.ToolDefinition, options Type: "string", Description: "Requires --await. Same as await-pause-point's --expect (repeatable)", }, + listOption{ + Name: "--" + PausePointTriggerFlagName, + Type: "string", + Description: "Requires --await. Same as await-pause-point's --trigger", + }, + listOption{ + Name: "--" + PausePointResumePlayFlagName, + Type: "boolean", + Description: "Requires --await. After confirming the marker is armed, resume PlayMode if paused (before --trigger), so a paused-arm workflow can fire input in one call", + }, ) } diff --git a/cli/project-runner/internal/projectrunner/list_output_test.go b/cli/project-runner/internal/projectrunner/list_output_test.go index 82d8600a0..372f3fe09 100644 --- a/cli/project-runner/internal/projectrunner/list_output_test.go +++ b/cli/project-runner/internal/projectrunner/list_output_test.go @@ -147,6 +147,8 @@ func TestNewListCatalogIncludesEnablePausePointAwaitOptions(t *testing.T) { findListOption(t, enablePausePoint, "--"+PausePointCapturedVariablesFlagName) findListOption(t, enablePausePoint, "--"+PausePointCapturedVariableNamesFlagName) findListOption(t, enablePausePoint, "--"+PausePointExpectFlagName) + findListOption(t, enablePausePoint, "--"+PausePointTriggerFlagName) + findListOption(t, enablePausePoint, "--"+PausePointResumePlayFlagName) } func decodeListCatalog(t *testing.T, content []byte) listCatalog { diff --git a/cli/project-runner/internal/projectrunner/native_command_help.go b/cli/project-runner/internal/projectrunner/native_command_help.go index 9a3797065..fa058fb94 100644 --- a/cli/project-runner/internal/projectrunner/native_command_help.go +++ b/cli/project-runner/internal/projectrunner/native_command_help.go @@ -19,6 +19,7 @@ const ( PausePointCapturedVariableNamesFlagName = "captured-variable-names" PausePointExpectFlagName = "expect" PausePointTriggerFlagName = "trigger" + PausePointResumePlayFlagName = "resume-play" ) // runnerNativeCommandOptions lists the flags accepted by each runner-owned @@ -34,6 +35,7 @@ var runnerNativeCommandOptions = map[string][]string{ "--" + PausePointCapturedVariableNamesFlagName, "--" + PausePointExpectFlagName, "--" + PausePointTriggerFlagName, + "--" + PausePointResumePlayFlagName, }, clicore.PausePointStatusUserCommandName: { "--" + PausePointIDFlagName, diff --git a/cli/project-runner/internal/projectrunner/native_command_help_test.go b/cli/project-runner/internal/projectrunner/native_command_help_test.go index 85c57ab13..dcbe321e0 100644 --- a/cli/project-runner/internal/projectrunner/native_command_help_test.go +++ b/cli/project-runner/internal/projectrunner/native_command_help_test.go @@ -20,7 +20,7 @@ func TestRunProjectLocalAwaitPausePointHelpListsExpectedFlags(t *testing.T) { if code != 0 { t.Fatalf("await-pause-point --help failed: code=%d stderr=%s", code, stderr.String()) } - for _, flag := range []string{"--id", "--timeout-seconds", "--matching-logs-max-count", "--captured-variables", "--expect"} { + for _, flag := range []string{"--id", "--timeout-seconds", "--matching-logs-max-count", "--captured-variables", "--expect", "--trigger", "--resume-play"} { if !strings.Contains(stdout.String(), flag) { t.Fatalf("await-pause-point --help must list %s: %s", flag, stdout.String()) } diff --git a/cli/project-runner/internal/projectrunner/pause_point_enable.go b/cli/project-runner/internal/projectrunner/pause_point_enable.go index 20cd0ceb2..3ea77d607 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_enable.go +++ b/cli/project-runner/internal/projectrunner/pause_point_enable.go @@ -25,11 +25,12 @@ const pausePointEnableCommandName = "enable-pause-point" const pausePointEnableAwaitFlagName = "await" // extractPausePointEnableAwaitFlags pulls the CLI-only --await/--captured-variables/ -// --captured-variable-names/--expect/--trigger flags out of enable-pause-point args before -// generic schema parsing, because none of them are part of the Unity-side EnablePausePointSchema. +// --captured-variable-names/--expect/--trigger/--resume-play flags out of enable-pause-point args +// before generic schema parsing, because none of them are part of the Unity-side +// EnablePausePointSchema. func extractPausePointEnableAwaitFlags( args []string, -) ([]string, bool, pausePointCapturedVariablesMode, []string, []pausePointExpectation, string, []string, error) { +) ([]string, bool, pausePointCapturedVariablesMode, []string, []pausePointExpectation, string, []string, bool, error) { remaining := make([]string, 0, len(args)) await := false mode := pausePointCapturedVariablesModeFull @@ -40,6 +41,7 @@ func extractPausePointEnableAwaitFlags( var triggerCommand string var triggerArgs []string triggerSet := false + resumePlay := false for index := 0; index < len(args); index++ { arg := args[index] @@ -49,10 +51,37 @@ func extractPausePointEnableAwaitFlags( continue } + if arg == "--"+PausePointResumePlayFlagName { + resumePlay = true + continue + } + + // --resume-play=true|1 must be accepted here too: otherwise the =value form falls through + // to Unity schema parsing and becomes a confusing unrelated error. + if isPausePointFlag(arg, PausePointResumePlayFlagName) { + name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) + if err != nil { + return nil, false, mode, nil, nil, "", nil, false, err + } + if name != PausePointResumePlayFlagName { + remaining = append(remaining, arg) + continue + } + if value != "true" && value != "1" { + return nil, false, mode, nil, nil, "", nil, false, clierrors.InvalidValueArgumentError( + "--"+PausePointResumePlayFlagName, value, "boolean flag (pass with no value, or =true)") + } + resumePlay = true + if consumedNext { + index++ + } + continue + } + if isPausePointFlag(arg, PausePointTriggerFlagName) { name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) if err != nil { - return nil, false, mode, nil, nil, "", nil, err + return nil, false, mode, nil, nil, "", nil, false, err } if name != PausePointTriggerFlagName { remaining = append(remaining, arg) @@ -60,7 +89,7 @@ func extractPausePointEnableAwaitFlags( } parsedCommand, parsedArgs, parseErr := parsePausePointTriggerCommand(pausePointEnableCommandName, value) if parseErr != nil { - return nil, false, mode, nil, nil, "", nil, parseErr + return nil, false, mode, nil, nil, "", nil, false, parseErr } triggerCommand = parsedCommand triggerArgs = parsedArgs @@ -74,7 +103,7 @@ func extractPausePointEnableAwaitFlags( if isPausePointFlag(arg, PausePointCapturedVariablesFlagName) { name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) if err != nil { - return nil, false, mode, nil, nil, "", nil, err + return nil, false, mode, nil, nil, "", nil, false, err } if name != PausePointCapturedVariablesFlagName { remaining = append(remaining, arg) @@ -82,7 +111,7 @@ func extractPausePointEnableAwaitFlags( } parsedMode, err := parsePausePointCapturedVariablesMode(value) if err != nil { - return nil, false, mode, nil, nil, "", nil, err + return nil, false, mode, nil, nil, "", nil, false, err } mode = parsedMode modeSet = true @@ -95,7 +124,7 @@ func extractPausePointEnableAwaitFlags( if isPausePointFlag(arg, PausePointCapturedVariableNamesFlagName) { name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) if err != nil { - return nil, false, mode, nil, nil, "", nil, err + return nil, false, mode, nil, nil, "", nil, false, err } if name != PausePointCapturedVariableNamesFlagName { remaining = append(remaining, arg) @@ -112,7 +141,7 @@ func extractPausePointEnableAwaitFlags( if isPausePointFlag(arg, PausePointExpectFlagName) { name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) if err != nil { - return nil, false, mode, nil, nil, "", nil, err + return nil, false, mode, nil, nil, "", nil, false, err } if name != PausePointExpectFlagName { remaining = append(remaining, arg) @@ -120,7 +149,7 @@ func extractPausePointEnableAwaitFlags( } expectation, parseErr := parsePausePointExpectFlagValue(value) if parseErr != nil { - return nil, false, mode, nil, nil, "", nil, parseErr + return nil, false, mode, nil, nil, "", nil, false, parseErr } expectations = append(expectations, expectation) if consumedNext { @@ -132,9 +161,11 @@ func extractPausePointEnableAwaitFlags( remaining = append(remaining, arg) } - if !await && (modeSet || namesSet || len(expectations) > 0 || triggerSet) { + if !await && (modeSet || namesSet || len(expectations) > 0 || triggerSet || resumePlay) { option := "--" + PausePointCapturedVariablesFlagName switch { + case resumePlay: + option = "--" + PausePointResumePlayFlagName case triggerSet: option = "--" + PausePointTriggerFlagName case len(expectations) > 0: @@ -142,8 +173,8 @@ func extractPausePointEnableAwaitFlags( case namesSet: option = "--" + PausePointCapturedVariableNamesFlagName } - return nil, false, mode, nil, nil, "", nil, &clierrors.ArgumentError{ - Message: "--captured-variables, --captured-variable-names, --expect, and --trigger require --await", + return nil, false, mode, nil, nil, "", nil, false, &clierrors.ArgumentError{ + Message: "--captured-variables, --captured-variable-names, --expect, --trigger, and --resume-play require --await", Option: option, Command: pausePointEnableCommandName, NextActions: []string{ @@ -152,7 +183,7 @@ func extractPausePointEnableAwaitFlags( } } - return remaining, await, mode, capturedVariableNames, expectations, triggerCommand, triggerArgs, nil + return remaining, await, mode, capturedVariableNames, expectations, triggerCommand, triggerArgs, resumePlay, nil } func isPausePointFlag(arg string, flagName string) bool { @@ -167,7 +198,7 @@ func runEnablePausePointCommand( stdout io.Writer, stderr io.Writer, ) int { - remainingArgs, await, capturedVariablesMode, capturedVariableNames, expectations, triggerCommand, triggerArgs, err := extractPausePointEnableAwaitFlags(args) + remainingArgs, await, capturedVariablesMode, capturedVariableNames, expectations, triggerCommand, triggerArgs, resumePlay, err := extractPausePointEnableAwaitFlags(args) if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, @@ -220,7 +251,7 @@ func runEnablePausePointCommand( return runEnablePausePointAndAwait( ctx, connection, params, capturedVariablesMode, capturedVariableNames, expectations, - triggerCommand, triggerArgs, startPath, stdout, stderr) + triggerCommand, triggerArgs, resumePlay, startPath, stdout, stderr) } // runEnablePausePointAndAwait sends the same single enable-pause-point IPC request the @@ -236,6 +267,7 @@ func runEnablePausePointAndAwait( expectations []pausePointExpectation, triggerCommand string, triggerArgs []string, + resumePlay bool, startPath string, stdout io.Writer, stderr io.Writer, @@ -288,6 +320,7 @@ func runEnablePausePointAndAwait( triggerCommand: triggerCommand, triggerArgs: triggerArgs, startPath: startPath, + resumePlay: resumePlay, } return runPausePointWaitAfterEnable(ctx, connection, waitOptions, enableResponse.Warning, stdout, stderr) @@ -306,7 +339,7 @@ func runPausePointWaitAfterEnable( stderr io.Writer, ) int { spinner := clicore.NewToolSpinner(stderr, pausePointEnableCommandName) - response, state, triggerResult, err := waitForPausePoint(ctx, connection, options) + response, state, triggerResult, resumeResult, err := waitForPausePoint(ctx, connection, options) spinner.Stop() if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ @@ -322,6 +355,7 @@ func runPausePointWaitAfterEnable( expectations := evaluatePausePointExpectations(response.CapturedVariables, options.expectations) response.TriggerResult = triggerResult + response.ResumePlayResult = resumeResult response = filterPausePointCapturedVariableHistory(response) response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) @@ -379,6 +413,9 @@ func runPausePointWaitAfterEnable( if triggerResult != nil { waitErr.Details["TriggerResult"] = triggerResult } + if resumeResult != nil { + waitErr.Details["ResumePlayResult"] = resumeResult + } if state == pausePointWaitStateTimeout { logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) if logsErr == nil { diff --git a/cli/project-runner/internal/projectrunner/pause_point_enable_test.go b/cli/project-runner/internal/projectrunner/pause_point_enable_test.go index e91003bbe..637b67882 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_enable_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_enable_test.go @@ -16,7 +16,7 @@ import ( // Verifies --await is extracted and the remaining args are left untouched for schema parsing. func TestExtractPausePointEnableAwaitFlagsExtractsAwait(t *testing.T) { - remaining, await, mode, names, expectations, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--await"}) + remaining, await, mode, names, expectations, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--await"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -39,7 +39,7 @@ func TestExtractPausePointEnableAwaitFlagsExtractsAwait(t *testing.T) { // Verifies --captured-variables/--captured-variable-names are extracted alongside --await. func TestExtractPausePointEnableAwaitFlagsExtractsCapturedVariableOptions(t *testing.T) { - remaining, await, mode, names, _, _, _, err := extractPausePointEnableAwaitFlags([]string{ + remaining, await, mode, names, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{ "--id", "jump", "--await", "--captured-variables", "names", "--captured-variable-names", "a,b", }) if err != nil { @@ -61,7 +61,7 @@ func TestExtractPausePointEnableAwaitFlagsExtractsCapturedVariableOptions(t *tes // Verifies --expect is extracted (repeatably) alongside --await, and unrelated args are untouched. func TestExtractPausePointEnableAwaitFlagsExtractsExpect(t *testing.T) { - remaining, await, _, _, expectations, _, _, err := extractPausePointEnableAwaitFlags([]string{ + remaining, await, _, _, expectations, _, _, _, err := extractPausePointEnableAwaitFlags([]string{ "--id", "jump", "--await", "--expect", "Health=100", "--expect", "Name=Enemy", }) if err != nil { @@ -86,7 +86,7 @@ func TestExtractPausePointEnableAwaitFlagsExtractsExpect(t *testing.T) { // Verifies --captured-variables without --await is rejected, since it has no effect otherwise. func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForCapturedVariables(t *testing.T) { - _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--captured-variables", "names"}) + _, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--captured-variables", "names"}) if err == nil { t.Fatalf("expected an error") } @@ -97,7 +97,7 @@ func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForCapturedVariables(t *t // Verifies --expect without --await is rejected, since it has no effect otherwise. func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForExpect(t *testing.T) { - _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--expect", "Health=100"}) + _, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--expect", "Health=100"}) if err == nil { t.Fatalf("expected an error") } @@ -108,7 +108,7 @@ func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForExpect(t *testing.T) { // Verifies enable-pause-point without --await leaves File/Line/Id/Mode args untouched. func TestExtractPausePointEnableAwaitFlagsWithoutAwaitLeavesArgsUnchanged(t *testing.T) { - remaining, await, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--file", "Assets/Foo.cs", "--line", "10"}) + remaining, await, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--file", "Assets/Foo.cs", "--line", "10"}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cli/project-runner/internal/projectrunner/pause_point_resume_play.go b/cli/project-runner/internal/projectrunner/pause_point_resume_play.go new file mode 100644 index 000000000..08c38e88e --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_resume_play.go @@ -0,0 +1,110 @@ +package projectrunner + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/hatayama/unity-cli-loop/common/clicontract" + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const ( + pausePointResumePlayCommandName = "control-play-mode" + pausePointResumePlayTimeout = 10 * time.Second +) + +// pausePointResumePlayResult reports what --resume-play did (or why it skipped). Set only when +// --resume-play was passed, mirroring TriggerResult's omit-when-unused contract. +type pausePointResumePlayResult struct { + WasPaused bool `json:"WasPaused"` + Resumed bool `json:"Resumed"` + Error string `json:"Error,omitempty"` +} + +type controlPlayModeToolResponse struct { + Success bool `json:"Success"` + IsPaused bool `json:"IsPaused"` + Message string `json:"Message"` +} + +// resumePlayModeForPausePoint is overridden in tests so waitForPausePoint can assert resume/ +// trigger ordering without a live Unity connection. +var resumePlayModeForPausePoint = resumePlayModeForPausePointFromUnity + +// sendControlPlayModeForPausePointResume is overridden in tests so +// resumePlayModeForPausePointFromUnity's Status/Play branches can be exercised without IPC. +var sendControlPlayModeForPausePointResume = sendControlPlayModeForPausePointResumeFromUnity + +// resumePlayModeForPausePointFromUnity asks control-play-mode for Status, then sends Play only when +// the Editor is currently paused. A Status or Play failure is returned as Error so the wait path +// can skip --trigger without inventing a success. +func resumePlayModeForPausePointFromUnity( + ctx context.Context, + connection unityipc.Connection, +) pausePointResumePlayResult { + resumeContext, cancel := context.WithTimeout(ctx, pausePointResumePlayTimeout) + defer cancel() + + statusResponse, err := sendControlPlayModeForPausePointResume(resumeContext, connection, "Status") + if err != nil { + return pausePointResumePlayResult{ + Error: fmt.Sprintf("control-play-mode Status failed: %v", err), + } + } + if !statusResponse.Success { + message := statusResponse.Message + if message == "" { + message = "control-play-mode Status returned Success=false" + } + return pausePointResumePlayResult{Error: message} + } + + if !statusResponse.IsPaused { + return pausePointResumePlayResult{WasPaused: false, Resumed: false} + } + + playResponse, err := sendControlPlayModeForPausePointResume(resumeContext, connection, "Play") + if err != nil { + return pausePointResumePlayResult{ + WasPaused: true, + Resumed: false, + Error: fmt.Sprintf("control-play-mode Play failed: %v", err), + } + } + if !playResponse.Success { + message := playResponse.Message + if message == "" { + message = "control-play-mode Play returned Success=false" + } + return pausePointResumePlayResult{ + WasPaused: true, + Resumed: false, + Error: message, + } + } + + return pausePointResumePlayResult{WasPaused: true, Resumed: true} +} + +func sendControlPlayModeForPausePointResumeFromUnity( + ctx context.Context, + connection unityipc.Connection, + action string, +) (controlPlayModeToolResponse, error) { + result, err := unityipc.NewClient(connection, clicontract.ProjectRunnerVersion()).Send( + ctx, + pausePointResumePlayCommandName, + map[string]any{"Action": action}, + ) + if err != nil { + return controlPlayModeToolResponse{}, err + } + + response := controlPlayModeToolResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return controlPlayModeToolResponse{}, err + } + return response, nil +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go b/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go new file mode 100644 index 000000000..26c5faa65 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go @@ -0,0 +1,507 @@ +package projectrunner + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// Verifies --resume-play without --await is rejected with the same require-await family of +// errors as --trigger, so the flag cannot silently do nothing on a plain enable call. +func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForResumePlay(t *testing.T) { + _, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{ + "--id", "jump", "--resume-play", + }) + if err == nil { + t.Fatalf("expected an error") + } + if !strings.Contains(err.Error(), "require --await") { + t.Fatalf("error message mismatch: %v", err) + } + if !strings.Contains(err.Error(), "--resume-play") { + t.Fatalf("error message must mention --resume-play: %v", err) + } +} + +// Verifies --await --resume-play extracts resumePlay=true and leaves Unity-side args untouched. +func TestExtractPausePointEnableAwaitFlagsExtractsResumePlay(t *testing.T) { + remaining, await, _, _, _, _, _, resumePlay, err := extractPausePointEnableAwaitFlags([]string{ + "--id", "jump", "--await", "--resume-play", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !await { + t.Fatalf("expected await to be true") + } + if !resumePlay { + t.Fatalf("expected resumePlay to be true") + } + if len(remaining) != 2 || remaining[0] != "--id" || remaining[1] != "jump" { + t.Fatalf("remaining args mismatch: %#v", remaining) + } +} + +// Verifies enable-pause-point accepts --resume-play=true the same way await-pause-point does, +// so the =value form is not leaked into Unity schema parsing. +func TestExtractPausePointEnableAwaitFlagsExtractsResumePlayEqualsTrue(t *testing.T) { + _, await, _, _, _, _, _, resumePlay, err := extractPausePointEnableAwaitFlags([]string{ + "--id", "jump", "--await", "--resume-play=true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !await || !resumePlay { + t.Fatalf("expected await and resumePlay true, got await=%v resumePlay=%v", await, resumePlay) + } +} + +// Verifies await-pause-point accepts --resume-play as a boolean flag. +func TestParseWaitForPausePointOptionsParsesResumePlayFlag(t *testing.T) { + options, err := parseWaitForPausePointOptions([]string{ + "--id", "jump", "--resume-play", + }) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if !options.resumePlay { + t.Fatalf("expected resumePlay to be true: %#v", options) + } + + optionsEquals, err := parseWaitForPausePointOptions([]string{ + "--id", "jump", "--resume-play=true", + }) + if err != nil { + t.Fatalf("parse --resume-play=true failed: %v", err) + } + if !optionsEquals.resumePlay { + t.Fatalf("expected resumePlay to be true for =true form: %#v", optionsEquals) + } +} + +// Verifies armed + IsPaused=true resumes Play before dispatching --trigger, and reports +// ResumePlayResult{WasPaused:true, Resumed:true}. +func TestWaitForPausePointResumesPlayBeforeTriggerWhenPaused(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalResume := resumePlayModeForPausePoint + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + resumePlayModeForPausePoint = originalResume + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil + } + + calls := make([]string, 0, 2) + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + calls = append(calls, "resume") + return pausePointResumePlayResult{WasPaused: true, Resumed: true} + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + calls = append(calls, "trigger") + _, _ = stdout.Write([]byte(`{"Success":true}`)) + return 0 + } + + _, _, triggerResult, resumeResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Press"}, + resumePlay: true, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if len(calls) != 2 || calls[0] != "resume" || calls[1] != "trigger" { + t.Fatalf("expected resume then trigger, got %#v", calls) + } + if resumeResult == nil || !resumeResult.WasPaused || !resumeResult.Resumed || resumeResult.Error != "" { + t.Fatalf("ResumePlayResult mismatch: %#v", resumeResult) + } + if triggerResult == nil || !triggerResult.Completed { + t.Fatalf("expected a completed trigger result, got %#v", triggerResult) + } +} + +// Verifies armed + IsPaused=false skips the Play request, reports WasPaused=false/Resumed=false, +// and still dispatches the trigger. +func TestWaitForPausePointSkipsPlayWhenAlreadyUnpaused(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalResume := resumePlayModeForPausePoint + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + resumePlayModeForPausePoint = originalResume + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: false, CapturedAt: "PausePointHit"}, + }, nil + } + + triggerDispatched := false + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + // The real implementation only sends Play when Status reports IsPaused=true; this stub + // mirrors that contract so the test asserts the wait path still accepts a no-op resume. + return pausePointResumePlayResult{WasPaused: false, Resumed: false} + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + triggerDispatched = true + _, _ = stdout.Write([]byte(`{"Success":true}`)) + return 0 + } + + _, _, triggerResult, resumeResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Press"}, + resumePlay: true, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if resumeResult == nil || resumeResult.WasPaused || resumeResult.Resumed { + t.Fatalf("expected a no-op ResumePlayResult, got %#v", resumeResult) + } + if !triggerDispatched { + t.Fatal("expected trigger to still be dispatched after a no-op resume") + } + if triggerResult == nil || !triggerResult.Completed { + t.Fatalf("expected a completed trigger result, got %#v", triggerResult) + } +} + +// Verifies a failed Status/Play resume skips --trigger and records the fixed skip Error on +// TriggerResult while still returning ResumePlayResult.Error. +func TestWaitForPausePointSkipsTriggerWhenResumePlayFails(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalResume := resumePlayModeForPausePoint + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + resumePlayModeForPausePoint = originalResume + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil + } + + dispatchCalled := false + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + return pausePointResumePlayResult{ + WasPaused: true, + Resumed: false, + Error: "control-play-mode Status failed: boom", + } + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + dispatchCalled = true + _, _ = stdout.Write([]byte(`{"Success":true}`)) + return 0 + } + + _, _, triggerResult, resumeResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Press"}, + resumePlay: true, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if dispatchCalled { + t.Fatal("expected trigger not to be dispatched after a resume failure") + } + if resumeResult == nil || resumeResult.Error == "" { + t.Fatalf("expected ResumePlayResult.Error, got %#v", resumeResult) + } + if triggerResult == nil || triggerResult.Completed { + t.Fatalf("expected a skipped TriggerResult, got %#v", triggerResult) + } + if triggerResult.Error != "trigger was not dispatched: --resume-play failed to resume play mode" { + t.Fatalf("TriggerResult.Error mismatch: %#v", triggerResult) + } +} + +// Verifies --resume-play alone still confirms arm before resuming, and skips resume (and has no +// trigger) when the marker is not armed — matching the --trigger not-armed skip path. +func TestWaitForPausePointSkipsResumeWhenNotArmed(t *testing.T) { + originalQuery := queryPausePointStatus + originalResume := resumePlayModeForPausePoint + defer func() { + queryPausePointStatus = originalQuery + resumePlayModeForPausePoint = originalResume + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusNotEnabled}, nil + } + + resumeCalled := false + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + resumeCalled = true + return pausePointResumePlayResult{WasPaused: true, Resumed: true} + } + + _, state, triggerResult, resumeResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "does-not-exist", + timeoutSeconds: 1, + timeout: time.Second, + resumePlay: true, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateNotEnabled { + t.Fatalf("expected not_enabled state, got %q", state) + } + if resumeCalled { + t.Fatal("expected resumePlayModeForPausePoint not to be called for a not-armed marker") + } + if triggerResult != nil { + t.Fatalf("expected nil TriggerResult when --trigger was not given, got %#v", triggerResult) + } + if resumeResult == nil || resumeResult.Error == "" { + t.Fatalf("expected ResumePlayResult.Error explaining the skip, got %#v", resumeResult) + } +} + +// Verifies armed + --resume-play alone (no --trigger) still resumes and leaves TriggerResult nil. +func TestWaitForPausePointResumesWithoutTriggerWhenArmed(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalResume := resumePlayModeForPausePoint + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + resumePlayModeForPausePoint = originalResume + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil + } + + resumeCalled := false + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + resumeCalled = true + return pausePointResumePlayResult{WasPaused: true, Resumed: true} + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + t.Fatal("dispatchPausePointTriggerCommand must not be called without --trigger") + return 0 + } + + _, _, triggerResult, resumeResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + resumePlay: true, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if !resumeCalled { + t.Fatal("expected resumePlayModeForPausePoint to be called") + } + if resumeResult == nil || !resumeResult.WasPaused || !resumeResult.Resumed { + t.Fatalf("ResumePlayResult mismatch: %#v", resumeResult) + } + if triggerResult != nil { + t.Fatalf("expected nil TriggerResult when --trigger was not given, got %#v", triggerResult) + } +} + +// Verifies resumePlayModeForPausePointFromUnity's Status/Play branches without a live Unity IPC. +func TestResumePlayModeForPausePointFromUnityBranches(t *testing.T) { + originalSend := sendControlPlayModeForPausePointResume + defer func() { sendControlPlayModeForPausePointResume = originalSend }() + + t.Run("Status transport failure", func(t *testing.T) { + actions := make([]string, 0, 1) + sendControlPlayModeForPausePointResume = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + actions = append(actions, action) + return controlPlayModeToolResponse{}, fmt.Errorf("boom") + } + + result := resumePlayModeForPausePointFromUnity(context.Background(), unityipc.Connection{}) + if result.WasPaused || result.Resumed || !strings.Contains(result.Error, "control-play-mode Status failed") { + t.Fatalf("result mismatch: %#v", result) + } + if len(actions) != 1 || actions[0] != "Status" { + t.Fatalf("expected only Status, got %#v", actions) + } + }) + + t.Run("Status Success=false", func(t *testing.T) { + actions := make([]string, 0, 1) + sendControlPlayModeForPausePointResume = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + actions = append(actions, action) + return controlPlayModeToolResponse{Success: false, Message: "status denied"}, nil + } + + result := resumePlayModeForPausePointFromUnity(context.Background(), unityipc.Connection{}) + if result.WasPaused || result.Resumed || result.Error != "status denied" { + t.Fatalf("result mismatch: %#v", result) + } + if len(actions) != 1 || actions[0] != "Status" { + t.Fatalf("expected only Status, got %#v", actions) + } + }) + + t.Run("IsPaused=false skips Play", func(t *testing.T) { + actions := make([]string, 0, 1) + sendControlPlayModeForPausePointResume = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + actions = append(actions, action) + return controlPlayModeToolResponse{Success: true, IsPaused: false}, nil + } + + result := resumePlayModeForPausePointFromUnity(context.Background(), unityipc.Connection{}) + if result.WasPaused || result.Resumed || result.Error != "" { + t.Fatalf("result mismatch: %#v", result) + } + if len(actions) != 1 || actions[0] != "Status" { + t.Fatalf("expected only Status, got %#v", actions) + } + }) + + t.Run("Play transport failure", func(t *testing.T) { + actions := make([]string, 0, 2) + sendControlPlayModeForPausePointResume = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + actions = append(actions, action) + if action == "Status" { + return controlPlayModeToolResponse{Success: true, IsPaused: true}, nil + } + return controlPlayModeToolResponse{}, fmt.Errorf("play boom") + } + + result := resumePlayModeForPausePointFromUnity(context.Background(), unityipc.Connection{}) + if !result.WasPaused || result.Resumed || !strings.Contains(result.Error, "control-play-mode Play failed") { + t.Fatalf("result mismatch: %#v", result) + } + if len(actions) != 2 || actions[0] != "Status" || actions[1] != "Play" { + t.Fatalf("expected Status then Play, got %#v", actions) + } + }) +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_trigger_test.go b/cli/project-runner/internal/projectrunner/pause_point_trigger_test.go index 3c8c3f125..0a6e48257 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_trigger_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_trigger_test.go @@ -271,7 +271,7 @@ func TestWaitForPausePointJoinsTriggerResult(t *testing.T) { return 0 } - _, _, triggerResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + _, _, triggerResult, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: time.Second, @@ -307,7 +307,7 @@ func TestWaitForPausePointJoinsTriggerResult(t *testing.T) { return 0 } - _, _, triggerResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + _, _, triggerResult, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: time.Second, @@ -362,7 +362,7 @@ func TestWaitForPausePointSkipsTriggerWhenNotArmed(t *testing.T) { return 0 } - _, state, triggerResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + _, state, triggerResult, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "does-not-exist", timeoutSeconds: 1, timeout: time.Second, @@ -497,7 +497,7 @@ func TestParseWaitForPausePointOptionsParsesTriggerFlag(t *testing.T) { // Verifies enable-pause-point extracts --trigger alongside --await, and rejects --trigger without // --await since it would otherwise have no effect. func TestExtractPausePointEnableAwaitFlagsExtractsTrigger(t *testing.T) { - remaining, await, _, _, _, triggerCommand, triggerArgs, err := extractPausePointEnableAwaitFlags([]string{ + remaining, await, _, _, _, triggerCommand, triggerArgs, _, err := extractPausePointEnableAwaitFlags([]string{ "--id", "jump", "--await", "--trigger", "simulate-keyboard --action Press --key Space --duration 5", }) if err != nil { @@ -525,7 +525,7 @@ func TestExtractPausePointEnableAwaitFlagsExtractsTrigger(t *testing.T) { // Verifies --trigger without --await is rejected, since it has no effect otherwise. func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForTrigger(t *testing.T) { - _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{ + _, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{ "--id", "jump", "--trigger", "simulate-keyboard --action Press", }) if err == nil { diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index b6aba9e32..f83953a1c 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -45,6 +45,10 @@ type pausePointStatusResponse struct { // TriggerResult is set by the CLI, not Unity, only when --trigger was passed. It is omitted // entirely otherwise, so callers that never use --trigger see no schema change at all. TriggerResult *pausePointTriggerResult `json:"TriggerResult,omitempty"` + + // ResumePlayResult is set by the CLI, not Unity, only when --resume-play was passed. It is + // omitted entirely otherwise, matching TriggerResult's omit-when-unused contract. + ResumePlayResult *pausePointResumePlayResult `json:"ResumePlayResult,omitempty"` } type pausePointEditorState struct { diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index 685f4cd9d..e1f055ec7 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -49,6 +49,11 @@ type waitForPausePointOptions struct { triggerCommand string triggerArgs []string startPath string + + // resumePlay comes from --resume-play: after the marker is confirmed armed, resume PlayMode + // (if paused) before dispatching --trigger so a paused-arm workflow can fire input triggers + // in one CLI call. + resumePlay bool } type pausePointStatusOptions struct { @@ -190,7 +195,7 @@ func runWaitForPausePoint( ) int { startedAt := time.Now() spinner := clicore.NewToolSpinner(stderr, clicore.PausePointAwaitCommandName) - response, state, triggerResult, err := waitForPausePoint(ctx, connection, options) + response, state, triggerResult, resumeResult, err := waitForPausePoint(ctx, connection, options) spinner.Stop() if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ @@ -207,6 +212,7 @@ func runWaitForPausePoint( expectations := evaluatePausePointExpectations(response.CapturedVariables, options.expectations) response.TriggerResult = triggerResult + response.ResumePlayResult = resumeResult response = filterPausePointCapturedVariableHistory(response) response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) @@ -261,6 +267,9 @@ func runWaitForPausePoint( if triggerResult != nil { waitErr.Details["TriggerResult"] = triggerResult } + if resumeResult != nil { + waitErr.Details["ResumePlayResult"] = resumeResult + } if state == pausePointWaitStateTimeout { // Best-effort: the timeout diagnosis must not depend on a second Unity round trip succeeding. logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) @@ -286,6 +295,13 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err for index := 0; index < len(args); index++ { arg := args[index] + + // --resume-play is a boolean flag (no value). ParseFlagValue would otherwise demand one. + if arg == "--"+PausePointResumePlayFlagName { + options.resumePlay = true + continue + } + name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) if err != nil { return waitForPausePointOptions{}, err @@ -329,6 +345,14 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err } options.triggerCommand = triggerCommand options.triggerArgs = triggerArgs + case PausePointResumePlayFlagName: + // --resume-play=true style is accepted; any other value is rejected so a typo cannot + // silently disable the resume step. + if value != "true" && value != "1" { + return waitForPausePointOptions{}, clierrors.InvalidValueArgumentError( + "--"+PausePointResumePlayFlagName, value, "boolean flag (pass with no value)") + } + options.resumePlay = true default: return waitForPausePointOptions{}, pausePointUnknownOptionError(clicore.PausePointAwaitCommandName, name) } diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go b/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go index 288e601d0..f00e8b4ab 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go @@ -23,38 +23,69 @@ const ( ) // waitForPausePoint confirms the marker is actually armed with one status query before starting -// the --trigger command: extendPausePointExpiryBeforeWait (the await-pause-point entry point) is -// best-effort and does not confirm arm, so a typo'd or already-expired --id must not still get the -// trigger dispatched into the running game before the wait immediately fails on it. Once confirmed -// armed (or already hit), the trigger races the status poll loop and is joined once the wait itself -// settles, so a slow trigger cannot delay reporting a pause-point hit. +// --resume-play / --trigger: extendPausePointExpiryBeforeWait (the await-pause-point entry point) +// is best-effort and does not confirm arm, so a typo'd or already-expired --id must not still get +// Play resumed or the trigger dispatched into the running game before the wait immediately fails +// on it. Once confirmed armed (or already hit), optional resume runs synchronously, then the +// trigger races the status poll loop and is joined once the wait itself settles, so a slow trigger +// cannot delay reporting a pause-point hit. func waitForPausePoint( ctx context.Context, connection unityipc.Connection, options waitForPausePointOptions, -) (pausePointStatusResponse, pausePointWaitState, *pausePointTriggerResult, error) { +) (pausePointStatusResponse, pausePointWaitState, *pausePointTriggerResult, *pausePointResumePlayResult, error) { var triggerHandle *pausePointTriggerHandle var skippedTriggerResult *pausePointTriggerResult - if options.triggerCommand != "" { + var resumeResult *pausePointResumePlayResult + + if options.triggerCommand != "" || options.resumePlay { if pausePointIsArmed(ctx, connection, options.id) { - triggerHandle = startPausePointTrigger(ctx, connection, options.startPath, options.triggerCommand, options.triggerArgs) + if options.resumePlay { + result := resumePlayModeForPausePoint(ctx, connection) + resumeResult = &result + if result.Error != "" { + if options.triggerCommand != "" { + skippedTriggerResult = &pausePointTriggerResult{ + Command: pausePointTriggerCommandString(options.triggerCommand, options.triggerArgs), + Error: "trigger was not dispatched: --resume-play failed to resume play mode", + } + } + } else if options.triggerCommand != "" { + triggerHandle = startPausePointTrigger(ctx, connection, options.startPath, options.triggerCommand, options.triggerArgs) + } + } else if options.triggerCommand != "" { + triggerHandle = startPausePointTrigger(ctx, connection, options.startPath, options.triggerCommand, options.triggerArgs) + } } else { - // --trigger always yields a TriggerResult when given, even when skipped: a silently - // omitted TriggerResult would be indistinguishable from "the trigger ran but this CLI - // exited before it reported anything," hiding the real reason (marker not confirmed - // armed) behind a plain timeout/not-enabled error with no clue why the trigger never fired. - skippedTriggerResult = &pausePointTriggerResult{ - Command: pausePointTriggerCommandString(options.triggerCommand, options.triggerArgs), - Error: "trigger was not dispatched: the marker could not be confirmed armed at wait start", + if options.resumePlay { + // --resume-play always yields a ResumePlayResult when given, even when skipped: a + // silently omitted result would hide "arm was never confirmed" behind a plain + // timeout/not-enabled error with no clue why Play was never resumed. + resumeResult = &pausePointResumePlayResult{ + Error: "resume was not dispatched: the marker could not be confirmed armed at wait start", + } + } + if options.triggerCommand != "" { + // --trigger always yields a TriggerResult when given, even when skipped: a silently + // omitted TriggerResult would be indistinguishable from "the trigger ran but this CLI + // exited before it reported anything," hiding the real reason (marker not confirmed + // armed) behind a plain timeout/not-enabled error with no clue why the trigger never fired. + skippedTriggerResult = &pausePointTriggerResult{ + Command: pausePointTriggerCommandString(options.triggerCommand, options.triggerArgs), + Error: "trigger was not dispatched: the marker could not be confirmed armed at wait start", + } } } } response, state, err := waitForPausePointStatus(ctx, connection, options) if skippedTriggerResult != nil { - return response, state, skippedTriggerResult, err + return response, state, skippedTriggerResult, resumeResult, err + } + if triggerHandle != nil { + return response, state, triggerHandle.join(), resumeResult, err } - return response, state, triggerHandle.join(), err + return response, state, nil, resumeResult, err } // pausePointIsArmed reports whether the marker is enabled or already hit. A query failure is diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go index c95f24cc8..699d87cc6 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -172,7 +172,7 @@ func TestWaitForPausePointReturnsHitAfterEnabledStatus(t *testing.T) { return response, nil } - response, state, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + response, state, _, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: time.Second, @@ -426,7 +426,7 @@ func TestWaitForPausePointReturnsNotEnabledStateImmediately(t *testing.T) { }, nil } - response, state, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + response, state, _, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: time.Second, diff --git a/docs/regression-harness.md b/docs/regression-harness.md index a4b504f10..e9f5e8210 100644 --- a/docs/regression-harness.md +++ b/docs/regression-harness.md @@ -31,6 +31,7 @@ scenario covers. |------|-------|--------| | Key state divergence after a pause-point interruption | `Assets/RegressionHarness/KeyStateAfterPauseInterruption/` | `scripts/regression-harness-key-state-after-pause-interruption.sh` | | `await-pause-point --trigger` hits within the marker timeout instead of waiting out the triggered command's full duration | `Assets/RegressionHarness/KeyStateAfterPauseInterruption/` (reused) | `scripts/regression-harness-pause-point-trigger.sh` | +| `enable-pause-point --await --resume-play --trigger` resumes a manually paused PlayMode before firing the input trigger and hits within the marker timeout | `Assets/RegressionHarness/KeyStateAfterPauseInterruption/` (reused) | `scripts/regression-harness-resume-play-paused-arm.sh` | | A pause point armed on a physics message method (or a method called one hop from one) can miss a GameObject that already existed before arming. The miss itself is environment-dependent and does not reproduce deterministically (see below) -- the harness runs three scenarios (direct/OnCollisionEnter2D, indirect callee with priming, and OnTriggerEnter2D), each triggering a fresh contact after arming and classifying the result from the component's own hit counter plus `IsHit` | `Assets/RegressionHarness/PhysicsCallbackExistingInstance/` | `scripts/regression-harness-physics-callback-existing-instance.sh` | ### Physics-callback existing-instance miss: environment-dependent, not deterministic diff --git a/scripts/regression-harness-resume-play-paused-arm.sh b/scripts/regression-harness-resume-play-paused-arm.sh new file mode 100755 index 000000000..529fcdeb9 --- /dev/null +++ b/scripts/regression-harness-resume-play-paused-arm.sh @@ -0,0 +1,102 @@ +#!/bin/sh +set -e +# Regression harness for enable-pause-point --await --resume-play --trigger: +# arming a pause-point while PlayMode is manually paused, then resuming and +# firing the input trigger in one CLI call, must hit the marker and report +# ResumePlayResult.WasPaused=true / Resumed=true. Reuses the +# KeyStateAfterPauseInterruption scene scaffolding (see +# regression-harness-key-state-after-pause-interruption.sh). +# +# Usage: sh scripts/regression-harness-resume-play-paused-arm.sh [--project-path ] +# +# Prerequisites: +# - Assets/RegressionHarness/KeyStateAfterPauseInterruption/KeyStateAfterPauseInterruption.unity +# must be open in a running Unity Editor +# - uloop CLI must be installed +# - jq must be installed + +PROJECT_PATH="" +# Why: reject malformed argv before cleanup can touch the wrong Unity project. +if [ "$#" -eq 0 ]; then + : +elif [ "$#" -eq 2 ] && [ "$1" = "--project-path" ] && [ -n "$2" ]; then + PROJECT_PATH="$2" +else + printf '%s\n' "Usage: $0 [--project-path ]" >&2 + exit 2 +fi + +MARKER_FILE="Assets/RegressionHarness/KeyStateAfterPauseInterruption/SpaceHoldPoller.cs" +MARKER_LINE="19" +PRESS_DURATION="5" +RESULT_FILE="$(mktemp)" + +run_uloop() { + if [ -n "$PROJECT_PATH" ]; then + uloop "$@" --project-path "$PROJECT_PATH" + else + uloop "$@" + fi +} + +log() { + printf "\033[36m[resume-play-paused-arm]\033[0m %s\n" "$1" +} + +cleanup() { + rm -f "$RESULT_FILE" + run_uloop clear-pause-point --all > /dev/null 2>&1 || true + run_uloop control-play-mode --action Stop > /dev/null 2>&1 || true +} +trap cleanup EXIT + +log "Stopping any existing Play Mode session..." +run_uloop control-play-mode --action Stop > /dev/null + +log "Clearing any existing pause-point markers..." +run_uloop clear-pause-point --all > /dev/null + +log "Starting Play Mode..." +run_uloop control-play-mode --action Play > /dev/null + +log "Pausing Play Mode before arming..." +run_uloop control-play-mode --action Pause > /dev/null + +log "Arming + awaiting with --resume-play --trigger on $MARKER_FILE:$MARKER_LINE..." +# Why: TimeoutSeconds is not frozen during a manual Pause, so keep the window +# generous enough for resume + trigger + hit under a slow Editor tick. +run_uloop enable-pause-point \ + --file "$MARKER_FILE" \ + --line "$MARKER_LINE" \ + --timeout-seconds 60 \ + --await \ + --resume-play \ + --trigger "simulate-keyboard --action Press --key Space --duration $PRESS_DURATION" \ + > "$RESULT_FILE" 2>&1 || true + +SUCCESS="$(jq -r '.Success' "$RESULT_FILE")" +STATUS="$(jq -r '.Status' "$RESULT_FILE")" +WAS_PAUSED="$(jq -r '.ResumePlayResult.WasPaused' "$RESULT_FILE")" +RESUMED="$(jq -r '.ResumePlayResult.Resumed' "$RESULT_FILE")" +TRIGGER_INTERRUPTED="$(jq -r '.TriggerResult.Response.InterruptedByPausePoint' "$RESULT_FILE")" + +if [ "$SUCCESS" != "true" ] || [ "$STATUS" != "Hit" ]; then + log "FAIL: expected Success=true Status=Hit, got Success=$SUCCESS Status=$STATUS" + cat "$RESULT_FILE" + exit 1 +fi + +if [ "$WAS_PAUSED" != "true" ] || [ "$RESUMED" != "true" ]; then + log "FAIL: expected ResumePlayResult WasPaused=true Resumed=true, got WasPaused=$WAS_PAUSED Resumed=$RESUMED" + cat "$RESULT_FILE" + exit 1 +fi + +if [ "$TRIGGER_INTERRUPTED" != "true" ]; then + log "FAIL: expected TriggerResult.Response.InterruptedByPausePoint=true, got $TRIGGER_INTERRUPTED" + cat "$RESULT_FILE" + exit 1 +fi + +log "PASS: paused-arm --resume-play --trigger hit with ResumePlayResult.Resumed=true." +exit 0