import assert from "node:assert/strict"; import { test } from "node:test"; import { ActorDebugAdapter } from "../src/debugger"; import { DebugVariable } from "../src/debugVariables"; import { DebugHarness } from "../test/debugHarness"; import { ImageClient } from "../src/client"; import { parseValue } from "../src/values"; import { startImage } from "./server"; test("debugger variables show committed data rather than invocation drafts and never execute Rune", 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(); }); const source = 'pub fn handle(ctx, state, sender, message) {\n state.items.push(message);\n nested(state)\n}\nfn nested(state) {\n if state.other.flag { state } else { panic("unexpected flag") }\n}'; const file = await client.writeSource("tests/variables", null, source); const state = parseValue('{"items":[9223372036854775807],"other":{"flag":true}}'); const actor = await client.spawn(file.path, state); await client.send(actor, parseValue('{"value":-9223372036854775808}')); const h = new DebugHarness( (send) => new ActorDebugAdapter( client, actor, () => "runa-execution://1/loaded/test.rn", () => {}, send, (path) => `runa-image://${image.address}/source/${path}.rn`, () => true, ), ); t.after(() => h.adapter.dispose()); await h.attach(); const bps = await h.request<{ breakpoints: { verified: boolean }[] }>("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [{ line: 6 }], }); assert.equal(bps.breakpoints[0]?.verified, true); await h.request("continue", { threadId: 1 }); await client.tick(1, 1000); h.adapter.invalidate(); await h.wait( (message) => message.event === "stopped" && (message.body as { reason: string }).reason === "breakpoint", ); const before = await client.inspectActor(actor); const stack = await h.request<{ stackFrames: { id: number }[] }>("stackTrace", { threadId: 1 }); assert.equal(stack.stackFrames.length, 2); const scope = async (frame: number) => { const reply = await h.request<{ scopes: { variablesReference: number }[] }>("scopes", { frameId: frame, }); return reply.scopes[0]!.variablesReference; }; const reference = await scope(stack.stackFrames[0]!.id); assert.equal( await scope(stack.stackFrames[1]!.id), reference, "actor scope is shared, not fabricated per-frame locals", ); const children = async (reference: number) => ( await h.request<{ variables: DebugVariable[] }>("variables", { variablesReference: reference, }) ).variables; const roots = await children(reference); const stateFields = await children( roots.find((field) => field.name === "committed_state")!.variablesReference, ); const items = stateFields.find((field) => field.name === "items")!; assert.equal(items.indexedVariables, 1, "the invocation's push has not committed"); assert.equal((await children(items.variablesReference))[0]?.value, "9223372036854775807"); const mailbox = await children( roots.find((field) => field.name === "mailbox")!.variablesReference, ); const reserved = await children( mailbox.find((field) => field.name === "reserved")!.variablesReference, ); const message = await children(reserved[0]!.variablesReference); assert.equal(message[0]?.value, "-9223372036854775808"); const watch = await h.request<{ result: string; variablesReference: number }>("evaluate", { expression: "actor/committed_state/items", context: "watch", }); assert.equal(watch.result, "List(1)"); assert.equal(watch.variablesReference, items.variablesReference); assert.equal( ( await h.request<{ result: string }>("evaluate", { expression: "actor/mailbox/reserved/0/value", context: "repl", }) ).result, "-9223372036854775808", ); await assert.rejects( h.request("evaluate", { expression: "actor/committed_state/items.push(1)", context: "repl", }), /not found/, ); assert.deepEqual( await client.inspectActor(actor), before, "scope expansion must not mutate or advance the actor", ); let completed = false; for (let count = 0; count < 10; count++) { const since = h.messages.length; await h.request("next", { threadId: 1 }); await h.wait((message) => message.event === "stopped", since); if (!(await client.inspectActor(actor)).execution) { completed = true; break; } } assert.equal(completed, true); assert.deepEqual( (await client.inspectActor(actor)).state, parseValue( '{"items":[9223372036854775807,{"value":-9223372036854775808}],"other":{"flag":true}}', ), ); await assert.rejects(h.request("variables", { variablesReference: reference }), /Stale/); assert.equal( ( await h.request<{ result: string }>("evaluate", { expression: "actor/committed_state/items", context: "watch", }) ).result, "List(2)", "the same watch resolves against the new committed stop snapshot", ); await assert.rejects( h.request("variables", { variablesReference: watch.variablesReference }), /Stale/, ); }); test("failed actor debug attachment exposes retained data and exception details without retrying", 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(); }); const source = 'pub fn handle(ctx, state, sender, message) {\n state.items.push(9);\n panic("failure inspection probe")\n}'; const file = await client.writeSource("tests/failed_debug", null, source); for (const failBeforeAttach of [true, false]) { const actor = await client.spawn(file.path, parseValue('{"items":[1]}')); await client.send(actor, parseValue('{"op":"fail","value":-9223372036854775808}')); if (failBeforeAttach) await client.tick(1, 1000); const h = new DebugHarness( (send) => new ActorDebugAdapter( client, actor, () => "runa-execution://1/loaded/failure.rn", () => {}, send, (path) => `runa-image://${image.address}/source/${path}.rn`, () => true, ), ); t.after(() => h.adapter.dispose()); await h.attach(); if (!failBeforeAttach) { await h.request("continue", { threadId: 1 }); await client.tick(1, 1000); h.adapter.invalidate(); await h.wait( (message) => message.event === "stopped" && (message.body as { reason: string }).reason === "exception", ); } const before = await client.inspectActor(actor); assert.equal(before.status.status, "failed"); const details = await h.request<{ description: string; breakMode: string; details: { message: string; stackTrace?: string }; }>("exceptionInfo", { threadId: 1 }); assert.equal(details.description, before.status.message); assert.match(details.description, /failure inspection probe/); assert.equal(details.breakMode, "unhandled"); assert.equal(details.details.stackTrace, undefined); assert.deepEqual(await h.request("stackTrace", { threadId: 1 }), { stackFrames: [], totalFrames: 0, }); assert.equal( ( await h.request<{ result: string }>("evaluate", { expression: "actor/committed_state/items/0", context: "watch", }) ).result, "1", ); assert.equal( ( await h.request<{ result: string }>("evaluate", { expression: "actor/mailbox/reserved/0/value", context: "repl", }) ).result, "-9223372036854775808", ); for (const command of ["continue", "next", "stepIn", "stepOut", "pause"]) await assert.rejects(h.request(command, { threadId: 1 }), /repair\/restart explicitly/); assert.deepEqual(await client.inspectActor(actor), before); if (failBeforeAttach) { await h.request("disconnect"); assert.deepEqual(await client.inspectActor(actor), before); } else { const since = h.messages.length; await client.editActor(actor, parseValue('{"items":[2]}'), null); h.adapter.invalidate(); await h.wait((message) => message.event === "stopped", since); assert.equal( ( await h.request<{ result: string }>("evaluate", { expression: "actor/committed_state/items/0", context: "watch", }) ).result, "2", ); assert.equal( (await h.request<{ description: string }>("exceptionInfo", { threadId: 1 })).description, before.status.message, ); await client.system(actor, { kind: "restart" }); h.adapter.invalidate(); await h.wait((message) => message.event === "terminated"); assert.equal((await client.inspectActor(actor)).incarnation, before.incarnation + 1n); } await client.system(actor, { kind: "kill" }); } });