Skip to content

Commit 2fa6fea

Browse files
committed
Add initial smoketests
1 parent 49d9d2f commit 2fa6fea

File tree

12 files changed

+451
-2
lines changed

12 files changed

+451
-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 -m smoketest"
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: 5 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,17 @@ 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 -m 'not smoketest'"
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",
148+
"smoketest: end-to-end smoke tests against real API",
146149
]
147150

148151
[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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 = [
10+
pytest.mark.smoketest,
11+
pytest.mark.skipif(os.getenv("RUN_SMOKETESTS") != "1", reason="smoketests only run in CI"),
12+
]
13+
14+
15+
client = make_client()
16+
17+
18+
"""
19+
Tests are run sequentially and can be dependent on each other.
20+
This is to avoid overloading resources and save efficiency.
21+
"""
22+
_blueprint_id = None
23+
_blueprint_name = unique_name("bp")
24+
25+
26+
def teardown_module() -> None:
27+
global _blueprint_id
28+
if _blueprint_id:
29+
try:
30+
client.blueprints.delete(_blueprint_id)
31+
except Exception:
32+
pass
33+
34+
35+
@pytest.mark.timeout(30)
36+
def test_create_blueprint_and_await_build() -> None:
37+
global _blueprint_id
38+
created = client.blueprints.create_and_await_build_complete(
39+
name=_blueprint_name,
40+
polling_config=PollingConfig(max_attempts=180, interval_seconds=5.0, timeout_seconds=30 * 60),
41+
)
42+
assert created.status == "build_complete"
43+
_blueprint_id = created.id
44+
45+
46+
@pytest.mark.timeout(30)
47+
def test_start_devbox_from_base_blueprint_by_id() -> None:
48+
assert _blueprint_id
49+
devbox = client.devboxes.create_and_await_running(
50+
blueprint_id=_blueprint_id,
51+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
52+
)
53+
assert devbox.blueprint_id == _blueprint_id
54+
assert devbox.status == "running"
55+
56+
57+
@pytest.mark.timeout(30)
58+
def test_start_devbox_from_base_blueprint_by_name() -> None:
59+
devbox = client.devboxes.create_and_await_running(
60+
blueprint_name=_blueprint_name,
61+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
62+
)
63+
assert devbox.blueprint_id
64+
assert devbox.status == "running"

tests/smoketests/test_devboxes.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 = [
10+
pytest.mark.smoketest,
11+
pytest.mark.skipif(os.getenv("RUN_SMOKETESTS") != "1", reason="smoketests only run in CI"),
12+
]
13+
14+
15+
client = make_client()
16+
17+
"""
18+
Tests are run sequentially and can be dependent on each other.
19+
This is to avoid overloading resources and save efficiency.
20+
"""
21+
_devbox_id = None
22+
23+
24+
@pytest.mark.timeout(30)
25+
def test_create_devbox() -> None:
26+
created = client.devboxes.create(name=unique_name("smoke-devbox"))
27+
assert created.id
28+
client.devboxes.shutdown(created.id)
29+
30+
31+
@pytest.mark.timeout(30)
32+
def test_await_running_create_and_await_running() -> None:
33+
global _devbox_id
34+
created = client.devboxes.create_and_await_running(
35+
name=unique_name("smoketest-devbox2"),
36+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
37+
)
38+
assert created.status == "running"
39+
_devbox_id = created.id
40+
41+
42+
def test_list_devboxes() -> None:
43+
page = client.devboxes.list(limit=10)
44+
assert isinstance(page.devboxes, list)
45+
assert len(page.devboxes) > 0
46+
47+
48+
def test_retrieve_devbox() -> None:
49+
assert _devbox_id
50+
view = client.devboxes.retrieve(_devbox_id)
51+
assert view.id == _devbox_id
52+
53+
54+
def test_shutdown_devbox() -> None:
55+
assert _devbox_id
56+
view = client.devboxes.shutdown(_devbox_id)
57+
assert view.id == _devbox_id
58+
assert view.status == "shutdown"
59+
60+
61+
@pytest.mark.timeout(30)
62+
def test_create_and_await_running_long_set_up() -> None:
63+
created = client.devboxes.create_and_await_running(
64+
name=unique_name("smoketest-devbox-await-running-long-set-up"),
65+
launch_parameters={"launch_commands": ["sleep 70"]},
66+
polling_config=PollingConfig(interval_seconds=5.0, timeout_seconds=80),
67+
)
68+
assert created.status == "running"
69+
70+
71+
@pytest.mark.timeout(30)
72+
def test_create_and_await_running_timeout() -> None:
73+
with pytest.raises(PollingTimeout):
74+
client.devboxes.create_and_await_running(
75+
name=unique_name("smoketest-devbox-await-running-timeout"),
76+
launch_parameters={"launch_commands": ["sleep 70"], "keep_alive_time_seconds": 30},
77+
polling_config=PollingConfig(max_attempts=1, interval_seconds=0.1),
78+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 = [
10+
pytest.mark.smoketest,
11+
pytest.mark.skipif(os.getenv("RUN_SMOKETESTS") != "1", reason="smoketests only run in CI"),
12+
]
13+
14+
15+
client = make_client()
16+
17+
18+
"""
19+
Tests are run sequentially and can be dependent on each other.
20+
This is to avoid overloading resources and save efficiency.
21+
"""
22+
_devbox_id = None
23+
_exec_id = None
24+
25+
26+
@pytest.mark.timeout(30)
27+
def test_launch_devbox() -> None:
28+
global _devbox_id
29+
created = client.devboxes.create_and_await_running(
30+
name=unique_name("exec-devbox"),
31+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
32+
)
33+
_devbox_id = created.id
34+
35+
36+
@pytest.mark.timeout(30)
37+
def test_execute_async_and_await_completion() -> None:
38+
assert _devbox_id
39+
global _exec_id
40+
started = client.devboxes.executions.execute_async(_devbox_id, command="echo hello && sleep 1")
41+
_exec_id = started.execution_id
42+
completed = client.devboxes.executions.await_completed(
43+
_exec_id,
44+
devbox_id=_devbox_id,
45+
polling_config=PollingConfig(max_attempts=120, interval_seconds=2.0, timeout_seconds=10 * 60),
46+
)
47+
assert completed.status == "completed"
48+
49+
50+
@pytest.mark.timeout(30)
51+
def test_tail_stdout_logs() -> None:
52+
assert _devbox_id and _exec_id
53+
stream = client.devboxes.executions.stream_stdout_updates(execution_id=_exec_id, devbox_id=_devbox_id)
54+
received = ""
55+
for chunk in stream:
56+
received += getattr(chunk, "output", "") or ""
57+
if received:
58+
break
59+
assert isinstance(received, str)

0 commit comments

Comments
 (0)