runa/vscode/integration/debugVariables.test.ts

254 lines
9.1 KiB
TypeScript
Raw Permalink Normal View History

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" });
}
});