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