254 lines
8.3 KiB
TypeScript
254 lines
8.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { parse } from "lossless-json";
|
|
import {
|
|
decodeResponse,
|
|
decodeNotification,
|
|
decodeValue,
|
|
formatSnapshot,
|
|
ProtocolError,
|
|
Command,
|
|
} from "../src/protocol";
|
|
import { actor, json, REF } from "./fixtures";
|
|
|
|
const command: Command = { operation: "list_actors" };
|
|
const envelope = (reply: unknown) =>
|
|
json({ version: 3, request_id: "test", result: { Ok: reply } });
|
|
|
|
test("notification envelopes preserve u64 revisions and reject malformed events", () => {
|
|
const notification = (data: unknown) =>
|
|
json({
|
|
version: 3,
|
|
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("v3 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: 3,
|
|
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: 3,
|
|
request_id: "test",
|
|
result: { Err: { code: "not_found", message: "No actor" } },
|
|
}),
|
|
"test",
|
|
command,
|
|
),
|
|
(error) => error instanceof ProtocolError && error.code === "not_found",
|
|
);
|
|
});
|
|
|
|
test("mutation replies must match the requested operation and carry validated data", () => {
|
|
const spawn: Command = {
|
|
operation: "spawn",
|
|
parameters: {
|
|
behavior_name: "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),
|
|
);
|
|
for (const operation of [
|
|
"stop_actor",
|
|
"restart_actor",
|
|
"pause_actor",
|
|
"resume_actor",
|
|
"send",
|
|
] as const) {
|
|
const command: Command =
|
|
operation === "send"
|
|
? { operation, parameters: { actor_ref: REF, message: { type: "null" } } }
|
|
: { operation, parameters: { actor_ref: REF } };
|
|
assert.deepEqual(decodeResponse(envelope({ kind: "acknowledged" }), "test", command), {
|
|
kind: "acknowledged",
|
|
});
|
|
assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command));
|
|
}
|
|
});
|
|
|
|
test("behavior listings validate names, revisions, history, and uniqueness", () => {
|
|
const behavior = {
|
|
name: "counter",
|
|
revision: 1n,
|
|
current_implementation_id: "id",
|
|
history: [{ revision: 1n, implementation_id: "id" }],
|
|
};
|
|
assert.deepEqual(
|
|
decodeResponse(envelope({ kind: "behaviors", data: [behavior] }), "test", {
|
|
operation: "get_behaviors",
|
|
}),
|
|
{ kind: "behaviors", data: [behavior] },
|
|
);
|
|
for (const data of [
|
|
[{ ...behavior, name: "" }],
|
|
[{ ...behavior, revision: 0 }],
|
|
[{ ...behavior, history: [{ revision: 0, implementation_id: "id" }] }],
|
|
[behavior, behavior],
|
|
])
|
|
assert.throws(() =>
|
|
decodeResponse(envelope({ kind: "behaviors", data }), "test", { operation: "get_behaviors" }),
|
|
);
|
|
});
|
|
|
|
test("behavior source replies require text and the matching operation", () => {
|
|
const command: Command = {
|
|
operation: "get_behavior_source",
|
|
parameters: {
|
|
name: "counter",
|
|
revision: 18446744073709551615n,
|
|
},
|
|
};
|
|
const source = "pub fn handle(state, 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: "get_behaviors",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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/);
|
|
});
|