Skip to content

Commit c9dd72f

Browse files
committed
Add initial smoketests
1 parent 49d9d2f commit c9dd72f

File tree

12 files changed

+435
-2
lines changed

12 files changed

+435
-2
lines changed

.github/workflows/smoketests.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Smoketests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
environment:
7+
description: "Target environment"
8+
type: choice
9+
default: dev
10+
options:
11+
- dev
12+
- prod
13+
14+
jobs:
15+
smoke:
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 120
18+
defaults:
19+
run:
20+
working-directory: api-client-python
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Setup uv
26+
uses: astral-sh/setup-uv@v4
27+
with:
28+
python-version: "3.11"
29+
30+
- name: Install dependencies
31+
run: |
32+
uv pip install -r requirements-dev.lock
33+
34+
- name: Configure environment
35+
env:
36+
DEV_KEY: ${{ secrets.RUNLOOP_SMOKETEST_DEV_API_KEY }}
37+
PROD_KEY: ${{ secrets.RUNLOOP_SMOKETEST_PROD_API_KEY }}
38+
run: |
39+
if [ "${{ github.event.inputs.environment }}" = "prod" ]; then
40+
echo "RUNLOOP_API_KEY=${PROD_KEY}" >> $GITHUB_ENV
41+
echo "RUNLOOP_BASE_URL=https://api.runloop.ai" >> $GITHUB_ENV
42+
else
43+
echo "RUNLOOP_API_KEY=${DEV_KEY}" >> $GITHUB_ENV
44+
echo "RUNLOOP_BASE_URL=https://api.runloop.pro" >> $GITHUB_ENV
45+
fi
46+
echo "DEBUG=false" >> $GITHUB_ENV
47+
echo "RUN_SMOKETESTS=1" >> $GITHUB_ENV
48+
echo "PYTHONPATH=${{ github.workspace }}/api-client-python/src" >> $GITHUB_ENV
49+
50+
- name: Run smoke tests (pytest via uv)
51+
env:
52+
# Force sequential to avoid overloading remote resources
53+
PYTEST_ADDOPTS: "-n 1"
54+
run: |
55+
uv run pytest -q -vv tests/smoketests

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ dist
1313
.envrc
1414
codegen.log
1515
Brewfile.lock.json
16+
17+
.DS_Store

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dev-dependencies = [
5151
"respx",
5252
"pytest",
5353
"pytest-asyncio",
54+
"pytest-timeout",
5455
"ruff",
5556
"time-machine",
5657
"nox",
@@ -134,15 +135,16 @@ replacement = '[\1](https://github.com/runloopai/api-client-python/tree/main/\g<
134135

135136
[tool.pytest.ini_options]
136137
testpaths = ["tests"]
137-
addopts = "--tb=short -n auto"
138+
addopts = "--tb=short -n auto --dist=loadfile"
138139
xfail_strict = true
139140
asyncio_mode = "auto"
140141
asyncio_default_fixture_loop_scope = "session"
141142
filterwarnings = [
142143
"error"
143144
]
144145
markers = [
145-
"skip_if_strict: skip test if using strict validation (for prism mock server issues)"
146+
"skip_if_strict: skip test if using strict validation (for prism mock server issues)",
147+
"timeout: per-test timeout provided by pytest-timeout",
146148
]
147149

148150
[tool.pyright]

requirements-dev.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ pyright==1.1.399
9898
pytest==8.3.3
9999
# via pytest-asyncio
100100
# via pytest-xdist
101+
pytest-timeout==2.3.1
102+
# via runloop-api-client (dev)
101103
pytest-asyncio==0.24.0
102104
pytest-xdist==3.7.0
103105
python-dateutil==2.8.2

tests/smoketests/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Smoke tests
2+
3+
End-to-end smoke tests run against the real API to validate critical flows (devboxes, snapshots, blueprints, executions/log tailing, scenarios/benchmarks).
4+
5+
- Local run (requires `RUNLOOP_API_KEY`):
6+
7+
```bash
8+
export RUNLOOP_API_KEY=... # required
9+
# optionally override API base
10+
# export RUNLOOP_BASE_URL=https://api.runloop.ai
11+
12+
# Install deps and run via uv
13+
uv pip install -r requirements-dev.lock
14+
15+
# Run all tests
16+
RUN_SMOKETESTS=1 uv run pytest -q -vv tests/smoketests
17+
18+
# Run a single file
19+
RUN_SMOKETESTS=1 uv run pytest -q -vv tests/smoketests/test_devboxes.py
20+
21+
# Run a single test by name
22+
RUN_SMOKETESTS=1 uv run pytest -q -k "test_create_and_await_running_timeout" tests/smoketests/test_devboxes.py
23+
```
24+
25+
- GitHub Actions: add repo secret `RUNLOOP_SMOKETEST_DEV_API_KEY` and `RUNLOOP_SMOKETEST_PROD_API_KEY`. The workflow `.github/workflows/smoketests.yml` supports an input `environment` (dev|prod) and runs these tests in CI.
26+
27+

tests/smoketests/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Test package for smoketests.
2+
3+
Ensures relative imports like `from .utils import ...` work under pytest.
4+
"""
5+
6+
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import os
2+
3+
import pytest
4+
5+
from runloop_api_client.lib.polling import PollingConfig
6+
7+
from .utils import make_client, unique_name
8+
9+
pytestmark = pytest.mark.skipif(os.getenv("RUN_SMOKETESTS") != "1", reason="smoketests only run in CI")
10+
11+
12+
client = make_client()
13+
14+
15+
"""
16+
Tests are run sequentially and can be dependent on each other.
17+
This is to avoid overloading resources and save efficiency.
18+
"""
19+
_blueprint_id = None
20+
_blueprint_name = unique_name("bp")
21+
22+
23+
def teardown_module() -> None:
24+
global _blueprint_id
25+
if _blueprint_id:
26+
try:
27+
client.blueprints.delete(_blueprint_id)
28+
except Exception:
29+
pass
30+
31+
32+
@pytest.mark.timeout(30)
33+
def test_create_blueprint_and_await_build() -> None:
34+
global _blueprint_id
35+
created = client.blueprints.create_and_await_build_complete(
36+
name=_blueprint_name,
37+
polling_config=PollingConfig(max_attempts=180, interval_seconds=5.0, timeout_seconds=30 * 60),
38+
)
39+
assert created.status == "build_complete"
40+
_blueprint_id = created.id
41+
42+
43+
@pytest.mark.timeout(30)
44+
def test_start_devbox_from_base_blueprint_by_id() -> None:
45+
assert _blueprint_id
46+
devbox = client.devboxes.create_and_await_running(
47+
blueprint_id=_blueprint_id,
48+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
49+
)
50+
assert devbox.blueprint_id == _blueprint_id
51+
assert devbox.status == "running"
52+
53+
54+
@pytest.mark.timeout(30)
55+
def test_start_devbox_from_base_blueprint_by_name() -> None:
56+
devbox = client.devboxes.create_and_await_running(
57+
blueprint_name=_blueprint_name,
58+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
59+
)
60+
assert devbox.blueprint_id
61+
assert devbox.status == "running"

tests/smoketests/test_devboxes.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import os
2+
3+
import pytest
4+
5+
from runloop_api_client.lib.polling import PollingConfig, PollingTimeout
6+
7+
from .utils import make_client, unique_name
8+
9+
pytestmark = pytest.mark.skipif(os.getenv("RUN_SMOKETESTS") != "1", reason="smoketests only run in CI")
10+
11+
12+
client = make_client()
13+
14+
"""
15+
Tests are run sequentially and can be dependent on each other.
16+
This is to avoid overloading resources and save efficiency.
17+
"""
18+
_devbox_id = None
19+
20+
21+
@pytest.mark.timeout(30)
22+
def test_create_devbox() -> None:
23+
created = client.devboxes.create(name=unique_name("smoke-devbox"))
24+
assert created.id
25+
client.devboxes.shutdown(created.id)
26+
27+
28+
@pytest.mark.timeout(30)
29+
def test_await_running_create_and_await_running() -> None:
30+
global _devbox_id
31+
created = client.devboxes.create_and_await_running(
32+
name=unique_name("smoketest-devbox2"),
33+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
34+
)
35+
assert created.status == "running"
36+
_devbox_id = created.id
37+
38+
39+
def test_list_devboxes() -> None:
40+
page = client.devboxes.list(limit=10)
41+
assert isinstance(page.devboxes, list)
42+
assert len(page.devboxes) > 0
43+
44+
45+
def test_retrieve_devbox() -> None:
46+
assert _devbox_id
47+
view = client.devboxes.retrieve(_devbox_id)
48+
assert view.id == _devbox_id
49+
50+
51+
def test_shutdown_devbox() -> None:
52+
assert _devbox_id
53+
view = client.devboxes.shutdown(_devbox_id)
54+
assert view.id == _devbox_id
55+
assert view.status == "shutdown"
56+
57+
58+
@pytest.mark.timeout(30)
59+
def test_create_and_await_running_long_set_up() -> None:
60+
created = client.devboxes.create_and_await_running(
61+
name=unique_name("smoketest-devbox-await-running-long-set-up"),
62+
launch_parameters={"launch_commands": ["sleep 70"]},
63+
polling_config=PollingConfig(interval_seconds=5.0, timeout_seconds=80),
64+
)
65+
assert created.status == "running"
66+
67+
68+
@pytest.mark.timeout(30)
69+
def test_create_and_await_running_timeout() -> None:
70+
with pytest.raises(PollingTimeout):
71+
client.devboxes.create_and_await_running(
72+
name=unique_name("smoketest-devbox-await-running-timeout"),
73+
launch_parameters={"launch_commands": ["sleep 70"], "keep_alive_time_seconds": 30},
74+
polling_config=PollingConfig(max_attempts=1, interval_seconds=0.1),
75+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import os
2+
3+
import pytest
4+
5+
from runloop_api_client.lib.polling import PollingConfig
6+
7+
from .utils import make_client, unique_name
8+
9+
pytestmark = pytest.mark.skipif(os.getenv("RUN_SMOKETESTS") != "1", reason="smoketests only run in CI")
10+
11+
12+
client = make_client()
13+
14+
15+
"""
16+
Tests are run sequentially and can be dependent on each other.
17+
This is to avoid overloading resources and save efficiency.
18+
"""
19+
_devbox_id = None
20+
_exec_id = None
21+
22+
23+
@pytest.mark.timeout(30)
24+
def test_launch_devbox() -> None:
25+
global _devbox_id
26+
created = client.devboxes.create_and_await_running(
27+
name=unique_name("exec-devbox"),
28+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
29+
)
30+
_devbox_id = created.id
31+
32+
33+
@pytest.mark.timeout(30)
34+
def test_execute_async_and_await_completion() -> None:
35+
assert _devbox_id
36+
global _exec_id
37+
started = client.devboxes.executions.execute_async(_devbox_id, command="echo hello && sleep 1")
38+
_exec_id = started.execution_id
39+
completed = client.devboxes.executions.await_completed(
40+
_exec_id,
41+
devbox_id=_devbox_id,
42+
polling_config=PollingConfig(max_attempts=120, interval_seconds=2.0, timeout_seconds=10 * 60),
43+
)
44+
assert completed.status == "completed"
45+
46+
47+
@pytest.mark.timeout(30)
48+
def test_tail_stdout_logs() -> None:
49+
assert _devbox_id and _exec_id
50+
stream = client.devboxes.executions.stream_stdout_updates(execution_id=_exec_id, devbox_id=_devbox_id)
51+
received = ""
52+
for chunk in stream:
53+
received += getattr(chunk, "output", "") or ""
54+
if received:
55+
break
56+
assert isinstance(received, str)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import os
2+
3+
import pytest
4+
5+
from runloop_api_client.lib.polling import PollingConfig
6+
7+
from .utils import make_client, unique_name
8+
9+
pytestmark = pytest.mark.skipif(os.getenv("RUN_SMOKETESTS") != "1", reason="smoketests only run in CI")
10+
11+
12+
client = make_client()
13+
14+
15+
"""
16+
Tests are run sequentially and can be dependent on each other.
17+
This is to avoid overloading resources and save efficiency.
18+
"""
19+
_scenario_id = None
20+
_run_id = None
21+
22+
23+
@pytest.mark.timeout(30)
24+
def test_create_scenario() -> None:
25+
global _scenario_id
26+
scenario = client.scenarios.create(
27+
name=unique_name("scenario"),
28+
input_context={"problem_statement": "echo hello"},
29+
scoring_contract={
30+
"scoring_function_parameters": [
31+
{
32+
"name": "cmd-zero",
33+
"scorer": {"type": "command_scorer", "command": "true"},
34+
"weight": 1,
35+
}
36+
]
37+
},
38+
)
39+
_scenario_id = scenario.id
40+
41+
42+
@pytest.mark.timeout(30)
43+
def test_start_scenario_run_and_await_env_ready() -> None:
44+
assert _scenario_id
45+
run = client.scenarios.start_run_and_await_env_ready(
46+
scenario_id=_scenario_id,
47+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
48+
)
49+
assert run.scenario_id == _scenario_id
50+
global _run_id
51+
_run_id = run.id
52+
53+
54+
@pytest.mark.timeout(30)
55+
def test_score_and_complete_scenario_run() -> None:
56+
assert _run_id
57+
scored = client.scenarios.runs.score_and_complete(
58+
_run_id, polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60)
59+
)
60+
assert scored.state in {"completed", "scored", "running", "failed", "timeout", "canceled"}
61+
62+
63+
@pytest.mark.timeout(30)
64+
def test_create_benchmark_and_start_run() -> None:
65+
assert _scenario_id
66+
benchmark = client.benchmarks.create(name=unique_name("benchmark"), scenario_ids=[_scenario_id])
67+
assert benchmark.id
68+
run = client.benchmarks.start_run(benchmark_id=benchmark.id)
69+
assert run.benchmark_id == benchmark.id

0 commit comments

Comments
 (0)