Skip to content

Commit 290889d

Browse files
authored
Merge pull request usnavy13#42 from usnavy13/dev
Functional testing framework and dependency updates
2 parents b8e7c59 + 6f50193 commit 290889d

11 files changed

Lines changed: 1530 additions & 2 deletions

File tree

scripts/run_functional_tests.py

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
#!/usr/bin/env python3
2+
"""Run functional tests against a remote API endpoint.
3+
4+
This script bypasses the tests/conftest.py which overrides API_KEY.
5+
6+
Usage:
7+
python scripts/run_functional_tests.py \
8+
--api-base "https://example.com" \
9+
--api-key "your-api-key"
10+
"""
11+
12+
import argparse
13+
import asyncio
14+
import sys
15+
import time
16+
import uuid
17+
from dataclasses import dataclass
18+
from typing import Dict, List, Tuple
19+
20+
import httpx
21+
22+
# Language snippets: (code, expected_substring_in_stdout)
23+
LANGUAGE_SNIPPETS: Dict[str, Tuple[str, str]] = {
24+
"py": ("print('py: sum(1..10)=', sum(range(1,11)))", "55"),
25+
"js": ("console.log('js: sum(1..10)=' + (1+2+3+4+5+6+7+8+9+10));", "55"),
26+
"ts": ("console.log('ts: sum(1..10)=' + (1+2+3+4+5+6+7+8+9+10));", "55"),
27+
"go": (
28+
'package main\n\nimport (\n\t"fmt"\n)\n\nfunc main() {\n\ts := 0\n\t'
29+
'for i := 1; i <= 10; i++ {\n\t\ts += i\n\t}\n\t'
30+
'fmt.Printf("go: sum(1..10)=%d\\n", s)\n}',
31+
"55",
32+
),
33+
"java": (
34+
"public class Code { public static void main(String[] args){ "
35+
'int s=0; for(int i=1;i<=10;i++) s+=i; System.out.println("java: sum(1..10)="+s); } }',
36+
"55",
37+
),
38+
"c": (
39+
'#include <stdio.h>\nint main(){int s=0; for(int i=1;i<=10;i++) s+=i; '
40+
'printf("c: sum(1..10)=%d\\n", s); return 0;}',
41+
"55",
42+
),
43+
"cpp": (
44+
"#include <iostream>\nint main(){int s=0; for(int i=1;i<=10;i++) s+=i; "
45+
'std::cout << "cpp: sum(1..10)=" << s << std::endl; return 0;}',
46+
"55",
47+
),
48+
"php": (
49+
'<?php $s=0; for($i=1;$i<=10;$i++){ $s+=$i; } echo "php: sum(1..10)=$s\\n";',
50+
"55",
51+
),
52+
"rs": (
53+
"fn main(){ let mut s = 0; for i in 1..=10 { s += i; } "
54+
'println!("rs: sum(1..10)={}", s); }',
55+
"55",
56+
),
57+
"r": ("cat('r: sum(1..10)=', sum(1:10), '\\n')", "55"),
58+
"f90": (
59+
"program sum\n integer :: s, i\n s = 0\n do i = 1, 10\n s = s + i\n end do\n"
60+
' print *, "f90: sum(1..10)=", s\nend program sum\n',
61+
"55",
62+
),
63+
"d": (
64+
'import std.stdio;\nvoid main(){ int s=0; foreach(i; 1..11) s+=i; writeln("d: sum(1..10)=", s); }',
65+
"55",
66+
),
67+
}
68+
69+
70+
@dataclass
71+
class TestResult:
72+
name: str
73+
passed: bool
74+
message: str
75+
duration_ms: float
76+
77+
78+
class FunctionalTester:
79+
def __init__(self, api_base: str, api_key: str, timeout: int = 60):
80+
self.api_base = api_base.rstrip("/")
81+
self.api_key = api_key
82+
self.timeout = timeout
83+
self.results: List[TestResult] = []
84+
85+
def headers(self) -> Dict[str, str]:
86+
return {"x-api-key": self.api_key, "Content-Type": "application/json"}
87+
88+
async def run_all(self):
89+
async with httpx.AsyncClient(
90+
base_url=self.api_base, timeout=self.timeout, verify=False
91+
) as client:
92+
# Health tests
93+
await self.test_health(client)
94+
await self.test_health_detailed(client)
95+
96+
# Language execution tests
97+
for lang, (code, expected) in LANGUAGE_SNIPPETS.items():
98+
await self.test_language_execution(client, lang, code, expected)
99+
100+
# State persistence tests
101+
await self.test_state_persistence(client)
102+
103+
# File tests
104+
await self.test_file_upload_download(client)
105+
106+
self.print_summary()
107+
108+
async def test_health(self, client: httpx.AsyncClient):
109+
start = time.perf_counter()
110+
try:
111+
r = await client.get("/health")
112+
passed = r.status_code == 200 and "status" in r.json()
113+
msg = f"Status: {r.status_code}" if passed else f"Failed: {r.text[:100]}"
114+
except Exception as e:
115+
passed = False
116+
msg = str(e)
117+
self.results.append(TestResult(
118+
"health_check", passed, msg, (time.perf_counter() - start) * 1000
119+
))
120+
121+
async def test_health_detailed(self, client: httpx.AsyncClient):
122+
start = time.perf_counter()
123+
try:
124+
r = await client.get("/health/detailed", headers=self.headers())
125+
passed = r.status_code in [200, 503]
126+
msg = f"Status: {r.status_code}" if passed else f"Failed: {r.text[:100]}"
127+
except Exception as e:
128+
passed = False
129+
msg = str(e)
130+
self.results.append(TestResult(
131+
"health_detailed", passed, msg, (time.perf_counter() - start) * 1000
132+
))
133+
134+
async def test_language_execution(
135+
self, client: httpx.AsyncClient, lang: str, code: str, expected: str
136+
):
137+
start = time.perf_counter()
138+
try:
139+
entity_id = f"test-{uuid.uuid4().hex[:8]}"
140+
r = await client.post(
141+
"/exec",
142+
headers=self.headers(),
143+
json={"code": code, "lang": lang, "entity_id": entity_id},
144+
)
145+
if r.status_code == 200:
146+
data = r.json()
147+
stdout = data.get("stdout", "")
148+
if expected in stdout:
149+
passed = True
150+
msg = f"OK - output contains '{expected}'"
151+
else:
152+
passed = False
153+
msg = f"Expected '{expected}' in stdout, got: {stdout[:100]}"
154+
else:
155+
passed = False
156+
msg = f"Status {r.status_code}: {r.text[:100]}"
157+
except Exception as e:
158+
passed = False
159+
msg = str(e)
160+
self.results.append(TestResult(
161+
f"exec_{lang}", passed, msg, (time.perf_counter() - start) * 1000
162+
))
163+
164+
async def test_state_persistence(self, client: httpx.AsyncClient):
165+
start = time.perf_counter()
166+
entity_id = f"state-test-{uuid.uuid4().hex[:8]}"
167+
try:
168+
# Step 1: Create variable
169+
r1 = await client.post(
170+
"/exec",
171+
headers=self.headers(),
172+
json={"code": "test_var = 42", "lang": "py", "entity_id": entity_id},
173+
)
174+
if r1.status_code != 200:
175+
self.results.append(TestResult(
176+
"state_persistence", False, f"Step 1 failed: {r1.text[:100]}",
177+
(time.perf_counter() - start) * 1000
178+
))
179+
return
180+
181+
has_state = r1.json().get("has_state", False)
182+
183+
# Step 2: Use variable
184+
r2 = await client.post(
185+
"/exec",
186+
headers=self.headers(),
187+
json={"code": "print(test_var + 1)", "lang": "py", "entity_id": entity_id},
188+
)
189+
if r2.status_code != 200:
190+
self.results.append(TestResult(
191+
"state_persistence", False, f"Step 2 failed: {r2.text[:100]}",
192+
(time.perf_counter() - start) * 1000
193+
))
194+
return
195+
196+
stdout = r2.json().get("stdout", "")
197+
if "43" in stdout:
198+
passed = True
199+
msg = f"OK - state persisted (has_state={has_state})"
200+
else:
201+
passed = False
202+
msg = f"Expected '43' in stdout, got: {stdout[:100]}, stderr: {r2.json().get('stderr', '')[:100]}"
203+
204+
except Exception as e:
205+
passed = False
206+
msg = str(e)
207+
self.results.append(TestResult(
208+
"state_persistence", passed, msg, (time.perf_counter() - start) * 1000
209+
))
210+
211+
async def test_file_upload_download(self, client: httpx.AsyncClient):
212+
start = time.perf_counter()
213+
entity_id = f"file-test-{uuid.uuid4().hex[:8]}"
214+
try:
215+
# Upload
216+
files = {"files": ("test.txt", b"hello world", "text/plain")}
217+
r = await client.post(
218+
"/upload",
219+
headers={"x-api-key": self.api_key},
220+
files=files,
221+
data={"entity_id": entity_id},
222+
)
223+
if r.status_code != 200:
224+
self.results.append(TestResult(
225+
"file_upload", False, f"Upload failed: {r.text[:100]}",
226+
(time.perf_counter() - start) * 1000
227+
))
228+
return
229+
230+
data = r.json()
231+
session_id = data.get("session_id")
232+
file_list = data.get("files", [])
233+
if not file_list:
234+
self.results.append(TestResult(
235+
"file_upload", False, "No files in response",
236+
(time.perf_counter() - start) * 1000
237+
))
238+
return
239+
240+
file_id = file_list[0].get("fileId")
241+
242+
# Download
243+
r2 = await client.get(
244+
f"/download/{session_id}/{file_id}",
245+
headers=self.headers(),
246+
)
247+
if r2.status_code == 200 and r2.content == b"hello world":
248+
passed = True
249+
msg = "OK - upload and download successful"
250+
else:
251+
passed = False
252+
msg = f"Download failed: status={r2.status_code}"
253+
254+
except Exception as e:
255+
passed = False
256+
msg = str(e)
257+
self.results.append(TestResult(
258+
"file_upload_download", passed, msg, (time.perf_counter() - start) * 1000
259+
))
260+
261+
def print_summary(self):
262+
passed = sum(1 for r in self.results if r.passed)
263+
failed = len(self.results) - passed
264+
265+
print("\n" + "=" * 70)
266+
print("FUNCTIONAL TEST RESULTS")
267+
print("=" * 70)
268+
print(f"Endpoint: {self.api_base}")
269+
print("=" * 70)
270+
271+
for r in self.results:
272+
status = "PASS" if r.passed else "FAIL"
273+
print(f"[{status}] {r.name:30} ({r.duration_ms:7.1f}ms) - {r.message[:50]}")
274+
275+
print("=" * 70)
276+
print(f"TOTAL: {passed}/{len(self.results)} passed, {failed} failed")
277+
print(f"Success rate: {passed/len(self.results)*100:.1f}%")
278+
print("=" * 70)
279+
280+
return failed == 0
281+
282+
283+
def main():
284+
parser = argparse.ArgumentParser(description="Run functional tests")
285+
parser.add_argument("--api-base", required=True, help="API base URL")
286+
parser.add_argument("--api-key", required=True, help="API key")
287+
parser.add_argument("--timeout", type=int, default=60, help="Request timeout")
288+
args = parser.parse_args()
289+
290+
tester = FunctionalTester(args.api_base, args.api_key, args.timeout)
291+
asyncio.run(tester.run_all())
292+
293+
294+
if __name__ == "__main__":
295+
main()

src/services/container/pool.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,15 +251,21 @@ def get_stats(self, language: str = None) -> Dict[str, PoolStats]:
251251
async def _create_fresh_container(
252252
self, session_id: str, language: str
253253
) -> Container:
254-
"""Create a new container."""
254+
"""Create a new container when pool is exhausted."""
255255
image = self._container_manager.get_image_for_language(language)
256256

257257
# Ensure image is available
258258
await self._container_manager.pull_image_if_needed(image)
259259

260+
# Enable REPL mode for Python if configured (same as pooled containers)
261+
use_repl_mode = language == "py" and settings.repl_enabled
262+
260263
# Create and start container
261264
container = self._container_manager.create_container(
262-
image=image, session_id=session_id, language=language
265+
image=image,
266+
session_id=session_id,
267+
language=language,
268+
repl_mode=use_repl_mode,
263269
)
264270

265271
started = await self._container_manager.start_container(container)
@@ -270,11 +276,22 @@ async def _create_fresh_container(
270276
pass
271277
raise RuntimeError(f"Failed to start container for {language}")
272278

279+
# For REPL containers, wait for REPL to be ready before returning
280+
if use_repl_mode:
281+
repl_ready = await self._wait_for_repl_ready(container)
282+
if not repl_ready:
283+
logger.warning(
284+
"REPL not ready in fresh container",
285+
container_id=container.id[:12],
286+
language=language,
287+
)
288+
273289
logger.info(
274290
"Created fresh container",
275291
session_id=session_id[:12] if session_id else "none",
276292
container_id=container.id[:12],
277293
language=language,
294+
repl_mode=use_repl_mode,
278295
)
279296

280297
return container

src/services/execution/runner.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,14 +398,29 @@ async def _create_fresh_container(
398398
image = self.container_manager.get_image_for_language(language)
399399
await self.container_manager.pull_image_if_needed(image)
400400

401+
# Enable REPL mode for Python if configured (matches pool behavior)
402+
use_repl_mode = language == "py" and settings.repl_enabled
403+
401404
container = self.container_manager.create_container(
402405
image=image,
403406
session_id=session_id,
404407
working_dir="/mnt/data",
405408
language=language,
409+
repl_mode=use_repl_mode,
406410
)
407411
await self.container_manager.start_container(container)
408412

413+
# For REPL containers, wait for REPL to be ready before returning
414+
if use_repl_mode:
415+
repl_executor = REPLExecutor(self.container_manager.client)
416+
ready = await repl_executor.wait_for_ready(container, timeout=10.0)
417+
if not ready:
418+
logger.warning(
419+
"REPL not ready in fresh container, may affect performance",
420+
session_id=session_id[:12],
421+
container_id=container.id[:12],
422+
)
423+
409424
self.session_containers[session_id] = container
410425
logger.info(
411426
"Fresh container created",

tests/functional/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Functional tests for live API endpoint testing."""

0 commit comments

Comments
 (0)