Skip to content

Commit 23aa644

Browse files
runpod-Henrikclaude
andcommitted
feat(e2e): add E2E workflow, CPU smoke test, and conftest isolation improvements
- 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: CPU smoke test — deploys a minimal 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 - tests/conftest.py: restore_flash_sys_modules autouse fixture (fixes class identity splits under -n auto) + stale state file cleanup in reset_singletons - 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 23aa644

5 files changed

Lines changed: 1940 additions & 1515 deletions

File tree

.github/workflows/e2e.yml

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