2026-11-03 20:48:08 +00:00
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { test } from "node:test";
|
|
|
|
|
import { ActorVariables } from "../src/debugVariables";
|
|
|
|
|
import { actor, REF } from "./fixtures";
|
|
|
|
|
|
|
|
|
|
test("actor variables preserve exact values, types, escaped strings and typed references", () => {
|
|
|
|
|
const variables = new ActorVariables();
|
|
|
|
|
variables.capture(actor());
|
|
|
|
|
const scope = variables.scope();
|
|
|
|
|
assert.match(scope.name, /committed; not frame locals/);
|
|
|
|
|
const roots = variables.variables(scope.variablesReference);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
roots.map((variable) => variable.name),
|
|
|
|
|
["committed_state", "mailbox", "config", "metadata"],
|
|
|
|
|
);
|
|
|
|
|
const state = roots[0]!;
|
|
|
|
|
assert.equal(state.value, "Record(5)");
|
|
|
|
|
assert.equal(state.namedVariables, 5);
|
|
|
|
|
const fields = variables.variables(state.variablesReference);
|
|
|
|
|
assert.equal(fields.find((field) => field.name === "minimum")?.value, "-9223372036854775808");
|
|
|
|
|
assert.equal(fields.find((field) => field.name === "maximum")?.value, "9223372036854775807");
|
|
|
|
|
assert.equal(fields.find((field) => field.name === "reference")?.value, `@${REF}`);
|
|
|
|
|
assert.equal(fields.find((field) => field.name === "reference")?.type, "actor_ref");
|
|
|
|
|
assert.equal(fields.find((field) => field.name === "text")?.type, "string");
|
|
|
|
|
assert.equal(
|
|
|
|
|
fields.find((field) => field.name === "text")?.value,
|
|
|
|
|
JSON.stringify(`@${REF} "雪"`),
|
|
|
|
|
);
|
|
|
|
|
assert.ok(fields.every((field) => field.presentationHint.attributes[0] === "readOnly"));
|
|
|
|
|
const list = fields.find((field) => field.name === "list")!;
|
|
|
|
|
assert.equal(list.indexedVariables, 2);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
variables.variables(list.variablesReference).map((child) => child.value),
|
|
|
|
|
["null", "true"],
|
|
|
|
|
);
|
|
|
|
|
const metadata = variables.variables(roots[3]!.variablesReference);
|
|
|
|
|
assert.equal(
|
|
|
|
|
metadata.find((field) => field.name === "mailbox_id")?.value,
|
|
|
|
|
"18446744073709551615",
|
|
|
|
|
);
|
|
|
|
|
const mailbox = variables.variables(roots[1]!.variablesReference);
|
|
|
|
|
const reserved = mailbox.find((field) => field.name === "reserved")!;
|
|
|
|
|
assert.equal(
|
|
|
|
|
reserved.indexedVariables,
|
|
|
|
|
1,
|
|
|
|
|
"a reserved null message must not be confused with no reservation",
|
|
|
|
|
);
|
|
|
|
|
assert.equal(variables.variables(reserved.variablesReference)[0]?.value, "null");
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-03 21:47:18 +00:00
|
|
|
test("snapshot watches resolve JSON Pointer paths and expose expandable evaluate names", () => {
|
|
|
|
|
const snapshot = actor();
|
|
|
|
|
snapshot.state = {
|
|
|
|
|
type: "record",
|
|
|
|
|
data: Object.fromEntries([
|
|
|
|
|
[
|
|
|
|
|
"a/b",
|
|
|
|
|
{
|
|
|
|
|
type: "record",
|
|
|
|
|
data: {
|
|
|
|
|
"~key": { type: "list", data: [{ type: "integer", data: -9223372036854775808n }] },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
["", { type: "null" }],
|
|
|
|
|
["__proto__", { type: "string", data: "ordinary own field" }],
|
|
|
|
|
["~1", { type: "bool", data: true }],
|
|
|
|
|
['"quoted"\n雪', { type: "integer", data: 7n }],
|
|
|
|
|
]),
|
|
|
|
|
};
|
|
|
|
|
const variables = new ActorVariables();
|
|
|
|
|
variables.capture(snapshot);
|
|
|
|
|
assert.equal(variables.evaluate("actor").value, "Record(4)");
|
|
|
|
|
const path = "actor/committed_state/a~1b/~0key/0";
|
|
|
|
|
assert.equal(variables.evaluate(path).value, "-9223372036854775808");
|
|
|
|
|
assert.equal(variables.evaluate("actor/committed_state/").value, "null");
|
|
|
|
|
assert.equal(variables.evaluate("actor/committed_state/__proto__").value, '"ordinary own field"');
|
|
|
|
|
assert.equal(variables.evaluate("actor/committed_state/~01").value, "true");
|
|
|
|
|
assert.equal(variables.evaluate('actor/committed_state/"quoted"\n雪').value, "7");
|
|
|
|
|
assert.throws(() => variables.evaluate("actor/committed_state/constructor"), /not found/);
|
|
|
|
|
const pending = [variables.scope().variablesReference];
|
|
|
|
|
while (pending.length) {
|
|
|
|
|
for (const variable of variables.variables(pending.pop()!)) {
|
|
|
|
|
assert.deepEqual(variables.evaluate(variable.evaluateName), {
|
|
|
|
|
...variable,
|
|
|
|
|
name: variable.evaluateName,
|
|
|
|
|
});
|
|
|
|
|
if (variable.variablesReference) pending.push(variable.variablesReference);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("snapshot watches reject expressions, malformed pointers, scalar traversal and invalid indices", () => {
|
|
|
|
|
const variables = new ActorVariables();
|
|
|
|
|
variables.capture(actor());
|
|
|
|
|
for (const expression of [
|
|
|
|
|
"",
|
|
|
|
|
"/committed_state",
|
|
|
|
|
"state",
|
|
|
|
|
"actor.committed_state",
|
|
|
|
|
"actor()",
|
|
|
|
|
"actor = 1",
|
|
|
|
|
"actor/committed_state/text/0",
|
|
|
|
|
"actor/committed_state/list/-1",
|
|
|
|
|
"actor/committed_state/list/01",
|
|
|
|
|
"actor/committed_state/list/1.0",
|
|
|
|
|
"actor/committed_state/list/2",
|
|
|
|
|
"actor/committed_state/list/9007199254740992",
|
|
|
|
|
"actor/committed_state/list/constructor",
|
|
|
|
|
"actor/committed_state/list/-",
|
|
|
|
|
"actor/committed_state/list/ 0",
|
|
|
|
|
"actor/committed_state/list/0 + 1",
|
|
|
|
|
"actor/committed_state/~",
|
|
|
|
|
"actor/committed_state/~2",
|
|
|
|
|
"actor/committed_state/~00~",
|
|
|
|
|
`actor/${"x/".repeat(65)}`,
|
|
|
|
|
`actor/${"x".repeat(4096)}`,
|
|
|
|
|
])
|
|
|
|
|
assert.throws(() => variables.evaluate(expression), expression);
|
|
|
|
|
assert.equal(variables.evaluate("actor/committed_state/list/0").value, "null");
|
|
|
|
|
const reference = variables.evaluate("actor/committed_state").variablesReference;
|
|
|
|
|
variables.clear();
|
|
|
|
|
assert.throws(() => variables.evaluate("actor/committed_state"), /unavailable/);
|
|
|
|
|
assert.throws(() => variables.variables(reference), /Stale/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("snapshot watch path length accepts the exact documented boundary", () => {
|
|
|
|
|
const prefix = "actor/committed_state/";
|
|
|
|
|
const key = "x".repeat(4096 - prefix.length);
|
|
|
|
|
const snapshot = actor();
|
|
|
|
|
snapshot.state = { type: "record", data: { [key]: { type: "bool", data: true } } };
|
|
|
|
|
const variables = new ActorVariables();
|
|
|
|
|
variables.capture(snapshot);
|
|
|
|
|
assert.equal(variables.evaluate(prefix + key).value, "true");
|
|
|
|
|
assert.throws(() => variables.evaluate(prefix + key + "x"), /exceeds 4096/);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-03 20:48:08 +00:00
|
|
|
test("variable handles are stable per snapshot, stale after clear, and snapshots never alias inputs", () => {
|
|
|
|
|
const snapshot = actor();
|
|
|
|
|
const variables = new ActorVariables();
|
|
|
|
|
variables.capture(snapshot);
|
|
|
|
|
const scope = variables.scope();
|
|
|
|
|
const state = variables.variables(scope.variablesReference)[0]!;
|
|
|
|
|
assert.deepEqual(variables.variables(scope.variablesReference)[0], state);
|
|
|
|
|
snapshot.state = { type: "integer", data: 999n };
|
|
|
|
|
snapshot.queued.push({ type: "null" });
|
|
|
|
|
assert.equal(variables.variables(scope.variablesReference)[0]?.value, "Record(5)");
|
|
|
|
|
const mailbox = variables.variables(
|
|
|
|
|
variables.variables(scope.variablesReference)[1]!.variablesReference,
|
|
|
|
|
);
|
|
|
|
|
assert.equal(mailbox.find((field) => field.name === "queued")?.indexedVariables, 1);
|
|
|
|
|
variables.capture(snapshot);
|
|
|
|
|
assert.throws(() => variables.variables(scope.variablesReference), /Stale/);
|
|
|
|
|
const next = variables.scope();
|
|
|
|
|
assert.notEqual(scope.variablesReference, next.variablesReference);
|
|
|
|
|
assert.equal(variables.variables(next.variablesReference)[0]?.value, "999");
|
|
|
|
|
variables.clear();
|
|
|
|
|
assert.throws(() => variables.scope(), /unavailable/);
|
|
|
|
|
assert.throws(() => variables.variables(next.variablesReference), /Stale/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("variables support named/indexed paging and bounded previews without recursive serialization", () => {
|
|
|
|
|
const snapshot = actor();
|
|
|
|
|
snapshot.state = {
|
|
|
|
|
type: "list",
|
|
|
|
|
data: [
|
|
|
|
|
{ type: "string", data: "\n".repeat(300) },
|
|
|
|
|
{ type: "record", data: {} },
|
|
|
|
|
{ type: "list", data: [] },
|
|
|
|
|
{ type: "integer", data: 1n },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
snapshot.reserved = null;
|
|
|
|
|
const variables = new ActorVariables();
|
|
|
|
|
variables.capture(snapshot);
|
|
|
|
|
const root = variables.scope().variablesReference;
|
|
|
|
|
const state = variables.variables(root)[0]!.variablesReference;
|
|
|
|
|
assert.deepEqual(variables.variables(state, "named"), []);
|
|
|
|
|
assert.deepEqual(variables.variables(root, "indexed"), []);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
variables.variables(state, "indexed", 1, 2).map((field) => field.name),
|
|
|
|
|
["1", "2"],
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
variables.variables(state, undefined, 2, 0).map((field) => field.name),
|
|
|
|
|
["2", "3"],
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(variables.variables(state, "indexed", 100), []);
|
|
|
|
|
const fields = variables.variables(state);
|
|
|
|
|
assert.equal(fields[0]?.value, `${JSON.stringify("\n".repeat(256))}... (300 UTF-16 code units)`);
|
|
|
|
|
assert.equal(fields[1]?.variablesReference, 0);
|
|
|
|
|
assert.equal(fields[2]?.variablesReference, 0);
|
|
|
|
|
assert.throws(() => variables.variables(root, "invalid"), /filter/);
|
|
|
|
|
});
|