158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
/**
|
|
* End-to-end exercise of the subprocess-backed Ruby runner.
|
|
*
|
|
* Gated by `PI_RUBY_INTEGRATION=1` so CI without a real Ruby interpreter
|
|
* (or sandboxes where subprocess spawning is restricted) does not fail.
|
|
*/
|
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
import { disposeAllRubyKernelSessions, executeRubyWithKernel } from "@oh-my-pi/pi-coding-agent/eval/rb/executor";
|
|
import { RubyKernel } from "@oh-my-pi/pi-coding-agent/eval/rb/kernel";
|
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
const SHOULD_RUN = Bun.env.PI_RUBY_INTEGRATION === "1";
|
|
|
|
describe.skipIf(!SHOULD_RUN)("ruby runner subprocess", () => {
|
|
afterEach(async () => {
|
|
await disposeAllRubyKernelSessions();
|
|
});
|
|
|
|
it("streams stdout chunks as they are produced", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-stream-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const chunks: string[] = [];
|
|
const result = await executeRubyWithKernel(kernel, "5.times { |i| puts i }", {
|
|
onChunk: chunk => {
|
|
chunks.push(chunk);
|
|
},
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
expect(chunks.join("")).toContain("0\n");
|
|
expect(chunks.join("")).toContain("4\n");
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
|
|
it("keeps local variables across cells on one kernel", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-state-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const first = await executeRubyWithKernel(kernel, "x = 41", {});
|
|
expect(first.exitCode).toBe(0);
|
|
const second = await executeRubyWithKernel(kernel, "x + 1", {});
|
|
expect(second.exitCode).toBe(0);
|
|
expect(second.output).toContain("42");
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
|
|
it("auto-displays the last expression but suppresses assignments", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-display-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const assigned = await executeRubyWithKernel(kernel, "y = { a: 1, b: [2, 3] }", {});
|
|
expect(assigned.displayOutputs).toHaveLength(0);
|
|
|
|
const expr = await executeRubyWithKernel(kernel, "y", {});
|
|
const json = expr.displayOutputs.find(o => o.type === "json");
|
|
expect(json).toBeDefined();
|
|
if (json?.type === "json") {
|
|
expect(json.data).toEqual({ a: 1, b: [2, 3] });
|
|
}
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
|
|
it("cancels a running cell via signal and keeps the kernel usable", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-cancel-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const controller = new AbortController();
|
|
// Real delay: this interrupts a live OS subprocess mid-`sleep`, which runs on
|
|
// the platform clock — fake timers cannot drive it. Abort once the cell is in flight.
|
|
setTimeout(() => controller.abort(), 300);
|
|
const cancelled = await executeRubyWithKernel(kernel, "sleep 30", { signal: controller.signal });
|
|
expect(cancelled.cancelled).toBe(true);
|
|
|
|
const after = await executeRubyWithKernel(kernel, "20 + 22", {});
|
|
expect(after.exitCode).toBe(0);
|
|
expect(after.output).toContain("42");
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
|
|
it("does not write an already-aborted request and keeps the kernel usable", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-preabort-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const cancelled = await executeRubyWithKernel(kernel, 'File.write("aborted.txt", "nope")', {
|
|
signal: controller.signal,
|
|
});
|
|
expect(cancelled.cancelled).toBe(true);
|
|
|
|
const sideEffect = await executeRubyWithKernel(kernel, 'File.exist?("aborted.txt")', {});
|
|
expect(sideEffect.exitCode).toBe(0);
|
|
expect(sideEffect.output).toContain("false");
|
|
|
|
const after = await executeRubyWithKernel(kernel, "20 + 22", {});
|
|
expect(after.exitCode).toBe(0);
|
|
expect(after.output).toContain("42");
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
|
|
it("surfaces Ruby errors with a non-zero exit and the message", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-error-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const result = await executeRubyWithKernel(kernel, "raise 'boom'", {});
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.output).toContain("boom");
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
|
|
it("surfaces Ruby and child-process stderr in output", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-stderr-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const result = await executeRubyWithKernel(
|
|
kernel,
|
|
[
|
|
'$stderr.print("ruby stderr\\n")',
|
|
'STDERR.print("constant stderr\\n")',
|
|
'require "rbconfig"',
|
|
'system(RbConfig.ruby, "-e", \'STDERR.print("child stderr\\\\n"); STDOUT.print("child stdout\\\\n")\')',
|
|
].join("\n"),
|
|
{},
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.output).toContain("ruby stderr\n");
|
|
expect(result.output).toContain("constant stderr\n");
|
|
expect(result.output).toContain("child stderr\n");
|
|
expect(result.output).toContain("child stdout\n");
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
|
|
it("exposes prelude file helpers", async () => {
|
|
using tempDir = TempDir.createSync("@ruby-runner-prelude-");
|
|
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
|
|
try {
|
|
const written = await executeRubyWithKernel(kernel, 'write("note.txt", "hello"); read("note.txt")', {});
|
|
expect(written.output).toContain("hello");
|
|
} finally {
|
|
await kernel.shutdown();
|
|
}
|
|
});
|
|
});
|