Skip to content

Commit a09bbd2

Browse files
committed
fix: add runtime context tests
1 parent 5ac43ca commit a09bbd2

File tree

2 files changed

+148
-1
lines changed

2 files changed

+148
-1
lines changed

src/uipath/runtime/context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ def __exit__(self, exc_type, exc_val, exc_tb):
126126

127127
# Write the execution output to file if requested
128128
if self.output_file:
129+
output_payload = content.get("output", {})
129130
with open(self.output_file, "w") as f:
130-
f.write(content.get("output", "{}"))
131+
json.dump(output_payload, f, default=str)
131132

132133
# Don't suppress exceptions
133134
return False

tests/test_context.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import json
2+
from pathlib import Path
3+
from typing import Any
4+
5+
import pytest
6+
7+
from uipath.runtime.context import UiPathRuntimeContext
8+
from uipath.runtime.errors import (
9+
UiPathErrorCode,
10+
UiPathRuntimeError,
11+
)
12+
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
13+
14+
15+
class DummyLogsInterceptor:
16+
"""Minimal interceptor used to avoid touching real logging in tests."""
17+
18+
def __init__(self, *args: Any, **kwargs: Any) -> None:
19+
self.setup_called = False
20+
self.teardown_called = False
21+
22+
def setup(self) -> None:
23+
self.setup_called = True
24+
25+
def teardown(self) -> None:
26+
self.teardown_called = True
27+
28+
29+
@pytest.fixture(autouse=True)
30+
def patch_logs_interceptor(monkeypatch: pytest.MonkeyPatch) -> None:
31+
"""Patch UiPathRuntimeLogsInterceptor with a dummy so tests don't depend on logging."""
32+
monkeypatch.setattr(
33+
"uipath.runtime.context.UiPathRuntimeLogsInterceptor",
34+
DummyLogsInterceptor,
35+
)
36+
37+
38+
def test_context_loads_json_input_file(tmp_path: Path) -> None:
39+
input_data = {"foo": "bar", "answer": 42}
40+
input_path = tmp_path / "input.json"
41+
input_path.write_text(json.dumps(input_data))
42+
43+
ctx = UiPathRuntimeContext(input_file=str(input_path))
44+
45+
with ctx:
46+
# input should be loaded from the JSON file
47+
assert ctx.input == input_data
48+
# logs interceptor should have been set up
49+
assert isinstance(ctx.logs_interceptor, DummyLogsInterceptor)
50+
assert ctx.logs_interceptor.setup_called
51+
52+
# After leaving the context, interceptor should be torn down
53+
assert ctx.logs_interceptor.teardown_called
54+
55+
56+
def test_context_raises_for_invalid_json(tmp_path: Path) -> None:
57+
bad_input_path = tmp_path / "input.json"
58+
bad_input_path.write_text("{not: valid json") # invalid JSON
59+
60+
ctx = UiPathRuntimeContext(input_file=str(bad_input_path))
61+
62+
with pytest.raises(UiPathRuntimeError) as excinfo:
63+
with ctx:
64+
# __enter__ should fail before body executes
65+
pass
66+
67+
err = excinfo.value.error_info
68+
assert err.code == f"Python.{UiPathErrorCode.INPUT_INVALID_JSON.value}"
69+
70+
71+
def test_output_file_written_on_successful_execution(tmp_path: Path) -> None:
72+
output_path = tmp_path / "output.json"
73+
74+
ctx = UiPathRuntimeContext(
75+
output_file=str(output_path),
76+
)
77+
78+
with ctx:
79+
# Simulate a successful runtime that produced some output
80+
ctx.result = UiPathRuntimeResult(
81+
status=UiPathRuntimeStatus.SUCCESSFUL,
82+
output={"foo": "bar"},
83+
)
84+
pass
85+
86+
assert output_path.exists()
87+
written = json.loads(output_path.read_text())
88+
assert written == {"foo": "bar"}
89+
90+
91+
def test_result_file_written_on_success_contains_output(tmp_path: Path) -> None:
92+
runtime_dir = tmp_path / "runtime"
93+
ctx = UiPathRuntimeContext(
94+
job_id="job-123", # triggers writing result file
95+
runtime_dir=str(runtime_dir),
96+
result_file="result.json",
97+
)
98+
99+
with ctx:
100+
ctx.result = UiPathRuntimeResult(
101+
status=UiPathRuntimeStatus.SUCCESSFUL,
102+
output={"foo": "bar"},
103+
)
104+
pass
105+
106+
# Assert: result file is written whether successful or faulted
107+
result_path = Path(ctx.result_file_path)
108+
assert result_path.exists()
109+
110+
content = json.loads(result_path.read_text())
111+
112+
# Should contain output and no error
113+
assert content["output"] == {"foo": "bar"}
114+
assert "error" not in content or content["error"] is None
115+
116+
117+
def test_result_file_written_on_fault_contains_error_contract(tmp_path: Path) -> None:
118+
runtime_dir = tmp_path / "runtime"
119+
ctx = UiPathRuntimeContext(
120+
job_id="job-456", # triggers writing result file
121+
runtime_dir=str(runtime_dir),
122+
result_file="result.json",
123+
)
124+
125+
# No pre-set result -> context will create a default UiPathRuntimeResult()
126+
127+
# Act: simulate a failing runtime
128+
with pytest.raises(RuntimeError, match="Stream blew up"):
129+
with ctx:
130+
raise RuntimeError("Stream blew up")
131+
132+
# Assert: result file is written even when faulted
133+
result_path = Path(ctx.result_file_path)
134+
assert result_path.exists()
135+
136+
content = json.loads(result_path.read_text())
137+
138+
# We always have an output key, even if it's an empty dict
139+
assert "output" in content
140+
141+
# Error contract should be present and structured
142+
assert "error" in content
143+
error = content["error"]
144+
assert error["code"] == "ERROR_RuntimeError"
145+
assert error["title"] == "Runtime error: RuntimeError"
146+
assert "Stream blew up" in error["detail"]

0 commit comments

Comments
 (0)