diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index debad2993c..fc65968875 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -14,7 +14,8 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +5. Report what happened ## Tool Reference @@ -43,6 +44,13 @@ Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. +- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. + ### KeyDown/KeyUp Rules - `KeyDown` fails if the key is already held @@ -84,6 +92,9 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key +- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption ## Prerequisites diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index da66d0b1fd..78b3f0fa8c 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -15,7 +15,8 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +6. Report what happened ## Tool Reference @@ -47,6 +48,13 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. +- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. + ### Global Options (optional) | Option | Description | @@ -106,5 +114,8 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) +- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption -These are the only six fields. There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, and target position are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md new file mode 100644 index 0000000000..4c4bafc80e --- /dev/null +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -0,0 +1,71 @@ +--- +name: uloop-wait-for-debug-break +description: "Pause Unity at named checkpoints for frame-specific PlayMode bugs and transient input, physics, async, or UI state changes." +--- + +# uloop wait-for-debug-break + +Pause Unity when execution reaches a named marker in user code. + +## When to use + +- Use when a bug appears only on a specific frame, timing window, animation step, physics tick, or `Update` / `FixedUpdate` branch. +- Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. +- Use it alongside screenshots and logs; it is not only for cases where screenshots fail. +- Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. +- If you use sleep, repeated polling, or a second state read to catch a transient PlayMode state, prefer `wait-for-debug-break` instead. + +## Workflow + +1. Add a marker at the state you want to inspect: + +```csharp +using io.github.hatayama.UnityCliLoop.Runtime; + +UnityCliLoopDebug.Break("player-jumped"); +``` + +2. Compile the project. +3. Enable the marker before triggering the code path: + +```bash +uloop enable-debug-break --id player-jumped --timeout-seconds 30 +``` + +4. Check marker state if needed: + +```bash +uloop debug-break-status --id player-jumped +``` + +5. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. +6. Wait for the marker: + +```bash +uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 +``` + +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. + +7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. +8. Clear the marker if you stop waiting: + +```bash +uloop clear-debug-break --id player-jumped +``` + +## Marker placement + +- Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. +- Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. +- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. + +## Safety + +- `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index debad2993c..fc65968875 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -14,7 +14,8 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +5. Report what happened ## Tool Reference @@ -43,6 +44,13 @@ Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. +- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. + ### KeyDown/KeyUp Rules - `KeyDown` fails if the key is already held @@ -84,6 +92,9 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key +- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption ## Prerequisites diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index da66d0b1fd..78b3f0fa8c 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -15,7 +15,8 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +6. Report what happened ## Tool Reference @@ -47,6 +48,13 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. +- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. + ### Global Options (optional) | Option | Description | @@ -106,5 +114,8 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) +- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption -These are the only six fields. There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, and target position are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md new file mode 100644 index 0000000000..4c4bafc80e --- /dev/null +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -0,0 +1,71 @@ +--- +name: uloop-wait-for-debug-break +description: "Pause Unity at named checkpoints for frame-specific PlayMode bugs and transient input, physics, async, or UI state changes." +--- + +# uloop wait-for-debug-break + +Pause Unity when execution reaches a named marker in user code. + +## When to use + +- Use when a bug appears only on a specific frame, timing window, animation step, physics tick, or `Update` / `FixedUpdate` branch. +- Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. +- Use it alongside screenshots and logs; it is not only for cases where screenshots fail. +- Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. +- If you use sleep, repeated polling, or a second state read to catch a transient PlayMode state, prefer `wait-for-debug-break` instead. + +## Workflow + +1. Add a marker at the state you want to inspect: + +```csharp +using io.github.hatayama.UnityCliLoop.Runtime; + +UnityCliLoopDebug.Break("player-jumped"); +``` + +2. Compile the project. +3. Enable the marker before triggering the code path: + +```bash +uloop enable-debug-break --id player-jumped --timeout-seconds 30 +``` + +4. Check marker state if needed: + +```bash +uloop debug-break-status --id player-jumped +``` + +5. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. +6. Wait for the marker: + +```bash +uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 +``` + +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. + +7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. +8. Clear the marker if you stop waiting: + +```bash +uloop clear-debug-break --id player-jumped +``` + +## Marker placement + +- Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. +- Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. +- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. + +## Safety + +- `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index 0db1594228..8db4d5e51b 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -31,14 +31,14 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag() } [Test] - public void GetMinimumRequiredCliVersion_RequiresExternalSceneChangeFlagCliRelease() + public void GetMinimumRequiredCliVersion_RequiresDebugBreakCliRelease() { - // Verifies this package release requires the CLI with external Scene change options. + // Verifies this package release requires the CLI with debug-break native commands. CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.24")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.26")); } [Test] @@ -49,7 +49,7 @@ public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag() new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.24")); + Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.26")); } [Test] diff --git a/Assets/Tests/Editor/CompileSessionResultServiceTests.cs b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs index 000533691d..05fef90734 100644 --- a/Assets/Tests/Editor/CompileSessionResultServiceTests.cs +++ b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs @@ -138,5 +138,32 @@ public void CreateCompileResult_WhenForceCompileHasPreservedFailure_MapsDetailed Assert.That(response.Errors[0].File, Is.EqualTo("Assets/Scenes/SampleScene.unity")); Assert.That(response.Message, Does.Contain("externally")); } + + [Test] + public void CreateCompileResult_WhenUnityTestFrameworkSymbolIsMissing_AddsTestAsmdefHint() + { + // Verifies compile failures from unmarked test asmdefs include the TestAssemblies fix. + CompilerMessage error = new CompilerMessage + { + type = CompilerMessageType.Error, + message = "The type or namespace name 'UnityTestAttribute' could not be found", + file = "Assets/Tests/PlayMode/SampleTest.cs", + line = 8 + }; + CompileResult result = new CompileResult( + success: false, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: new[] { error }, + errors: new[] { error }, + warnings: Array.Empty()); + + UnityCliLoopCompileResult response = + CompileSessionResultService.CreateCompileResult(result, forceRecompile: false); + + Assert.That(response.Message, Does.Contain("TestAssemblies")); + Assert.That(response.Message, Does.Contain("com.unity.test-framework")); + } } } diff --git a/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs new file mode 100644 index 0000000000..b058c9de0c --- /dev/null +++ b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests user-facing input-simulation duration formatting. + /// + public sealed class InputSimulationDurationFormatterTests + { + [Test] + public void FormatSeconds_WhenDurationHasSubTenthPrecision_PreservesMeaningfulDigits() + { + // Verifies short requested durations are not rounded up to a misleading tenth of a second. + string formatted = InputSimulationDurationFormatter.FormatSeconds(0.05f); + + Assert.That(formatted, Is.EqualTo("0.05")); + } + + [Test] + public void FormatSeconds_WhenDurationUsesWholeSeconds_DropsTrailingDecimal() + { + // Verifies whole-second durations stay compact in command result messages. + string formatted = InputSimulationDurationFormatter.FormatSeconds(2f); + + Assert.That(formatted, Is.EqualTo("2")); + } + } +} diff --git a/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta new file mode 100644 index 0000000000..bee5f5199c --- /dev/null +++ b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99af7b1d355d4f0c85c280aeb19bdde5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs index 77e2ddd1a1..4587b1dacf 100644 --- a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs +++ b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs @@ -121,6 +121,8 @@ public async Task ProcessRequest_WhenFirstToolWaitsForMainThread_ReturnsServerBu Assert.That(data["type"]?.ToString(), Is.EqualTo("server_busy")); Assert.That(data["runningToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); Assert.That(data["requestedToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); + Assert.That(data["isPlaying"], Is.Null); + Assert.That(data["isPaused"], Is.Null); LogAssert.NoUnexpectedReceived(); } finally @@ -184,6 +186,8 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows Assert.That(data["type"]?.ToString(), Is.EqualTo("server_busy")); Assert.That(data["runningToolName"]?.ToString(), Is.EqualTo(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE)); Assert.That(data["requestedToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); + Assert.That(data["isPlaying"], Is.Null); + Assert.That(data["isPaused"], Is.Null); LogAssert.NoUnexpectedReceived(); } finally diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 74cf5b2cc5..0acc69991b 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -31,6 +31,8 @@ public sealed class OnionAssemblyDependencyTests private const string FindGameObjectsAssemblyName = "UnityCLILoop.FirstPartyTools.FindGameObjects.Editor"; private const string GetHierarchyAssemblyName = "UnityCLILoop.FirstPartyTools.GetHierarchy.Editor"; private const string GetLogsAssemblyName = "UnityCLILoop.FirstPartyTools.GetLogs.Editor"; + private const string PausePointAssemblyName = "UnityCLILoop.FirstPartyTools.PausePoint.Editor"; + private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime"; private const string RecordInputAssemblyName = "UnityCLILoop.FirstPartyTools.RecordInput.Editor"; private const string ReplayInputAssemblyName = "UnityCLILoop.FirstPartyTools.ReplayInput.Editor"; private const string RunTestsAssemblyName = "UnityCLILoop.FirstPartyTools.RunTests.Editor"; @@ -131,16 +133,13 @@ public void ProjectAsmdefNames_WhenLoaded_UseUnityCliLoopName() } [Test] - public void ProjectAsmdefs_WhenLoaded_AutoReferenceOnlyPublicToolContracts() + public void ProjectAsmdefs_WhenLoaded_AutoReferenceOnlyPublicAssemblies() { - // Tests that internal assemblies require explicit asmdef references. + // Tests that internal assemblies require explicit asmdef references while public API assemblies stay reachable. string[] offendingAssemblyNames = ReadProjectAsmdefPaths() .Where(ReadAutoReferencedFromAbsolutePath) .Select(ReadAsmdefName) - .Where(assemblyName => !string.Equals( - assemblyName, - ToolContractsAssemblyName, - StringComparison.Ordinal)) + .Where(assemblyName => !IsPublicAutoReferencedAssembly(assemblyName)) .OrderBy(assemblyName => assemblyName) .ToArray(); @@ -568,9 +567,9 @@ public void PresentationAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotReferenc } [Test] - public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotReferencePresentation() + public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationRuntimeAndDoesNotReferencePresentation() { - // Tests that infrastructure and presentation remain sibling outer layers. + // Tests that infrastructure can bridge public runtime APIs while presentation remains a sibling outer layer. string[] references = ReadResolvedReferences("Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef"); Assert.That(references, Is.EquivalentTo(new[] @@ -578,6 +577,7 @@ public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotRefere InternalApiBridgeAssemblyName, ApplicationAssemblyName, DomainAssemblyName, + PausePointsRuntimeAssemblyName, ToolContractsAssemblyName })); } @@ -595,6 +595,7 @@ public void FirstPartyToolsAsmdef_WhenLoaded_DoesNotReferenceImplementationLayer Assert.That(references, Does.Contain(FindGameObjectsAssemblyName)); Assert.That(references, Does.Contain(GetHierarchyAssemblyName)); Assert.That(references, Does.Contain(GetLogsAssemblyName)); + Assert.That(references, Does.Contain(PausePointAssemblyName)); Assert.That(references, Does.Contain(RecordInputAssemblyName)); Assert.That(references, Does.Contain(ReplayInputAssemblyName)); Assert.That(references, Does.Contain(RunTestsAssemblyName)); @@ -1262,6 +1263,12 @@ private static string[] ReadResolvedReferences(string relativeAsmdefPath) return ReadResolvedReferencesFromAbsolutePath(asmdefPath); } + private static bool IsPublicAutoReferencedAssembly(string assemblyName) + { + return string.Equals(assemblyName, ToolContractsAssemblyName, StringComparison.Ordinal) || + string.Equals(assemblyName, PausePointsRuntimeAssemblyName, StringComparison.Ordinal); + } + private static string[] ReadResolvedReferencesFromAbsolutePath(string asmdefPath) { Dictionary guidToAssemblyNameMap = BuildGuidToAssemblyNameMap(); diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs new file mode 100644 index 0000000000..9d10457e32 --- /dev/null +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -0,0 +1,224 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies named pause point behavior without pausing the real Unity Editor during tests. + /// + [TestFixture] + public sealed class PausePointTests + { + private DateTime _nowUtc; + private bool _originalEnterPlayModeOptionsEnabled; + private EnterPlayModeOptions _originalEnterPlayModeOptions; + private FakePauseController _pauseController; + + [SetUp] + public void SetUp() + { + _nowUtc = new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc); + _originalEnterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + _originalEnterPlayModeOptions = EditorSettings.enterPlayModeOptions; + _pauseController = new FakePauseController(); + UloopPausePointRegistry.ConfigureForTests(_pauseController, () => _nowUtc); + } + + [TearDown] + public void TearDown() + { + EditorSettings.enterPlayModeOptionsEnabled = _originalEnterPlayModeOptionsEnabled; + EditorSettings.enterPlayModeOptions = _originalEnterPlayModeOptions; + UloopPausePointRegistry.ResetForTests(); + } + + [Test] + public void Break_WhenPausePointIsNotEnabled_DoesNotPause() + { + // Verifies marker calls are no-op until the CLI enables the same id. + UnityCliLoopDebug.Break("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.NotEnabled)); + Assert.That(snapshot.IsEnabled, Is.False); + } + + [Test] + public void Break_WhenPausePointIsEnabled_RecordsHitAndRequestsPause() + { + // Verifies an enabled marker hit records state and requests a Unity pause. + UloopPausePointRegistry.Enable("jump", 30); + + UnityCliLoopDebug.Break("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Hit)); + Assert.That(snapshot.IsHit, Is.True); + Assert.That(snapshot.IsEnabled, Is.False); + Assert.That(snapshot.IsPaused, Is.True); + Assert.That(snapshot.HitCount, Is.EqualTo(1)); + } + + [Test] + public void Break_WhenPausePointIsEnabled_StoresLatestHitSnapshot() + { + // Verifies input interruption responses can read the latest marker hit. + UloopPausePointRegistry.Enable("jump", 30); + + UnityCliLoopDebug.Break("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + Assert.That(snapshot, Is.Not.Null); + Assert.That(snapshot.Id, Is.EqualTo("jump")); + Assert.That(snapshot.HitCount, Is.EqualTo(1)); + } + + [Test] + public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() + { + // Verifies timeout disables the marker before a late hit can pause Unity. + UloopPausePointRegistry.Enable("jump", 1); + _nowUtc = _nowUtc.AddSeconds(2); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + UnityCliLoopDebug.Break("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired)); + Assert.That(snapshot.IsEnabled, Is.False); + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + } + + [Test] + public void GetStatus_WhenEnabled_ReportsElapsedSinceEnabledMilliseconds() + { + // Verifies elapsed time is named as time since the marker was enabled. + UloopPausePointRegistry.Enable("jump", 30); + _nowUtc = _nowUtc.AddMilliseconds(250); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.ElapsedSinceEnabledMilliseconds, Is.EqualTo(250)); + } + + [Test] + public void Clear_WhenPausePointIsEnabled_DisablesWithoutPause() + { + // Verifies explicit clear prevents later marker hits from pausing Unity. + UloopPausePointRegistry.Enable("jump", 30); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); + UnityCliLoopDebug.Break("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.IsEnabled, Is.False); + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + } + + [Test] + public void Enable_WhenSamePausePointWasHit_ClearsLatestHitSnapshot() + { + // Verifies re-enabling a marker does not leave stale hit details for input tools. + UloopPausePointRegistry.Enable("jump", 30); + UnityCliLoopDebug.Break("jump"); + + UloopPausePointRegistry.Enable("jump", 30); + + Assert.That(UloopPausePointRegistry.GetLatestHitSnapshot(), Is.Null); + } + + [Test] + public void ClearAll_WhenPausePointWasHit_ClearsTerminalStatus() + { + // Verifies bulk clear hides stale terminal hit status from future waits. + UloopPausePointRegistry.Enable("jump", 30); + UnityCliLoopDebug.Break("jump"); + + UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll(); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(result.ClearedCount, Is.EqualTo(1)); + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.IsHit, Is.False); + Assert.That(UloopPausePointRegistry.GetLatestHitSnapshot(), Is.Null); + } + + [Test] + public void BreakMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() + { + // Verifies the public marker follows Unity's conditional call-site removal pattern. + string sourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs"); + string source = File.ReadAllText(sourcePath); + + Assert.That(source, Does.Contain("[Conditional(\"UNITY_EDITOR\")]")); + Assert.That(source, Does.Contain("public static void Break(string id)")); + Assert.That(source, Does.Not.Contain("Debug.Break")); + } + + [Test] + public async Task Enable_WhenPlayModeInactiveAndDomainReloadEnabled_ReturnsWarning() + { + // Verifies PlayMode entry risk is reported only when Domain Reload can clear the marker. + EditorSettings.enterPlayModeOptionsEnabled = false; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.None; + + PausePointResponse response = await EnableDebugBreakAsync("jump"); + + Assert.That(response.Warning, Does.Contain("Domain Reload is enabled")); + Assert.That(response.Warning, Does.Contain("keep Domain Reload disabled")); + } + + [Test] + public async Task Enable_WhenPlayModeInactiveAndDomainReloadDisabled_ReturnsNoWarning() + { + // Verifies the normal no-domain-reload workflow does not suggest re-arming after Play starts. + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload; + + PausePointResponse response = await EnableDebugBreakAsync("dash"); + + Assert.That(response.Warning, Is.Empty); + } + + private static async Task EnableDebugBreakAsync(string id) + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = id, + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + return response; + } + + /// + /// Test double that records pause requests without mutating Unity Editor state. + /// + private sealed class FakePauseController : IUloopPausePointPauseController + { + public bool IsPlaying { get; private set; } = true; + public bool IsPaused { get; private set; } + public int PauseCount { get; private set; } + + public void Pause() + { + PauseCount++; + IsPaused = true; + } + } + } +} diff --git a/Assets/Tests/Editor/PausePointTests.cs.meta b/Assets/Tests/Editor/PausePointTests.cs.meta new file mode 100644 index 0000000000..edb7364f0c --- /dev/null +++ b/Assets/Tests/Editor/PausePointTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ef9b4ed66664a08857f24346bc8b174 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs new file mode 100644 index 0000000000..97c200da14 --- /dev/null +++ b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs @@ -0,0 +1,37 @@ +using System.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies tool settings use case catalog behavior. + /// + [TestFixture] + public class ToolSettingsUseCaseTests + { + [Test] + public void TryGetToolCatalog_WhenRegistryAvailable_IncludesNativeDebugBreakCommands() + { + // Verifies CLI-native debug break commands are user-toggleable built-in tools. + ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); + UnityCliLoopToolRegistrarService toolRegistrarService = new( + new EmptyInternalToolNameProvider(), + toolSettingsService, + new UnityCliLoopToolExecutionService()); + ToolSettingsUseCase useCase = new(toolSettingsService, toolRegistrarService); + useCase.WarmupRegistry(); + + bool isAvailable = useCase.TryGetToolCatalog(out ToolSettingsUseCase.ToolCatalogItem[] allTools); + string[] toolNames = allTools.Select(tool => tool.Name).ToArray(); + + Assert.That(isAvailable, Is.True); + Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_DEBUG_BREAK)); + Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_DEBUG_BREAK_STATUS)); + } + } +} diff --git a/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs.meta b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs.meta new file mode 100644 index 0000000000..b6504af015 --- /dev/null +++ b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 309b868e2332c41e085aa054a5961de2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef index f8562f30b4..c6a49eefbc 100644 --- a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef +++ b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef @@ -15,6 +15,8 @@ "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", + "GUID:94d8abc693f543a691a4645a5ff42e5c", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:a39242918a7ab45519b2c80f798dce33", "GUID:0cf5a90ce140e4729abd701bea48b00e", diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs new file mode 100644 index 0000000000..dfe04781c3 --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs @@ -0,0 +1,61 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests tool execution coordination helpers. + /// + public sealed class UnityCliLoopToolExecutionServiceTests + { + [Test] + public void CreateBusyException_WhenDispatcherReportsBackgroundThread_UsesEditorStateSnapshot() + { + // Verifies busy responses include the last editor state even before the request reaches Unity's main thread. + CapturingMainThreadDispatcher dispatcher = new CapturingMainThreadDispatcher(); + MainThreadSwitcher.RegisterService(dispatcher); + UnityCliLoopEditorStateSnapshot.SetPlayStateForTesting( + isPlaying: true, + isPaused: true); + + try + { + UnityCliLoopToolBusyException exception = + UnityCliLoopToolExecutionService.CreateBusyException("running-tool", "requested-tool"); + + Assert.That(exception.RunningToolName, Is.EqualTo("running-tool")); + Assert.That(exception.RequestedToolName, Is.EqualTo("requested-tool")); + Assert.That(exception.IsPlaying, Is.True); + Assert.That(exception.IsPaused, Is.True); + } + finally + { + UnityCliLoopEditorStateSnapshot.ClearForTesting(); + RestoreEditorMainThreadDispatcher(); + } + } + + private static void RestoreEditorMainThreadDispatcher() + { + EditorMainThreadDispatcher dispatcher = new EditorMainThreadDispatcher(); + MainThreadSwitcher.RegisterService(dispatcher); + dispatcher.Initialize(); + } + + private sealed class CapturingMainThreadDispatcher : IMainThreadDispatcher + { + public bool IsMainThread => false; + + public void Initialize() + { + } + + public void AddContinuation(System.Action continuation) + { + Assert.That(continuation, Is.Not.Null); + } + } + } +} diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta new file mode 100644 index 0000000000..26b69b7d76 --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e12df2738074ead93ebfdbd84dee5b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index b273491233..a52d6ff556 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -37,6 +37,7 @@ public sealed class UnityCliLoopToolRegistryTests private const string FindGameObjectsAssemblyName = "UnityCLILoop.FirstPartyTools.FindGameObjects.Editor"; private const string GetHierarchyAssemblyName = "UnityCLILoop.FirstPartyTools.GetHierarchy.Editor"; private const string GetLogsAssemblyName = "UnityCLILoop.FirstPartyTools.GetLogs.Editor"; + private const string PausePointAssemblyName = "UnityCLILoop.FirstPartyTools.PausePoint.Editor"; private const string RecordInputAssemblyName = "UnityCLILoop.FirstPartyTools.RecordInput.Editor"; private const string ReplayInputAssemblyName = "UnityCLILoop.FirstPartyTools.ReplayInput.Editor"; private const string RunTestsAssemblyName = "UnityCLILoop.FirstPartyTools.RunTests.Editor"; @@ -53,6 +54,8 @@ public void Constructor_WhenFirstPartyToolsUseToolAttribute_RegistersThem() Assert.That(registry.IsToolRegistered("compile"), Is.True); Assert.That(registry.IsToolRegistered("get-logs"), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK), Is.True); Assert.That(registry.IsToolRegistered("execute-dynamic-code"), Is.True); Assert.That(registry.IsToolRegistered("clear-console"), Is.True); Assert.That(registry.IsToolRegistered("get-hierarchy"), Is.True); @@ -248,6 +251,23 @@ public void GetToolType_WhenSimulateMouseUiComesFromFirstPartyToolsAssembly_Retu AssertThirdPartyTool(toolType, false); } + [Test] + public void GetToolType_WhenPausePointComesFromFirstPartyToolsAssembly_ReturnsBundledPluginType() + { + // Tests that pause point tools are bundled plugins instead of application-layer tools. + UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); + + System.Type enableToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK); + System.Type clearToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK); + + Assert.That(enableToolType, Is.Not.Null); + Assert.That(clearToolType, Is.Not.Null); + Assert.That(enableToolType.Assembly.GetName().Name, Is.EqualTo(PausePointAssemblyName)); + Assert.That(clearToolType.Assembly.GetName().Name, Is.EqualTo(PausePointAssemblyName)); + AssertThirdPartyTool(enableToolType, false); + AssertThirdPartyTool(clearToolType, false); + } + [Test] public void Constructor_WhenFocusWindowIsNativeCliCommand_DoesNotRegisterItAsTool() { @@ -497,6 +517,7 @@ public void FirstPartyToolsAsmdef_DoesNotReferenceImplementationLayers() Assert.That(references, Does.Contain(FindGameObjectsAssemblyName)); Assert.That(references, Does.Contain(GetHierarchyAssemblyName)); Assert.That(references, Does.Contain(GetLogsAssemblyName)); + Assert.That(references, Does.Contain(PausePointAssemblyName)); Assert.That(references, Does.Contain(RecordInputAssemblyName)); Assert.That(references, Does.Contain(ReplayInputAssemblyName)); Assert.That(references, Does.Contain(RunTestsAssemblyName)); diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index ad1a6c170a..13b4296990 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -1,5 +1,6 @@ #if ULOOP_HAS_INPUT_SYSTEM #nullable enable +using System; using System.Collections; using System.Threading; using System.Threading.Tasks; @@ -14,6 +15,7 @@ using UnityEngine.InputSystem.LowLevel; using UnityEngine.UI; using UnityEngine.TestTools; +using Object = UnityEngine.Object; namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode { @@ -59,6 +61,8 @@ public override void Setup() public override void TearDown() { + InputSystemUpdateHelper.ResetPauseProviderForTests(); + UloopPausePointRegistry.ResetForTests(); InputSettings settings = RequireInputSettings(); settings.updateMode = originalUpdateMode; Time.timeScale = originalTimeScale; @@ -112,6 +116,74 @@ public IEnumerator Press_WithDuration_Should_HoldKey() Assert.AreEqual("Space", lastResponse.KeyName); } + [UnityTest] + public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsDebugBreakInterruption() + { + // Verifies that a debug-break pause releases the tool slot instead of leaving the press command busy. + yield return null; + + SimulateKeyboardSchema parameters = new() + { + Action = UnityCliLoopKeyboardAction.Press, + Key = "Space", + Duration = 1f + }; + Task task = + tool.ExecuteWithCancellationAsync(parameters, CancellationToken.None); + + yield return new WaitUntil(() => keyboard[Key.Space].isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the press observation window."); + + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("Press", lastResponse.Action); + Assert.AreEqual("Space", lastResponse.KeyName); + Assert.IsNull(lastResponse.DebugBreakId); + Assert.IsNull(lastResponse.DebugBreakHitCount); + Assert.IsFalse(keyboard[Key.Space].isPressed, "Debug-break interruption should release the injected key state."); + Assert.IsFalse(SimulateKeyboardOverlayState.IsActive, "Debug-break interruption should clear keyboard overlay state."); + } + + [UnityTest] + public IEnumerator Press_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() + { + // Verifies marker-caused interruption reports the marker id and hit count. + yield return null; + + UloopPausePointRegistry.ConfigureForTests( + new FakePausePointPauseController(), + () => new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)); + UloopPausePointRegistry.Enable("space-press", 30); + SimulateKeyboardSchema parameters = new() + { + Action = UnityCliLoopKeyboardAction.Press, + Key = "Space", + Duration = 1f + }; + Task task = + tool.ExecuteWithCancellationAsync(parameters, CancellationToken.None); + + yield return new WaitUntil(() => keyboard[Key.Space].isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the press observation window."); + + UnityCliLoopDebug.Break("space-press"); + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("space-press", lastResponse.DebugBreakId); + Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + Assert.IsFalse(keyboard[Key.Space].isPressed, "Marker interruption should release the injected key state."); + } + [UnityTest] public IEnumerator Press_Space_Should_SetWasPressedThisFrame() { @@ -711,6 +783,20 @@ private static InputSettings RequireInputSettings() return settings!; } + /// + /// Records pause requests without pausing the real Unity Editor. + /// + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused { get; private set; } + + public void Pause() + { + IsPaused = true; + } + } + private static BadgeVisual RequireBadgeVisual(string keyName) { InputVisualizationCanvas canvas = Object.FindAnyObjectByType(); diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index 26c85a7867..aef127ee48 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -1,5 +1,6 @@ #if ULOOP_HAS_INPUT_SYSTEM #nullable enable +using System; using System.Collections; using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -10,6 +11,7 @@ using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.TestTools; +using Object = UnityEngine.Object; namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode { @@ -35,6 +37,8 @@ public override void Setup() public override void TearDown() { + InputSystemUpdateHelper.ResetPauseProviderForTests(); + UloopPausePointRegistry.ResetForTests(); MouseInputState.ReleaseAllButtons(); Object.DestroyImmediate(mouseObserverGo); base.TearDown(); @@ -101,6 +105,72 @@ public IEnumerator Click_MiddleButton_Should_InjectMiddleClick() Assert.IsFalse(mouse.middleButton.isPressed, "Middle button should be released after click"); } + [UnityTest] + public IEnumerator Click_WhenUnityPausesDuringObservation_Should_CompleteAsDebugBreakInterruption() + { + // Verifies that a debug-break pause releases the tool slot instead of leaving the click command busy. + yield return null; + + Task task = tool.ExecuteAsync(new JObject + { + ["action"] = MouseInputAction.Click.ToString(), + ["x"] = 400, + ["y"] = 300, + ["duration"] = 1f + }, System.Threading.CancellationToken.None); + + yield return new WaitUntil(() => mouse.leftButton.isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the click observation window."); + + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = (SimulateMouseInputResponse)task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("Click", lastResponse.Action); + Assert.AreEqual("Left", lastResponse.Button); + Assert.IsNull(lastResponse.DebugBreakId); + Assert.IsNull(lastResponse.DebugBreakHitCount); + Assert.IsFalse(mouse.leftButton.isPressed, "Debug-break interruption should release the injected mouse button state."); + Assert.IsFalse(SimulateMouseInputOverlayState.HasAnyActivity, "Debug-break interruption should clear mouse overlay state."); + } + + [UnityTest] + public IEnumerator Click_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() + { + // Verifies marker-caused interruption reports the marker id and hit count. + yield return null; + + UloopPausePointRegistry.ConfigureForTests( + new FakePausePointPauseController(), + () => new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)); + UloopPausePointRegistry.Enable("left-click", 30); + Task task = tool.ExecuteAsync(new JObject + { + ["action"] = MouseInputAction.Click.ToString(), + ["x"] = 400, + ["y"] = 300, + ["duration"] = 1f + }, System.Threading.CancellationToken.None); + + yield return new WaitUntil(() => mouse.leftButton.isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the click observation window."); + + UnityCliLoopDebug.Break("left-click"); + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = (SimulateMouseInputResponse)task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("left-click", lastResponse.DebugBreakId); + Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + Assert.IsFalse(mouse.leftButton.isPressed, "Marker interruption should release the injected mouse button state."); + } + #endregion #region LongPress Tests @@ -188,6 +258,51 @@ public IEnumerator LongPress_Should_RestoreRunInBackground_WhenOriginallyDisable #endregion + #region SmoothDelta Tests + + [UnityTest] + public IEnumerator SmoothDelta_WhenDebugBreakMarkerHitsDuringGameplayUpdate_Should_ReturnMarkerDetails() + { + // Verifies SmoothDelta observes gameplay-frame debug breaks before scheduling the next delta. + yield return null; + + MouseDeltaDebugBreakObserver observer = mouseObserverGo.AddComponent(); + observer.MarkerId = "smooth-delta"; + UloopPausePointRegistry.ConfigureForTests( + new FakePausePointPauseController(), + () => new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)); + UloopPausePointRegistry.Enable(observer.MarkerId, 30); + + Task task = tool.ExecuteAsync(new JObject + { + ["action"] = MouseInputAction.SmoothDelta.ToString(), + ["deltaX"] = 120f, + ["deltaY"] = 0f, + ["duration"] = 1f + }, System.Threading.CancellationToken.None); + + yield return new WaitUntil(() => task.IsCompleted || observer.HasTriggered); + Assert.IsTrue(observer.HasTriggered, "The test must hit the marker from gameplay Update."); + + try + { + yield return WaitForTask(task); + } + finally + { + InputSystemUpdateHelper.ResetPauseProviderForTests(); + } + + lastResponse = (SimulateMouseInputResponse)task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("SmoothDelta", lastResponse.Action); + Assert.AreEqual("smooth-delta", lastResponse.DebugBreakId); + Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + } + + #endregion + #region MoveDelta Tests [UnityTest] @@ -246,12 +361,31 @@ public IEnumerator Scroll_Horizontal_Should_InjectScrollX() private IEnumerator RunTool(JObject parameters) { Task task = tool.ExecuteAsync(parameters, System.Threading.CancellationToken.None); + yield return WaitForTask(task); + lastResponse = (SimulateMouseInputResponse)task.Result; + } + + private static IEnumerator WaitForTask(Task task) + { float timeoutAt = Time.realtimeSinceStartup + 5f; yield return new WaitUntil(() => task.IsCompleted || Time.realtimeSinceStartup >= timeoutAt); Assert.IsTrue(task.IsCompleted, "Tool execution timed out."); Assert.IsFalse(task.IsFaulted, $"Tool execution should not fault: {task.Exception}"); - lastResponse = (SimulateMouseInputResponse)task.Result; + } + + /// + /// Records pause requests without pausing the real Unity Editor. + /// + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused { get; private set; } + + public void Pause() + { + IsPaused = true; + } } #endregion @@ -283,5 +417,38 @@ public void ResetCount() LeftButtonPressedUpdateCount = 0; } } + + /// + /// Test support type that hits a debug-break marker when gameplay observes mouse delta. + /// + public class MouseDeltaDebugBreakObserver : MonoBehaviour + { + public string MarkerId { get; set; } = ""; + public bool HasTriggered { get; private set; } + + private void Update() + { + if (HasTriggered) + { + return; + } + + Mouse mouse = Mouse.current; + if (mouse == null) + { + return; + } + + Vector2 delta = mouse.delta.ReadValue(); + if (delta.sqrMagnitude <= 0f) + { + return; + } + + HasTriggered = true; + UnityCliLoopDebug.Break(MarkerId); + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + } + } } #endif diff --git a/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef b/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef index fd43ec1da1..02021ece61 100644 --- a/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef +++ b/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef @@ -12,6 +12,7 @@ "GUID:b21158f16291043699bc11600a45a9ee", "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:dc412b2cd951d452b8dcec99c3adeb19", diff --git a/Packages/src/Editor/Application/ApplicationEditorStartup.cs b/Packages/src/Editor/Application/ApplicationEditorStartup.cs index 893c52ebbc..84907b3a35 100644 --- a/Packages/src/Editor/Application/ApplicationEditorStartup.cs +++ b/Packages/src/Editor/Application/ApplicationEditorStartup.cs @@ -17,6 +17,7 @@ internal static void Initialize(IDomainReloadDetectionService domainReloadDetect UnityCliLoopEditorDomainReloadStateRegistration.RegisterForEditorStartup(); MainThreadSwitcher.InitializeForEditorStartup(); + UnityCliLoopEditorStateSnapshot.InitializeForEditorStartup(); EditorDelayManager.InitializeForEditorStartup(); domainReloadDetectionService.RegisterForEditorStartup(); } diff --git a/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs b/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs index ab8986464a..ee781e3c9d 100644 --- a/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs +++ b/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs @@ -42,12 +42,20 @@ internal static void ValidateForState(string toolName, bool isCompiling, bool is if ((condition & GuardCondition.NotCompiling) != 0 && isCompiling) { - throw new UnityCliLoopToolBusyException(UnityCompileOperationName, toolName); + throw new UnityCliLoopToolBusyException( + UnityCompileOperationName, + toolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); } if ((condition & GuardCondition.NotUpdating) != 0 && isUpdating) { - throw new UnityCliLoopToolBusyException(UnityAssetDatabaseUpdateOperationName, toolName); + throw new UnityCliLoopToolBusyException( + UnityAssetDatabaseUpdateOperationName, + toolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); } } diff --git a/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs new file mode 100644 index 0000000000..b7350efdf7 --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs @@ -0,0 +1,72 @@ +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// Stores the latest Unity play state for error responses created outside the main thread. + /// + internal static class UnityCliLoopEditorStateSnapshot + { + private static readonly object StateLock = new(); + private static bool _hasPlayState; + private static bool _isPlaying; + private static bool _isPaused; + + internal static void InitializeForEditorStartup() + { + EditorApplication.update -= RefreshFromEditor; + EditorApplication.update += RefreshFromEditor; + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + RefreshFromEditor(); + } + + internal static (bool HasValue, bool IsPlaying, bool IsPaused) GetPlayState() + { + lock (StateLock) + { + return ( + HasValue: _hasPlayState, + IsPlaying: _isPlaying, + IsPaused: _isPaused); + } + } + + internal static void SetPlayStateForTesting(bool isPlaying, bool isPaused) + { + SetPlayState(isPlaying, isPaused); + } + + internal static void ClearForTesting() + { + lock (StateLock) + { + _hasPlayState = false; + _isPlaying = false; + _isPaused = false; + } + } + + private static void OnPlayModeStateChanged(PlayModeStateChange state) + { + RefreshFromEditor(); + } + + private static void RefreshFromEditor() + { + SetPlayState( + EditorApplication.isPlaying, + EditorApplication.isPaused); + } + + private static void SetPlayState(bool isPlaying, bool isPaused) + { + lock (StateLock) + { + _hasPlayState = true; + _isPlaying = isPlaying; + _isPaused = isPaused; + } + } + } +} diff --git a/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs.meta b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs.meta new file mode 100644 index 0000000000..29b89386e6 --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f988033516bec4782ae73a71c45d1861 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs b/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs index 9ed6de076e..0a57a67213 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs @@ -7,17 +7,27 @@ namespace io.github.hatayama.UnityCliLoop.Application /// public sealed class UnityCliLoopToolBusyException : Exception { - public UnityCliLoopToolBusyException(string runningToolName, string requestedToolName) + public UnityCliLoopToolBusyException( + string runningToolName, + string requestedToolName, + bool? isPlaying = null, + bool? isPaused = null) : base(CreateMessage(runningToolName, requestedToolName)) { RunningToolName = runningToolName; RequestedToolName = requestedToolName; + IsPlaying = isPlaying; + IsPaused = isPaused; } public string RunningToolName { get; } public string RequestedToolName { get; } + public bool? IsPlaying { get; } + + public bool? IsPaused { get; } + private static string CreateMessage(string runningToolName, string requestedToolName) { return $"Unity is busy running '{runningToolName}'. Retry '{requestedToolName}' after the running tool completes."; diff --git a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs index 752af16874..5d67695078 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using UnityEditor; using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -49,7 +50,7 @@ internal async Task ExecuteToolAsync( if (!TryEnterExecution(toolName, out string runningToolName)) { - throw new UnityCliLoopToolBusyException(runningToolName, toolName); + throw CreateBusyException(runningToolName, toolName); } try @@ -117,6 +118,36 @@ private static bool CanShareExecutionSlot(string runningToolName, string request && requestedToolName == UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE; } + internal static UnityCliLoopToolBusyException CreateBusyException( + string runningToolName, + string requestedToolName) + { + Debug.Assert(!string.IsNullOrWhiteSpace(runningToolName), "runningToolName must not be null or whitespace"); + Debug.Assert(!string.IsNullOrWhiteSpace(requestedToolName), "requestedToolName must not be null or whitespace"); + + if (MainThreadSwitcher.IsMainThread) + { + return new UnityCliLoopToolBusyException( + runningToolName, + requestedToolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); + } + + (bool HasValue, bool IsPlaying, bool IsPaused) playState = + UnityCliLoopEditorStateSnapshot.GetPlayState(); + if (playState.HasValue) + { + return new UnityCliLoopToolBusyException( + runningToolName, + requestedToolName, + playState.IsPlaying, + playState.IsPaused); + } + + return new UnityCliLoopToolBusyException(runningToolName, requestedToolName); + } + private string GetRunningToolNameInsideLock() { return string.IsNullOrWhiteSpace(_runningToolName) diff --git a/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs b/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs index 10af05d75c..2e239a4ba6 100644 --- a/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs +++ b/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs @@ -3,11 +3,24 @@ using System.Linq; using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Application { internal sealed class ToolSettingsUseCase { + private static readonly ToolCatalogItem[] NativeToolCatalogItems = + { + new( + UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_DEBUG_BREAK, + displayDevelopmentOnly: false, + isThirdParty: false), + new( + UnityCliLoopConstants.COMMAND_NAME_DEBUG_BREAK_STATUS, + displayDevelopmentOnly: false, + isThirdParty: false) + }; + private readonly ToolSettingsService _toolSettingsService; private readonly UnityCliLoopToolRegistrarService _toolRegistrarService; @@ -67,12 +80,13 @@ internal bool TryGetToolCatalog(out ToolCatalogItem[] allTools) return false; } - allTools = registry.GetToolSettingsCatalog() + ToolCatalogItem[] registryTools = registry.GetToolSettingsCatalog() .Select(item => new ToolCatalogItem( item.Name, item.DisplayDevelopmentOnly, item.IsThirdParty)) .ToArray(); + allTools = registryTools.Concat(NativeToolCatalogItems).ToArray(); return true; } diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint.meta new file mode 100644 index 0000000000..4c775e7656 --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f0336ed66c5e45a39afa8c0e11f94dd0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta new file mode 100644 index 0000000000..898172cc7e --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 842781e920d14996b5f2cafff69f80db +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md new file mode 100644 index 0000000000..4c4bafc80e --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -0,0 +1,71 @@ +--- +name: uloop-wait-for-debug-break +description: "Pause Unity at named checkpoints for frame-specific PlayMode bugs and transient input, physics, async, or UI state changes." +--- + +# uloop wait-for-debug-break + +Pause Unity when execution reaches a named marker in user code. + +## When to use + +- Use when a bug appears only on a specific frame, timing window, animation step, physics tick, or `Update` / `FixedUpdate` branch. +- Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. +- Use it alongside screenshots and logs; it is not only for cases where screenshots fail. +- Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. +- If you use sleep, repeated polling, or a second state read to catch a transient PlayMode state, prefer `wait-for-debug-break` instead. + +## Workflow + +1. Add a marker at the state you want to inspect: + +```csharp +using io.github.hatayama.UnityCliLoop.Runtime; + +UnityCliLoopDebug.Break("player-jumped"); +``` + +2. Compile the project. +3. Enable the marker before triggering the code path: + +```bash +uloop enable-debug-break --id player-jumped --timeout-seconds 30 +``` + +4. Check marker state if needed: + +```bash +uloop debug-break-status --id player-jumped +``` + +5. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. +6. Wait for the marker: + +```bash +uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 +``` + +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. + +7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. +8. Clear the marker if you stop waiting: + +```bash +uloop clear-debug-break --id player-jumped +``` + +## Marker placement + +- Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. +- Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. +- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. + +## Safety + +- `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta new file mode 100644 index 0000000000..a1f8969697 --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6cb0f0310dd648ac86977590bcfa405f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 1e063fecbd..ff24e36468 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.25"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.26"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs new file mode 100644 index 0000000000..448e704edc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs @@ -0,0 +1,18 @@ +#nullable enable +using System.Globalization; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Formats input-simulation durations for user-facing CLI result messages. + /// + internal static class InputSimulationDurationFormatter + { + private const string SecondsFormat = "0.###"; + + internal static string FormatSeconds(float seconds) + { + return seconds.ToString(SecondsFormat, CultureInfo.InvariantCulture); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta new file mode 100644 index 0000000000..3e3a472a0a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5223c9557411434a895cad0ebc1434ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs index 3c6a7ef704..4bd14d6cd0 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs @@ -95,6 +95,9 @@ public sealed class UnityCliLoopKeyboardSimulationResult public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? KeyName { get; set; } + public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } } /// @@ -124,6 +127,9 @@ public sealed class UnityCliLoopMouseInputSimulationResult public string? Button { get; set; } public float? PositionX { get; set; } public float? PositionY { get; set; } + public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } } /// diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs index 8b101b1d60..bc48561f97 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs @@ -3,6 +3,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using UnityEditor; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; @@ -11,6 +12,15 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { + /// + /// Reports whether input observation finished normally or stopped because Unity paused. + /// + internal enum InputSimulationWaitOutcome + { + Completed = 0, + Paused = 1 + } + // Shared helper for applying Input System state changes at the correct update phase. // Both keyboard and mouse simulation need frame-precise timing so that // wasPressedThisFrame / wasReleasedThisFrame detect the injected state. @@ -21,6 +31,7 @@ internal static class InputSystemUpdateHelper { private const int StandardPressObservationFrames = 2; private const int ManualPressObservationFrames = 3; + private static Func isPausedProvider = () => EditorApplication.isPaused; public static Task ApplyOnNextConfiguredUpdate(Action apply, CancellationToken ct) { @@ -84,12 +95,12 @@ public static int GetMinimumObservationFrameCount() return ManualPressObservationFrames; } - public static async Task WaitForObservationFrames(CancellationToken ct) + public static async Task WaitForObservationFrames(CancellationToken ct) { - await WaitForRuntimeFrames(GetMinimumObservationFrameCount(), ct); + return await WaitForRuntimeFrames(GetMinimumObservationFrameCount(), ct); } - public static async Task WaitForPressLifetime(float duration, CancellationToken ct) + public static async Task WaitForPressLifetime(float duration, CancellationToken ct) { int minimumObservationFrames = GetMinimumObservationFrameCount(); int startFrameCount = Time.frameCount; @@ -99,10 +110,22 @@ public static async Task WaitForPressLifetime(float duration, CancellationToken while (observedFrames < minimumObservationFrames || elapsed < duration) { + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + await EditorDelay.DelayFrame(1, ct); + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + observedFrames = Time.frameCount - startFrameCount; elapsed = Time.realtimeSinceStartup - startTime; } + + return InputSimulationWaitOutcome.Completed; } public static void RunExplicitUpdate(InputUpdateType targetUpdateType) @@ -203,16 +226,49 @@ private static InputSettings.UpdateMode GetExplicitUpdateMode( return fallbackUpdateMode; } - private static async Task WaitForRuntimeFrames(int frameCount, CancellationToken ct) + public static async Task WaitForRuntimeFrames(int frameCount, CancellationToken ct) { int startFrameCount = Time.frameCount; int observedFrames = 0; while (observedFrames < frameCount) { + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + await EditorDelay.DelayFrame(1, ct); + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + observedFrames = Time.frameCount - startFrameCount; } + + return InputSimulationWaitOutcome.Completed; + } + + internal static void ConfigurePauseProviderForTests(Func provider) + { + Debug.Assert(provider != null, "provider must not be null"); + if (provider == null) + { + throw new ArgumentNullException(nameof(provider)); + } + + isPausedProvider = provider; + } + + internal static void ResetPauseProviderForTests() + { + isPausedProvider = () => EditorApplication.isPaused; + } + + private static bool IsPaused() + { + return isPausedProvider(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs index 71bc73a85b..607667f429 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs @@ -14,6 +14,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class CompileSessionResultService { + private const string MissingTestFrameworkReferenceHint = + "Possible test asmdef issue: Unity test framework symbols are missing. Make sure com.unity.test-framework is installed and add optionalUnityReferences: [\"TestAssemblies\"] or enable testAssemblies on the test asmdef."; + internal static UnityCliLoopCompileResult CreateCompileResult( CompileResult result, bool forceRecompile) @@ -45,7 +48,7 @@ internal static UnityCliLoopCompileResult CreateCompileResult( WarningCount = result.Warnings?.Length ?? 0, Errors = ToIssues(result.Errors), Warnings = ToIssues(result.Warnings), - Message = result.Message + Message = AddMissingTestFrameworkReferenceHint(result.Message, result.Errors) }; } @@ -119,6 +122,50 @@ private static UnityCliLoopCompileIssue[] ToIssues(UnityEditor.Compilation.Compi Line = message.line }).ToArray(); } + + private static string AddMissingTestFrameworkReferenceHint( + string message, + UnityEditor.Compilation.CompilerMessage[] errors) + { + if (!ContainsMissingTestFrameworkReference(errors)) + { + return message; + } + + if (string.IsNullOrWhiteSpace(message)) + { + return MissingTestFrameworkReferenceHint; + } + + if (message.Contains(MissingTestFrameworkReferenceHint, StringComparison.Ordinal)) + { + return message; + } + + return $"{message} {MissingTestFrameworkReferenceHint}"; + } + + private static bool ContainsMissingTestFrameworkReference( + UnityEditor.Compilation.CompilerMessage[] errors) + { + if (errors == null) + { + return false; + } + + foreach (UnityEditor.Compilation.CompilerMessage error in errors) + { + string errorMessage = error.message ?? string.Empty; + if (errorMessage.Contains("UnityTestAttribute", StringComparison.Ordinal) + || errorMessage.Contains("UnityEngine.TestTools", StringComparison.Ordinal) + || errorMessage.Contains("NUnit.Framework", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } } /// diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint.meta b/Packages/src/Editor/FirstPartyTools/PausePoint.meta new file mode 100644 index 0000000000..919d00a53e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 15bef1671be348ba88df00a8f77f17e6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs new file mode 100644 index 0000000000..e914012996 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -0,0 +1,199 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Parameters for enabling one named debug break marker. + /// + public class EnablePausePointSchema : UnityCliLoopToolSchema + { + public string Id { get; set; } = string.Empty; + + public int TimeoutSeconds { get; set; } = UloopPausePointRegistry.DefaultTimeoutSeconds; + } + + /// + /// Parameters for clearing one or all debug break markers. + /// + public class ClearPausePointSchema : UnityCliLoopToolSchema + { + public string Id { get; set; } = string.Empty; + + public bool All { get; set; } + } + + /// + /// Response shared by debug break tool commands. + /// + public class PausePointResponse : UnityCliLoopToolResponse + { + public string Id { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool IsEnabled { get; set; } + public bool IsHit { get; set; } + public int HitCount { get; set; } + public int TimeoutSeconds { get; set; } + public long ElapsedSinceEnabledMilliseconds { get; set; } + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public int ClearedCount { get; set; } + public string Message { get; set; } = string.Empty; + public string Warning { get; set; } = string.Empty; + + internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointResponse + { + Id = snapshot.Id, + Status = snapshot.Status, + IsEnabled = snapshot.IsEnabled, + IsHit = snapshot.IsHit, + HitCount = snapshot.HitCount, + TimeoutSeconds = snapshot.TimeoutSeconds, + ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, + IsPlaying = snapshot.IsPlaying, + IsPaused = snapshot.IsPaused, + Message = snapshot.Message + }; + } + + internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult result) + { + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + return new PausePointResponse + { + Status = UloopPausePointStatus.Cleared, + ClearedCount = result.ClearedCount, + Message = "Debug breaks cleared." + }; + } + } + + /// + /// Exposes debug break enabling as a Unity CLI Loop tool. + /// + [UnityCliLoopTool] + public class EnablePausePointTool : UnityCliLoopTool + { + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK; + + protected override Task ExecuteAsync(EnablePausePointSchema parameters, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + PausePointUseCase useCase = new(); + PausePointResponse response = useCase.Enable(parameters); + return Task.FromResult(response); + } + } + + /// + /// Exposes debug break clearing as a Unity CLI Loop tool. + /// + [UnityCliLoopTool] + public class ClearPausePointTool : UnityCliLoopTool + { + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK; + + protected override Task ExecuteAsync(ClearPausePointSchema parameters, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + PausePointUseCase useCase = new(); + PausePointResponse response = useCase.Clear(parameters); + return Task.FromResult(response); + } + } + + /// + /// Coordinates debug break tool validation and registry updates. + /// + internal sealed class PausePointUseCase + { + public PausePointResponse Enable(EnablePausePointSchema parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + string id = RequireId(parameters.Id); + if (parameters.TimeoutSeconds <= 0) + { + throw new UnityCliLoopToolParameterValidationException("TimeoutSeconds must be greater than zero."); + } + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable(id, parameters.TimeoutSeconds); + PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); + response.Warning = CreateEnableWarning(); + return response; + } + + public PausePointResponse Clear(ClearPausePointSchema parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + if (parameters.All) + { + UloopPausePointClearAllResult clearAllResult = UloopPausePointRegistry.ClearAll(); + return PausePointResponse.FromClearAll(clearAllResult); + } + + string id = RequireId(parameters.Id); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear(id); + return PausePointResponse.FromSnapshot(snapshot); + } + + private static string RequireId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new UnityCliLoopToolParameterValidationException("Id must not be null or empty."); + } + + return id; + } + + private static string CreateEnableWarning() + { + if (EditorApplication.isPlaying) + { + return string.Empty; + } + + if (IsDomainReloadDisabledOnEnterPlayMode()) + { + return string.Empty; + } + + return "Debug break was enabled before PlayMode while Domain Reload is enabled. " + + "Entering PlayMode may clear this marker; keep Domain Reload disabled for this workflow or enable the marker after PlayMode starts."; + } + + private static bool IsDomainReloadDisabledOnEnterPlayMode() + { + if (!EditorSettings.enterPlayModeOptionsEnabled) + { + return false; + } + + return (EditorSettings.enterPlayModeOptions & EnterPlayModeOptions.DisableDomainReload) != 0; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta new file mode 100644 index 0000000000..319c845923 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7420cff4bb8f43b9bbf7857c811e3540 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef new file mode 100644 index 0000000000..e40c785b96 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "UnityCLILoop.FirstPartyTools.PausePoint.Editor", + "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", + "references": [ + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:527f26a36b5043c2bd4d4036d04cd76d" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta new file mode 100644 index 0000000000..f6e7edc674 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 94d8abc693f543a691a4645a5ff42e5c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 8bfe3b4615..6d52278470 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -13,6 +13,9 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? KeyName { get; set; } + public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } public SimulateKeyboardResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs index 2c009ffa56..b4420e1016 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -48,6 +48,9 @@ private static SimulateKeyboardResponse ToResponse(UnityCliLoopKeyboardSimulatio Message = result.Message, Action = result.Action, KeyName = result.KeyName, + InterruptedByDebugBreak = result.InterruptedByDebugBreak, + DebugBreakId = result.DebugBreakId, + DebugBreakHitCount = result.DebugBreakHitCount, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 12417750df..701676be40 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -92,6 +92,8 @@ public async Task SimulateKeyboardAsync( }; } + UloopPausePointRegistry.ClearLatestHitSnapshot(); + VibeLogger.LogInfo( "simulate_keyboard_start", "Keyboard simulation started", @@ -169,12 +171,13 @@ private async Task ExecutePress( SimulateKeyboardOverlayState.ShowPress(keyName); KeyboardKeyState.RegisterTransientKey(key); bool pressWasApplied = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, true), ct); pressWasApplied = true; - await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct); + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct); } finally { @@ -182,7 +185,14 @@ private async Task ExecutePress( { await ReleaseKeyStateIfPossible(keyboard, key); KeyboardKeyState.UnregisterTransientKey(key); - await FinalizePressOverlay(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateKeyboardOverlayState.ClearPress(); + } + else + { + await FinalizePressOverlay(ct); + } } else { @@ -191,7 +201,12 @@ private async Task ExecutePress( } } - string durationText = duration > 0f ? $" for {duration:F1}s" : ""; + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedPressResult(keyName); + } + + string durationText = duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(duration)}s" : ""; return new UnityCliLoopKeyboardSimulationResult { Success = true, @@ -218,6 +233,7 @@ private async Task ExecuteKeyDown(Keyboard bool keyDownApplied = false; bool committed = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { @@ -225,8 +241,8 @@ private async Task ExecuteKeyDown(Keyboard keyDownApplied = true; KeyboardKeyState.SetKeyDown(key); SimulateKeyboardOverlayState.AddHeldKey(keyName); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); - committed = true; + waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + committed = waitOutcome == InputSimulationWaitOutcome.Completed; } finally { @@ -236,6 +252,11 @@ private async Task ExecuteKeyDown(Keyboard } } + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName); + } + return new UnityCliLoopKeyboardSimulationResult { Success = true, @@ -263,7 +284,11 @@ private async Task ExecuteKeyUp(Keyboard k await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), ct); KeyboardKeyState.SetKeyUp(key); SimulateKeyboardOverlayState.RemoveHeldKey(keyName); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + } return new UnityCliLoopKeyboardSimulationResult { @@ -283,6 +308,56 @@ private static string NormalizeKeyName(string keyName) return keyName; } + private static UnityCliLoopKeyboardSimulationResult InterruptedPressResult(string keyName) + { + return InterruptedKeyResult(UnityCliLoopKeyboardAction.Press, keyName); + } + + private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( + UnityCliLoopKeyboardAction action, + string keyName) + { + UnityCliLoopKeyboardSimulationResult result = new() + { + Success = true, + Message = $"Keyboard input stopped because Unity paused during Debug Break inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", + Action = action.ToString(), + KeyName = keyName, + InterruptedByDebugBreak = true + }; + AttachDebugBreakHit(result); + return result; + } + + private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult result) + { + if (result == null) + { + Debug.Assert(false, "result must not be null"); + return; + } + + UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + if (snapshot == null) + { + return; + } + + if (!snapshot.IsHit) + { + return; + } + + string? snapshotId = snapshot.Id; + if (string.IsNullOrEmpty(snapshotId)) + { + return; + } + + result.DebugBreakId = snapshotId; + result.DebugBreakHitCount = snapshot.HitCount; + } + private static async Task FinalizePressOverlay(CancellationToken ct) { if (ct.IsCancellationRequested) @@ -309,12 +384,19 @@ private static async Task ReleaseKeyStateIfPossible(Keyboard keyboard, Key key) return; } + if (EditorApplication.isPaused) + { + KeyboardKeyState.SetKeyState(keyboard, key, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + return; + } + await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), CancellationToken.None); } private static bool CanInjectKeyboardState(Keyboard keyboard) { - return EditorApplication.isPlaying && !EditorApplication.isPaused && Keyboard.current == keyboard; + return EditorApplication.isPlaying && keyboard != null; } #endif } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index debad2993c..fc65968875 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -14,7 +14,8 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +5. Report what happened ## Tool Reference @@ -43,6 +44,13 @@ Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. +- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. + ### KeyDown/KeyUp Rules - `KeyDown` fails if the key is already held @@ -84,6 +92,9 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key +- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption ## Prerequisites diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef index 1d95197a8a..655fedc003 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef @@ -5,6 +5,7 @@ "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:c956a21f824994ef087b6de566690b3d", "GUID:75469ad4d38634e559750d17036d5f7c" diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs index a7f3ffa646..673a755e84 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs @@ -15,6 +15,9 @@ public class SimulateMouseInputResponse : UnityCliLoopToolResponse public string? Button { get; set; } public float? PositionX { get; set; } public float? PositionY { get; set; } + public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } public SimulateMouseInputResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs index d8a5a49541..d97a39a1e2 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs @@ -57,6 +57,9 @@ private static SimulateMouseInputResponse ToResponse(UnityCliLoopMouseInputSimul Button = result.Button, PositionX = result.PositionX, PositionY = result.PositionY, + InterruptedByDebugBreak = result.InterruptedByDebugBreak, + DebugBreakId = result.DebugBreakId, + DebugBreakHitCount = result.DebugBreakHitCount, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 80fb62410d..5ae729df61 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -92,6 +92,8 @@ public async Task SimulateMouseInputAsyn }; } + UloopPausePointRegistry.ClearLatestHitSnapshot(); + VibeLogger.LogInfo( "simulate_mouse_input_start", "Mouse input simulation started", @@ -182,13 +184,14 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( MouseInputState.SetButtonDown(button); SimulateMouseInputOverlayState.SetButtonHeld(button, true); bool pressWasApplied = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetButtonState(mouse, button, true), ct); pressWasApplied = true; - await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); } finally { @@ -201,10 +204,25 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( { MouseInputState.SetButtonUp(button); } - SimulateMouseInputOverlayState.SetButtonHeld(button, false); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + } + + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedButtonResult( + UnityCliLoopMouseInputAction.Click, + buttonName, + inputPos); } - string durationText = request.Duration > 0f ? $" for {request.Duration:F1}s" : ""; + string durationText = request.Duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(request.Duration)}s" : ""; return new UnityCliLoopMouseInputSimulationResult { Success = true, @@ -242,6 +260,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( MouseInputState.SetButtonDown(button); SimulateMouseInputOverlayState.SetButtonHeld(button, true); bool pressWasApplied = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { @@ -251,7 +270,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( // Hold for at least the minimum observation frames so the press // is visible to game code, then continue until duration elapses. - await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); } finally { @@ -260,13 +279,28 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( await ReleaseButtonIfPossible(mouse, button); } MouseInputState.SetButtonUp(button); - SimulateMouseInputOverlayState.SetButtonHeld(button, false); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + } + + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedButtonResult( + UnityCliLoopMouseInputAction.LongPress, + buttonName, + inputPos); } return new UnityCliLoopMouseInputSimulationResult { Success = true, - Message = $"Long-pressed {buttonName} at ({inputPos.x:F1}, {inputPos.y:F1}) for {request.Duration:F1}s", + Message = $"Long-pressed {buttonName} at ({inputPos.x:F1}, {inputPos.y:F1}) for {InputSimulationDurationFormatter.FormatSeconds(request.Duration)}s", Action = UnityCliLoopMouseInputAction.LongPress.ToString(), Button = buttonName, PositionX = inputPos.x, @@ -282,7 +316,12 @@ private async Task ExecuteMoveDelta( await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetDeltaState(mouse, delta), ct); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + return InterruptedActionResult(UnityCliLoopMouseInputAction.MoveDelta); + } return new UnityCliLoopMouseInputSimulationResult { @@ -302,7 +341,12 @@ private async Task ExecuteScroll( await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetScrollState(mouse, scroll), ct); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + return InterruptedActionResult(UnityCliLoopMouseInputAction.Scroll); + } return new UnityCliLoopMouseInputSimulationResult { @@ -346,19 +390,17 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.InjectDelta(mouse, frameDelta), ct); previousT = t; - - if (t >= 1f) + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForRuntimeFrames(1, ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) { - break; + ResetDeltaIfPossible(mouse); + SimulateMouseInputOverlayState.Clear(); + return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); } - // Explicit/manual update completes synchronously, so an extra - // frame delay is needed to prevent the loop from collapsing into - // a single burst. Dynamic/Fixed modes already yield naturally via - // ApplyOnNextConfiguredUpdate's onBeforeUpdate callback. - if (InputUpdateTypeResolver.RequiresExplicitUpdate()) + if (t >= 1f) { - await EditorDelay.DelayFrame(1, ct); + break; } } @@ -374,6 +416,61 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( }; } + private static UnityCliLoopMouseInputSimulationResult InterruptedButtonResult( + UnityCliLoopMouseInputAction action, + string buttonName, + Vector2 inputPos) + { + UnityCliLoopMouseInputSimulationResult result = InterruptedActionResult(action); + result.Button = buttonName; + result.PositionX = inputPos.x; + result.PositionY = inputPos.y; + return result; + } + + private static UnityCliLoopMouseInputSimulationResult InterruptedActionResult( + UnityCliLoopMouseInputAction action) + { + UnityCliLoopMouseInputSimulationResult result = new() + { + Success = true, + Message = "Mouse input stopped because Unity paused during Debug Break inspection. Unity CLI Loop released its held input bookkeeping.", + Action = action.ToString(), + InterruptedByDebugBreak = true + }; + AttachDebugBreakHit(result); + return result; + } + + private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult result) + { + if (result == null) + { + Debug.Assert(false, "result must not be null"); + return; + } + + UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + if (snapshot == null) + { + return; + } + + if (!snapshot.IsHit) + { + return; + } + + string? snapshotId = snapshot.Id; + if (string.IsNullOrEmpty(snapshotId)) + { + return; + } + + result.DebugBreakId = snapshotId; + result.DebugBreakHitCount = snapshot.HitCount; + } + private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) { if (!CanInjectMouseState(mouse)) @@ -381,13 +478,34 @@ private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButto return; } + if (EditorApplication.isPaused) + { + MouseInputState.SetButtonState(mouse, button, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + return; + } + await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetButtonState(mouse, button, false), CancellationToken.None); } + private static void ResetDeltaIfPossible(Mouse mouse) + { + if (!CanInjectMouseState(mouse)) + { + return; + } + + MouseInputState.InjectDelta(mouse, Vector2.zero); + if (EditorApplication.isPaused) + { + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + } + } + private static bool CanInjectMouseState(Mouse mouse) { - return EditorApplication.isPlaying && !EditorApplication.isPaused && Mouse.current == mouse; + return EditorApplication.isPlaying && mouse != null; } private static RuntimeMouseButton ToRuntimeMouseButton(UnityCliLoopMouseButton button) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index da66d0b1fd..78b3f0fa8c 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,8 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +6. Report what happened ## Tool Reference @@ -47,6 +48,13 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. +- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. + ### Global Options (optional) | Option | Description | @@ -106,5 +114,8 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) +- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption -These are the only six fields. There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, and target position are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef index 27e6e0e652..83b30c5ae9 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef @@ -5,6 +5,7 @@ "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:c956a21f824994ef087b6de566690b3d", "GUID:75469ad4d38634e559750d17036d5f7c" diff --git a/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef index d41404befe..5b96c037c7 100644 --- a/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef @@ -9,6 +9,7 @@ "GUID:ee36dcf19655f43c08e0a7009ca7a624", "GUID:1b127da462d854085a0384d517187480", "GUID:54dfc5fd447794df2aa683025462d668", + "GUID:94d8abc693f543a691a4645a5ff42e5c", "GUID:dc412b2cd951d452b8dcec99c3adeb19", "GUID:3b7aa330d87dc452293296dd5830859b", "GUID:9ed21973545c44e28af522b049dec47d", diff --git a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs index d3c9d8ccec..d66a8c6332 100644 --- a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs +++ b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs @@ -15,6 +15,8 @@ public static bool IsInternalCommand(string commandName) { return commandName == UnityCliLoopConstants.COMMAND_NAME_GET_VERSION || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_GET_DEBUG_BREAK_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS; } @@ -37,6 +39,16 @@ public static UnityCliLoopToolResponse Execute(string commandName, JToken params return CompileStatusBridgeCommand.Execute(paramsToken); } + if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_DEBUG_BREAK_STATUS) + { + return PausePointStatusBridgeCommand.Execute(paramsToken); + } + + if (commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS) + { + return PausePointStatusBridgeCommand.Clear(paramsToken); + } + throw new ArgumentException($"Unknown internal bridge command: {commandName}", nameof(commandName)); } } diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs index 8ed8d81baf..4a395b0d1d 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs @@ -394,6 +394,8 @@ private static string CreateErrorResponse(object id, Exception ex) errorData = new ServerBusyErrorData( busyEx.RunningToolName, busyEx.RequestedToolName, + busyEx.IsPlaying, + busyEx.IsPaused, exceptionResponse.Explanation ?? ex.Message); } else @@ -501,14 +503,24 @@ public class ServerBusyErrorData : JsonRpcErrorData public string requestedToolName { get; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? isPlaying { get; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? isPaused { get; } + public ServerBusyErrorData( string runningToolName, string requestedToolName, + bool? isPlaying, + bool? isPaused, string message) : base(message) { this.runningToolName = runningToolName; this.requestedToolName = requestedToolName; + this.isPlaying = isPlaying; + this.isPaused = isPaused; } } diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs new file mode 100644 index 0000000000..6c1000cda4 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -0,0 +1,80 @@ +using System; +using Newtonsoft.Json.Linq; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Serves CLI-only debug break status and cleanup requests outside the normal tool slot. + /// + internal static class PausePointStatusBridgeCommand + { + private const string IdParamName = "Id"; + + public static PausePointStatusResponse Execute(JToken paramsToken) + { + string id = ReadId(paramsToken); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id); + return PausePointStatusResponse.FromSnapshot(snapshot); + } + + public static PausePointStatusResponse Clear(JToken paramsToken) + { + string id = ReadId(paramsToken); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear(id); + return PausePointStatusResponse.FromSnapshot(snapshot); + } + + private static string ReadId(JToken paramsToken) + { + if (paramsToken is not JObject paramsObject) + { + return string.Empty; + } + + JToken idToken = paramsObject.GetValue(IdParamName, StringComparison.OrdinalIgnoreCase); + return idToken?.ToString() ?? string.Empty; + } + } + + /// + /// Debug break status payload returned by the internal CLI polling bridge command. + /// + public class PausePointStatusResponse : UnityCliLoopToolResponse + { + public string Id { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool IsEnabled { get; set; } + public bool IsHit { get; set; } + public int HitCount { get; set; } + public int TimeoutSeconds { get; set; } + public long ElapsedSinceEnabledMilliseconds { get; set; } + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public string Message { get; set; } = string.Empty; + + internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointStatusResponse + { + Id = snapshot.Id, + Status = snapshot.Status, + IsEnabled = snapshot.IsEnabled, + IsHit = snapshot.IsHit, + HitCount = snapshot.HitCount, + TimeoutSeconds = snapshot.TimeoutSeconds, + ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, + IsPlaying = snapshot.IsPlaying, + IsPaused = snapshot.IsPaused, + Message = snapshot.Message + }; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta new file mode 100644 index 0000000000..1e38ac0d92 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bc8ad5e99ba7447b80713de893faab9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef index 191f71e2f8..7da9fee33e 100644 --- a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef +++ b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef @@ -5,7 +5,8 @@ "GUID:214998e563c124e8a88199b2dd1f522d", "GUID:5c4588558a3624eacbce0f50007cf1eb", "GUID:fc3fd32eddbee40e39c2d76dc184957b", - "GUID:5079a8d3a72924a81aa1cbc25f65ed1b" + "GUID:5079a8d3a72924a81aa1cbc25f65ed1b", + "GUID:527f26a36b5043c2bd4d4036d04cd76d" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index d92ca6a235..b5eba119f9 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -61,6 +61,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo // Command name constants public const string TOOL_NAME_COMPILE = "compile"; public const string TOOL_NAME_CONTROL_PLAY_MODE = "control-play-mode"; + public const string TOOL_NAME_ENABLE_DEBUG_BREAK = "enable-debug-break"; + public const string TOOL_NAME_CLEAR_DEBUG_BREAK = "clear-debug-break"; public const string TOOL_NAME_EXECUTE_DYNAMIC_CODE = "execute-dynamic-code"; public const string TOOL_NAME_GET_HIERARCHY = "get-hierarchy"; public const string TOOL_NAME_GET_LOGS = "get-logs"; @@ -68,6 +70,10 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; + public const string COMMAND_NAME_WAIT_FOR_DEBUG_BREAK = "wait-for-debug-break"; + public const string COMMAND_NAME_DEBUG_BREAK_STATUS = "debug-break-status"; + public const string COMMAND_NAME_GET_DEBUG_BREAK_STATUS = "get-debug-break-status"; + public const string COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS = "clear-debug-break-status"; // Optional package names public const string PACKAGE_NAME_TEST_FRAMEWORK = "com.unity.test-framework"; diff --git a/Packages/src/Runtime/PausePoints.meta b/Packages/src/Runtime/PausePoints.meta new file mode 100644 index 0000000000..8f6cda50cc --- /dev/null +++ b/Packages/src/Runtime/PausePoints.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ffc175d8085b462e953bf596f441c344 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs new file mode 100644 index 0000000000..0fe20cf7d3 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..73cdf1d046 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d53a34cf2974d2d9657844299a17702 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs new file mode 100644 index 0000000000..cd650f59c3 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -0,0 +1,355 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Stores enabled pause point state for the current Editor domain. + /// + internal static class UloopPausePointRegistry + { + public const int DefaultTimeoutSeconds = 30; + + private static readonly Dictionary Entries = new(); + private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); + private static Func _nowProvider = () => DateTime.UtcNow; + private static UloopPausePointSnapshot _latestHitSnapshot; + + public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be greater than zero"); + + DateTime now = NowUtc(); + UloopPausePointEntry entry = new(id, timeoutSeconds, now); + Entries[id] = entry; + ClearLatestHitSnapshotIfMatches(id); + return entry.ToSnapshot(now, _pauseController); + } + + public static UloopPausePointSnapshot Clear(string id) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + + DateTime now = NowUtc(); + if (!Entries.ContainsKey(id)) + { + return UloopPausePointSnapshot.NotEnabled(id, _pauseController); + } + + UloopPausePointEntry entry = Entries[id]; + entry.MarkCleared(); + ClearLatestHitSnapshotIfMatches(id); + return entry.ToSnapshot(now, _pauseController); + } + + public static UloopPausePointClearAllResult ClearAll() + { + DateTime now = NowUtc(); + int clearedCount = 0; + foreach (UloopPausePointEntry entry in Entries.Values) + { + if (entry.Status == UloopPausePointStatus.Cleared) + { + continue; + } + + entry.MarkCleared(); + clearedCount++; + } + ClearLatestHitSnapshot(); + + return new UloopPausePointClearAllResult(clearedCount, now); + } + + public static UloopPausePointSnapshot GetStatus(string id) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + + DateTime now = NowUtc(); + if (!Entries.ContainsKey(id)) + { + return UloopPausePointSnapshot.NotEnabled(id, _pauseController); + } + + UloopPausePointEntry entry = Entries[id]; + entry.ExpireIfNeeded(now); + return entry.ToSnapshot(now, _pauseController); + } + + public static UloopPausePointSnapshot Hit(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + Debug.Assert(false, "id must not be null or empty"); + return UloopPausePointSnapshot.NotEnabled(id ?? string.Empty, _pauseController); + } + + DateTime now = NowUtc(); + if (!Entries.ContainsKey(id)) + { + return UloopPausePointSnapshot.NotEnabled(id, _pauseController); + } + + UloopPausePointEntry entry = Entries[id]; + entry.ExpireIfNeeded(now); + if (!entry.IsEnabled) + { + return entry.ToSnapshot(now, _pauseController); + } + + _pauseController.Pause(); + entry.RecordHit(now, _pauseController.IsPlaying, _pauseController.IsPaused); + UloopPausePointSnapshot snapshot = entry.ToSnapshot(now, _pauseController); + _latestHitSnapshot = snapshot; + return snapshot; + } + + public static UloopPausePointSnapshot GetLatestHitSnapshot() + { + return _latestHitSnapshot; + } + + public static void ClearLatestHitSnapshot() + { + _latestHitSnapshot = null; + } + + private static void ClearLatestHitSnapshotIfMatches(string id) + { + if (_latestHitSnapshot == null) + { + return; + } + + if (_latestHitSnapshot.Id != id) + { + return; + } + + _latestHitSnapshot = null; + } + + public static void ConfigureForTests(IUloopPausePointPauseController pauseController, Func nowProvider) + { + Debug.Assert(pauseController != null, "pauseController must not be null"); + Debug.Assert(nowProvider != null, "nowProvider must not be null"); + + _pauseController = pauseController; + _nowProvider = nowProvider; + } + + public static void ResetForTests() + { + Entries.Clear(); + _latestHitSnapshot = null; + _pauseController = new UnityEditorPausePointPauseController(); + _nowProvider = () => DateTime.UtcNow; + } + + private static DateTime NowUtc() + { + DateTime now = _nowProvider(); + return now.Kind == DateTimeKind.Utc ? now : now.ToUniversalTime(); + } + } + + /// + /// Provides the current pause state and performs the actual Unity Editor pause request. + /// + internal interface IUloopPausePointPauseController + { + bool IsPlaying { get; } + bool IsPaused { get; } + void Pause(); + } + + /// + /// Adapts pause point hits to UnityEditor.EditorApplication state. + /// + internal sealed class UnityEditorPausePointPauseController : IUloopPausePointPauseController + { + public bool IsPlaying => EditorApplication.isPlaying; + public bool IsPaused => EditorApplication.isPaused; + + public void Pause() + { + EditorApplication.isPaused = true; + } + } + + /// + /// Immutable view of one pause point state returned to tools and the CLI bridge. + /// + internal sealed class UloopPausePointSnapshot + { + public UloopPausePointSnapshot( + string id, + string status, + bool isEnabled, + bool isHit, + int hitCount, + int timeoutSeconds, + long elapsedMilliseconds, + bool isPlaying, + bool isPaused, + string message) + { + Id = id ?? string.Empty; + Status = status ?? UloopPausePointStatus.NotEnabled; + IsEnabled = isEnabled; + IsHit = isHit; + HitCount = hitCount; + TimeoutSeconds = timeoutSeconds; + ElapsedSinceEnabledMilliseconds = elapsedMilliseconds; + IsPlaying = isPlaying; + IsPaused = isPaused; + Message = message ?? string.Empty; + } + + public string Id { get; } + public string Status { get; } + public bool IsEnabled { get; } + public bool IsHit { get; } + public int HitCount { get; } + public int TimeoutSeconds { get; } + public long ElapsedSinceEnabledMilliseconds { get; } + public bool IsPlaying { get; } + public bool IsPaused { get; } + public string Message { get; } + + public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPauseController pauseController) + { + Debug.Assert(pauseController != null, "pauseController must not be null"); + + return new UloopPausePointSnapshot( + id, + UloopPausePointStatus.NotEnabled, + false, + false, + 0, + 0, + 0, + pauseController.IsPlaying, + pauseController.IsPaused, + "Debug break is not enabled."); + } + } + + /// + /// Reports the number of pause point entries cleared by a bulk clear request. + /// + internal sealed class UloopPausePointClearAllResult + { + public UloopPausePointClearAllResult(int clearedCount, DateTime clearedAtUtc) + { + ClearedCount = clearedCount; + ClearedAtUtc = clearedAtUtc; + } + + public int ClearedCount { get; } + public DateTime ClearedAtUtc { get; } + } + + /// + /// Owns mutable state for one enabled pause point id. + /// + internal sealed class UloopPausePointEntry + { + public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc) + { + Id = id; + TimeoutSeconds = timeoutSeconds; + EnabledAtUtc = enabledAtUtc; + ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds); + Status = UloopPausePointStatus.Enabled; + IsEnabled = true; + Message = "Debug break enabled."; + } + + public string Id { get; } + public int TimeoutSeconds { get; } + public DateTime EnabledAtUtc { get; } + public DateTime ExpiresAtUtc { get; } + public string Status { get; private set; } + public bool IsEnabled { get; private set; } + public int HitCount { get; private set; } + public DateTime HitAtUtc { get; private set; } + public bool IsPlayingAtHit { get; private set; } + public bool IsPausedAtHit { get; private set; } + public string Message { get; private set; } + + public void ExpireIfNeeded(DateTime nowUtc) + { + if (!IsEnabled) + { + return; + } + + if (nowUtc < ExpiresAtUtc) + { + return; + } + + IsEnabled = false; + Status = UloopPausePointStatus.Expired; + Message = "Debug break expired before it was hit."; + } + + public void MarkCleared() + { + IsEnabled = false; + Status = UloopPausePointStatus.Cleared; + Message = "Debug break cleared."; + } + + public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) + { + HitCount++; + HitAtUtc = nowUtc; + IsPlayingAtHit = isPlaying; + IsPausedAtHit = isPaused; + IsEnabled = false; + Status = UloopPausePointStatus.Hit; + Message = "Debug break hit; Unity pause was requested."; + } + + public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPauseController pauseController) + { + Debug.Assert(pauseController != null, "pauseController must not be null"); + + bool isHit = Status == UloopPausePointStatus.Hit; + bool isPlaying = isHit ? IsPlayingAtHit : pauseController.IsPlaying; + bool isPaused = isHit ? IsPausedAtHit : pauseController.IsPaused; + long elapsedMilliseconds = Math.Max(0, (long)(nowUtc - EnabledAtUtc).TotalMilliseconds); + + return new UloopPausePointSnapshot( + Id, + Status, + IsEnabled, + isHit, + HitCount, + TimeoutSeconds, + elapsedMilliseconds, + isPlaying, + isPaused, + Message); + } + } + + /// + /// Centralizes status names shared by Editor tools and the native CLI. + /// + internal static class UloopPausePointStatus + { + public const string NotEnabled = "NotEnabled"; + public const string Enabled = "Enabled"; + public const string Hit = "Hit"; + public const string Expired = "Expired"; + public const string Cleared = "Cleared"; + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta new file mode 100644 index 0000000000..b57442209b --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f744d2d95214a71ab1dc79ac5a06a32 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef new file mode 100644 index 0000000000..4089c94fc9 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef @@ -0,0 +1,14 @@ +{ + "name": "UnityCLILoop.PausePoints.Runtime", + "rootNamespace": "io.github.hatayama.UnityCliLoop.Runtime", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta new file mode 100644 index 0000000000..73bfd49818 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 527f26a36b5043c2bd4d4036d04cd76d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs new file mode 100644 index 0000000000..5c51e866fa --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Provides Editor-only debug helpers for Unity CLI Loop workflows. + /// + public static class UnityCliLoopDebug + { + /// + /// Breaks at a named marker when the Editor has enabled the same id. + /// + [Conditional("UNITY_EDITOR")] + public static void Break(string id) + { +#if UNITY_EDITOR + UloopPausePointRegistry.Hit(id); +#endif + } + } +} diff --git a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta new file mode 100644 index 0000000000..3e13a26690 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67fae0568edb47b1a0644686db4874aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/contract.json b/cli/contract.json index 06e57c6b20..f57f0693c2 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.25" + "cliVersion": "3.0.0-beta.26" } diff --git a/cli/internal/cli/busy_status.go b/cli/internal/cli/busy_status.go index 22e8c8e415..5f26618d20 100644 --- a/cli/internal/cli/busy_status.go +++ b/cli/internal/cli/busy_status.go @@ -9,19 +9,63 @@ import ( const cliStatusBusy = "Busy" type cliStatusEnvelope struct { - Status string `json:"Status"` - Message string `json:"Message"` + Status string `json:"Status"` + Message string `json:"Message"` + RunningToolName string `json:"RunningToolName,omitempty"` + RequestedToolName string `json:"RequestedToolName,omitempty"` + IsPlaying *bool `json:"IsPlaying,omitempty"` + IsPaused *bool `json:"IsPaused,omitempty"` } -func writeBusyStatusEnvelope(writer io.Writer, message string) { +type serverBusyStatusDetails struct { + runningToolName string + requestedToolName string + isPlaying bool + hasIsPlaying bool + isPaused bool + hasIsPaused bool +} + +func writeBusyStatusEnvelope(writer io.Writer, message string, details serverBusyStatusDetails) { encoder := json.NewEncoder(writer) encoder.SetIndent("", " ") _ = encoder.Encode(cliStatusEnvelope{ - Status: cliStatusBusy, - Message: message, + Status: cliStatusBusy, + Message: message, + RunningToolName: details.runningToolName, + RequestedToolName: details.requestedToolName, + IsPlaying: optionalBool(details.hasIsPlaying, details.isPlaying), + IsPaused: optionalBool(details.hasIsPaused, details.isPaused), }) } +func serverBusyStatusDetailsFromError(err cliError) serverBusyStatusDetails { + data, ok := err.Details["data"].(map[string]any) + if !ok { + return serverBusyStatusDetails{ + requestedToolName: err.Command, + } + } + + isPlaying, hasIsPlaying := rpcBoolData(data, "isPlaying") + isPaused, hasIsPaused := rpcBoolData(data, "isPaused") + return serverBusyStatusDetails{ + runningToolName: rpcStringData(data, "runningToolName"), + requestedToolName: firstNonEmpty(rpcStringData(data, "requestedToolName"), err.Command), + isPlaying: isPlaying, + hasIsPlaying: hasIsPlaying, + isPaused: isPaused, + hasIsPaused: hasIsPaused, + } +} + +func optionalBool(hasValue bool, value bool) *bool { + if !hasValue { + return nil + } + return &value +} + func unityServerBusyMessage(fallback string, data map[string]any, requestedCommand string) string { runningToolName := rpcStringData(data, "runningToolName") requestedToolName := firstNonEmpty(rpcStringData(data, "requestedToolName"), requestedCommand) @@ -41,3 +85,11 @@ func rpcStringData(data map[string]any, key string) string { } return value } + +func rpcBoolData(data map[string]any, key string) (bool, bool) { + value, ok := data[key].(bool) + if !ok { + return false, false + } + return value, true +} diff --git a/cli/internal/cli/command_help.go b/cli/internal/cli/command_help.go index 9cf86f6733..2bbf0e997f 100644 --- a/cli/internal/cli/command_help.go +++ b/cli/internal/cli/command_help.go @@ -211,7 +211,7 @@ func nativeCommandDescription(command string) (string, bool) { func nativeCommandUsesProject(command string) bool { switch command { - case launchCommandName, "list", "sync", "focus-window", skillsCommandName: + case launchCommandName, "list", "sync", "focus-window", skillsCommandName, debugBreakWaitCommandName, debugBreakStatusUserCommandName: return true default: return false diff --git a/cli/internal/cli/command_registry.go b/cli/internal/cli/command_registry.go index 78c7bafb50..927c756efa 100644 --- a/cli/internal/cli/command_registry.go +++ b/cli/internal/cli/command_registry.go @@ -10,6 +10,8 @@ var nativeCommands = []nativeCommandEntry{ {name: "list", description: "Show Unity tools currently exposed by the Editor"}, {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, + {name: "wait-for-debug-break", description: "Wait until a named UnityCliLoopDebug.Break marker pauses Unity"}, + {name: "debug-break-status", description: "Show the state of a named UnityCliLoopDebug.Break marker"}, {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "install", description: "Configure the global uloop launcher binary"}, diff --git a/cli/internal/cli/completion.go b/cli/internal/cli/completion.go index e9be29df91..1786d8d8ca 100644 --- a/cli/internal/cli/completion.go +++ b/cli/internal/cli/completion.go @@ -24,20 +24,6 @@ const ( pwshProfileSubpath = "Documents/PowerShell/Microsoft.PowerShell_profile.ps1" ) -var nativeCommandOptions = map[string][]string{ - completionCommand: {installCompletionFlag, shellFlag}, - launchCommandName: { - "--" + projectPathFlagName, - "--delete-recovery", - "--max-depth", - "--platform", - "--quit", - "--restart", - }, - installCommandName: {"--" + installDirFlagName}, - updateCommandName: {"--" + updateToVersionFlagName}, -} - func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Writer, stderr io.Writer) (bool, int) { if len(args) == 0 { return false, 0 diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go new file mode 100644 index 0000000000..1fd89b3a51 --- /dev/null +++ b/cli/internal/cli/completion_options.go @@ -0,0 +1,24 @@ +package cli + +var nativeCommandOptions = map[string][]string{ + completionCommand: {installCompletionFlag, shellFlag}, + launchCommandName: { + "--" + projectPathFlagName, + "--delete-recovery", + "--max-depth", + "--platform", + "--quit", + "--restart", + }, + installCommandName: {"--" + installDirFlagName}, + updateCommandName: {"--" + updateToVersionFlagName}, + debugBreakWaitCommandName: { + "--" + debugBreakIDFlagName, + "--" + debugBreakTimeoutFlagName, + "--" + projectPathFlagName, + }, + debugBreakStatusUserCommandName: { + "--" + debugBreakIDFlagName, + "--" + projectPathFlagName, + }, +} diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go new file mode 100644 index 0000000000..ba68eaf448 --- /dev/null +++ b/cli/internal/cli/debug_break_wait.go @@ -0,0 +1,494 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +const ( + debugBreakWaitCommandName = "wait-for-debug-break" + debugBreakStatusUserCommandName = "debug-break-status" + debugBreakStatusCommandName = "get-debug-break-status" + debugBreakClearStatusCommandName = "clear-debug-break-status" + debugBreakIDFlagName = "id" + debugBreakTimeoutFlagName = "timeout-seconds" + debugBreakDefaultTimeoutSeconds = 30 + debugBreakStatusProbeTimeout = 5 * time.Second + debugBreakStatusEnabled = "Enabled" + debugBreakStatusHit = "Hit" + debugBreakStatusNotEnabled = "NotEnabled" + debugBreakStatusExpired = "Expired" + debugBreakStatusCleared = "Cleared" + debugBreakFinalStatusProbeTimeout = 250 * time.Millisecond +) + +var ( + debugBreakStatusPoll = 50 * time.Millisecond + queryDebugBreakStatus = queryDebugBreakStatusFromUnity + clearDebugBreakStatus = clearDebugBreakStatusFromUnity +) + +type waitForDebugBreakOptions struct { + id string + timeoutSeconds int + timeout time.Duration +} + +type debugBreakStatusOptions struct { + id string +} + +type debugBreakStatusResponse struct { + Id string `json:"Id"` + Status string `json:"Status"` + IsEnabled bool `json:"IsEnabled"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + Message string `json:"Message"` +} + +type debugBreakWaitState string + +const ( + debugBreakWaitStateHit debugBreakWaitState = "hit" + debugBreakWaitStateTimeout debugBreakWaitState = "timeout" + debugBreakWaitStateNotEnabled debugBreakWaitState = "not_enabled" + debugBreakWaitStateExpired debugBreakWaitState = "expired" + debugBreakWaitStateCleared debugBreakWaitState = "cleared" +) + +func runWaitForDebugBreakCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + options, err := parseWaitForDebugBreakOptions(args) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakWaitCommandName, + }) + return 1 + } + + return runWaitForDebugBreak(ctx, connection, options, stdout, stderr) +} + +func runDebugBreakStatusCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + options, err := parseDebugBreakStatusOptions(args) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakStatusUserCommandName, + }) + return 1 + } + + response, err := queryDebugBreakStatus(ctx, connection, options.id) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakStatusUserCommandName, + }) + return 1 + } + + result, err := json.Marshal(response) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakStatusUserCommandName, + }) + return 1 + } + + writeJSON(stdout, result) + return 0 +} + +func runWaitForDebugBreak( + ctx context.Context, + connection unityipc.Connection, + options waitForDebugBreakOptions, + stdout io.Writer, + stderr io.Writer, +) int { + startedAt := time.Now() + spinner := newToolSpinner(stderr, debugBreakWaitCommandName) + response, state, err := waitForDebugBreak(ctx, connection, options) + spinner.Stop() + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakWaitCommandName, + }) + return 1 + } + + if state == debugBreakWaitStateHit { + result, marshalErr := json.Marshal(response) + if marshalErr != nil { + writeClassifiedError(stderr, marshalErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakWaitCommandName, + }) + return 1 + } + writeJSON(stdout, result) + writeDebugTiming(stderr, debugBreakWaitCommandName, time.Since(startedAt), unityipc.UnitySendOutcome{}) + return 0 + } + + if state == debugBreakWaitStateTimeout { + clearDebugBreakAfterWaitTimeout(ctx, connection, options.id) + } + + writeErrorEnvelope(stderr, debugBreakWaitError(connection.ProjectRoot, options, response, state)) + return 1 +} + +func parseWaitForDebugBreakOptions(args []string) (waitForDebugBreakOptions, error) { + options := waitForDebugBreakOptions{ + timeoutSeconds: debugBreakDefaultTimeoutSeconds, + timeout: time.Duration(debugBreakDefaultTimeoutSeconds) * time.Second, + } + + for index := 0; index < len(args); index++ { + arg := args[index] + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return waitForDebugBreakOptions{}, err + } + + switch name { + case debugBreakIDFlagName: + options.id = value + case debugBreakTimeoutFlagName: + timeoutSeconds, parseErr := parseDebugBreakTimeoutSeconds(value) + if parseErr != nil { + return waitForDebugBreakOptions{}, parseErr + } + options.timeoutSeconds = timeoutSeconds + options.timeout = time.Duration(timeoutSeconds) * time.Second + default: + return waitForDebugBreakOptions{}, &argumentError{ + message: "Unknown option for wait-for-debug-break: --" + name, + option: "--" + name, + command: debugBreakWaitCommandName, + nextActions: []string{"Run `uloop wait-for-debug-break --help` to inspect supported options."}, + } + } + + if consumedNext { + index++ + } + } + + if options.id == "" { + return waitForDebugBreakOptions{}, &argumentError{ + message: "Missing required option: --id", + option: "--" + debugBreakIDFlagName, + expectedType: "value", + command: debugBreakWaitCommandName, + nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, + } + } + + return options, nil +} + +func parseDebugBreakStatusOptions(args []string) (debugBreakStatusOptions, error) { + options := debugBreakStatusOptions{} + + for index := 0; index < len(args); index++ { + arg := args[index] + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return debugBreakStatusOptions{}, err + } + + switch name { + case debugBreakIDFlagName: + options.id = value + default: + return debugBreakStatusOptions{}, &argumentError{ + message: "Unknown option for debug-break-status: --" + name, + option: "--" + name, + command: debugBreakStatusUserCommandName, + nextActions: []string{"Run `uloop debug-break-status --help` to inspect supported options."}, + } + } + + if consumedNext { + index++ + } + } + + if options.id == "" { + return debugBreakStatusOptions{}, &argumentError{ + message: "Missing required option: --id", + option: "--" + debugBreakIDFlagName, + expectedType: "value", + command: debugBreakStatusUserCommandName, + nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, + } + } + + return options, nil +} + +func parseDebugBreakTimeoutSeconds(value string) (int, error) { + timeoutSeconds, err := strconv.Atoi(value) + if err != nil || timeoutSeconds <= 0 { + return 0, invalidValueArgumentError("--"+debugBreakTimeoutFlagName, value, "positive integer") + } + return timeoutSeconds, nil +} + +func waitForDebugBreak( + ctx context.Context, + connection unityipc.Connection, + options waitForDebugBreakOptions, +) (debugBreakStatusResponse, debugBreakWaitState, error) { + waitContext, cancel := context.WithTimeout(ctx, options.timeout) + defer cancel() + + lastResponse := debugBreakStatusResponse{Id: options.id} + var lastErr error + hasResponse := false + for { + response, err := queryDebugBreakStatus(waitContext, connection, options.id) + if err == nil { + lastResponse = response + hasResponse = true + state := debugBreakWaitStateForStatus(response.Status) + if state != "" { + return response, state, nil + } + } else { + lastErr = err + } + + select { + case <-waitContext.Done(): + if ctx.Err() != nil { + return lastResponse, "", ctx.Err() + } + finalResponse, finalState, hasFinalResponse, finalErr := queryDebugBreakStatusAtTimeout(ctx, connection, options.id) + if hasFinalResponse { + lastResponse = finalResponse + hasResponse = true + if finalState != "" { + return finalResponse, finalState, nil + } + } else if lastErr == nil { + lastErr = finalErr + } + if hasResponse { + return lastResponse, debugBreakWaitStateTimeout, nil + } + if lastErr != nil { + return lastResponse, "", fmt.Errorf("timed out waiting for debug break status: %w", lastErr) + } + return lastResponse, debugBreakWaitStateTimeout, nil + case <-time.After(debugBreakStatusPoll): + } + } +} + +func queryDebugBreakStatusAtTimeout( + ctx context.Context, + connection unityipc.Connection, + id string, +) (debugBreakStatusResponse, debugBreakWaitState, bool, error) { + finalContext, cancel := context.WithTimeout(ctx, debugBreakFinalStatusProbeTimeout) + defer cancel() + + response, err := queryDebugBreakStatus(finalContext, connection, id) + if err != nil { + return debugBreakStatusResponse{}, "", false, err + } + + return response, debugBreakWaitStateForStatus(response.Status), true, nil +} + +func debugBreakWaitStateForStatus(status string) debugBreakWaitState { + switch status { + case debugBreakStatusHit: + return debugBreakWaitStateHit + case debugBreakStatusNotEnabled: + return debugBreakWaitStateNotEnabled + case debugBreakStatusExpired: + return debugBreakWaitStateExpired + case debugBreakStatusCleared: + return debugBreakWaitStateCleared + case debugBreakStatusEnabled: + return "" + default: + return "" + } +} + +func queryDebugBreakStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (debugBreakStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + debugBreakStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return debugBreakStatusResponse{}, err + } + + response := debugBreakStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return debugBreakStatusResponse{}, err + } + return response, nil +} + +func clearDebugBreakStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (debugBreakStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + debugBreakClearStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return debugBreakStatusResponse{}, err + } + + response := debugBreakStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return debugBreakStatusResponse{}, err + } + return response, nil +} + +func clearDebugBreakAfterWaitTimeout(ctx context.Context, connection unityipc.Connection, id string) { + clearContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) + defer cancel() + _, _ = clearDebugBreakStatus(clearContext, connection, id) +} + +func debugBreakWaitError( + projectRoot string, + options waitForDebugBreakOptions, + response debugBreakStatusResponse, + state debugBreakWaitState, +) cliError { + switch state { + case debugBreakWaitStateNotEnabled: + return debugBreakStateError( + errorCodeDebugBreakNotEnabled, + "Debug break is not enabled.", + projectRoot, + options, + response, + false) + case debugBreakWaitStateExpired: + return debugBreakStateError( + errorCodeDebugBreakExpired, + "Debug break expired before it was hit.", + projectRoot, + options, + response, + true) + case debugBreakWaitStateCleared: + return debugBreakStateError( + errorCodeDebugBreakCleared, + "Debug break was cleared before it was hit.", + projectRoot, + options, + response, + true) + default: + return debugBreakStateError( + errorCodeDebugBreakWaitTimeout, + fmt.Sprintf("Debug break was not hit within %ds.", options.timeoutSeconds), + projectRoot, + options, + response, + true) + } +} + +func debugBreakStateError( + errorCode string, + message string, + projectRoot string, + options waitForDebugBreakOptions, + response debugBreakStatusResponse, + retryable bool, +) cliError { + return cliError{ + ErrorCode: errorCode, + Phase: errorPhaseResponseWaiting, + Message: message, + Retryable: retryable, + SafeToRetry: retryable, + ProjectRoot: projectRoot, + Command: debugBreakWaitCommandName, + NextActions: []string{ + "Run `uloop enable-debug-break --id ` before waiting.", + "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", + "Check `details.status`, `details.isPlaying`, `details.isPaused`, `details.elapsedSinceEnabledMilliseconds`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", + "If the marker is inside a custom asmdef, add a reference to `UnityCLILoop.PausePoints.Runtime`.", + }, + Details: map[string]any{ + "id": options.id, + "status": response.Status, + "hitCount": response.HitCount, + "timeoutSeconds": options.timeoutSeconds, + "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, + "isPlaying": response.IsPlaying, + "isPaused": response.IsPaused, + "remainingMilliseconds": debugBreakRemainingMilliseconds(options, response), + "markerMessage": response.Message, + }, + } +} + +func debugBreakRemainingMilliseconds(options waitForDebugBreakOptions, response debugBreakStatusResponse) int64 { + timeoutSeconds := response.TimeoutSeconds + if timeoutSeconds <= 0 { + return 0 + } + + totalMilliseconds := int64(timeoutSeconds) * int64(time.Second/time.Millisecond) + remainingMilliseconds := totalMilliseconds - response.ElapsedSinceEnabledMilliseconds + if remainingMilliseconds <= 0 { + return 0 + } + return remainingMilliseconds +} diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go new file mode 100644 index 0000000000..a690ec5d57 --- /dev/null +++ b/cli/internal/cli/debug_break_wait_test.go @@ -0,0 +1,397 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +// Verifies wait-for-debug-break polls until Unity reports the marker hit. +func TestWaitForDebugBreakReturnsHitAfterEnabledStatus(t *testing.T) { + originalQuery := queryDebugBreakStatus + originalPoll := debugBreakStatusPoll + debugBreakStatusPoll = time.Millisecond + defer func() { + queryDebugBreakStatus = originalQuery + debugBreakStatusPoll = originalPoll + }() + + responses := []debugBreakStatusResponse{ + {Id: "jump", Status: debugBreakStatusEnabled, IsEnabled: true}, + {Id: "jump", Status: debugBreakStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, + } + requestCount := 0 + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + if id != "jump" { + t.Fatalf("id mismatch: %s", id) + } + response := responses[requestCount] + requestCount++ + return response, nil + } + + response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForDebugBreak failed: %v", err) + } + if state != debugBreakWaitStateHit { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != debugBreakStatusHit || response.HitCount != 1 { + t.Fatalf("response mismatch: %#v", response) + } + if requestCount != 2 { + t.Fatalf("request count mismatch: %d", requestCount) + } +} + +// Verifies wait-for-debug-break clears the enabled marker after its own timeout. +func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { + originalQuery := queryDebugBreakStatus + originalClear := clearDebugBreakStatus + originalPoll := debugBreakStatusPoll + debugBreakStatusPoll = time.Millisecond + defer func() { + queryDebugBreakStatus = originalQuery + clearDebugBreakStatus = originalClear + debugBreakStatusPoll = originalPoll + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusEnabled, + IsEnabled: true, + TimeoutSeconds: 1, + ElapsedSinceEnabledMilliseconds: 100, + IsPlaying: true, + IsPaused: false, + Message: "Debug break enabled.", + }, nil + } + + clearedID := "" + clearDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + clearedID = id + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) + } + if clearedID != "jump" { + t.Fatalf("cleared id mismatch: %s", clearedID) + } + if !strings.Contains(stderr.String(), errorCodeDebugBreakWaitTimeout) { + t.Fatalf("timeout error missing from stderr: %s", stderr.String()) + } + envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.Details["isPlaying"] != true { + t.Fatalf("isPlaying detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["isPaused"] != false { + t.Fatalf("isPaused detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["markerMessage"] != "Debug break enabled." { + t.Fatalf("markerMessage detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["elapsedSinceEnabledMilliseconds"] != float64(100) { + t.Fatalf("elapsedSinceEnabledMilliseconds detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["remainingMilliseconds"] != float64(900) { + t.Fatalf("remainingMilliseconds detail mismatch: %#v", envelope.Error.Details) + } +} + +// Verifies wait-for-debug-break does one final status probe before treating timeout as missed. +func TestRunWaitForDebugBreakReturnsFinalHitAtTimeoutBoundary(t *testing.T) { + originalQuery := queryDebugBreakStatus + originalClear := clearDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + clearDebugBreakStatus = originalClear + }() + + requestCount := 0 + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + requestCount++ + if requestCount == 1 { + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusEnabled, + IsEnabled: true, + }, nil + } + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusHit, + IsHit: true, + IsPaused: true, + HitCount: 1, + }, nil + } + + clearedID := "" + clearDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + clearedID = id + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + if clearedID != "" { + t.Fatalf("marker should not be cleared after final hit: %s", clearedID) + } + var response debugBreakStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if response.Status != debugBreakStatusHit || response.HitCount != 1 { + t.Fatalf("response mismatch: %#v", response) + } + if requestCount != 2 { + t.Fatalf("request count mismatch: %d", requestCount) + } +} + +// Verifies wait-for-debug-break rejects calls before the marker is enabled. +func TestWaitForDebugBreakReturnsNotEnabledStateImmediately(t *testing.T) { + originalQuery := queryDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusNotEnabled, IsPlaying: true}, nil + } + + response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForDebugBreak failed: %v", err) + } + if state != debugBreakWaitStateNotEnabled { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != debugBreakStatusNotEnabled { + t.Fatalf("response mismatch: %#v", response) + } +} + +// Verifies not-enabled failures use the user-facing enabled terminology. +func TestRunWaitForDebugBreakReportsNotEnabledError(t *testing.T) { + originalQuery := queryDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusNotEnabled, + IsPlaying: true, + IsPaused: false, + Message: "Debug break is not enabled.", + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) + } + envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != errorCodeDebugBreakNotEnabled { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Details["status"] != debugBreakStatusNotEnabled { + t.Fatalf("status detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["isPlaying"] != true || envelope.Error.Details["isPaused"] != false { + t.Fatalf("play state details mismatch: %#v", envelope.Error.Details) + } +} + +// Verifies expired markers report no remaining enabled lifetime. +func TestDebugBreakExpiredErrorReportsNoRemainingTime(t *testing.T) { + response := debugBreakStatusResponse{ + Id: "jump", + Status: debugBreakStatusExpired, + TimeoutSeconds: 1, + ElapsedSinceEnabledMilliseconds: 1200, + IsPlaying: true, + Message: "Debug break expired before it was hit.", + } + + cliErr := debugBreakWaitError("/tmp/MyProject", waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + }, response, debugBreakWaitStateExpired) + + if cliErr.ErrorCode != errorCodeDebugBreakExpired { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if cliErr.Details["remainingMilliseconds"] != int64(0) { + t.Fatalf("remainingMilliseconds detail mismatch: %#v", cliErr.Details) + } +} + +// Verifies disabled native debug-break commands are rejected before Unity dispatch. +func TestRunProjectLocalWaitForDebugBreakRespectsToolSettings(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeToolSettings(t, projectRoot, `{"disabledTools":["wait-for-debug-break"]}`) + t.Chdir(filepath.Dir(projectRoot)) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunProjectLocal( + context.Background(), + []string{"--project-path", projectRoot, debugBreakWaitCommandName, "--id", "jump"}, + &stdout, + &stderr) + + if code != 1 { + t.Fatalf("expected disabled command failure, got %d with stdout %s", code, stdout.String()) + } + envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != errorCodeToolDisabled { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Command != debugBreakWaitCommandName { + t.Fatalf("command mismatch: %#v", envelope.Error) + } +} + +// Verifies wait-for-debug-break requires a marker id. +func TestParseWaitForDebugBreakOptionsRequiresID(t *testing.T) { + _, err := parseWaitForDebugBreakOptions([]string{"--timeout-seconds", "1"}) + + if err == nil { + t.Fatal("expected missing id error") + } + if !strings.Contains(err.Error(), "Missing required option") { + t.Fatalf("error mismatch: %v", err) + } +} + +// Verifies debug-break-status reports the current marker state without waiting for a hit. +func TestRunDebugBreakStatusReturnsCurrentStatus(t *testing.T) { + originalQuery := queryDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusEnabled, IsEnabled: true}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runDebugBreakStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + []string{"--id", "jump"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + var response debugBreakStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if response.Status != debugBreakStatusEnabled { + t.Fatalf("status mismatch: %#v", response) + } +} + +// Verifies debug-break-status requires a marker id. +func TestParseDebugBreakStatusOptionsRequiresID(t *testing.T) { + _, err := parseDebugBreakStatusOptions([]string{}) + + if err == nil { + t.Fatal("expected missing id error") + } + if !strings.Contains(err.Error(), "Missing required option") { + t.Fatalf("error mismatch: %v", err) + } +} + +func parseDebugBreakErrorEnvelope(t *testing.T, payload []byte) cliErrorEnvelope { + t.Helper() + + var envelope cliErrorEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\n%s", err, string(payload)) + } + return envelope +} diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index 0ae8169f76..a2e4313b55 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -21,8 +21,13 @@ const ( errorCodeUnityRPCError = "UNITY_RPC_ERROR" errorCodeUnityServerBusy = "UNITY_SERVER_BUSY" errorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" + errorCodeToolDisabled = "TOOL_DISABLED" errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" errorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" + errorCodeDebugBreakNotEnabled = "DEBUG_BREAK_NOT_ENABLED" + errorCodeDebugBreakWaitTimeout = "DEBUG_BREAK_WAIT_TIMEOUT" + errorCodeDebugBreakExpired = "DEBUG_BREAK_EXPIRED" + errorCodeDebugBreakCleared = "DEBUG_BREAK_CLEARED" errorCodeInternalError = "INTERNAL_ERROR" errorPhaseArgumentParsing = "argument_parsing" @@ -63,7 +68,7 @@ type errorContext struct { func writeErrorEnvelope(writer io.Writer, err cliError) { if err.ErrorCode == errorCodeUnityServerBusy { - writeBusyStatusEnvelope(writer, err.Message) + writeBusyStatusEnvelope(writer, err.Message, serverBusyStatusDetailsFromError(err)) return } encoder := json.NewEncoder(writer) @@ -439,36 +444,6 @@ func unknownCommandError(command string, cache toolsCache, context errorContext) } } -func compileWaitTimeoutError(projectRoot string) cliError { - return cliError{ - ErrorCode: errorCodeCompileWaitTimeout, - Phase: errorPhaseCompileWaiting, - Message: "Compile status wait timed out after 180000ms.", - Retryable: true, - SafeToRetry: true, - ProjectRoot: projectRoot, - Command: compileCommandName, - NextActions: []string{ - "Retry `uloop compile` after Unity becomes responsive.", - }, - } -} - -func internalCLIError(message string, context errorContext) cliError { - return cliError{ - ErrorCode: errorCodeInternalError, - Phase: errorPhaseExecution, - Message: message, - Retryable: false, - SafeToRetry: false, - ProjectRoot: context.projectRoot, - Command: context.command, - NextActions: []string{ - "Read the message and fix the local environment or command input before retrying.", - }, - } -} - func availableCommandNames(cache toolsCache) []string { seen := map[string]bool{} names := []string{} diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 83f384d978..2136ea5b30 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -218,7 +218,7 @@ func TestClassifyServerBusyRPCError(t *testing.T) { Code: -32603, Message: "Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes.", Data: json.RawMessage( - `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), + `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","isPlaying":true,"isPaused":true,"message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), } cliErr := classifyError(err, errorContext{projectRoot: "/tmp/MyProject", command: "get-logs"}) @@ -235,6 +235,13 @@ func TestClassifyServerBusyRPCError(t *testing.T) { if cliErr.Message != expectedMessage { t.Fatalf("message mismatch: %s", cliErr.Message) } + data, ok := cliErr.Details["data"].(map[string]any) + if !ok { + t.Fatalf("busy data missing: %#v", cliErr.Details) + } + if data["isPlaying"] != true || data["isPaused"] != true { + t.Fatalf("play state mismatch: %#v", data) + } } func TestWriteClassifiedServerBusyRPCErrorWritesBusyStatus(t *testing.T) { @@ -243,7 +250,7 @@ func TestWriteClassifiedServerBusyRPCErrorWritesBusyStatus(t *testing.T) { Code: -32603, Message: "Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes.", Data: json.RawMessage( - `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), + `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","isPlaying":true,"isPaused":true,"message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), } var stderr bytes.Buffer @@ -260,6 +267,15 @@ func TestWriteClassifiedServerBusyRPCErrorWritesBusyStatus(t *testing.T) { if envelope.Message != expectedMessage { t.Fatalf("message mismatch: %#v", envelope) } + if envelope.RunningToolName != "compile" || envelope.RequestedToolName != "get-logs" { + t.Fatalf("tool names mismatch: %#v", envelope) + } + if envelope.IsPlaying == nil || *envelope.IsPlaying != true { + t.Fatalf("isPlaying mismatch: %#v", envelope) + } + if envelope.IsPaused == nil || *envelope.IsPaused != true { + t.Fatalf("isPaused mismatch: %#v", envelope) + } if bytes.Contains(stderr.Bytes(), []byte("Success")) || bytes.Contains(stderr.Bytes(), []byte("errorCode")) { t.Fatalf("busy output should not include error envelope fields: %s", stderr.String()) } @@ -437,7 +453,7 @@ func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { names := availableCommandNames(toolsCache{}) - expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "skills", "completion", "install", "update"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-debug-break", "debug-break-status", "skills", "completion", "install", "update"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/internal/cli/execution_errors.go b/cli/internal/cli/execution_errors.go new file mode 100644 index 0000000000..a44c90549d --- /dev/null +++ b/cli/internal/cli/execution_errors.go @@ -0,0 +1,31 @@ +package cli + +func compileWaitTimeoutError(projectRoot string) cliError { + return cliError{ + ErrorCode: errorCodeCompileWaitTimeout, + Phase: errorPhaseCompileWaiting, + Message: "Compile status wait timed out after 180000ms.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + Command: compileCommandName, + NextActions: []string{ + "Retry `uloop compile` after Unity becomes responsive.", + }, + } +} + +func internalCLIError(message string, context errorContext) cliError { + return cliError{ + ErrorCode: errorCodeInternalError, + Phase: errorPhaseExecution, + Message: message, + Retryable: false, + SafeToRetry: false, + ProjectRoot: context.projectRoot, + Command: context.command, + NextActions: []string{ + "Read the message and fix the local environment or command input before retrying.", + }, + } +} diff --git a/cli/internal/cli/native_tool_settings.go b/cli/internal/cli/native_tool_settings.go new file mode 100644 index 0000000000..0ccf7f7b2a --- /dev/null +++ b/cli/internal/cli/native_tool_settings.go @@ -0,0 +1,25 @@ +package cli + +func isSettingsManagedNativeToolCommand(command string) bool { + switch command { + case debugBreakWaitCommandName, debugBreakStatusUserCommandName: + return true + default: + return false + } +} + +func nativeToolDisabledError(projectRoot string, command string) cliError { + return cliError{ + ErrorCode: errorCodeToolDisabled, + Phase: errorPhaseDispatch, + Message: "Tool is disabled in Unity CLI Loop settings: " + command, + Retryable: false, + SafeToRetry: false, + ProjectRoot: projectRoot, + Command: command, + NextActions: []string{ + "Enable the tool in Unity CLI Loop settings before running this command.", + }, + } +} diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index a830b3a372..5e02fe12d0 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -79,6 +79,11 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder writeClassifiedError(stderr, err, errorContext{command: command}) return 1 } + if isSettingsManagedNativeToolCommand(command) && + isToolDisabledByToolSettings(command, loadDisabledTools(connection.ProjectRoot)) { + writeErrorEnvelope(stderr, nativeToolDisabledError(connection.ProjectRoot, command)) + return 1 + } switch command { case "list": return runList(ctx, connection, stdout, stderr) @@ -86,6 +91,10 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return runSync(ctx, connection, stdout, stderr) case "focus-window": return runFocusWindow(ctx, connection.ProjectRoot, stdout, stderr) + case debugBreakWaitCommandName: + return runWaitForDebugBreakCommand(ctx, connection, commandArgs, stdout, stderr) + case debugBreakStatusUserCommandName: + return runDebugBreakStatusCommand(ctx, connection, commandArgs, stdout, stderr) default: tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) if err != nil { diff --git a/cli/internal/cli/skills_targets.go b/cli/internal/cli/skills_targets.go index cd807bf736..647735e7dd 100644 --- a/cli/internal/cli/skills_targets.go +++ b/cli/internal/cli/skills_targets.go @@ -160,6 +160,13 @@ func isSkillDisabledByToolSettings(skill skillDefinition, disabledTools []string if toolName == "" { return false } + return isToolDisabledByToolSettings(toolName, disabledTools) +} + +func isToolDisabledByToolSettings(toolName string, disabledTools []string) bool { + if len(disabledTools) == 0 { + return false + } for _, disabledTool := range disabledTools { if disabledTool == toolName { return true diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 15575a6171..a725cf2ab6 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.25", + "version": "3.0.0-beta.26", "tools": [ { "name": "compile", @@ -328,6 +328,41 @@ } } }, + { + "name": "enable-debug-break", + "description": "Enable a named UnityCliLoopDebug.Break marker so Unity pauses when that code path is reached", + "inputSchema": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Named debug break id passed to UnityCliLoopDebug.Break" + }, + "TimeoutSeconds": { + "type": "integer", + "description": "Seconds before the enable request expires and stops pausing late hits", + "default": 30 + } + } + } + }, + { + "name": "clear-debug-break", + "description": "Clear one or all named UnityCliLoopDebug.Break markers", + "inputSchema": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Named debug break id to clear" + }, + "All": { + "type": "boolean", + "description": "Clear every active debug break marker" + } + } + } + }, { "name": "simulate-mouse-ui", "description": "Simulate mouse click, long-press, and drag on PlayMode UI elements via EventSystem screen coordinates",