Skip to content

Commit fae7559

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

File tree

14 files changed

+460
-5
lines changed

14 files changed

+460
-5
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,4 @@ jobs:
9595
run: ./scripts/bootstrap
9696

9797
- name: Run tests
98-
run: ./scripts/test
98+
run: ./scripts/test --ignore=tests/smoketests

.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

scripts/test

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,15 @@ fi
5555
export DEFER_PYDANTIC_BUILD=false
5656

5757
echo "==> Running tests"
58-
rye run pytest "$@"
58+
59+
# By default, exclude smoketests unless explicitly enabled.
60+
# This ensures PR CI doesn't run E2E smoketests unless RUN_SMOKETESTS=1.
61+
PYTEST_ARGS=()
62+
if [ "${RUN_SMOKETESTS}" != "1" ]; then
63+
PYTEST_ARGS+=( -m "not smoketest" )
64+
fi
65+
66+
rye run pytest "${PYTEST_ARGS[@]}" "$@"
5967

6068
echo "==> Running Pydantic v1 tests"
61-
rye run nox -s test-pydantic-v1 -- "$@"
69+
rye run nox -s test-pydantic-v1 -- "${PYTEST_ARGS[@]}" "$@"

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 pytest
2+
3+
from runloop_api_client.lib.polling import PollingConfig
4+
5+
from .utils import make_client, unique_name
6+
7+
pytestmark = [pytest.mark.smoketest]
8+
9+
10+
client = make_client()
11+
12+
13+
"""
14+
Tests are run sequentially and can be dependent on each other.
15+
This is to avoid overloading resources and save efficiency.
16+
"""
17+
_blueprint_id = None
18+
_blueprint_name = unique_name("bp")
19+
20+
21+
def teardown_module() -> None:
22+
global _blueprint_id
23+
if _blueprint_id:
24+
try:
25+
client.blueprints.delete(_blueprint_id)
26+
except Exception:
27+
pass
28+
29+
30+
@pytest.mark.timeout(30)
31+
def test_create_blueprint_and_await_build() -> None:
32+
global _blueprint_id
33+
created = client.blueprints.create_and_await_build_complete(
34+
name=_blueprint_name,
35+
polling_config=PollingConfig(max_attempts=180, interval_seconds=5.0, timeout_seconds=30 * 60),
36+
)
37+
assert created.status == "build_complete"
38+
_blueprint_id = created.id
39+
40+
41+
@pytest.mark.timeout(30)
42+
def test_start_devbox_from_base_blueprint_by_id() -> None:
43+
assert _blueprint_id
44+
devbox = client.devboxes.create_and_await_running(
45+
blueprint_id=_blueprint_id,
46+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
47+
)
48+
assert devbox.blueprint_id == _blueprint_id
49+
assert devbox.status == "running"
50+
client.devboxes.shutdown(devbox.id)
51+
52+
53+
@pytest.mark.timeout(30)
54+
def test_start_devbox_from_base_blueprint_by_name() -> None:
55+
devbox = client.devboxes.create_and_await_running(
56+
blueprint_name=_blueprint_name,
57+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
58+
)
59+
assert devbox.blueprint_id
60+
assert devbox.status == "running"
61+
client.devboxes.shutdown(devbox.id)

tests/smoketests/test_devboxes.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
3+
from runloop_api_client.lib.polling import PollingConfig, PollingTimeout
4+
5+
from .utils import make_client, unique_name
6+
7+
pytestmark = [pytest.mark.smoketest]
8+
9+
10+
client = make_client()
11+
12+
"""
13+
Tests are run sequentially and can be dependent on each other.
14+
This is to avoid overloading resources and save efficiency.
15+
"""
16+
_devbox_id = None
17+
18+
19+
@pytest.mark.timeout(30)
20+
def test_create_devbox() -> None:
21+
created = client.devboxes.create(name=unique_name("smoke-devbox"))
22+
assert created.id
23+
client.devboxes.shutdown(created.id)
24+
25+
26+
@pytest.mark.timeout(30)
27+
def test_await_running_create_and_await_running() -> None:
28+
global _devbox_id
29+
created = client.devboxes.create_and_await_running(
30+
name=unique_name("smoketest-devbox2"),
31+
polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60),
32+
)
33+
assert created.status == "running"
34+
_devbox_id = created.id
35+
36+
37+
def test_list_devboxes() -> None:
38+
page = client.devboxes.list(limit=10)
39+
assert isinstance(page.devboxes, list)
40+
assert len(page.devboxes) > 0
41+
42+
43+
def test_retrieve_devbox() -> None:
44+
assert _devbox_id
45+
view = client.devboxes.retrieve(_devbox_id)
46+
assert view.id == _devbox_id
47+
48+
49+
def test_shutdown_devbox() -> None:
50+
assert _devbox_id
51+
view = client.devboxes.shutdown(_devbox_id)
52+
assert view.id == _devbox_id
53+
assert view.status == "shutdown"
54+
55+
56+
@pytest.mark.timeout(30)
57+
def test_create_and_await_running_long_set_up() -> None:
58+
created = client.devboxes.create_and_await_running(
59+
name=unique_name("smoketest-devbox-await-running-long-set-up"),
60+
launch_parameters={"launch_commands": ["sleep 70"], "keep_alive_time_seconds": 30},
61+
polling_config=PollingConfig(interval_seconds=5.0, timeout_seconds=80),
62+
)
63+
assert created.status == "running"
64+
65+
66+
@pytest.mark.timeout(30)
67+
def test_create_and_await_running_timeout() -> None:
68+
with pytest.raises(PollingTimeout):
69+
client.devboxes.create_and_await_running(
70+
name=unique_name("smoketest-devbox-await-running-timeout"),
71+
launch_parameters={"launch_commands": ["sleep 70"], "keep_alive_time_seconds": 30},
72+
polling_config=PollingConfig(max_attempts=1, interval_seconds=0.1),
73+
)

0 commit comments

Comments
 (0)