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((resolve) => { ready = resolve; }); let future = () => {}; const received = new Promise((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((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((resolve) => { reply = resolve; }); let event = () => {}; const logged = new Promise((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), ); }, );