Skip to content

Commit 4cc1b94

Browse files
committed
feat: US-085 - Add cross-runtime terminal tests: node -e and python3 -c from brush-shell
1 parent 8bd5e56 commit 4cc1b94

2 files changed

Lines changed: 154 additions & 2 deletions

File tree

packages/kernel/src/kernel.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,17 @@ class KernelImpl implements Kernel {
400400
// Register PID ownership before driver.spawn() so the driver can use it
401401
this.driverPids.get(driver.name)?.add(pid);
402402

403+
// Cross-runtime spawn: parent driver must also track child PID so
404+
// it can waitpid/kill/interact with the child process
405+
if (callerPid !== undefined) {
406+
for (const [name, pids] of this.driverPids) {
407+
if (name !== driver.name && pids.has(callerPid)) {
408+
pids.add(pid);
409+
break;
410+
}
411+
}
412+
}
413+
403414
// Create FD table — wire pipe FDs when overrides are provided
404415
const table = this.createChildFDTable(pid, options, callerPid);
405416

@@ -411,15 +422,43 @@ class KernelImpl implements Kernel {
411422
const stdoutBuf: Uint8Array[] = [];
412423
const stderrBuf: Uint8Array[] = [];
413424

425+
// Resolve output callbacks: when a child inherits non-piped stdio from
426+
// a parent, forward output to the parent's DriverProcess callbacks so
427+
// cross-runtime child output reaches the top-level collector.
428+
let stdoutCb: ((data: Uint8Array) => void) | undefined;
429+
let stderrCb: ((data: Uint8Array) => void) | undefined;
430+
if (!stdoutPiped) {
431+
if (options?.onStdout) {
432+
stdoutCb = options.onStdout;
433+
} else if (callerPid !== undefined) {
434+
const parent = this.processTable.get(callerPid);
435+
if (parent?.driverProcess.onStdout) {
436+
stdoutCb = parent.driverProcess.onStdout;
437+
}
438+
}
439+
if (!stdoutCb) stdoutCb = (data) => stdoutBuf.push(data);
440+
}
441+
if (!stderrPiped) {
442+
if (options?.onStderr) {
443+
stderrCb = options.onStderr;
444+
} else if (callerPid !== undefined) {
445+
const parent = this.processTable.get(callerPid);
446+
if (parent?.driverProcess.onStderr) {
447+
stderrCb = parent.driverProcess.onStderr;
448+
}
449+
}
450+
if (!stderrCb) stderrCb = (data) => stderrBuf.push(data);
451+
}
452+
414453
// Build process context with pre-wired callbacks
415454
const ctx: ProcessContext = {
416455
pid,
417456
ppid: callerPid ?? 0,
418457
env: { ...this.env, ...options?.env },
419458
cwd: options?.cwd ?? this.cwd,
420459
fds: { stdin: 0, stdout: 1, stderr: 2 },
421-
onStdout: stdoutPiped ? undefined : (data) => stdoutBuf.push(data),
422-
onStderr: stderrPiped ? undefined : (data) => stderrBuf.push(data),
460+
onStdout: stdoutCb,
461+
onStderr: stderrCb,
423462
};
424463

425464
// Spawn via driver
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Cross-runtime terminal tests — node -e and python3 -c from brush-shell.
3+
*
4+
* Mounts WasmVM + Node + Python into the same kernel and verifies
5+
* interactive output through TerminalHarness.
6+
*
7+
* Gated: WasmVM binary required for all tests, Pyodide import for Python.
8+
*/
9+
10+
import { describe, it, expect, afterEach } from 'vitest';
11+
import { TerminalHarness } from '../../../kernel/test/terminal-harness.ts';
12+
import {
13+
createIntegrationKernel,
14+
skipUnlessWasmBuilt,
15+
type IntegrationKernelResult,
16+
} from './helpers.ts';
17+
18+
/** brush-shell interactive prompt. */
19+
const PROMPT = 'sh-0.4$ ';
20+
21+
const wasmSkip = skipUnlessWasmBuilt();
22+
23+
// Dynamic import check — require.resolve finds pyodide but ESM import may fail
24+
let pyodideImportable = false;
25+
try {
26+
await import('pyodide');
27+
pyodideImportable = true;
28+
} catch {
29+
// pyodide can't be imported as ESM — skip Python tests
30+
}
31+
32+
// ---------------------------------------------------------------------------
33+
// Node cross-runtime terminal tests
34+
// ---------------------------------------------------------------------------
35+
36+
describe.skipIf(wasmSkip)('cross-runtime terminal: node', () => {
37+
let harness: TerminalHarness;
38+
let ctx: IntegrationKernelResult;
39+
40+
afterEach(async () => {
41+
await harness?.dispose();
42+
await ctx?.dispose();
43+
});
44+
45+
it('node -e "console.log(42)" → 42 appears on screen', async () => {
46+
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
47+
harness = new TerminalHarness(ctx.kernel);
48+
49+
await harness.waitFor(PROMPT);
50+
await harness.type('node -e "console.log(42)"\n');
51+
await harness.waitFor(PROMPT, 2, 10_000);
52+
53+
const screen = harness.screenshotTrimmed();
54+
expect(screen).toContain('42');
55+
// Verify prompt returned
56+
const lines = screen.split('\n');
57+
expect(lines[lines.length - 1]).toBe(PROMPT);
58+
}, 15_000);
59+
60+
it('^C during node -e — shell survives and prompt returns', async () => {
61+
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
62+
harness = new TerminalHarness(ctx.kernel);
63+
64+
await harness.waitFor(PROMPT);
65+
// Start a long-running node process
66+
harness.shell.write('node -e "setTimeout(() => {}, 60000)"\n');
67+
68+
// Give it a moment to start, then send ^C
69+
await new Promise((r) => setTimeout(r, 500));
70+
harness.shell.write('\x03');
71+
72+
// Wait for prompt to return
73+
await harness.waitFor(PROMPT, 2, 10_000);
74+
75+
// Verify shell is still alive — type another command
76+
await harness.type('echo alive\n');
77+
await harness.waitFor('alive', 1, 5_000);
78+
79+
const screen = harness.screenshotTrimmed();
80+
expect(screen).toContain('alive');
81+
}, 20_000);
82+
});
83+
84+
// ---------------------------------------------------------------------------
85+
// Python cross-runtime terminal tests
86+
// ---------------------------------------------------------------------------
87+
88+
describe.skipIf(wasmSkip || !pyodideImportable)('cross-runtime terminal: python', () => {
89+
let harness: TerminalHarness;
90+
let ctx: IntegrationKernelResult;
91+
92+
afterEach(async () => {
93+
await harness?.dispose();
94+
await ctx?.dispose();
95+
});
96+
97+
it('python3 -c "print(99)" → 99 appears on screen', async () => {
98+
ctx = await createIntegrationKernel({
99+
runtimes: ['wasmvm', 'python'],
100+
});
101+
harness = new TerminalHarness(ctx.kernel);
102+
103+
await harness.waitFor(PROMPT);
104+
await harness.type('python3 -c "print(99)"\n');
105+
await harness.waitFor(PROMPT, 2, 30_000);
106+
107+
const screen = harness.screenshotTrimmed();
108+
expect(screen).toContain('99');
109+
// Verify prompt returned
110+
const lines = screen.split('\n');
111+
expect(lines[lines.length - 1]).toBe(PROMPT);
112+
}, 45_000);
113+
});

0 commit comments

Comments
 (0)