runa/vscode/test/debugger.test.ts

345 lines
13 KiB
TypeScript

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, empty 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);
assert.deepEqual(await h.request("scopes", { frameId: frame.id }), { scopes: [] });
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("scopes", { frameId: frame.id }), /Stale/);
await assert.rejects(h.request("evaluate", { expression: "state" }), /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 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<Result>("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<Result>("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 failed 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 failed = fixture(t);
failed.snapshot.status = { status: "failed", message: "boom" };
await assert.rejects(failed.harness.request("attach"), /nonfailed/);
assert.equal(failed.mutations(), 0);
});