Skip to content

Commit ea1bb08

Browse files
runpod-Henrikclaude
andcommitted
feat(e2e): add E2E workflow and CPU smoke test
- e2e/conftest.py: credential handling from env or ~/.runpod/config.toml; hard fail in CI if RUNPOD_API_KEY not set - e2e/test_cpu_smoke.py: deploys a minimal CPU worker, invokes it, asserts output, undeploys; unique name per run to avoid template collision; 180s invoke timeout; warns on undeploy failure - .github/workflows/e2e.yml: manual workflow_dispatch trigger (push/PR triggers commented out); unit+integration job with coverage; E2E job; summary job writing results + coverage to GitHub step summary - e2e/ lives at repo root, not under tests/ — prevents ci.yml from collecting e2e tests and accidentally deploying real infrastructure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 09c50d6 commit ea1bb08

4 files changed

Lines changed: 1908 additions & 1515 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
name: E2E Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- Henrik/e2e-cpu-smoke
7+
# Uncomment to run on every push to main / pull request:
8+
# push:
9+
# branches: [main]
10+
# pull_request:
11+
# branches: [main]
12+
workflow_dispatch:
13+
inputs:
14+
tests:
15+
description: 'E2E test filter (pytest -k expression, leave empty to run all e2e tests)'
16+
required: false
17+
default: ''
18+
19+
permissions:
20+
contents: read
21+
22+
env:
23+
PYTHON_VERSION: '3.11'
24+
25+
jobs:
26+
unit-tests:
27+
name: Unit + Integration
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 15
30+
31+
steps:
32+
- name: Checkout code
33+
uses: actions/checkout@v4
34+
35+
- name: Set up Python
36+
uses: actions/setup-python@v5
37+
with:
38+
python-version: ${{ env.PYTHON_VERSION }}
39+
40+
- name: Install uv
41+
uses: astral-sh/setup-uv@v5
42+
with:
43+
enable-cache: true
44+
cache-dependency-glob: "pyproject.toml"
45+
46+
- name: Install dependencies
47+
run: uv sync --all-groups
48+
49+
- name: Run unit + integration tests
50+
run: |
51+
uv run pytest tests/unit/ tests/integration/ \
52+
-n auto \
53+
--timeout=60 \
54+
--junitxml=unit-results.xml \
55+
--cov-report=xml:coverage.xml \
56+
--cov-fail-under=0
57+
58+
- name: Upload test results
59+
uses: actions/upload-artifact@v4
60+
if: always()
61+
with:
62+
name: unit-results
63+
path: unit-results.xml
64+
65+
- name: Upload coverage
66+
uses: actions/upload-artifact@v4
67+
if: always()
68+
with:
69+
name: coverage
70+
path: coverage.xml
71+
72+
e2e:
73+
name: E2E
74+
runs-on: ubuntu-latest
75+
timeout-minutes: 30
76+
77+
steps:
78+
- name: Checkout code
79+
uses: actions/checkout@v4
80+
81+
- name: Set up Python
82+
uses: actions/setup-python@v5
83+
with:
84+
python-version: ${{ env.PYTHON_VERSION }}
85+
86+
- name: Install uv
87+
uses: astral-sh/setup-uv@v5
88+
with:
89+
enable-cache: true
90+
cache-dependency-glob: "pyproject.toml"
91+
92+
- name: Install dependencies
93+
run: uv sync --all-groups
94+
95+
- name: Run E2E tests
96+
env:
97+
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
98+
run: |
99+
uv run pytest e2e/ \
100+
${{ inputs.tests != '' && format('-k "{0}"', inputs.tests) || '' }} \
101+
-v \
102+
--timeout=0 \
103+
--no-cov \
104+
-p no:xdist \
105+
--override-ini="addopts=" \
106+
--junitxml=e2e-results.xml \
107+
-s
108+
109+
- name: Check at least one test ran
110+
if: always()
111+
run: |
112+
python - <<'EOF'
113+
import xml.etree.ElementTree as ET, sys
114+
try:
115+
tree = ET.parse("e2e-results.xml")
116+
root = tree.getroot()
117+
if root.tag == "testsuites":
118+
tests = sum(int(s.attrib.get("tests", 0)) for s in root.findall("testsuite"))
119+
else:
120+
tests = int(root.attrib.get("tests", 0))
121+
print(f"Tests run: {tests}")
122+
if tests == 0:
123+
print("ERROR: 0 tests ran — check test filter or test collection")
124+
sys.exit(1)
125+
except FileNotFoundError:
126+
print("ERROR: e2e-results.xml not found — pytest did not run")
127+
sys.exit(1)
128+
EOF
129+
130+
- name: Upload test results
131+
uses: actions/upload-artifact@v4
132+
if: always()
133+
with:
134+
name: e2e-results
135+
path: e2e-results.xml
136+
137+
summary:
138+
name: Summary
139+
needs: [unit-tests, e2e]
140+
if: always()
141+
runs-on: ubuntu-latest
142+
timeout-minutes: 5
143+
144+
steps:
145+
- name: Download unit results
146+
uses: actions/download-artifact@v4
147+
with:
148+
name: unit-results
149+
continue-on-error: true
150+
151+
- name: Download coverage
152+
uses: actions/download-artifact@v4
153+
with:
154+
name: coverage
155+
continue-on-error: true
156+
157+
- name: Download E2E results
158+
uses: actions/download-artifact@v4
159+
with:
160+
name: e2e-results
161+
continue-on-error: true
162+
163+
- name: Write summary
164+
env:
165+
UNIT_RESULT: ${{ needs.unit-tests.result }}
166+
E2E_RESULT: ${{ needs.e2e.result }}
167+
run: |
168+
python - <<'EOF'
169+
import xml.etree.ElementTree as ET, os, sys
170+
171+
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
172+
out = open(summary_file, "a") if summary_file else sys.stdout
173+
174+
def parse_junit(path):
175+
"""Return (total, failures, duration) from a JUnit XML file."""
176+
try:
177+
root = ET.parse(path).getroot()
178+
suites = root.findall("testsuite") if root.tag == "testsuites" else [root]
179+
total = sum(int(s.attrib.get("tests", 0)) for s in suites)
180+
failures = sum(int(s.attrib.get("failures", 0)) + int(s.attrib.get("errors", 0)) for s in suites)
181+
duration = sum(float(s.attrib.get("time", 0)) for s in suites)
182+
failed_names = [
183+
tc.get("classname", "") + "::" + tc.get("name", "")
184+
for s in suites
185+
for tc in s.findall("testcase")
186+
if tc.find("failure") is not None or tc.find("error") is not None
187+
]
188+
return total, failures, duration, failed_names
189+
except FileNotFoundError:
190+
return None, None, None, []
191+
192+
def status_icon(result, total, failures):
193+
if total is None: return ":x: Did not run"
194+
if total == 0: return ":warning: No tests ran"
195+
if failures == 0: return ":white_check_mark: Passed"
196+
return ":x: Failed"
197+
198+
unit_total, unit_fail, unit_dur, unit_failed_names = parse_junit("unit-results.xml")
199+
e2e_total, e2e_fail, e2e_dur, e2e_failed_names = parse_junit("e2e-results.xml")
200+
201+
unit_pass = (unit_total - unit_fail) if unit_total is not None else None
202+
e2e_pass = (e2e_total - e2e_fail) if e2e_total is not None else None
203+
204+
unit_result = os.environ.get("UNIT_RESULT", "")
205+
e2e_result = os.environ.get("E2E_RESULT", "")
206+
207+
print("# Test Results\n", file=out)
208+
print("| Suite | Status | Passed | Failed | Total | Duration |", file=out)
209+
print("|---|---|---|---|---|---|", file=out)
210+
print(f"| Unit + Integration | {status_icon(unit_result, unit_total, unit_fail)} | "
211+
f"{unit_pass if unit_pass is not None else '-'} | "
212+
f"{unit_fail if unit_fail is not None else '-'} | "
213+
f"{unit_total if unit_total is not None else '-'} | "
214+
f"{unit_dur:.1f}s |" if unit_dur is not None else "- |", file=out)
215+
print(f"| E2E | {status_icon(e2e_result, e2e_total, e2e_fail)} | "
216+
f"{e2e_pass if e2e_pass is not None else '-'} | "
217+
f"{e2e_fail if e2e_fail is not None else '-'} | "
218+
f"{e2e_total if e2e_total is not None else '-'} | "
219+
f"{e2e_dur:.1f}s |" if e2e_dur is not None else "- |", file=out)
220+
print("", file=out)
221+
222+
all_failed = [("Unit", n) for n in unit_failed_names] + [("E2E", n) for n in e2e_failed_names]
223+
if all_failed:
224+
print("## Failed Tests\n", file=out)
225+
print("| Suite | Test |", file=out)
226+
print("|---|---|", file=out)
227+
for suite, name in all_failed:
228+
print(f"| {suite} | `{name}` |", file=out)
229+
print("", file=out)
230+
231+
# Coverage
232+
print("## Coverage\n", file=out)
233+
try:
234+
cov_root = ET.parse("coverage.xml").getroot()
235+
line_rate = float(cov_root.attrib.get("line-rate", 0))
236+
total_cov = f"{line_rate * 100:.1f}%"
237+
print(f"**Total: {total_cov}**\n", file=out)
238+
print("<details>", file=out)
239+
print("<summary>Per-package breakdown</summary>\n", file=out)
240+
print("| Package | Coverage |", file=out)
241+
print("|---|---|", file=out)
242+
for pkg in cov_root.iter("package"):
243+
name = pkg.attrib.get("name", "")
244+
rate = float(pkg.attrib.get("line-rate", 0))
245+
print(f"| `{name}` | {rate * 100:.1f}% |", file=out)
246+
print("</details>", file=out)
247+
except FileNotFoundError:
248+
print("> Coverage data not available.", file=out)
249+
EOF

e2e/conftest.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""E2E test configuration.
2+
3+
Restores real credentials that the global conftest removes for unit test isolation.
4+
E2E tests need real credentials to deploy, invoke, and undeploy live endpoints.
5+
"""
6+
7+
import os
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
try:
13+
import tomllib
14+
except ImportError:
15+
import tomli as tomllib # type: ignore[no-redef]
16+
17+
18+
def _api_key_from_config() -> str | None:
19+
"""Read API key from ~/.runpod/config.toml if not in environment."""
20+
config_file = Path.home() / ".runpod" / "config.toml"
21+
if not config_file.exists():
22+
return None
23+
try:
24+
data = tomllib.loads(config_file.read_text())
25+
return data.get("default", {}).get("api_key")
26+
except Exception:
27+
return None
28+
29+
30+
# Capture before any monkeypatching happens
31+
_REAL_API_KEY = os.environ.get("RUNPOD_API_KEY") or _api_key_from_config()
32+
33+
34+
@pytest.fixture(autouse=True)
35+
def restore_real_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
36+
"""Restore RUNPOD_API_KEY after the global conftest removes it."""
37+
if _REAL_API_KEY:
38+
monkeypatch.setenv("RUNPOD_API_KEY", _REAL_API_KEY)
39+
elif os.environ.get("CI"):
40+
pytest.fail("RUNPOD_API_KEY secret not configured — set it in repository secrets")
41+
else:
42+
pytest.skip("No credentials available — skipping E2E test")

e2e/test_cpu_smoke.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""CPU smoke: deploy → invoke → undeploy.
2+
3+
Verifies the full deployment pipeline end-to-end. Runs every release.
4+
"""
5+
6+
import os
7+
import pickle
8+
import subprocess
9+
import uuid
10+
from pathlib import Path
11+
12+
import runpod
13+
14+
WORKER_NAME = f"flash-qa-smoke-{uuid.uuid4().hex[:8]}"
15+
16+
WORKER_CODE = f'''\
17+
from runpod_flash import Endpoint
18+
19+
20+
@Endpoint(name="{WORKER_NAME}", cpu="cpu3c-1-2")
21+
async def echo(msg: str = "") -> dict:
22+
return {{"echo": msg, "status": "ok"}}
23+
'''
24+
25+
PYPROJECT_TOML = f'''\
26+
[project]
27+
name = "{WORKER_NAME}"
28+
version = "0.1.0"
29+
requires-python = ">=3.11,<3.13"
30+
dependencies = ["runpod-flash"]
31+
'''
32+
33+
34+
def _endpoint_id_from_state(project_dir: Path) -> str:
35+
"""Read deployed endpoint ID from .flash/resources.pkl.
36+
37+
The state file is a (resources_dict, config_hashes_dict) tuple.
38+
resources_dict keys are "ResourceType:name", values are resource objects with .id.
39+
"""
40+
state_file = project_dir / ".flash" / "resources.pkl"
41+
if not state_file.exists():
42+
raise FileNotFoundError(f"State file not found: {state_file}")
43+
with open(state_file, "rb") as f:
44+
data = pickle.load(f)
45+
resources = data[0] if isinstance(data, tuple) else data
46+
for _key, resource in resources.items():
47+
endpoint_id = getattr(resource, "id", None)
48+
if endpoint_id:
49+
return endpoint_id
50+
raise ValueError(f"No endpoint ID found in state file. Keys: {list(resources)}")
51+
52+
53+
class TestCpuSmoke:
54+
"""CPU smoke: deploy → invoke → undeploy."""
55+
56+
def test_deploy_invoke_undeploy(self, tmp_path: Path) -> None:
57+
"""Deploy a minimal CPU worker, invoke it, verify output, undeploy."""
58+
env = os.environ.copy()
59+
60+
(tmp_path / "worker.py").write_text(WORKER_CODE)
61+
(tmp_path / "pyproject.toml").write_text(PYPROJECT_TOML)
62+
63+
try:
64+
# Deploy
65+
result = subprocess.run(
66+
["uv", "run", "flash", "deploy"],
67+
cwd=tmp_path,
68+
env=env,
69+
capture_output=True,
70+
text=True,
71+
timeout=300,
72+
)
73+
assert result.returncode == 0, (
74+
f"flash deploy failed (exit {result.returncode}):\n"
75+
f"stdout: {result.stdout}\nstderr: {result.stderr}"
76+
)
77+
78+
endpoint_id = _endpoint_id_from_state(tmp_path)
79+
80+
# Invoke
81+
runpod.api_key = env.get("RUNPOD_API_KEY")
82+
output = runpod.Endpoint(endpoint_id).run_sync({"msg": "smoke"}, timeout=180)
83+
84+
assert output is not None, "run_sync returned None"
85+
assert output.get("echo") == "smoke", f"Unexpected output: {output}"
86+
assert output.get("status") == "ok", f"Unexpected status: {output}"
87+
88+
finally:
89+
# Always undeploy by name
90+
undeploy = subprocess.run(
91+
["uv", "run", "flash", "undeploy", WORKER_NAME, "--force"],
92+
cwd=tmp_path,
93+
env=env,
94+
capture_output=True,
95+
text=True,
96+
timeout=60,
97+
)
98+
if undeploy.returncode != 0:
99+
print(
100+
f"WARNING: undeploy failed (exit {undeploy.returncode}):\n"
101+
f"stdout: {undeploy.stdout}\nstderr: {undeploy.stderr}"
102+
)

0 commit comments

Comments
 (0)