2026-11-03 20:36:30 +00:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 20:48:08 +00:00
|
|
|
test("DAP attach pauses only its actor, exposes exact stack/source, actor scopes and bounded modes", async (t) => {
|
2026-11-03 20:36:30 +00:00
|
|
|
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);
|
2026-11-03 20:48:08 +00:00
|
|
|
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");
|
2026-11-03 20:36:30 +00:00
|
|
|
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"]);
|
2026-11-03 20:48:08 +00:00
|
|
|
await assert.rejects(h.request("variables", { variablesReference: reference }), /Stale/);
|
2026-11-03 20:36:30 +00:00
|
|
|
await assert.rejects(h.request("scopes", { frameId: frame.id }), /Stale/);
|
2026-11-03 21:47:18 +00:00
|
|
|
await assert.rejects(
|
|
|
|
|
h.request("evaluate", { expression: "state" }),
|
|
|
|
|
/Rune expressions are unsupported/,
|
|
|
|
|
);
|
2026-11-03 20:48:08 +00:00
|
|
|
await assert.rejects(
|
|
|
|
|
h.request("setVariable", {
|
|
|
|
|
variablesReference: reference,
|
|
|
|
|
name: "committed_state",
|
|
|
|
|
value: "1",
|
|
|
|
|
}),
|
|
|
|
|
/Unsupported/,
|
|
|
|
|
);
|
2026-11-03 20:36:30 +00:00
|
|
|
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"));
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-03 21:47:18 +00:00
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-03 20:48:08 +00:00
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-03 20:36:30 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-03 22:40:04 +00:00
|
|
|
test("DAP honors zero-based client coordinates, paging, unknown locations and stopped attach", async (t) => {
|
2026-11-03 20:36:30 +00:00
|
|
|
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);
|
2026-11-03 22:40:04 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2026-11-03 20:36:30 +00:00
|
|
|
});
|