runa/vscode/integration/repl.test.ts

210 lines
7.4 KiB
TypeScript
Raw Normal View History

import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
import { once } from "node:events";
import { resolve } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { ImageClient } from "../src/client";
import { startImage } from "./server";
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();
}
},
);
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)));
await run("become", actor, "actors/counter");
await run("tick", "1");
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);
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)));
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);
},
);
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",
process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
);
const repl = spawn(executable, ["repl"], {
env: { ...process.env, RUNA_CONNECT: image.address },
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(
[
"let actor = spawn actors/counter 5",
'send actor {"op":"add","value":7}',
"tick 100",
"inspect actor",
"quit",
].join("\n") + "\n",
);
const [code] = await exited;
assert.equal(code, 0, stderr);
assert.equal(stderr, "");
assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i);
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);
for (const input of ["ps\nquit\n", "exit\n", ""]) {
const overridden = spawn(executable, ["--connect", image.address, "repl"], {
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);
}
},
);