runa/vscode/integration/logs.test.ts

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)),
);
},
);