Skip to content

Commit da6f305

Browse files
authored
Merge pull request #52 from UiPath/feat/e2e-tests
feat: add E2E tests for Textual TUI and Playwright web frontend
2 parents 0a81f5b + 8011cb7 commit da6f305

10 files changed

Lines changed: 778 additions & 5 deletions

File tree

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ jobs:
3030
- name: Install dependencies
3131
run: uv sync --all-extras
3232

33+
- name: Install Playwright browsers
34+
run: uv run playwright install chromium
35+
3336
- name: Run tests
3437
run: uv run pytest
3538

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Workflow Rules
44
- NEVER commit, push, or create PRs unless explicitly asked to do so.
55
- Always wait for explicit user confirmation before any git operations that affect the repository.
6+
- Always run `uv run ruff check src/ tests/`, `uv run ruff format --check .`, and `uv run mypy src/` before committing. Fix any errors before creating the commit.
67

78
## Project Structure
89
- Monorepo: Python backend (`src/uipath/dev/`) + React frontend (`src/uipath/dev/server/frontend/`)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dev = [
5252
"pytest-trio>=0.8.0",
5353
"pytest-cov>=4.1.0",
5454
"pytest-mock>=3.11.1",
55+
"pytest-playwright>=0.6.2",
5556
"pre-commit>=4.5.1",
5657
"filelock>=3.20.3",
5758
"virtualenv>=20.36.1",

tests/conftest.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,167 @@
11
"""Shared pytest fixtures for all tests."""
2+
3+
import asyncio
4+
from typing import Any, AsyncGenerator
5+
6+
import pytest
7+
from uipath.core.tracing import UiPathTraceManager
8+
from uipath.runtime import (
9+
UiPathExecuteOptions,
10+
UiPathRuntimeEvent,
11+
UiPathRuntimeFactorySettings,
12+
UiPathRuntimeResult,
13+
UiPathRuntimeStatus,
14+
UiPathRuntimeStorageProtocol,
15+
UiPathStreamOptions,
16+
)
17+
from uipath.runtime.schema import UiPathRuntimeSchema
18+
19+
ENTRYPOINT_GREETING = "agent/greeting.py:main"
20+
ENTRYPOINT_NUMBERS = "agent/numbers.py:analyze"
21+
22+
23+
class _MockGreetingRuntime:
24+
"""Lightweight greeting runtime for tests (no OTel tracing)."""
25+
26+
def __init__(self, entrypoint: str = ENTRYPOINT_GREETING) -> None:
27+
self.entrypoint = entrypoint
28+
29+
async def get_schema(self) -> UiPathRuntimeSchema:
30+
return UiPathRuntimeSchema(
31+
filePath=self.entrypoint,
32+
uniqueId="test-greeting",
33+
type="agent",
34+
input={
35+
"type": "object",
36+
"properties": {"name": {"type": "string"}},
37+
"required": ["name"],
38+
},
39+
output={
40+
"type": "object",
41+
"properties": {"greeting": {"type": "string"}},
42+
},
43+
)
44+
45+
async def execute(
46+
self,
47+
input: dict[str, Any] | None = None,
48+
options: UiPathExecuteOptions | None = None,
49+
) -> UiPathRuntimeResult:
50+
payload = input or {}
51+
name = str(payload.get("name", "world"))
52+
await asyncio.sleep(0.05)
53+
return UiPathRuntimeResult(
54+
output={"greeting": f"Hello, {name}!"},
55+
status=UiPathRuntimeStatus.SUCCESSFUL,
56+
)
57+
58+
async def stream(
59+
self,
60+
input: dict[str, Any] | None = None,
61+
options: UiPathStreamOptions | None = None,
62+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
63+
yield await self.execute(input=input, options=options)
64+
65+
async def dispose(self) -> None:
66+
pass
67+
68+
69+
class _MockNumbersRuntime:
70+
"""Lightweight numbers runtime for tests (no OTel tracing)."""
71+
72+
def __init__(self, entrypoint: str = ENTRYPOINT_NUMBERS) -> None:
73+
self.entrypoint = entrypoint
74+
75+
async def get_schema(self) -> UiPathRuntimeSchema:
76+
return UiPathRuntimeSchema(
77+
filePath=self.entrypoint,
78+
uniqueId="test-numbers",
79+
type="script",
80+
input={
81+
"type": "object",
82+
"properties": {
83+
"numbers": {
84+
"type": "array",
85+
"items": {"type": "number"},
86+
},
87+
"operation": {
88+
"type": "string",
89+
"enum": ["sum", "avg", "max"],
90+
"default": "sum",
91+
},
92+
},
93+
"required": ["numbers"],
94+
},
95+
output={
96+
"type": "object",
97+
"properties": {
98+
"operation": {"type": "string"},
99+
"result": {"type": "number"},
100+
"count": {"type": "integer"},
101+
},
102+
},
103+
)
104+
105+
async def execute(
106+
self,
107+
input: dict[str, Any] | None = None,
108+
options: UiPathExecuteOptions | None = None,
109+
) -> UiPathRuntimeResult:
110+
payload = input or {}
111+
numbers = [float(x) for x in (payload.get("numbers") or [])]
112+
operation = str(payload.get("operation", "sum")).lower()
113+
await asyncio.sleep(0.05)
114+
115+
if operation == "avg" and numbers:
116+
result = sum(numbers) / len(numbers)
117+
elif operation == "max" and numbers:
118+
result = max(numbers)
119+
else:
120+
operation = "sum"
121+
result = sum(numbers)
122+
123+
return UiPathRuntimeResult(
124+
output={"operation": operation, "result": result, "count": len(numbers)},
125+
status=UiPathRuntimeStatus.SUCCESSFUL,
126+
)
127+
128+
async def stream(
129+
self,
130+
input: dict[str, Any] | None = None,
131+
options: UiPathStreamOptions | None = None,
132+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
133+
yield await self.execute(input=input, options=options)
134+
135+
async def dispose(self) -> None:
136+
pass
137+
138+
139+
class MockRuntimeFactory:
140+
"""Test runtime factory compatible with UiPathRuntimeFactoryProtocol."""
141+
142+
async def new_runtime(self, entrypoint: str, runtime_id: str, **kwargs):
143+
if entrypoint == ENTRYPOINT_NUMBERS:
144+
return _MockNumbersRuntime(entrypoint=entrypoint)
145+
return _MockGreetingRuntime(entrypoint=entrypoint)
146+
147+
async def get_settings(self) -> UiPathRuntimeFactorySettings | None:
148+
return UiPathRuntimeFactorySettings()
149+
150+
async def get_storage(self) -> UiPathRuntimeStorageProtocol | None:
151+
return None
152+
153+
def discover_entrypoints(self) -> list[str]:
154+
return [ENTRYPOINT_GREETING, ENTRYPOINT_NUMBERS]
155+
156+
async def dispose(self) -> None:
157+
pass
158+
159+
160+
@pytest.fixture()
161+
def mock_factory():
162+
return MockRuntimeFactory()
163+
164+
165+
@pytest.fixture()
166+
def trace_manager():
167+
return UiPathTraceManager()

tests/e2e/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""End-to-end tests for UiPath Developer Console."""

tests/e2e/conftest.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""E2E-specific fixtures for Textual TUI and web server tests."""
2+
3+
import socket
4+
import threading
5+
import time
6+
7+
import pytest
8+
from uipath.core.tracing import UiPathTraceManager
9+
10+
from tests.conftest import MockRuntimeFactory
11+
from uipath.dev import UiPathDeveloperConsole
12+
13+
14+
@pytest.fixture()
15+
def app(mock_factory, trace_manager):
16+
"""Create a UiPathDeveloperConsole instance for Textual pilot tests."""
17+
return UiPathDeveloperConsole(
18+
runtime_factory=mock_factory,
19+
trace_manager=trace_manager,
20+
)
21+
22+
23+
def _find_free_port() -> int:
24+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
25+
s.bind(("127.0.0.1", 0))
26+
return s.getsockname()[1]
27+
28+
29+
@pytest.fixture(scope="session")
30+
def live_server_url():
31+
"""Start a real FastAPI server in a background thread and yield its URL.
32+
33+
Session-scoped so the server is started only once for all web tests.
34+
Uses 'live_server_url' (not 'base_url') to avoid conflicting with the
35+
autouse session fixture from pytest-base-url, which would force the
36+
server to start even for non-web tests.
37+
"""
38+
try:
39+
import uvicorn
40+
41+
from uipath.dev.server import UiPathDeveloperServer
42+
except ImportError:
43+
pytest.skip("server extras not installed (pip install uipath-dev[server])")
44+
45+
factory = MockRuntimeFactory()
46+
trace_mgr = UiPathTraceManager()
47+
port = _find_free_port()
48+
49+
server_obj = UiPathDeveloperServer(
50+
runtime_factory=factory,
51+
trace_manager=trace_mgr,
52+
host="127.0.0.1",
53+
port=port,
54+
open_browser=False,
55+
)
56+
fastapi_app = server_obj.create_app()
57+
58+
config = uvicorn.Config(
59+
fastapi_app,
60+
host="127.0.0.1",
61+
port=port,
62+
log_level="warning",
63+
)
64+
uv_server = uvicorn.Server(config)
65+
66+
thread = threading.Thread(target=uv_server.run, daemon=True)
67+
thread.start()
68+
69+
# Wait for server to be ready
70+
url = f"http://127.0.0.1:{port}"
71+
for _ in range(50):
72+
try:
73+
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
74+
break
75+
except OSError:
76+
time.sleep(0.1)
77+
else:
78+
raise RuntimeError("Server did not start in time")
79+
80+
yield url
81+
82+
uv_server.should_exit = True
83+
thread.join(timeout=5)

0 commit comments

Comments
 (0)