runa/vscode/integration/dap.test.ts

244 lines
8.8 KiB
TypeScript

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { EventEmitter, once } from "node:events";
import { resolve } from "node:path";
import test from "node:test";
import { ImageClient } from "../src/client";
import { parseValue } from "../src/values";
import { startImage } from "./server";
interface DapMessage {
type: string;
request_seq?: number;
success?: boolean;
message?: string;
event?: string;
body?: unknown;
}
function startAdapter(address: string) {
const child = spawn(
resolve(
__dirname,
"../../../target/debug",
process.platform === "win32" ? "runa-dap.exe" : "runa-dap",
),
["--connect", address],
{ stdio: ["pipe", "pipe", "pipe"] },
);
const messages: DapMessage[] = [];
const changed = new EventEmitter();
let buffer = Buffer.alloc(0);
let sequence = 0;
let failure: Error | undefined;
let diagnostics = "";
const fail = (error: Error) => {
failure = error;
changed.emit("message");
};
child.on("error", fail);
child.stderr.on("data", (chunk: Buffer) => {
diagnostics += chunk.toString();
});
child.on("exit", (code) => fail(new Error(`DAP exited (${code}): ${diagnostics}`)));
child.stdout.on("data", (chunk: Buffer) => {
try {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
const end = buffer.indexOf("\r\n\r\n");
if (end < 0) return;
const match = /^Content-Length: (\d+)$/im.exec(buffer.subarray(0, end).toString());
assert.ok(match, "DAP stdout must contain framed messages only");
const length = Number(match[1]);
if (buffer.length < end + 4 + length) return;
const message: DapMessage = JSON.parse(
buffer.subarray(end + 4, end + 4 + length).toString(),
);
buffer = buffer.subarray(end + 4 + length);
messages.push(message);
changed.emit("message");
}
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)));
}
});
const wait = (predicate: (message: DapMessage) => boolean, since = 0): Promise<DapMessage> =>
new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
changed.off("message", check);
};
const check = () => {
const found = messages.slice(since).find(predicate);
if (found) {
cleanup();
resolve(found);
} else if (failure) {
cleanup();
reject(failure);
}
};
const timer = setTimeout(() => {
cleanup();
reject(
new Error(
`DAP response timed out: ${diagnostics}; ${JSON.stringify(messages.slice(-5))}`,
),
);
}, 5000);
changed.on("message", check);
check();
});
return {
messages,
wait,
async request<T>(command: string, args: unknown = {}): Promise<T> {
const seq = ++sequence;
const body = Buffer.from(JSON.stringify({ seq, type: "request", command, arguments: args }));
child.stdin.write(
Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]),
);
const response = await wait(
(message) => message.type === "response" && message.request_seq === seq,
);
if (!response.success) throw new Error(response.message);
return response.body as T;
},
async stop() {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, "exit");
child.kill();
await exited;
},
};
}
test(
"standalone Rust DAP binds behavior.rn breakpoints to one actor and serves exact loaded code",
{ timeout: 20000 },
async (t) => {
const image = await startImage();
t.after(async () => {
try {
await client.close();
} finally {
await image.stop();
}
});
const client = await ImageClient.connect(image.address, (error) =>
assert.equal(error, undefined),
);
const source =
"pub fn handle(ctx, state, sender, message) {\n let next = state + 1;\n next\n}";
const file = await client.writeSource("tests/dap_server", null, source);
const actor = await client.spawn(file.path, parseValue("10"));
const other = await client.spawn(file.path, parseValue("20"));
const uri = `runa-actor://1/${actor}/behavior.rn`;
const adapter = startAdapter(image.address);
t.after(() => adapter.stop());
await adapter.request("initialize", { linesStartAt1: true, columnsStartAt1: true });
await adapter.request("attach", { actorRef: actor, behaviorSourceUri: uri });
await adapter.request("configurationDone");
await adapter.wait((message) => message.event === "stopped");
assert.equal((await client.inspectActor(actor)).status.status, "paused");
assert.equal((await client.inspectActor(other)).status.status, "idle");
const breakpoints = await adapter.request<{ breakpoints: { verified: boolean }[] }>(
"setBreakpoints",
{ source: { path: uri }, breakpoints: [{ line: 2 }] },
);
assert.equal(breakpoints.breakpoints[0]?.verified, true);
assert.equal((await client.inspectExecution(other)).debugger?.breakpoints.length ?? 0, 0);
const dirty = await adapter.request<{ breakpoints: { verified: boolean }[] }>(
"setBreakpoints",
{ source: { path: uri }, sourceModified: true, breakpoints: [{ line: 2 }] },
);
assert.equal(dirty.breakpoints[0]?.verified, false);
await adapter.request("setBreakpoints", { source: { path: uri }, breakpoints: [{ line: 2 }] });
assert.deepEqual(await adapter.request("source", { sourceReference: 1 }), {
content: source,
mimeType: "text/x-rune",
});
await adapter.request("continue", { threadId: 1 });
assert.equal((await client.inspectActor(actor)).execution, null, "continue must not tick");
const since = adapter.messages.length;
await client.send(actor, parseValue("null"));
await client.tick(1, 1000);
await adapter.wait((message) => message.event === "stopped", since);
const stack = await adapter.request<{
stackFrames: { id: number; line: number; source: { name: string } }[];
}>("stackTrace", { threadId: 1 });
assert.equal(stack.stackFrames[0]?.line, 2);
assert.equal(stack.stackFrames[0]?.source.name, "behavior.rn");
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10"));
await assert.rejects(adapter.request("evaluate", { expression: "state + 1", context: "repl" }));
await adapter.request("disconnect");
assert.equal((await client.inspectActor(actor)).status.status, "paused");
},
);
test(
"standalone Rust DAP inspects failed actors without retrying their invocation",
{ timeout: 20000 },
async (t) => {
const image = await startImage();
t.after(async () => {
try {
await client.close();
} finally {
await image.stop();
}
});
const client = await ImageClient.connect(image.address, (error) =>
assert.equal(error, undefined),
);
const file = await client.writeSource(
"tests/dap_failure",
null,
'pub fn handle(ctx, state, sender, message) { panic("DAP failure probe") }',
);
const actor = await client.spawn(file.path, parseValue("42"));
await client.send(actor, parseValue('{"op":"fail"}'));
await client.tick(1, 1000);
const before = await client.inspectActor(actor);
assert.equal(before.status.status, "failed");
const adapter = startAdapter(image.address);
t.after(() => adapter.stop());
await adapter.request("initialize");
await adapter.request("attach", { actorRef: actor });
await adapter.request("configurationDone");
const stop = await adapter.wait((message) => message.event === "stopped");
assert.equal((stop.body as { reason: string }).reason, "exception");
const exception = await adapter.request<{ description: string }>("exceptionInfo", {
threadId: 1,
});
assert.equal(exception.description, before.status.message);
assert.deepEqual(await adapter.request("stackTrace", { threadId: 1 }), {
stackFrames: [],
totalFrames: 0,
});
assert.equal(
(
await adapter.request<{ result: string }>("evaluate", {
expression: "actor/committed_state",
context: "watch",
})
).result,
"42",
);
assert.equal(
(
await adapter.request<{ result: string }>("evaluate", {
expression: "actor/mailbox/reserved/0/op",
context: "repl",
})
).result,
'"fail"',
);
for (const command of ["continue", "pause", "next", "stepIn", "stepOut"]) {
await assert.rejects(adapter.request(command, { threadId: 1 }), /repair\/restart explicitly/);
}
await adapter.request("disconnect");
assert.deepEqual(await client.inspectActor(actor), before);
},
);