241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
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 WebSocket from "ws";
|
|
import { ImageClient } from "../src/client";
|
|
import { parseValue } from "../src/values";
|
|
import { control, startImage } from "./server";
|
|
|
|
test(
|
|
"actor recovery edits, log filtering, history and CLI follow share the real image",
|
|
{ timeout: 15000 },
|
|
async (t) => {
|
|
const image = await startImage();
|
|
const client = await ImageClient.connect(image.address, (error) =>
|
|
assert.equal(error, undefined),
|
|
);
|
|
t.after(async () => {
|
|
await client.close();
|
|
await image.stop();
|
|
});
|
|
await client.writeSource(
|
|
"actors/logger",
|
|
null,
|
|
`pub fn handle(ctx, state, sender, message) {
|
|
ctx.trace("trace"); ctx.info(message); ctx.error("error"); state
|
|
}`,
|
|
);
|
|
const first = await client.spawn("actors/logger", parseValue("0"));
|
|
const second = await client.spawn("actors/logger", parseValue("0"));
|
|
await assert.rejects(client.editActor(first, parseValue("5"), null), /editing requires/);
|
|
await client.system(first, { kind: "pause" });
|
|
await client.editActor(first, parseValue("5"), {
|
|
reserved: parseValue('"repaired"'),
|
|
queued: [parseValue('"queued"')],
|
|
});
|
|
assert.deepEqual((await client.inspectActor(first)).state, parseValue("5"));
|
|
await client.system(first, { kind: "restart" });
|
|
await client.tick(100);
|
|
const executable = resolve(__dirname, "../../../target/debug/runa-cli");
|
|
const execute = promisify(execFile);
|
|
const run = async (...args: string[]) =>
|
|
(await execute(executable, ["--connect", image.address, ...args])).stdout;
|
|
assert.match(await run("logs", first), /repaired/);
|
|
assert.match(await run("logs", first), /queued/);
|
|
assert.doesNotMatch(await run("logs", first), /Trace/);
|
|
await run("log-level", first, "error");
|
|
await client.send(first, parseValue('"filtered"'));
|
|
await client.tick(100);
|
|
assert.doesNotMatch(await run("logs", first), /filtered/);
|
|
await client.setLogLevel(first, "trace");
|
|
await client.send(first, parseValue('"visible"'));
|
|
await client.tick(100);
|
|
assert.match(await run("logs", first), /Trace/);
|
|
|
|
const following = spawn(executable, ["--connect", image.address, "logs", "-f", first], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
t.after(() => {
|
|
if (following.exitCode === null) following.kill();
|
|
});
|
|
let output = "";
|
|
let stderr = "";
|
|
let ready = () => {};
|
|
const history = new Promise<void>((resolve) => {
|
|
ready = resolve;
|
|
});
|
|
let future = () => {};
|
|
const received = new Promise<void>((resolve) => {
|
|
future = resolve;
|
|
});
|
|
following.stdout.on("data", (chunk: Buffer) => {
|
|
output += chunk.toString();
|
|
if (output.includes("visible")) ready();
|
|
if (output.includes("future")) future();
|
|
});
|
|
following.stderr.on("data", (chunk: Buffer) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
await history;
|
|
await client.send(second, parseValue('"not-subscribed"'));
|
|
await client.send(first, parseValue('"future"'));
|
|
await client.tick(100);
|
|
await received;
|
|
const exited = once(following, "exit");
|
|
following.kill("SIGINT");
|
|
const [code] = await exited;
|
|
assert.equal(code, 0, stderr);
|
|
assert.equal(stderr, "");
|
|
assert.doesNotMatch(output, /not-subscribed/);
|
|
assert.match(await run("logs"), /not-subscribed/);
|
|
const repl = spawn(executable, ["--connect", image.address, "repl"], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
t.after(() => {
|
|
if (repl.exitCode === null) repl.kill();
|
|
});
|
|
let replOutput = "";
|
|
let replError = "";
|
|
let followingName = () => {};
|
|
const namedHistory = new Promise<void>((resolve) => {
|
|
followingName = resolve;
|
|
});
|
|
repl.stdout.on("data", (chunk: Buffer) => {
|
|
replOutput += chunk.toString();
|
|
if (replOutput.includes("Info: named-filter")) followingName();
|
|
});
|
|
repl.stderr.on("data", (chunk: Buffer) => {
|
|
replError += chunk.toString();
|
|
});
|
|
repl.stdin.write(
|
|
'let watched = spawn actors/logger 0\nsend watched "named-filter"\ntick 100\nlogs -f watched\n',
|
|
);
|
|
await namedHistory;
|
|
const replExit = once(repl, "exit");
|
|
repl.kill("SIGINT");
|
|
repl.stdin.end("ps\nquit\n");
|
|
const [replCode] = await replExit;
|
|
assert.equal(replCode, 0, replError);
|
|
assert.doesNotMatch(replOutput, /^error:/im);
|
|
assert.equal(replError, "");
|
|
await client.system(first, { kind: "pause" });
|
|
await run("set-state", first, "19");
|
|
await run("set-messages", first, '{"reserved":[],"queued":["fixed"]}');
|
|
assert.deepEqual((await client.inspectActor(first)).state, parseValue("19"));
|
|
assert.deepEqual((await client.inspectActor(first)).queued, [parseValue('"fixed"')]);
|
|
assert.doesNotMatch(image.diagnostics(), /reset without closing handshake/i);
|
|
},
|
|
);
|
|
|
|
test(
|
|
"server sends log events only to subscribed clients and applies actor filters",
|
|
{ timeout: 10000 },
|
|
async (t) => {
|
|
const image = await startImage();
|
|
const client = await ImageClient.connect(image.address, (error) =>
|
|
assert.equal(error, undefined),
|
|
);
|
|
t.after(async () => {
|
|
await client.close();
|
|
await image.stop();
|
|
});
|
|
await client.writeSource(
|
|
"actors/logger",
|
|
null,
|
|
'pub fn handle(ctx, state, sender, message) { ctx.info("hello"); state }',
|
|
);
|
|
const first = await client.spawn("actors/logger", parseValue("0"));
|
|
const second = await client.spawn("actors/logger", parseValue("0"));
|
|
const socket = new WebSocket(`ws://${image.address}/`, "runa-debug");
|
|
t.after(() => socket.close());
|
|
await once(socket, "open");
|
|
const entries: { actor_ref: string }[] = [];
|
|
let reply = () => {};
|
|
const subscribed = new Promise<void>((resolve) => {
|
|
reply = resolve;
|
|
});
|
|
let event = () => {};
|
|
const logged = new Promise<void>((resolve) => {
|
|
event = resolve;
|
|
});
|
|
socket.on("message", (data) => {
|
|
const message = JSON.parse(data.toString());
|
|
if (message.request_id === "subscribe") reply();
|
|
if (message.event?.event === "log") {
|
|
entries.push(message.event.data);
|
|
event();
|
|
}
|
|
});
|
|
socket.send(
|
|
JSON.stringify({
|
|
version: 5,
|
|
request_id: "subscribe",
|
|
command: { operation: "logs", parameters: { actors: [first], follow: true, after: 0 } },
|
|
}),
|
|
);
|
|
await subscribed;
|
|
await client.send(second, parseValue("null"));
|
|
await client.send(first, parseValue("null"));
|
|
await client.tick(100);
|
|
await logged;
|
|
assert.deepEqual(
|
|
entries.map((entry) => entry.actor_ref),
|
|
[first],
|
|
);
|
|
const history = JSON.stringify(
|
|
await control(image.address, {
|
|
operation: "logs",
|
|
parameters: { actors: [second], follow: false, after: 0 },
|
|
}),
|
|
);
|
|
assert.ok(history.includes(second));
|
|
assert.ok(!history.includes(first));
|
|
},
|
|
);
|
|
|
|
test(
|
|
"log history pagination preserves ordered entries beyond a single page",
|
|
{ timeout: 10000 },
|
|
async (t) => {
|
|
const image = await startImage();
|
|
const client = await ImageClient.connect(image.address, (error) =>
|
|
assert.equal(error, undefined),
|
|
);
|
|
t.after(async () => {
|
|
await client.close();
|
|
await image.stop();
|
|
});
|
|
await client.writeSource(
|
|
"actors/logger",
|
|
null,
|
|
`pub fn handle(ctx, state, sender, message) {
|
|
let n = 0; while n < 300 { ctx.info("line"); n += 1; } state
|
|
}`,
|
|
);
|
|
const actor = await client.spawn("actors/logger", parseValue("0"));
|
|
await client.send(actor, parseValue("null"));
|
|
await client.tick(1000);
|
|
const executable = resolve(__dirname, "../../../target/debug/runa-cli");
|
|
const { stdout } = await promisify(execFile)(executable, [
|
|
"--connect",
|
|
image.address,
|
|
"logs",
|
|
actor,
|
|
]);
|
|
const lines = stdout.trim().split("\n");
|
|
assert.equal(lines.length, 300);
|
|
const sequences = lines.map((line) => Number(line.split(" ")[0]));
|
|
assert.deepEqual(
|
|
sequences,
|
|
Array.from({ length: 300 }, (_, index) => index + 1),
|
|
);
|
|
assert.deepEqual(
|
|
(await client.logs(actor)).map((entry) => entry.sequence),
|
|
Array.from({ length: 300 }, (_, index) => BigInt(index + 1)),
|
|
);
|
|
},
|
|
);
|