|
6 | 6 | from unittest.mock import AsyncMock, Mock |
7 | 7 |
|
8 | 8 | import pytest |
| 9 | +from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType |
9 | 10 |
|
10 | 11 | from uipath.runtime import ( |
11 | 12 | UiPathBreakpointResult, |
@@ -131,6 +132,48 @@ async def get_schema(self) -> UiPathRuntimeSchema: |
131 | 132 | raise NotImplementedError() |
132 | 133 |
|
133 | 134 |
|
| 135 | +class SuspendedThenSuccessfulRuntime: |
| 136 | + """Mock runtime that suspends once and completes after resume.""" |
| 137 | + |
| 138 | + def __init__(self, trigger: UiPathResumeTrigger) -> None: |
| 139 | + self.trigger = trigger |
| 140 | + self.inputs: list[dict[str, Any] | None] = [] |
| 141 | + self.options: list[UiPathStreamOptions | None] = [] |
| 142 | + |
| 143 | + async def dispose(self) -> None: |
| 144 | + pass |
| 145 | + |
| 146 | + async def execute( |
| 147 | + self, |
| 148 | + input: dict[str, Any] | None = None, |
| 149 | + options: UiPathExecuteOptions | None = None, |
| 150 | + ) -> UiPathRuntimeResult: |
| 151 | + raise NotImplementedError() |
| 152 | + |
| 153 | + async def stream( |
| 154 | + self, |
| 155 | + input: dict[str, Any] | None = None, |
| 156 | + options: UiPathStreamOptions | None = None, |
| 157 | + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: |
| 158 | + self.inputs.append(input) |
| 159 | + self.options.append(options) |
| 160 | + |
| 161 | + if options and options.resume: |
| 162 | + yield UiPathRuntimeResult( |
| 163 | + status=UiPathRuntimeStatus.SUCCESSFUL, |
| 164 | + output={"resumed_with": input}, |
| 165 | + ) |
| 166 | + return |
| 167 | + |
| 168 | + yield UiPathRuntimeResult( |
| 169 | + status=UiPathRuntimeStatus.SUSPENDED, |
| 170 | + trigger=self.trigger, |
| 171 | + ) |
| 172 | + |
| 173 | + async def get_schema(self) -> UiPathRuntimeSchema: |
| 174 | + raise NotImplementedError() |
| 175 | + |
| 176 | + |
134 | 177 | @pytest.mark.asyncio |
135 | 178 | async def test_debug_runtime_streams_and_handles_breakpoints_and_state(): |
136 | 179 | """UiPathDebugRuntime should stream events, handle breakpoints and state updates.""" |
@@ -169,6 +212,51 @@ async def test_debug_runtime_streams_and_handles_breakpoints_and_state(): |
169 | 212 | ) # initial + after breakpoint |
170 | 213 |
|
171 | 214 |
|
| 215 | +@pytest.mark.asyncio |
| 216 | +async def test_debug_runtime_waits_for_timer_resume_without_polling(): |
| 217 | + """Timer triggers should wait for external resume in debug mode.""" |
| 218 | + trigger = UiPathResumeTrigger( |
| 219 | + interrupt_id="timer-interrupt", |
| 220 | + trigger_type=UiPathResumeTriggerType.TIMER, |
| 221 | + payload={"kind": "timeout"}, |
| 222 | + ) |
| 223 | + runtime_impl = SuspendedThenSuccessfulRuntime(trigger) |
| 224 | + bridge = make_debug_bridge_mock() |
| 225 | + cast(AsyncMock, bridge.wait_for_resume).side_effect = [ |
| 226 | + None, |
| 227 | + {"__uipath": {"kind": "timeout"}}, |
| 228 | + ] |
| 229 | + cast(Mock, bridge.get_breakpoints).return_value = [] |
| 230 | + |
| 231 | + trigger_manager = Mock() |
| 232 | + trigger_manager.read_trigger = AsyncMock( |
| 233 | + side_effect=AssertionError("Timer triggers must not be polled") |
| 234 | + ) |
| 235 | + |
| 236 | + debug_runtime = UiPathDebugRuntime( |
| 237 | + delegate=runtime_impl, |
| 238 | + debug_bridge=bridge, |
| 239 | + ) |
| 240 | + debug_runtime.get_resumable_runtime = Mock( # type: ignore[method-assign] |
| 241 | + return_value=Mock(trigger_manager=trigger_manager) |
| 242 | + ) |
| 243 | + |
| 244 | + result = await debug_runtime.execute({}) |
| 245 | + |
| 246 | + assert result.status == UiPathRuntimeStatus.SUCCESSFUL |
| 247 | + assert result.output == { |
| 248 | + "resumed_with": { |
| 249 | + "timer-interrupt": {"__uipath": {"kind": "timeout"}}, |
| 250 | + }, |
| 251 | + } |
| 252 | + assert cast(AsyncMock, bridge.wait_for_resume).await_count == 2 |
| 253 | + cast(AsyncMock, bridge.emit_execution_suspended).assert_awaited_once() |
| 254 | + cast(AsyncMock, bridge.emit_execution_resumed).assert_awaited_once_with( |
| 255 | + {"timer-interrupt": {"__uipath": {"kind": "timeout"}}} |
| 256 | + ) |
| 257 | + trigger_manager.read_trigger.assert_not_awaited() |
| 258 | + |
| 259 | + |
172 | 260 | @pytest.mark.asyncio |
173 | 261 | async def test_debug_runtime_falls_back_when_stream_not_supported(): |
174 | 262 | """If runtime raises UiPathStreamNotSupportedError, we fall back to execute().""" |
|
0 commit comments