Skip to content

Commit 1ee6460

Browse files
authored
Merge pull request #135 from LennartvdM/codex/make-test-console-fully-interactive
Enable interactive test console backed by Netlify runner
2 parents a069ff7 + ca6d05d commit 1ee6460

5 files changed

Lines changed: 957 additions & 36 deletions

File tree

netlify/functions/tes-runner.js

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
const PYTHON_TIMEOUT_MS = 60_000;
2+
const PYTHON_CANDIDATES = [
3+
process.env.WYRD_PYTHON,
4+
'python3',
5+
'python',
6+
].filter(Boolean);
7+
8+
const PYTHON_BRIDGE_CODE = String.raw`
9+
import json
10+
import sys
11+
12+
from tes.runner import run_script
13+
14+
15+
def _as_mapping(value):
16+
if isinstance(value, dict):
17+
return value
18+
return {}
19+
20+
21+
def main():
22+
raw = sys.stdin.read()
23+
try:
24+
data = json.loads(raw or '{}')
25+
except json.JSONDecodeError:
26+
data = {}
27+
28+
script = data.get('script') or ''
29+
runner_config = _as_mapping(data.get('runnerConfig'))
30+
inputs = _as_mapping(data.get('inputs'))
31+
32+
globals_update = {
33+
'RUNNER_CONFIG': runner_config,
34+
'EXECUTION_INPUTS': inputs,
35+
}
36+
37+
result = run_script(script, globals_update=globals_update)
38+
39+
payload = {
40+
'stdout': result.get('stdout', ''),
41+
'stderr': result.get('stderr', ''),
42+
'resultJSON': result.get('resultJSON'),
43+
}
44+
45+
raw_result = result.get('resultJSON')
46+
parsed = None
47+
if isinstance(raw_result, str) and raw_result:
48+
try:
49+
parsed = json.loads(raw_result)
50+
except json.JSONDecodeError:
51+
parsed = None
52+
payload['result'] = parsed
53+
54+
print(json.dumps(payload))
55+
56+
57+
if __name__ == '__main__':
58+
main()
59+
`;
60+
61+
let cachedEnvironment;
62+
63+
async function loadEnvironment() {
64+
if (cachedEnvironment) {
65+
return cachedEnvironment;
66+
}
67+
68+
const [{ spawn }, pathModule, urlModule] = await Promise.all([
69+
import('child_process'),
70+
import('path'),
71+
import('url'),
72+
]);
73+
74+
const path = pathModule.default || pathModule;
75+
const { fileURLToPath } = urlModule;
76+
const __filename = fileURLToPath(import.meta.url);
77+
const repoRoot = path.resolve(path.dirname(__filename), '..', '..');
78+
79+
cachedEnvironment = { spawn, path, repoRoot };
80+
return cachedEnvironment;
81+
}
82+
83+
function jsonResponse(statusCode, payload) {
84+
return {
85+
statusCode,
86+
headers: {
87+
'content-type': 'application/json; charset=utf-8',
88+
'access-control-allow-origin': '*',
89+
},
90+
body: JSON.stringify(payload),
91+
};
92+
}
93+
94+
function spawnPythonOnce(spawnFn, binary, args, options, input, timeoutMs) {
95+
return new Promise((resolve, reject) => {
96+
let stdout = '';
97+
let stderr = '';
98+
let completed = false;
99+
let timer = null;
100+
let child;
101+
102+
try {
103+
child = spawnFn(binary, args, options);
104+
} catch (error) {
105+
reject(error);
106+
return;
107+
}
108+
109+
const finalize = (error) => {
110+
if (completed) {
111+
return;
112+
}
113+
completed = true;
114+
if (timer) {
115+
clearTimeout(timer);
116+
}
117+
if (error) {
118+
error.stdout = stdout;
119+
error.stderr = stderr;
120+
reject(error);
121+
} else {
122+
resolve({ stdout, stderr, binary });
123+
}
124+
};
125+
126+
child.stdout.setEncoding('utf8');
127+
child.stdout.on('data', (chunk) => {
128+
stdout += chunk;
129+
});
130+
131+
child.stderr.setEncoding('utf8');
132+
child.stderr.on('data', (chunk) => {
133+
stderr += chunk;
134+
});
135+
136+
child.on('error', (error) => {
137+
finalize(error);
138+
});
139+
140+
child.on('close', (code) => {
141+
if (code === 0) {
142+
finalize(null);
143+
} else {
144+
const error = new Error(`Python exited with code ${code}`);
145+
error.code = code;
146+
finalize(error);
147+
}
148+
});
149+
150+
if (typeof input === 'string' && input.length > 0) {
151+
try {
152+
child.stdin.write(input);
153+
} catch (error) {
154+
finalize(error);
155+
return;
156+
}
157+
}
158+
child.stdin.end();
159+
160+
if (timeoutMs > 0) {
161+
timer = setTimeout(() => {
162+
const error = new Error('Python execution timed out.');
163+
error.code = 'TIMEOUT';
164+
try {
165+
child.kill('SIGKILL');
166+
} catch (killError) {
167+
// ignore inability to kill the process
168+
}
169+
finalize(error);
170+
}, timeoutMs);
171+
timer.unref?.();
172+
}
173+
});
174+
}
175+
176+
async function runPythonBridge(payload) {
177+
const { spawn, path, repoRoot } = await loadEnvironment();
178+
const args = ['-c', PYTHON_BRIDGE_CODE];
179+
const env = { ...process.env };
180+
const existingPath = env.PYTHONPATH ? env.PYTHONPATH.split(path.delimiter) : [];
181+
if (!existingPath.includes(repoRoot)) {
182+
env.PYTHONPATH = [repoRoot, ...existingPath].filter(Boolean).join(path.delimiter);
183+
}
184+
185+
const options = { cwd: repoRoot, env };
186+
const input = JSON.stringify(payload ?? {});
187+
188+
let lastError = null;
189+
for (const binary of PYTHON_CANDIDATES) {
190+
try {
191+
return await spawnPythonOnce(spawn, binary, args, options, input, PYTHON_TIMEOUT_MS);
192+
} catch (error) {
193+
if (error && error.code === 'ENOENT') {
194+
lastError = error;
195+
continue;
196+
}
197+
throw error;
198+
}
199+
}
200+
201+
if (lastError) {
202+
throw lastError;
203+
}
204+
205+
const error = new Error('No Python interpreter found.');
206+
error.code = 'ENOENT';
207+
throw error;
208+
}
209+
210+
export async function handler(event) {
211+
if (event.httpMethod && event.httpMethod !== 'POST') {
212+
return jsonResponse(405, { error: 'Method not allowed' });
213+
}
214+
215+
let payload;
216+
try {
217+
payload = event.body ? JSON.parse(event.body) : {};
218+
} catch (error) {
219+
return jsonResponse(400, {
220+
error: 'Invalid JSON body.',
221+
details: error?.message || 'Unable to parse request body.',
222+
});
223+
}
224+
225+
const script = typeof payload.script === 'string' ? payload.script : '';
226+
if (!script.trim()) {
227+
return jsonResponse(400, { error: 'Script is required.' });
228+
}
229+
230+
const runnerConfig = payload.runnerConfig ?? {};
231+
const inputs = payload.inputs ?? {};
232+
233+
const start = Date.now();
234+
let bridgeResult;
235+
try {
236+
bridgeResult = await runPythonBridge({
237+
script,
238+
runnerConfig,
239+
inputs,
240+
});
241+
} catch (error) {
242+
const elapsedMs = Date.now() - start;
243+
if (error?.code === 'ENOENT') {
244+
return jsonResponse(500, {
245+
error: 'Python interpreter not available.',
246+
elapsedMs,
247+
});
248+
}
249+
if (error?.code === 'TIMEOUT') {
250+
return jsonResponse(504, {
251+
error: 'Python execution timed out.',
252+
stdout: error.stdout || '',
253+
stderr: error.stderr || '',
254+
elapsedMs,
255+
});
256+
}
257+
return jsonResponse(502, {
258+
error: 'Python execution failed.',
259+
details: error?.message || 'Unknown error.',
260+
stdout: error?.stdout || '',
261+
stderr: error?.stderr || '',
262+
elapsedMs,
263+
});
264+
}
265+
266+
const elapsedMs = Date.now() - start;
267+
let responsePayload;
268+
try {
269+
responsePayload = bridgeResult.stdout ? JSON.parse(bridgeResult.stdout) : {};
270+
} catch (error) {
271+
return jsonResponse(502, {
272+
error: 'Failed to decode runner response.',
273+
details: error?.message || 'Invalid JSON emitted by runner.',
274+
raw: bridgeResult.stdout,
275+
bridgeStderr: bridgeResult.stderr,
276+
elapsedMs,
277+
});
278+
}
279+
280+
const resultValue =
281+
typeof responsePayload.result !== 'undefined' ? responsePayload.result : null;
282+
283+
return jsonResponse(200, {
284+
stdout: typeof responsePayload.stdout === 'string' ? responsePayload.stdout : '',
285+
stderr: typeof responsePayload.stderr === 'string' ? responsePayload.stderr : '',
286+
resultJSON:
287+
typeof responsePayload.resultJSON === 'string' || responsePayload.resultJSON === null
288+
? responsePayload.resultJSON
289+
: null,
290+
result: resultValue,
291+
structured:
292+
responsePayload.structured && typeof responsePayload.structured === 'object'
293+
? responsePayload.structured
294+
: null,
295+
elapsedMs,
296+
bridgeStderr: bridgeResult.stderr || '',
297+
pythonBinary: bridgeResult.binary,
298+
});
299+
}

tes/runner.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,36 @@
77
import sys
88
import traceback
99
from contextlib import redirect_stderr, redirect_stdout
10-
from typing import Any, Dict
10+
from typing import Any, Dict, Optional
1111

1212

1313
ResultDict = Dict[str, str | None]
1414

1515

16-
def run_script(source: str) -> ResultDict:
16+
def run_script(source: str, *, globals_update: Optional[Dict[str, Any]] = None) -> ResultDict:
1717
"""Execute a user provided script and capture its side effects.
1818
1919
The execution environment mirrors running the script as ``__main__`` while
2020
capturing ``stdout`` and ``stderr`` so that callers can surface the output in
2121
a UI. If the script defines a callable ``main`` function its return value is
2222
JSON serialised and stored under ``resultJSON``. Any exceptions are caught
2323
and their traceback is written to ``stderr``.
24+
25+
Parameters
26+
----------
27+
source:
28+
Python source code to execute.
29+
globals_update:
30+
Optional mapping merged into the script globals before execution. This
31+
allows hosts to pre-populate values such as configuration objects that a
32+
console script can read.
2433
"""
2534

2635
stdout_buffer = io.StringIO()
2736
stderr_buffer = io.StringIO()
2837
globals_dict: Dict[str, Any] = {"__name__": "__main__"}
38+
if globals_update:
39+
globals_dict.update(globals_update)
2940
result_json: str | None = None
3041

3142
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):

tests/test_runner.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import textwrap
45

56
from tes import run_script
@@ -64,3 +65,26 @@ def main():
6465
assert "not JSON serializable" in result["stderr"]
6566
assert result["resultJSON"] is None
6667

68+
69+
def test_run_script_injects_global_values() -> None:
70+
script = textwrap.dedent(
71+
"""
72+
def main():
73+
return {
74+
"cfg": RUNNER_CONFIG,
75+
"inputs": EXECUTION_INPUTS,
76+
}
77+
"""
78+
)
79+
result = run_script(
80+
script,
81+
globals_update={
82+
"RUNNER_CONFIG": {"rig": "workforce"},
83+
"EXECUTION_INPUTS": {"seed": 99},
84+
},
85+
)
86+
assert result["stderr"] == ""
87+
payload = json.loads(result["resultJSON"])
88+
assert payload["cfg"]["rig"] == "workforce"
89+
assert payload["inputs"]["seed"] == 99
90+

0 commit comments

Comments
 (0)