Skip to content

Commit d975fb7

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

File tree

13 files changed

+499
-2
lines changed

13 files changed

+499
-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/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None:
7171
)
7272
item.add_marker(pytest.mark.skip(reason=reason))
7373

74+
# Finally, deselect smoketests by default unless explicitly enabled.
75+
# This ensures PR CI will not run smoketests unintentionally.
76+
if os.getenv("RUN_SMOKETESTS") != "1":
77+
kept: list[pytest.Function] = []
78+
for item in items:
79+
if item.get_closest_marker("smoketest") is None:
80+
kept.append(item)
81+
if len(kept) != len(items):
82+
items[:] = kept
83+
7484

7585
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
7686

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

tests/smoketests/test_devboxes.py

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

0 commit comments

Comments
 (0)