2026-11-03 14:36:21 +00:00
|
|
|
import assert from "node:assert/strict";
|
2026-11-03 16:13:15 +00:00
|
|
|
import { execFile, spawn } from "node:child_process";
|
2026-11-03 14:36:21 +00:00
|
|
|
import { once } from "node:events";
|
|
|
|
|
import { resolve } from "node:path";
|
|
|
|
|
import test from "node:test";
|
2026-11-03 16:13:15 +00:00
|
|
|
import { promisify } from "node:util";
|
2026-11-03 14:36:21 +00:00
|
|
|
import { ImageClient } from "../src/client";
|
|
|
|
|
import { startImage } from "./server";
|
|
|
|
|
|
2026-11-03 16:21:41 +00:00
|
|
|
test(
|
|
|
|
|
"Ctrl+C exits a terminal REPL with a clean WebSocket handshake",
|
|
|
|
|
{ timeout: 10000, skip: process.platform !== "linux" },
|
|
|
|
|
async (t) => {
|
|
|
|
|
const image = await startImage();
|
|
|
|
|
t.after(() => image.stop());
|
|
|
|
|
const executable = resolve(__dirname, "../../../target/debug/runa-cli");
|
|
|
|
|
const terminal = spawn("script", ["-q", "-e", "-c", '"$RUNA_CLI_TEST" repl', "/dev/null"], {
|
|
|
|
|
env: {
|
|
|
|
|
...process.env,
|
|
|
|
|
TERM: "xterm",
|
|
|
|
|
RUNA_CONNECT: image.address,
|
|
|
|
|
RUNA_CLI_TEST: executable,
|
|
|
|
|
},
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
});
|
|
|
|
|
t.after(() => {
|
|
|
|
|
if (terminal.exitCode === null && terminal.signalCode === null) terminal.kill();
|
|
|
|
|
});
|
|
|
|
|
let stdout = "";
|
|
|
|
|
let stderr = "";
|
|
|
|
|
let interrupted = false;
|
|
|
|
|
terminal.stdout.on("data", (chunk: Buffer) => {
|
|
|
|
|
stdout += chunk.toString();
|
|
|
|
|
if (!interrupted && stdout.includes("runa> ")) {
|
|
|
|
|
interrupted = true;
|
|
|
|
|
terminal.stdin.write("\x03");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
terminal.stderr.on("data", (chunk: Buffer) => {
|
|
|
|
|
stderr += chunk.toString();
|
|
|
|
|
});
|
|
|
|
|
const [code] = await once(terminal, "exit");
|
|
|
|
|
assert.ok(interrupted, stdout + stderr);
|
|
|
|
|
assert.equal(code, 0, stdout + stderr);
|
|
|
|
|
assert.equal(stderr, "");
|
|
|
|
|
assert.match(stdout, /\^C/);
|
|
|
|
|
assert.doesNotMatch(stdout, /protocol error|reset without closing handshake/i);
|
|
|
|
|
assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i);
|
|
|
|
|
const inspector = await ImageClient.connect(image.address, (error) => {
|
|
|
|
|
assert.equal(error, undefined);
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
assert.deepEqual(await inspector.listActors(), []);
|
|
|
|
|
} finally {
|
|
|
|
|
inspector.close();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-11-03 16:13:15 +00:00
|
|
|
test(
|
|
|
|
|
"one-shot CLI commands share the image, report errors, and close cleanly",
|
|
|
|
|
{ timeout: 15000 },
|
|
|
|
|
async (t) => {
|
|
|
|
|
const image = await startImage();
|
|
|
|
|
t.after(() => image.stop());
|
|
|
|
|
const executable = resolve(
|
|
|
|
|
__dirname,
|
|
|
|
|
"../../../target/debug",
|
|
|
|
|
process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
|
|
|
|
|
);
|
|
|
|
|
const execute = promisify(execFile);
|
|
|
|
|
const run = async (...args: string[]) => {
|
|
|
|
|
const result = await execute(executable, ["--connect", image.address, ...args], {
|
|
|
|
|
env: { ...process.env, RUNA_CONNECT: "invalid" },
|
|
|
|
|
timeout: 5000,
|
|
|
|
|
});
|
|
|
|
|
assert.equal(result.stderr, "");
|
|
|
|
|
assert.doesNotMatch(result.stdout, /runa>|Connected to/);
|
|
|
|
|
return result.stdout.trim();
|
|
|
|
|
};
|
|
|
|
|
assert.match(await run("sources"), /source\/actors\/counter\.rn/);
|
|
|
|
|
await run("write", "actors/copy", resolve(__dirname, "../../../runa-std/actors/counter.rn"));
|
|
|
|
|
const committed = await run("commit", "actors/copy");
|
|
|
|
|
const revision = committed.match(/^revision ([a-z0-9]+)$/)?.[1];
|
|
|
|
|
assert.ok(revision);
|
|
|
|
|
assert.match(await run("history", "actors/copy"), new RegExp(revision));
|
|
|
|
|
assert.match(await run("rollback", "actors/copy", revision), /saved source\/actors\/copy\.rn/);
|
|
|
|
|
const actor = await run("spawn", "actors/copy", "-5");
|
|
|
|
|
assert.match(actor, /^@[A-Za-z0-9_-]{21}$/);
|
|
|
|
|
assert.match(await run("ps"), new RegExp(actor.slice(1)));
|
2026-11-03 18:23:43 +00:00
|
|
|
await run("become", actor, "actors/counter");
|
|
|
|
|
await run("tick", "1");
|
2026-11-03 16:13:15 +00:00
|
|
|
assert.equal(await run("send", actor, '{"op":"add","value":7}'), "ok");
|
|
|
|
|
await run("tick", "100", "32");
|
|
|
|
|
assert.match(await run("inspect", actor), /Integer\(\s*2/);
|
|
|
|
|
for (const command of ["pause", "resume", "reload"]) {
|
|
|
|
|
await run(command, actor);
|
|
|
|
|
}
|
|
|
|
|
await run("become", actor, "actors/counter");
|
|
|
|
|
await run("tick", "100");
|
|
|
|
|
await run("restart", actor);
|
|
|
|
|
await run("kill", actor);
|
2026-11-03 18:23:43 +00:00
|
|
|
const stopped = await run("spawn", "actors/counter", "0");
|
|
|
|
|
await run("stop", stopped);
|
|
|
|
|
await run("tick", "100");
|
|
|
|
|
assert.doesNotMatch(await run("ps"), new RegExp(stopped.slice(1)));
|
2026-11-03 16:13:15 +00:00
|
|
|
assert.match(await run("events"), /Spawned/);
|
|
|
|
|
for (const args of [
|
|
|
|
|
["spawn", "actors/missing", "0"],
|
|
|
|
|
["spawn", "actors/counter", "invalid"],
|
|
|
|
|
["inspect", "not-an-actor"],
|
|
|
|
|
["actors"],
|
|
|
|
|
["cancel", actor],
|
|
|
|
|
]) {
|
|
|
|
|
await assert.rejects(run(...args));
|
|
|
|
|
}
|
|
|
|
|
assert.equal(await run("quit"), "");
|
|
|
|
|
assert.equal(await run("exit"), "");
|
|
|
|
|
assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-11-03 14:36:21 +00:00
|
|
|
test(
|
|
|
|
|
"the real REPL mutates the same WebSocket image and notifies the inspection client",
|
|
|
|
|
{ timeout: 10000 },
|
|
|
|
|
async (t) => {
|
|
|
|
|
const image = await startImage();
|
|
|
|
|
let inspector: ImageClient | undefined;
|
|
|
|
|
t.after(async () => {
|
|
|
|
|
inspector?.close();
|
|
|
|
|
await image.stop();
|
|
|
|
|
});
|
|
|
|
|
const revisions: bigint[] = [];
|
|
|
|
|
let onChanged = () => {};
|
|
|
|
|
const changed = new Promise<void>((resolve) => {
|
|
|
|
|
onChanged = resolve;
|
|
|
|
|
});
|
|
|
|
|
inspector = await ImageClient.connect(
|
|
|
|
|
image.address,
|
|
|
|
|
(error) => assert.equal(error, undefined),
|
|
|
|
|
(revision) => {
|
|
|
|
|
revisions.push(revision);
|
|
|
|
|
onChanged();
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
const executable = resolve(
|
|
|
|
|
__dirname,
|
|
|
|
|
"../../../target/debug",
|
2026-11-03 14:49:35 +00:00
|
|
|
process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
|
2026-11-03 14:36:21 +00:00
|
|
|
);
|
2026-11-03 16:13:15 +00:00
|
|
|
const repl = spawn(executable, ["repl"], {
|
2026-11-03 14:49:35 +00:00
|
|
|
env: { ...process.env, RUNA_CONNECT: image.address },
|
2026-11-03 14:36:21 +00:00
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
});
|
|
|
|
|
t.after(() => {
|
|
|
|
|
if (repl.exitCode === null && repl.signalCode === null) repl.kill();
|
|
|
|
|
});
|
|
|
|
|
let stdout = "";
|
|
|
|
|
let stderr = "";
|
|
|
|
|
repl.stdout.on("data", (chunk: Buffer) => {
|
|
|
|
|
stdout += chunk.toString();
|
|
|
|
|
});
|
|
|
|
|
repl.stderr.on("data", (chunk: Buffer) => {
|
|
|
|
|
stderr += chunk.toString();
|
|
|
|
|
});
|
|
|
|
|
const exited = once(repl, "exit");
|
|
|
|
|
repl.stdin.end(
|
|
|
|
|
[
|
2026-11-03 16:01:24 +00:00
|
|
|
"let actor = spawn actors/counter 5",
|
2026-11-03 14:36:21 +00:00
|
|
|
'send actor {"op":"add","value":7}',
|
|
|
|
|
"tick 100",
|
|
|
|
|
"inspect actor",
|
|
|
|
|
"quit",
|
|
|
|
|
].join("\n") + "\n",
|
|
|
|
|
);
|
|
|
|
|
const [code] = await exited;
|
|
|
|
|
assert.equal(code, 0, stderr);
|
2026-11-03 16:05:29 +00:00
|
|
|
assert.equal(stderr, "");
|
|
|
|
|
assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i);
|
2026-11-03 14:36:21 +00:00
|
|
|
assert.doesNotMatch(stdout, /error:/i);
|
|
|
|
|
await changed;
|
|
|
|
|
assert.ok(revisions.length > 0);
|
|
|
|
|
const [actor] = await inspector.listActors();
|
|
|
|
|
assert.ok(actor);
|
|
|
|
|
assert.ok(stdout.includes(actor.actor_ref));
|
|
|
|
|
assert.deepEqual(actor.state, { type: "integer", data: 12n });
|
|
|
|
|
assert.deepEqual(actor.queued, []);
|
|
|
|
|
assert.equal(actor.reserved, null);
|
2026-11-03 16:13:15 +00:00
|
|
|
for (const input of ["ps\nquit\n", "exit\n", ""]) {
|
|
|
|
|
const overridden = spawn(executable, ["--connect", image.address, "repl"], {
|
2026-11-03 16:05:29 +00:00
|
|
|
env: { ...process.env, RUNA_CONNECT: "invalid" },
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
});
|
|
|
|
|
t.after(() => {
|
|
|
|
|
if (overridden.exitCode === null && overridden.signalCode === null) overridden.kill();
|
|
|
|
|
});
|
|
|
|
|
let diagnostics = "";
|
|
|
|
|
overridden.stderr.on("data", (chunk: Buffer) => {
|
|
|
|
|
diagnostics += chunk.toString();
|
|
|
|
|
});
|
|
|
|
|
const overriddenExit = once(overridden, "exit");
|
|
|
|
|
overridden.stdin.end(input);
|
|
|
|
|
const [overrideCode] = await overriddenExit;
|
|
|
|
|
assert.equal(overrideCode, 0, diagnostics);
|
|
|
|
|
assert.equal(diagnostics, "");
|
|
|
|
|
assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i);
|
|
|
|
|
}
|
2026-11-03 14:36:21 +00:00
|
|
|
},
|
|
|
|
|
);
|