diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 7ab11ad4a3..60b44e8187 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -60,6 +60,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. - When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. +- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the holder clears on resume. (file:line marker hits only — id-only markers store no capture) Before interpreting unexpected, missing, or truncated values, nested previews that render as type names, Unity-object `Value` strings, capture-time vs live evidence trade-offs, the hit response's `Warning`/`MatchingLogs` fields, marker freshness (`Generation`, `EnabledAtUtc`), or the raw capture API in detail, read [references/captured-variables.md](references/captured-variables.md). diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 7ab11ad4a3..60b44e8187 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -60,6 +60,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. - When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. +- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the holder clears on resume. (file:line marker hits only — id-only markers store no capture) Before interpreting unexpected, missing, or truncated values, nested previews that render as type names, Unity-object `Value` strings, capture-time vs live evidence trade-offs, the hit response's `Warning`/`MatchingLogs` fields, marker freshness (`Generation`, `EnabledAtUtc`), or the raw capture API in detail, read [references/captured-variables.md](references/captured-variables.md). diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 7ab11ad4a3..60b44e8187 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -60,6 +60,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. - When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. +- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the holder clears on resume. (file:line marker hits only — id-only markers store no capture) Before interpreting unexpected, missing, or truncated values, nested previews that render as type names, Unity-object `Value` strings, capture-time vs live evidence trade-offs, the hit response's `Warning`/`MatchingLogs` fields, marker freshness (`Generation`, `EnabledAtUtc`), or the raw capture API in detail, read [references/captured-variables.md](references/captured-variables.md). diff --git a/cli/project-runner/internal/projectrunner/list_output.go b/cli/project-runner/internal/projectrunner/list_output.go index 332121c1eb..5a0665aea2 100644 --- a/cli/project-runner/internal/projectrunner/list_output.go +++ b/cli/project-runner/internal/projectrunner/list_output.go @@ -178,6 +178,11 @@ func appendPausePointEnableAwaitListOptions(tool clicore.ToolDefinition, options Type: "string", Description: "Requires --await. Same as await-pause-point's --captured-variable-names", }, + listOption{ + Name: "--" + PausePointExpectFlagName, + Type: "string", + Description: "Requires --await. Same as await-pause-point's --expect (repeatable)", + }, ) } diff --git a/cli/project-runner/internal/projectrunner/native_command_help.go b/cli/project-runner/internal/projectrunner/native_command_help.go index f74fa5992d..6a6079f651 100644 --- a/cli/project-runner/internal/projectrunner/native_command_help.go +++ b/cli/project-runner/internal/projectrunner/native_command_help.go @@ -17,6 +17,7 @@ const ( PausePointLogsMaxCountFlagName = "matching-logs-max-count" PausePointCapturedVariablesFlagName = "captured-variables" PausePointCapturedVariableNamesFlagName = "captured-variable-names" + PausePointExpectFlagName = "expect" ) // runnerNativeCommandOptions lists the flags accepted by each runner-owned @@ -30,6 +31,7 @@ var runnerNativeCommandOptions = map[string][]string{ "--" + PausePointLogsMaxCountFlagName, "--" + PausePointCapturedVariablesFlagName, "--" + PausePointCapturedVariableNamesFlagName, + "--" + PausePointExpectFlagName, }, 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 9661c5508c..85c57ab13b 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"} { + for _, flag := range []string{"--id", "--timeout-seconds", "--matching-logs-max-count", "--captured-variables", "--expect"} { 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 988f54f967..47da1a44d0 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_enable.go +++ b/cli/project-runner/internal/projectrunner/pause_point_enable.go @@ -25,15 +25,18 @@ const pausePointEnableCommandName = "enable-pause-point" const pausePointEnableAwaitFlagName = "await" // extractPausePointEnableAwaitFlags pulls the CLI-only --await/--captured-variables/ -// --captured-variable-names 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, error) { +// --captured-variable-names/--expect 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, error) { remaining := make([]string, 0, len(args)) await := false mode := pausePointCapturedVariablesModeFull modeSet := false var capturedVariableNames []string namesSet := false + var expectations []pausePointExpectation for index := 0; index < len(args); index++ { arg := args[index] @@ -46,7 +49,7 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin if isPausePointFlag(arg, PausePointCapturedVariablesFlagName) { name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) if err != nil { - return nil, false, mode, nil, err + return nil, false, mode, nil, nil, err } if name != PausePointCapturedVariablesFlagName { remaining = append(remaining, arg) @@ -54,7 +57,7 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin } parsedMode, err := parsePausePointCapturedVariablesMode(value) if err != nil { - return nil, false, mode, nil, err + return nil, false, mode, nil, nil, err } mode = parsedMode modeSet = true @@ -67,7 +70,7 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin if isPausePointFlag(arg, PausePointCapturedVariableNamesFlagName) { name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) if err != nil { - return nil, false, mode, nil, err + return nil, false, mode, nil, nil, err } if name != PausePointCapturedVariableNamesFlagName { remaining = append(remaining, arg) @@ -81,12 +84,32 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin continue } + if isPausePointFlag(arg, PausePointExpectFlagName) { + name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) + if err != nil { + return nil, false, mode, nil, nil, err + } + if name != PausePointExpectFlagName { + remaining = append(remaining, arg) + continue + } + expectation, parseErr := parsePausePointExpectFlagValue(value) + if parseErr != nil { + return nil, false, mode, nil, nil, parseErr + } + expectations = append(expectations, expectation) + if consumedNext { + index++ + } + continue + } + remaining = append(remaining, arg) } - if !await && (modeSet || namesSet) { - return nil, false, mode, nil, &clierrors.ArgumentError{ - Message: "--captured-variables and --captured-variable-names require --await", + if !await && (modeSet || namesSet || len(expectations) > 0) { + return nil, false, mode, nil, nil, &clierrors.ArgumentError{ + Message: "--captured-variables, --captured-variable-names, and --expect require --await", Option: "--" + PausePointCapturedVariablesFlagName, Command: pausePointEnableCommandName, NextActions: []string{ @@ -95,7 +118,7 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin } } - return remaining, await, mode, capturedVariableNames, nil + return remaining, await, mode, capturedVariableNames, expectations, nil } func isPausePointFlag(arg string, flagName string) bool { @@ -110,7 +133,7 @@ func runEnablePausePointCommand( stdout io.Writer, stderr io.Writer, ) int { - remainingArgs, await, capturedVariablesMode, capturedVariableNames, err := extractPausePointEnableAwaitFlags(args) + remainingArgs, await, capturedVariablesMode, capturedVariableNames, expectations, err := extractPausePointEnableAwaitFlags(args) if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, @@ -161,7 +184,8 @@ func runEnablePausePointCommand( return 1 } - return runEnablePausePointAndAwait(ctx, connection, params, capturedVariablesMode, capturedVariableNames, stdout, stderr) + return runEnablePausePointAndAwait( + ctx, connection, params, capturedVariablesMode, capturedVariableNames, expectations, stdout, stderr) } // runEnablePausePointAndAwait sends the same single enable-pause-point IPC request the @@ -174,6 +198,7 @@ func runEnablePausePointAndAwait( params map[string]any, capturedVariablesMode pausePointCapturedVariablesMode, capturedVariableNames []string, + expectations []pausePointExpectation, stdout io.Writer, stderr io.Writer, ) int { @@ -221,6 +246,7 @@ func runEnablePausePointAndAwait( matchingLogsMaxCount: pausePointDefaultLogsMaxCount, capturedVariablesMode: capturedVariablesMode, capturedVariableNames: capturedVariableNames, + expectations: expectations, } return runPausePointWaitAfterEnable(ctx, connection, waitOptions, enableResponse.Warning, stdout, stderr) @@ -250,6 +276,10 @@ func runPausePointWaitAfterEnable( } if state == pausePointWaitStateHit { + // Evaluated against the raw, unfiltered CapturedVariables before the filters below can + // narrow or strip values, same as the plain await-pause-point path. + expectations := evaluatePausePointExpectations(response.CapturedVariables, options.expectations) + response = filterPausePointCapturedVariableHistory(response) response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) @@ -262,14 +292,19 @@ func runPausePointWaitAfterEnable( pausePointStatusResponse: response, MatchingLogs: logs.Logs, Warning: joinPausePointWarnings(enableWarning, buildPausePointWarning(logs, response.HitCount)), + Expectations: expectations, + AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), } - case enableWarning != "": + case enableWarning != "" || len(expectations) > 0: // Best-effort like the plain await path: a failed log fetch must not also drop the - // enable-time warning, since that is the only warning source left in this branch. + // enable-time warning or --expect results, since those are the only evidence left in + // this branch. payload = pausePointWaitResult{ pausePointStatusResponse: response, MatchingLogs: []pausePointMatchingLog{}, Warning: enableWarning, + Expectations: expectations, + AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), } } result, marshalErr := json.Marshal(payload) 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 f9197a9432..42e3dd6d6b 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, 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) } @@ -29,6 +29,9 @@ func TestExtractPausePointEnableAwaitFlagsExtractsAwait(t *testing.T) { if names != nil { t.Fatalf("expected no captured variable names, got %#v", names) } + if expectations != nil { + t.Fatalf("expected no expectations, got %#v", expectations) + } if len(remaining) != 2 || remaining[0] != "--id" || remaining[1] != "jump" { t.Fatalf("remaining args mismatch: %#v", remaining) } @@ -36,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 { @@ -56,9 +59,45 @@ 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{ + "--id", "jump", "--await", "--expect", "Health=100", "--expect", "Name=Enemy", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !await { + t.Fatalf("expected await to be true") + } + if len(expectations) != 2 { + t.Fatalf("expectations mismatch: %#v", expectations) + } + if expectations[0] != (pausePointExpectation{Name: "Health", Expected: "100"}) { + t.Fatalf("expectation[0] mismatch: %#v", expectations[0]) + } + if expectations[1] != (pausePointExpectation{Name: "Name", Expected: "Enemy"}) { + t.Fatalf("expectation[1] mismatch: %#v", expectations[1]) + } + if len(remaining) != 2 || remaining[0] != "--id" || remaining[1] != "jump" { + t.Fatalf("remaining args mismatch: %#v", remaining) + } +} + // 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") + } + if !strings.Contains(err.Error(), "require --await") { + t.Fatalf("error message mismatch: %v", err) + } +} + +// 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"}) if err == nil { t.Fatalf("expected an error") } @@ -69,7 +108,7 @@ func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForCapturedVariables(t *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_expect.go b/cli/project-runner/internal/projectrunner/pause_point_expect.go new file mode 100644 index 0000000000..f1087c4510 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_expect.go @@ -0,0 +1,93 @@ +package projectrunner + +import ( + "strings" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" +) + +// pausePointExpectation is one --expect 'Name=value' assertion parsed from the CLI args. +type pausePointExpectation struct { + Name string + Expected string +} + +// pausePointExpectationResult is one entry of the Expectations array the CLI adds to a hit +// response when --expect was passed. +type pausePointExpectationResult struct { + Name string `json:"Name"` + Expected string `json:"Expected"` + Actual string `json:"Actual,omitempty"` + Passed bool `json:"Passed"` + Found bool `json:"Found"` +} + +// parsePausePointExpectFlagValue splits a raw --expect value into Name and Expected on the +// first "=" only, so an expected value that itself contains "=" (for example a connection +// string) still round-trips instead of being truncated at an inner "=". +func parsePausePointExpectFlagValue(value string) (pausePointExpectation, error) { + name, expected, found := strings.Cut(value, "=") + if !found || name == "" { + return pausePointExpectation{}, &clierrors.ArgumentError{ + Message: "Invalid --expect value: " + value, + Option: "--" + PausePointExpectFlagName, + ExpectedType: "Name=value", + NextActions: []string{"Pass `--expect 'Name=value'`, for example `--expect 'Health=100'`."}, + } + } + return pausePointExpectation{Name: name, Expected: expected}, nil +} + +// evaluatePausePointExpectations checks each expectation against variables — the hit's raw +// CapturedVariables, evaluated before --captured-variable-names/--captured-variables narrow or +// strip the response, so an --expect target is never silently dropped just because it was not +// also requested via --captured-variable-names. Matching is not scoped to a particular kind of +// variable: it searches Local, Parameter, InstanceField, and This entries alike by Name, since +// --expect callers care about the variable's name, not which kind of variable it is. +func evaluatePausePointExpectations( + variables []pausePointCapturedVariable, + expectations []pausePointExpectation, +) []pausePointExpectationResult { + if len(expectations) == 0 { + return nil + } + + results := make([]pausePointExpectationResult, 0, len(expectations)) + for _, expectation := range expectations { + result := pausePointExpectationResult{ + Name: expectation.Name, + Expected: expectation.Expected, + } + if variable, ok := findPausePointCapturedVariableByName(variables, expectation.Name); ok { + result.Found = true + if variable.Value != nil { + result.Actual = *variable.Value + result.Passed = result.Actual == expectation.Expected + } + } + results = append(results, result) + } + return results +} + +func findPausePointCapturedVariableByName( + variables []pausePointCapturedVariable, + name string, +) (pausePointCapturedVariable, bool) { + for _, variable := range variables { + if variable.Name == name { + return variable, true + } + } + return pausePointCapturedVariable{}, false +} + +// allPausePointExpectationsPassed reports whether every expectation passed. +func allPausePointExpectationsPassed(results []pausePointExpectationResult) bool { + for _, result := range results { + if !result.Passed { + return false + } + } + return true +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_expect_test.go b/cli/project-runner/internal/projectrunner/pause_point_expect_test.go new file mode 100644 index 0000000000..6579b23d05 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_expect_test.go @@ -0,0 +1,168 @@ +package projectrunner + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// Verifies a valid "Name=value" splits into Name/Expected, and a value containing "=" is +// split on the first "=" only. +func TestParsePausePointExpectFlagValue(t *testing.T) { + expectation, err := parsePausePointExpectFlagValue("Health=100") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if expectation != (pausePointExpectation{Name: "Health", Expected: "100"}) { + t.Fatalf("expectation mismatch: %#v", expectation) + } + + nested, err := parsePausePointExpectFlagValue("ConnectionString=Server=localhost") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if nested != (pausePointExpectation{Name: "ConnectionString", Expected: "Server=localhost"}) { + t.Fatalf("nested expectation mismatch: %#v", nested) + } +} + +// Verifies a value with no "=" is rejected. +func TestParsePausePointExpectFlagValueRejectsMissingEquals(t *testing.T) { + if _, err := parsePausePointExpectFlagValue("NoEqualsSign"); err == nil { + t.Fatalf("expected an error") + } +} + +// Verifies evaluatePausePointExpectations covers match, mismatch, and variable-not-found cases, +// searching Local/Parameter/InstanceField/This alike by Name. +func TestEvaluatePausePointExpectations(t *testing.T) { + variables := []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", Value: pausePointVariableValue("100")}, + {Name: "speed", Scope: "Parameter", Value: pausePointVariableValue("4.2")}, + {Name: "isGrounded", Scope: "InstanceField", Value: pausePointVariableValue("false")}, + } + expectations := []pausePointExpectation{ + {Name: "health", Expected: "100"}, + {Name: "speed", Expected: "9.9"}, + {Name: "missing", Expected: "whatever"}, + } + + results := evaluatePausePointExpectations(variables, expectations) + if len(results) != 3 { + t.Fatalf("results count mismatch: %#v", results) + } + + if !results[0].Found || !results[0].Passed || results[0].Actual != "100" { + t.Fatalf("match case mismatch: %#v", results[0]) + } + if !results[1].Found || results[1].Passed || results[1].Actual != "4.2" { + t.Fatalf("mismatch case mismatch: %#v", results[1]) + } + if results[2].Found || results[2].Passed || results[2].Actual != "" { + t.Fatalf("not-found case mismatch: %#v", results[2]) + } + + if allPausePointExpectationsPassed(results) { + t.Fatalf("expected overall failure since one expectation failed") + } + if !allPausePointExpectationsPassed(results[:1]) { + t.Fatalf("expected overall success for only the passing expectation") + } +} + +// Verifies no --expect given yields neither Expectations nor AllExpectationsPassed in the JSON. +func TestRunWaitForPausePointOmitsExpectationsWhenNoneRequested(t *testing.T) { + originalQuery := queryPausePointStatus + originalFetch := fetchMatchingLogs + defer func() { + queryPausePointStatus = originalQuery + fetchMatchingLogs = originalFetch + }() + + queryPausePointStatus = func(ctx context.Context, connection unityipc.Connection, id string) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusHit, IsHit: true, HitCount: 1}, nil + } + fetchMatchingLogs = func(ctx context.Context, connection unityipc.Connection, searchText string, maxCount int) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{SearchText: searchText, Logs: []pausePointMatchingLog{}}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + if bytes.Contains(stdout.Bytes(), []byte("Expectations")) || bytes.Contains(stdout.Bytes(), []byte("AllExpectationsPassed")) { + t.Fatalf("Expectations/AllExpectationsPassed must be omitted when --expect was not given: %s", stdout.String()) + } +} + +// Verifies --expect assertions are evaluated against the raw CapturedVariables and surfaced in +// the hit response as Expectations + AllExpectationsPassed, covering match and mismatch. +func TestRunWaitForPausePointEvaluatesExpectations(t *testing.T) { + originalQuery := queryPausePointStatus + originalFetch := fetchMatchingLogs + defer func() { + queryPausePointStatus = originalQuery + fetchMatchingLogs = originalFetch + }() + + queryPausePointStatus = func(ctx context.Context, connection unityipc.Connection, id string) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", Value: pausePointVariableValue("100")}, + }, + }, nil + } + fetchMatchingLogs = func(ctx context.Context, connection unityipc.Connection, searchText string, maxCount int) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{SearchText: searchText, Logs: []pausePointMatchingLog{}}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, + expectations: []pausePointExpectation{ + {Name: "health", Expected: "100"}, + {Name: "missing", Expected: "whatever"}, + }, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + + var result pausePointWaitResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout parse failed: %v from %s", err, stdout.String()) + } + if len(result.Expectations) != 2 { + t.Fatalf("expectations mismatch: %#v", result.Expectations) + } + if !result.Expectations[0].Passed || !result.Expectations[0].Found || result.Expectations[0].Actual != "100" { + t.Fatalf("expectation[0] mismatch: %#v", result.Expectations[0]) + } + if result.Expectations[1].Passed || result.Expectations[1].Found { + t.Fatalf("expectation[1] mismatch: %#v", result.Expectations[1]) + } + if result.AllExpectationsPassed == nil || *result.AllExpectationsPassed { + t.Fatalf("expected AllExpectationsPassed to be false, got %#v", result.AllExpectationsPassed) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_logs.go b/cli/project-runner/internal/projectrunner/pause_point_logs.go index f91b9991c0..9a1206ba75 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_logs.go +++ b/cli/project-runner/internal/projectrunner/pause_point_logs.go @@ -42,6 +42,24 @@ type pausePointWaitResult struct { pausePointStatusResponse MatchingLogs []pausePointMatchingLog `json:"MatchingLogs"` Warning string `json:"Warning,omitempty"` + + // Expectations and AllExpectationsPassed are populated only when --expect was passed, so a + // caller that never used --expect sees neither field rather than a vacuous + // "AllExpectationsPassed":true with no Expectations behind it. AllExpectationsPassed is a + // pointer so omitempty can distinguish "no --expect given" (nil, omitted) from "the given + // expectations failed" (non-nil false, still emitted). + Expectations []pausePointExpectationResult `json:"Expectations,omitempty"` + AllExpectationsPassed *bool `json:"AllExpectationsPassed,omitempty"` +} + +// pausePointAllExpectationsPassedPointer returns nil when no --expect was given, and otherwise +// a pointer to whether every expectation passed. +func pausePointAllExpectationsPassedPointer(results []pausePointExpectationResult) *bool { + if results == nil { + return nil + } + passed := allPausePointExpectationsPassed(results) + return &passed } type pausePointGetLogsResponse struct { diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index 88bf317195..a0de1f5375 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -42,6 +42,7 @@ type waitForPausePointOptions struct { matchingLogsMaxCount int capturedVariablesMode pausePointCapturedVariablesMode capturedVariableNames []string + expectations []pausePointExpectation } type pausePointStatusOptions struct { @@ -202,6 +203,11 @@ func runWaitForPausePoint( } if state == pausePointWaitStateHit { + // Evaluated against the raw, unfiltered CapturedVariables before the filters below can + // narrow or strip values, so an --expect target is never dropped just because it was not + // also requested via --captured-variable-names or --captured-variables=names. + expectations := evaluatePausePointExpectations(response.CapturedVariables, options.expectations) + response = filterPausePointCapturedVariableHistory(response) response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) @@ -210,11 +216,23 @@ func runWaitForPausePoint( // means "the fetch succeeded and no matching log exists". var payload any = response logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) - if logsErr == nil { + switch { + case logsErr == nil: payload = pausePointWaitResult{ pausePointStatusResponse: response, MatchingLogs: logs.Logs, Warning: buildPausePointWarning(logs, response.HitCount), + Expectations: expectations, + AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), + } + case len(expectations) > 0: + // Best-effort: a failed log fetch must not also drop --expect results, since that is + // the only evidence a caller asked for by name in this branch. + payload = pausePointWaitResult{ + pausePointStatusResponse: response, + MatchingLogs: []pausePointMatchingLog{}, + Expectations: expectations, + AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), } } result, marshalErr := json.Marshal(payload) @@ -290,6 +308,12 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err options.capturedVariablesMode = mode case PausePointCapturedVariableNamesFlagName: options.capturedVariableNames = parsePausePointCapturedVariableNames(value) + case PausePointExpectFlagName: + expectation, parseErr := parsePausePointExpectFlagValue(value) + if parseErr != nil { + return waitForPausePointOptions{}, parseErr + } + options.expectations = append(options.expectations, expectation) default: return waitForPausePointOptions{}, pausePointUnknownOptionError(clicore.PausePointAwaitCommandName, name) } 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 b864b9adf1..7cd3b24615 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -558,6 +558,37 @@ func TestParsePausePointCapturedVariablesModeFlag(t *testing.T) { } } +// Verifies --expect is parsed repeatably and rejects a value with no "=". +func TestParseWaitForPausePointOptionsParsesExpectFlag(t *testing.T) { + defaults, err := parseWaitForPausePointOptions([]string{"--id", "jump"}) + if err != nil { + t.Fatalf("default parse failed: %v", err) + } + if defaults.expectations != nil { + t.Fatalf("expected no expectations by default, got %#v", defaults.expectations) + } + + options, err := parseWaitForPausePointOptions([]string{ + "--id", "jump", "--expect", "Health=100", "--expect", "Name=Enemy", + }) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if len(options.expectations) != 2 { + t.Fatalf("expectations mismatch: %#v", options.expectations) + } + if options.expectations[0] != (pausePointExpectation{Name: "Health", Expected: "100"}) { + t.Fatalf("expectation[0] mismatch: %#v", options.expectations[0]) + } + if options.expectations[1] != (pausePointExpectation{Name: "Name", Expected: "Enemy"}) { + t.Fatalf("expectation[1] mismatch: %#v", options.expectations[1]) + } + + if _, err := parseWaitForPausePointOptions([]string{"--id", "jump", "--expect", "NoEqualsSign"}); err == nil { + t.Fatalf("expected error for --expect value without '='") + } +} + // Verifies a hit response always embeds marker-matching logs. func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { originalQuery := queryPausePointStatus