import assert from "node:assert/strict"; import { test, TestContext } from "node:test"; import { ActorDebugAdapter } from "../src/debugger"; import { DebugHarness } from "./debugHarness"; import { actor, REF } from "./fixtures"; import { DebugStopReason, ExecutionInspection, SourceBreakpoint, StepMode } from "../src/protocol"; function fixture(t: TestContext) { const snapshot = actor(); snapshot.status = { status: "running" }; const inspection: ExecutionInspection = { actor_ref: REF, incarnation: 1n, implementation_id: snapshot.implementation_id, source_id: "source:test", source: "pub fn handle(ctx, state, sender, message) {\n state + 1\n}\n", behavior_path: "actors/test", execution: { vm_steps: 20n, awaiting: false, call_depth: 1n, frames_truncated: false, frames: [ { kind: "current", instruction_pointer: 12n, source_instruction_pointer: 12n, function: "handle", range: { start: { line: 1, character: 4 }, end: { line: 1, character: 9 } }, }, ], }, debugger: { breakpoints: [], stop_reason: null }, }; let present = true; let connected = true; let visibleMatches = true; let working = inspection.source; const modes: StepMode[] = []; let stepReason: DebugStopReason = "step"; let mutations = 0; const harness = new DebugHarness( (send) => new ActorDebugAdapter( { inspectActor: async () => snapshot, inspectExecution: async () => structuredClone(inspection), listActors: async () => (present ? [snapshot] : []), system: async (_actor, message) => { mutations++; if (message.kind === "pause") snapshot.status = { status: "paused" }; else if (message.kind === "resume") snapshot.status = { status: "running" }; }, readSource: async () => ({ source: working, path: "actors/test", source_id: "working" }), setBreakpoints: async (_actor, _implementation, lines) => { mutations++; const breakpoints = lines.map((line) => ({ line, instruction_pointers: line === 1 ? [12n] : [], })); inspection.debugger = { breakpoints, stop_reason: null }; return breakpoints; }, stepSource: async (_actor, _implementation, budget, mode = "into") => { assert.equal(budget, 1000); mutations++; modes.push(mode); inspection.execution!.vm_steps++; inspection.debugger!.stop_reason = stepReason; if (stepReason === "awaiting") inspection.execution!.awaiting = true; if (stepReason === "completed" || stepReason === "failed") { inspection.execution = null; snapshot.execution = null; } if (stepReason === "failed") snapshot.status = { status: "failed", message: "boom" }; if (stepReason === "removed") present = false; return stepReason; }, }, REF, () => "runa-execution://1/loaded/source.rn", () => { if (!connected) throw new Error("Connection changed"); }, send, (path) => `runa-image://127.0.0.1:3737/source/${path}.rn`, () => visibleMatches, ), ); t.after(() => harness.adapter.dispose()); return { harness, snapshot, inspection, modes, mutations: () => mutations, reason: (reason: DebugStopReason) => { stepReason = reason; }, remove: () => { present = false; }, disconnect: () => { connected = false; }, edit: (source: string) => { working = source; }, unsave: () => { visibleMatches = false; }, }; } test("DAP attach pauses only its actor, exposes exact stack/source, actor scopes and bounded modes", async (t) => { const f = fixture(t); const h = f.harness; await h.attach(); assert.equal(f.snapshot.status.status, "paused"); assert.equal(f.mutations(), 1); const stack = await h.request<{ stackFrames: { id: number; line: number; column: number; source: { sourceReference: number }; }[]; totalFrames: number; }>("stackTrace", { threadId: 1 }); assert.equal(stack.totalFrames, 1); const frame = stack.stackFrames[0]!; assert.equal(frame.line, 2); assert.equal(frame.column, 5); const scopes = await h.request<{ scopes: { name: string; variablesReference: number }[] }>( "scopes", { frameId: frame.id }, ); assert.match(scopes.scopes[0]!.name, /not frame locals/); const reference = scopes.scopes[0]!.variablesReference; const variables = await h.request<{ variables: { name: string; value: string }[] }>("variables", { variablesReference: reference, }); assert.equal(variables.variables[0]?.name, "committed_state"); assert.deepEqual(await h.request("source", { sourceReference: frame.source.sourceReference }), { content: f.inspection.source, mimeType: "text/x-rune", }); for (const command of ["stepIn", "next", "stepOut"]) { const since = h.messages.length; await h.request(command, { threadId: 1 }); await h.wait((message) => message.event === "stopped", since); } assert.deepEqual(f.modes, ["into", "over", "out"]); await assert.rejects(h.request("variables", { variablesReference: reference }), /Stale/); await assert.rejects(h.request("scopes", { frameId: frame.id }), /Stale/); await assert.rejects( h.request("evaluate", { expression: "state" }), /Rune expressions are unsupported/, ); await assert.rejects( h.request("setVariable", { variablesReference: reference, name: "committed_state", value: "1", }), /Unsupported/, ); await assert.rejects(h.request("next", { threadId: 2 }), /thread/); await assert.rejects( h.request("stepIn", { threadId: 1, granularity: "instruction" }), /source-line/, ); await h.request("continue", { threadId: 1 }); assert.equal(f.snapshot.status.status, "running"); assert.equal(f.mutations(), 5); const mutations = f.mutations(); await h.request("disconnect", { terminateDebuggee: true }); assert.equal(f.snapshot.status.status, "running"); assert.equal(f.mutations(), mutations, "disconnect must not kill/resume/clear breakpoints"); assert.ok(h.messages.some((message) => message.event === "terminated")); }); test("DAP snapshot watches read owned data, reject execution and honor frame/snapshot lifetimes", async (t) => { const f = fixture(t); const h = f.harness; await h.attach(); const stack = await h.request<{ stackFrames: { id: number }[] }>("stackTrace", { threadId: 1 }); const frameId = stack.stackFrames[0]!.id; const evaluate = (expression: string, context = "watch") => h.request<{ result: string; type: string; variablesReference: number }>("evaluate", { expression, context, frameId, }); assert.equal((await evaluate("actor/committed_state/maximum")).result, "9223372036854775807"); assert.equal( (await evaluate("actor/committed_state/minimum", "repl")).result, "-9223372036854775808", ); const result = await evaluate("actor/committed_state", "variables"); assert.equal(result.type, "record"); assert.equal(result.result, "Record(5)"); assert.ok(result.variablesReference > 0); await assert.rejects(evaluate("actor/committed_state", "hover"), /only in Watch/); await assert.rejects(evaluate("ctx.send(actor, null)"), /Rune expressions/); await assert.rejects(evaluate("actor/committed_state/missing"), /not found/); await assert.rejects(h.request("evaluate", { expression: null }), /snapshot path/); const since = h.messages.length; f.snapshot.state = { type: "integer", data: 2n }; h.adapter.invalidate(); await h.wait((message) => message.event === "stopped", since); await assert.rejects(evaluate("actor/committed_state"), /Stale/); await assert.rejects( h.request("variables", { variablesReference: result.variablesReference }), /Stale/, ); assert.equal( (await h.request<{ result: string }>("evaluate", { expression: "actor/committed_state" })) .result, "2", ); await h.request("continue", { threadId: 1 }); await assert.rejects( h.request("evaluate", { expression: "actor/committed_state" }), /unavailable while running/, ); assert.equal(f.mutations(), 2, "watches must not invoke actor execution or mutations"); }); test("DAP refreshes committed snapshots on external edits and invalidates variables on resume", async (t) => { const f = fixture(t); const h = f.harness; await h.attach(); const scope = async () => { const stack = await h.request<{ stackFrames: { id: number }[] }>("stackTrace", { threadId: 1 }); const scopes = await h.request<{ scopes: { variablesReference: number }[] }>("scopes", { frameId: stack.stackFrames[0]!.id, }); return scopes.scopes[0]!.variablesReference; }; const before = await scope(); const since = h.messages.length; f.snapshot.state = { type: "integer", data: 9223372036854775807n }; h.adapter.invalidate(); await h.wait((message) => message.event === "stopped", since); await assert.rejects(h.request("variables", { variablesReference: before }), /Stale/); const after = await scope(); const fields = await h.request<{ variables: { value: string }[] }>("variables", { variablesReference: after, }); assert.equal(fields.variables[0]?.value, "9223372036854775807"); await assert.rejects(h.request("variables", { variablesReference: after, start: -1 }), /integer/); await assert.rejects( h.request("variables", { variablesReference: after, count: 1.5 }), /integer/, ); await assert.rejects( h.request("variables", { variablesReference: after, filter: "all" }), /filter/, ); await h.request("continue", { threadId: 1 }); await assert.rejects(h.request("variables", { variablesReference: after }), /Stale/); assert.equal(f.mutations(), 2, "variables and scopes are reads, not actor execution"); }); test("DAP gutter breakpoints require exact loaded sources and explicitly reject unsupported forms", async (t) => { const f = fixture(t); const h = f.harness; await h.attach(); type Result = { breakpoints: { verified: boolean; line: number; message?: string }[] }; const set = (path: string, lines = [2, 3]) => h.request("setBreakpoints", { source: { path }, breakpoints: lines.map((line) => ({ line })), }); const path = "runa-image://127.0.0.1:3737/source/actors/test.rn"; assert.deepEqual( (await set(path)).breakpoints.map((bp) => bp.verified), [true, false], ); const before = f.mutations(); f.edit("newer image source"); assert.equal((await set(path, [2])).breakpoints[0]?.verified, false); assert.equal( f.mutations(), before + 1, "a changed working document removes only its previous breakpoint group", ); assert.deepEqual(f.inspection.debugger?.breakpoints, []); assert.equal( (await set("runa-image://127.0.0.1:9999/source/actors/test.rn")).breakpoints[0]?.verified, false, ); assert.equal(f.mutations(), before + 1, "unrelated files cannot replace actor breakpoints"); f.edit(f.inspection.source); f.unsave(); assert.equal((await set(path, [2])).breakpoints[0]?.verified, false); assert.equal(f.mutations(), before + 2); const conditional = await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [{ line: 2, condition: "true" }], }); assert.match(conditional.breakpoints[0]!.message!, /not supported/); assert.equal(conditional.breakpoints[0]?.verified, false); assert.deepEqual(f.inspection.debugger?.breakpoints, []); await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [{ line: 2 }] }); assert.deepEqual(f.inspection.debugger?.breakpoints, [ { line: 1, instruction_pointers: [12n] }, ] satisfies SourceBreakpoint[]); await set(path, []); assert.deepEqual( f.inspection.debugger?.breakpoints, [{ line: 1, instruction_pointers: [12n] }], "clearing an alias must not clear loaded-document breakpoints", ); const since = h.messages.length; f.inspection.debugger!.breakpoints = []; h.adapter.invalidate(); const changed = await h.wait((message) => message.event === "breakpoint", since); assert.equal((changed.body as { breakpoint: { verified: boolean } }).breakpoint.verified, false); await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [] }); assert.deepEqual(f.inspection.debugger?.breakpoints, []); }); test("DAP reports external pauses/failures/removal and terminates on version or connection changes", async (t) => { for (const change of [ "failure", "remove", "implementation", "incarnation", "connection", ] as const) { const f = fixture(t); const h = f.harness; await h.attach(); const since = h.messages.length; if (change === "failure") f.snapshot.status = { status: "failed", message: "boom" }; if (change === "remove") f.remove(); if (change === "implementation") f.inspection.implementation_id = "replacement"; if (change === "incarnation") f.inspection.incarnation++; if (change === "connection") f.disconnect(); h.adapter.invalidate(); const event = await h.wait( (message) => message.event === (change === "failure" ? "stopped" : "terminated"), since, ); if (change === "failure") assert.equal((event.body as { reason: string }).reason, "exception"); } }); test("DAP exposes bounded budget/await/completion/failure/removal outcomes without retrying", async (t) => { for (const reason of ["budget", "awaiting", "completed", "failed", "removed"] as const) { const f = fixture(t); const h = f.harness; await h.attach(); f.reason(reason); const since = h.messages.length; await h.request("next", { threadId: 1 }); const event = await h.wait( (message) => message.event === (reason === "removed" ? "terminated" : "stopped"), since, ); assert.equal(f.modes.length, 1); assert.ok( h.messages.some( (message) => message.event === "output" && JSON.stringify(message.body).includes(`Source step over: ${reason}`), ), ); if (reason === "failed") assert.equal((event.body as { reason: string }).reason, "exception"); if (reason === "completed") assert.deepEqual(await h.request("stackTrace", { threadId: 1 }), { stackFrames: [], totalFrames: 0, }); } }); test("DAP enforces the combined breakpoint limit and rejects malformed requests without mutation", async (t) => { const f = fixture(t); const h = f.harness; await h.attach(); const breakpoints = Array.from({ length: 128 }, (_, line) => ({ line: line + 2 })); await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints }); const mutations = f.mutations(); await assert.rejects( h.request("setBreakpoints", { source: { path: "runa-image://127.0.0.1:3737/source/actors/test.rn" }, breakpoints: [{ line: 130 }], }), /128 combined/, ); await assert.rejects( h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [...breakpoints, { line: 130 }], }), /128 breakpoints/, ); await assert.rejects( h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [{ line: 0 }], }), /integer/, ); await assert.rejects( h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: null }), /Expected source breakpoints/, ); await assert.rejects(h.request("source", { sourceReference: 2 }), /Unknown loaded source/); assert.equal(f.mutations(), mutations); assert.equal(f.inspection.debugger?.breakpoints.length, 128); }); test("DAP honors zero-based client coordinates, paging, unknown locations and stopped attach", async (t) => { const f = fixture(t); const h = f.harness; await h.request("initialize", { linesStartAt1: false, columnsStartAt1: false }); await h.request("attach"); await h.request("configurationDone"); await h.wait((message) => message.event === "stopped"); const result = await h.request<{ stackFrames: { line: number; column: number }[] }>( "stackTrace", { threadId: 1, startFrame: 0, levels: 1 }, ); assert.equal(result.stackFrames[0]?.line, 1); assert.equal(result.stackFrames[0]?.column, 4); assert.deepEqual(await h.request("stackTrace", { threadId: 1, startFrame: 1 }), { stackFrames: [], totalFrames: 1, }); f.inspection.execution!.frames[0]!.range = null; const since = h.messages.length; await h.request("next", { threadId: 1 }); await h.wait((message) => message.event === "stopped", since); const unmapped = await h.request<{ stackFrames: { line: number; source?: unknown }[] }>( "stackTrace", { threadId: 1 }, ); assert.equal(unmapped.stackFrames[0]?.line, 0); assert.equal(unmapped.stackFrames[0]?.source, undefined); const stopped = fixture(t); stopped.snapshot.status = { status: "stopped" }; await assert.rejects(stopped.harness.request("attach"), /stopped/); assert.equal(stopped.mutations(), 0); }); test("DAP attaches to failed actors without lifecycle mutations or fabricated frames", async (t) => { const f = fixture(t); f.snapshot.status = { status: "failed", message: 'Missing index "value"\nhandler failed' }; f.snapshot.execution = null; f.inspection.execution = null; const h = f.harness; const capabilities = await h.request<{ supportsExceptionInfoRequest: boolean }>("initialize"); assert.equal(capabilities.supportsExceptionInfoRequest, true); await h.request("attach"); await h.request("configurationDone"); const stop = await h.wait((message) => message.event === "stopped"); assert.equal((stop.body as { reason: string }).reason, "exception"); const details = await h.request("exceptionInfo", { threadId: 1 }); assert.deepEqual(details, { exceptionId: "runa.actorFailure", description: f.snapshot.status.message, breakMode: "unhandled", details: { typeName: "Runa actor failure", message: f.snapshot.status.message }, }); assert.deepEqual(await h.request("stackTrace", { threadId: 1 }), { stackFrames: [], totalFrames: 0, }); assert.equal( ( await h.request<{ result: string }>("evaluate", { expression: "actor/metadata/failure", context: "repl", }) ).result, JSON.stringify(f.snapshot.status.message), ); assert.equal( ( await h.request<{ result: string }>("evaluate", { expression: "actor/mailbox/reserved/0", context: "watch", }) ).result, "null", ); for (const command of ["continue", "pause", "next", "stepIn", "stepOut"]) await assert.rejects(h.request(command, { threadId: 1 }), /repair\/restart explicitly/); await assert.rejects(h.request("exceptionInfo", { threadId: 2 }), /thread/); assert.equal(f.mutations(), 0); await h.request("disconnect"); assert.equal(f.mutations(), 0); assert.equal(f.snapshot.status.status, "failed"); }); test("DAP exception details track new failures, reject nonfailed stops and identify missing detail", async (t) => { const f = fixture(t); const h = f.harness; await h.attach(); await assert.rejects(h.request("exceptionInfo", { threadId: 1 }), /No sampled actor failure/); for (const message of ["division failed", undefined]) { const since = h.messages.length; f.snapshot.status = { status: "failed", ...(message === undefined ? {} : { message }) }; f.snapshot.execution = null; f.inspection.execution = null; h.adapter.invalidate(); await h.wait((event) => event.event === "stopped", since); const reply = await h.request<{ description: string; details: { message?: string; stackTrace?: string }; }>("exceptionInfo", { threadId: 1 }); assert.equal(reply.description, message ?? "Actor failed; failure details unavailable"); assert.equal(reply.details.message, message); assert.equal(reply.details.stackTrace, undefined); } });