runa/vscode/test/protocol.test.ts

651 lines
21 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { parse } from "lossless-json";
import {
decodeResponse,
decodeNotification,
decodeValue,
formatSnapshot,
isSourcePath,
isSourceRevision,
ProtocolError,
Command,
formatLogEntry,
LOG_LEVELS,
} from "../src/protocol";
import { actor, json, REF } from "./fixtures";
const command: Command = { operation: "list_actors" };
const envelope = (reply: unknown) =>
json({ version: 5, request_id: "test", result: { Ok: reply } });
test("debugger replies validate breakpoint verification and explicit stop reasons", () => {
const command: Command = {
operation: "set_breakpoints",
parameters: { actor_ref: REF, implementation_id: "impl", lines: [3, 1, 3] },
};
const breakpoints = [
{ line: 1n, instruction_pointers: [7n, 18446744073709551615n] },
{ line: 3n, instruction_pointers: [] },
];
const reply = decodeResponse(
envelope({ kind: "breakpoints_set", data: { breakpoints } }),
"test",
command,
);
assert.deepEqual(reply, {
kind: "breakpoints_set",
data: {
breakpoints: [
{ line: 1, instruction_pointers: breakpoints[0]!.instruction_pointers },
{ line: 3, instruction_pointers: [] },
],
},
});
for (const value of [
[],
[...breakpoints].reverse(),
[breakpoints[0], breakpoints[0]],
[{ line: 1n, instruction_pointers: [2n, 2n] }, breakpoints[1]],
[{ line: 1n, instruction_pointers: [-1n] }, breakpoints[1]],
[{ line: 1n, instruction_pointers: [3n, 2n] }, breakpoints[1]],
])
assert.throws(() =>
decodeResponse(
envelope({ kind: "breakpoints_set", data: { breakpoints: value } }),
"test",
command,
),
);
const step: Command = {
operation: "step_source",
parameters: { actor_ref: REF, implementation_id: "impl", instruction_budget: 1000 },
};
for (const reason of [
"breakpoint",
"step",
"budget",
"awaiting",
"completed",
"failed",
"removed",
])
assert.deepEqual(
decodeResponse(envelope({ kind: "debug_step_completed", data: { reason } }), "test", step),
{ kind: "debug_step_completed", data: { reason } },
);
for (const reason of [null, 1, "success", "paused"])
assert.throws(() =>
decodeResponse(envelope({ kind: "debug_step_completed", data: { reason } }), "test", step),
);
});
test("execution inspections preserve u64 fields and validate stack order, bounds, identities and ranges", () => {
const command: Command = { operation: "inspect_execution", parameters: { actor_ref: REF } };
const frame = {
kind: "current",
instruction_pointer: 18446744073709551615n,
source_instruction_pointer: 18446744073709551615n,
function: "nested",
range: { start: { line: 1n, character: 2n }, end: { line: 1n, character: 5n } },
};
const execution = {
vm_steps: 18446744073709551615n,
awaiting: false,
call_depth: 0n,
frames: [frame],
frames_truncated: false,
};
const data = {
actor_ref: REF,
incarnation: 18446744073709551615n,
implementation_id: "impl",
source_id: "source",
source: "exact source",
behavior_path: "actors/test",
execution,
};
const decoded = decodeResponse(envelope({ kind: "execution_inspected", data }), "test", command);
assert.equal(decoded.kind, "execution_inspected");
if (decoded.kind !== "execution_inspected") throw new Error("unexpected reply");
assert.equal(decoded.data.incarnation, data.incarnation);
const debuggerState = {
breakpoints: [{ line: 0n, instruction_pointers: [1n] }],
stop_reason: "breakpoint",
};
const withDebugger = decodeResponse(
envelope({ kind: "execution_inspected", data: { ...data, debugger: debuggerState } }),
"test",
command,
);
assert.equal(withDebugger.kind, "execution_inspected");
if (withDebugger.kind === "execution_inspected")
assert.deepEqual(withDebugger.data.debugger, {
...debuggerState,
breakpoints: [{ line: 0, instruction_pointers: [1n] }],
});
assert.throws(() =>
decodeResponse(
envelope({
kind: "execution_inspected",
data: { ...data, debugger: { ...debuggerState, stop_reason: "invalid" } },
}),
"test",
command,
),
);
assert.deepEqual(decoded.data.execution?.frames[0]?.range, {
start: { line: 1, character: 2 },
end: { line: 1, character: 5 },
});
for (const change of [
{ actor_ref: "ABCDEFGHIJKLMNOPQRSTU" },
{ implementation_id: "" },
{ source_id: "" },
{ behavior_path: "../bad" },
{ incarnation: -1n },
{ execution: { ...execution, awaiting: "false" } },
{ execution: { ...execution, frames: [] } },
{ execution: { ...execution, frames_truncated: true } },
{ execution: { ...execution, frames: [{ ...frame, kind: "caller" }] } },
{ execution: { ...execution, frames: [{ ...frame, instruction_pointer: -1n }] } },
{
execution: {
...execution,
frames: [
{
...frame,
range: { start: { line: 2n, character: 0n }, end: { line: 1n, character: 0n } },
},
],
},
},
{
execution: {
...execution,
frames: [
{
...frame,
range: {
start: { line: 9007199254740992n, character: 0n },
end: { line: 9007199254740992n, character: 0n },
},
},
],
},
},
])
assert.throws(() =>
decodeResponse(
envelope({ kind: "execution_inspected", data: { ...data, ...change } }),
"test",
command,
),
);
assert.equal(
decodeResponse(
envelope({ kind: "execution_inspected", data: { ...data, execution: null } }),
"test",
command,
).kind,
"execution_inspected",
);
const unknown = {
...execution,
frames: [{ ...frame, source_instruction_pointer: null, function: null, range: null }],
};
assert.equal(
decodeResponse(
envelope({ kind: "execution_inspected", data: { ...data, execution: unknown } }),
"test",
command,
).kind,
"execution_inspected",
);
});
test("actor log lines use syslog priorities, exact sequence IDs and single-line messages", () => {
const priorities = [135, 135, 134, 132, 131, 130];
for (const [index, level] of LOG_LEVELS.entries()) {
assert.equal(
formatLogEntry({
actor_ref: REF,
level,
sequence: 18446744073709551615n,
message: "first\r\nsecond",
}),
`<${priorities[index]}>1 - - runa ${REF} 18446744073709551615 - [${level.toUpperCase()}] first\\r\\nsecond\n`,
);
}
});
test("actor behavior replies check identity, text and the requested operation", () => {
const command: Command = {
operation: "write_actor_behavior",
parameters: { actor_ref: REF, expected_source_id: "original", source: "updated" },
};
const reply = { kind: "actor_behavior", data: { source: "updated", source_id: "temporary" } };
assert.deepEqual(decodeResponse(envelope(reply), "test", command), reply);
for (const data of [
{ source: "other", source_id: "temporary" },
{ source: "updated", source_id: "" },
{ source: 7, source_id: "temporary" },
]) {
assert.throws(() =>
decodeResponse(envelope({ kind: "actor_behavior", data }), "test", command),
);
}
assert.throws(() => decodeResponse(envelope(reply), "test", { operation: "list_sources" }));
});
test("log pages preserve exact cursors, actor filters and strictly increasing sequences", () => {
const command: Command = {
operation: "logs",
parameters: { actors: [REF], follow: false, after: 9223372036854775807n },
};
const entry = { sequence: 9223372036854775808n, actor_ref: REF, level: "info", message: "line" };
const data = { entries: [entry], next_sequence: entry.sequence, has_more: false };
assert.deepEqual(decodeResponse(envelope({ kind: "logs", data }), "test", command), {
kind: "logs",
data,
});
for (const change of [
{ entries: [entry, entry] },
{ entries: [{ ...entry, actor_ref: "ABCDEFGHIJKLMNOPQRSTU" }] },
{ entries: [{ ...entry, level: "unknown" }] },
{ next_sequence: entry.sequence + 1n },
{ entries: [], has_more: true, next_sequence: command.parameters.after },
{ has_more: "false" },
]) {
assert.throws(() =>
decodeResponse(envelope({ kind: "logs", data: { ...data, ...change } }), "test", command),
);
}
});
test("notification envelopes preserve u64 revisions and reject malformed events", () => {
const notification = (data: unknown) =>
json({
version: 5,
event: {
event: "image_changed",
data,
},
});
assert.equal(
decodeNotification(notification({ revision: 18446744073709551615n })),
18446744073709551615n,
);
assert.equal(decodeNotification(envelope({ kind: "actors", data: [] })), undefined);
for (const data of [
{ revision: 0 },
{ revision: -1 },
{ revision: 1.5 },
{ revision: 18446744073709551616n },
{ revision: "1" },
{ revision: 1, extra: true },
{},
]) {
assert.throws(() => decodeNotification(notification(data)));
}
assert.throws(() =>
decodeNotification(
json({
version: 2,
event: { event: "image_changed", data: { revision: 1 } },
}),
),
);
});
test("v5 snapshots preserve all value variants, i64 limits, u64 telemetry, and mailbox order", () => {
const snapshot = actor();
const reply = decodeResponse(envelope({ kind: "actors", data: [snapshot] }), "test", command);
assert.deepEqual(reply, { kind: "actors", data: [snapshot] });
const formatted = formatSnapshot(snapshot, "[::1]:3737", "2026-11-03T00:00:00Z", false);
assert.match(formatted, /9223372036854775807/);
assert.match(formatted, /-9223372036854775808/);
assert.match(formatted, /18446744073709551615/);
assert.deepEqual(parse(formatted, undefined, BigInt), {
connection: { endpoint: "[::1]:3737", connected: false },
captured_at: "2026-11-03T00:00:00Z",
actor: snapshot,
});
});
test("version, correlation, result, reply kind, and actor identity are checked", () => {
for (const change of [
{ version: 2 },
{ request_id: "wrong" },
{ result: { Ok: { kind: "actor", data: actor() } } },
{ result: { Ok: {}, Err: {} } },
{ result: {} },
]) {
assert.throws(() =>
decodeResponse(
json({
version: 5,
request_id: "test",
result: { Ok: { kind: "actors", data: [] } },
...change,
}),
"test",
command,
),
);
}
assert.throws(() =>
decodeResponse(envelope({ kind: "actors", data: [actor(), actor()] }), "test", command),
);
assert.throws(() =>
decodeResponse(envelope({ kind: "actor", data: actor() }), "test", {
operation: "inspect_actor",
parameters: { actor_ref: "ABCDEFGHIJKLMNOPQRSTU" },
}),
);
});
test("server errors are explicit, typed, and not treated as empty actor lists", () => {
assert.throws(
() =>
decodeResponse(
json({
version: 5,
request_id: "test",
result: { Err: { code: "not_found", message: "No actor" } },
}),
"test",
command,
),
(error) => error instanceof ProtocolError && error.code === "not_found",
);
});
test("structured diagnostics validate severity and exact editor coordinates on replies and errors", () => {
const command: Command = {
operation: "check_source",
parameters: { source: "pub fn main() { missing }" },
};
const diagnostic = {
severity: "error",
message: "Missing variable",
range: { start: { line: 0, character: 16 }, end: { line: 0, character: 23 } },
};
const reply = { kind: "source_checked", data: { diagnostics: [diagnostic] } };
assert.deepEqual(decodeResponse(envelope(reply), "test", command), reply);
for (const change of [
{ severity: "unknown" },
{ message: "" },
{ range: { start: { line: -1, character: 0 }, end: { line: 0, character: 0 } } },
{ range: { start: { line: 0, character: 8 }, end: { line: 0, character: 2 } } },
{
range: {
start: { line: 9007199254740992n, character: 0 },
end: { line: 9007199254740992n, character: 1 },
},
},
]) {
assert.throws(() =>
decodeResponse(
envelope({ kind: "source_checked", data: { diagnostics: [{ ...diagnostic, ...change }] } }),
"test",
command,
),
);
}
assert.deepEqual(
decodeResponse(
envelope({
kind: "source_checked",
data: { diagnostics: [{ ...diagnostic, range: null, severity: "warning" }] },
}),
"test",
command,
),
{
kind: "source_checked",
data: { diagnostics: [{ ...diagnostic, range: null, severity: "warning" }] },
},
);
assert.throws(
() =>
decodeResponse(
json({
version: 5,
request_id: "test",
result: {
Err: { code: "runtime_failure", message: "Compile failed", diagnostics: [diagnostic] },
},
}),
"test",
command,
),
(error) =>
error instanceof ProtocolError &&
error.diagnostics.length === 1 &&
error.diagnostics[0]?.range?.end.character === 23,
);
});
test("mutation replies must match the requested operation and carry validated data", () => {
const spawn: Command = {
operation: "spawn",
parameters: {
path: "actors/counter",
state: { type: "integer", data: 0n },
mailbox_capacity: 64,
},
};
assert.deepEqual(
decodeResponse(envelope({ kind: "actor_spawned", data: { actor_ref: REF } }), "test", spawn),
{ kind: "actor_spawned", data: { actor_ref: REF } },
);
assert.throws(() =>
decodeResponse(envelope({ kind: "actor_spawned", data: { actor_ref: "bad" } }), "test", spawn),
);
assert.throws(() => decodeResponse(envelope({ kind: "acknowledged" }), "test", spawn));
const tick: Command = { operation: "tick", parameters: { ticks: 10, instruction_budget: 32 } };
assert.deepEqual(
decodeResponse(envelope({ kind: "tick_completed", data: { ticks: 10 } }), "test", tick),
{ kind: "tick_completed", data: { ticks: 10n } },
);
assert.throws(() =>
decodeResponse(envelope({ kind: "tick_completed", data: { ticks: 11 } }), "test", tick),
);
const commands: Command[] = [
{ operation: "send", parameters: { actor_ref: REF, message: { type: "null" } } },
...(["stop", "kill", "restart", "pause", "resume", "reload"] as const).map((kind): Command => ({
operation: "system",
parameters: { actor_ref: REF, message: { kind } },
})),
{
operation: "system",
parameters: { actor_ref: REF, message: { kind: "become", path: "actors/counter" } },
},
];
for (const command of commands) {
assert.deepEqual(decodeResponse(envelope({ kind: "acknowledged" }), "test", command), {
kind: "acknowledged",
});
assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command));
}
});
test("source paths are canonical extensionless relative paths", () => {
for (const path of ["actors/counter", "a", "x/y_z/W-1"]) assert.ok(isSourcePath(path), path);
for (const path of [
"",
"/actors/counter",
"../counter",
"actors/../counter",
"actors//counter",
"source/actors/counter",
"actors/counter.rn",
"actors/my.counter",
"actors\\counter",
"actors/",
"x".repeat(129),
])
assert.ok(!isSourcePath(path), path);
assert.ok(isSourceRevision("abc123DEF"));
for (const revision of ["", "1.2", "a-b", "x".repeat(65)]) assert.ok(!isSourceRevision(revision));
});
test("source listings and files validate paths, identities, and uniqueness", () => {
const file = {
path: "actors/counter",
source_id: "source:1",
source: "pub fn handle() {} // 雪\n",
};
assert.deepEqual(
decodeResponse(envelope({ kind: "sources", data: [file] }), "test", {
operation: "list_sources",
}),
{
kind: "sources",
data: [file],
},
);
for (const data of [
[{ ...file, path: "actors/counter.rn" }],
[{ ...file, source_id: "" }],
[{ ...file, source: 1 }],
[file, file],
])
assert.throws(() =>
decodeResponse(envelope({ kind: "sources", data }), "test", { operation: "list_sources" }),
);
const read: Command = { operation: "read_source", parameters: { path: file.path } };
assert.deepEqual(decodeResponse(envelope({ kind: "source", data: file }), "test", read), {
kind: "source",
data: file,
});
assert.throws(() =>
decodeResponse(
envelope({ kind: "source", data: { ...file, path: "actors/other" } }),
"test",
read,
),
);
const write: Command = {
operation: "write_source",
parameters: { path: file.path, expected_source_id: null, source: file.source },
};
assert.equal(
decodeResponse(envelope({ kind: "source", data: file }), "test", write).kind,
"source",
);
assert.throws(() =>
decodeResponse(envelope({ kind: "source", data: { ...file, source: "other" } }), "test", write),
);
const rollback: Command = {
operation: "rollback_source",
parameters: { path: file.path, revision: "abc" },
};
assert.equal(
decodeResponse(envelope({ kind: "source", data: file }), "test", rollback).kind,
"source",
);
assert.throws(() => decodeResponse(envelope({ kind: "sources", data: [] }), "test", read));
});
test("source commits and history carry readable revision strings", () => {
const commit = { revision: "k3x9q2", source_id: "source:1" };
const path = { path: "actors/counter" };
assert.deepEqual(
decodeResponse(envelope({ kind: "source_committed", data: commit }), "test", {
operation: "commit_source",
parameters: path,
}),
{ kind: "source_committed", data: commit },
);
const history: Command = { operation: "source_history", parameters: path };
assert.deepEqual(
decodeResponse(envelope({ kind: "source_history", data: [] }), "test", history),
{
kind: "source_history",
data: [],
},
);
for (const data of [
[{ ...commit, revision: "a.b" }],
[{ ...commit, source_id: 1 }],
[commit, commit],
])
assert.throws(() =>
decodeResponse(envelope({ kind: "source_history", data }), "test", history),
);
assert.throws(() =>
decodeResponse(envelope({ kind: "source_committed", data: commit }), "test", history),
);
});
test("revision source replies require text and the matching operation", () => {
const command: Command = {
operation: "revision_source",
parameters: { path: "actors/counter", revision: "k3x9q2" },
};
const source = "pub fn handle(ctx, state, sender, message) {\n state // 雪\n}\n";
assert.deepEqual(
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", command),
{ kind: "behavior_source", data: { source } },
);
for (const data of [{}, { source: null }, { source: 12 }, { source: [] }]) {
assert.throws(() =>
decodeResponse(envelope({ kind: "behavior_source", data }), "test", command),
);
}
assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command));
assert.throws(() =>
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", {
operation: "list_sources",
}),
);
});
test("malformed states and snapshots fail instead of being coerced", () => {
for (const state of [
{ type: "integer", data: 9223372036854775808n },
{ type: "integer", data: -9223372036854775809n },
{ type: "integer", data: 1.5 },
{ type: "actor_ref", data: "guessable" },
{ type: "string", data: REF, extra: true },
{ type: "null", data: null },
{ type: "float", data: 1 },
]) {
assert.throws(() =>
decodeResponse(envelope({ kind: "actors", data: [{ ...actor(), state }] }), "test", command),
);
}
for (const change of [
{ status: { status: "unknown" } },
{ status: { status: "failed" } },
{ incarnation: -1 },
{ mailbox_id: 18446744073709551616n },
{ execution: { vm_steps: "20" } },
{ reserved: undefined },
{ queued: null },
{ actor_ref: "@invalid" },
]) {
assert.throws(() =>
decodeResponse(
envelope({ kind: "actors", data: [{ ...actor(), ...change }] }),
"test",
command,
),
);
}
});
test("portable value limits have inclusive depth, node, and UTF-8 byte boundaries", () => {
let nested: unknown = { type: "null" };
for (let i = 0; i < 32; i++) nested = { type: "list", data: [nested] };
assert.doesNotThrow(() => decodeValue(nested));
assert.throws(() => decodeValue({ type: "list", data: [nested] }), /depth/);
const data = Array.from({ length: 4095 }, () => ({ type: "null" }));
assert.doesNotThrow(() => decodeValue({ type: "list", data }));
assert.throws(() => decodeValue({ type: "list", data: [...data, { type: "null" }] }), /node/);
assert.doesNotThrow(() => decodeValue({ type: "string", data: "é".repeat(524288) }));
assert.throws(() => decodeValue({ type: "string", data: "é".repeat(524288) + "a" }), /byte/);
});