207 lines
8.1 KiB
JavaScript
207 lines
8.1 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { referenceFromWire } from "../dist/references.js";
|
|
import {
|
|
bindQxHandler,
|
|
decodeQxValue,
|
|
encodeQxValue,
|
|
jsToProtoValue,
|
|
liveValue,
|
|
protoValueToJs,
|
|
opaqueReactPropsBinding,
|
|
} from "../dist/index.js";
|
|
const scalar = (name) => ({ kind: "scalar", name });
|
|
const unit = { kind: "builtin", name: "unit" };
|
|
test("opaque React props retain managed references while ordinary messages remain closed", () => {
|
|
const descriptorId = "org.quixos.web-studio.ReactProps";
|
|
const reference = referenceFromWire("obj:task");
|
|
const type = { kind: "message", descriptorId };
|
|
const messages = { [descriptorId]: opaqueReactPropsBinding };
|
|
const decoded = decodeQxValue(type, encodeQxValue(type, { subject: reference, title: "Task" }, messages), messages);
|
|
assert.ok(decoded.subject.equals(reference));
|
|
assert.equal(decoded.title, "Task");
|
|
assert.throws(() => opaqueReactPropsBinding.encode(null), /object/);
|
|
assert.throws(() => opaqueReactPropsBinding.encode([]), /object/);
|
|
assert.throws(
|
|
() =>
|
|
encodeQxValue(
|
|
{ kind: "message", descriptorId: "Ordinary" },
|
|
{ subject: reference },
|
|
{ Ordinary: opaqueReactPropsBinding },
|
|
),
|
|
/Managed references/,
|
|
);
|
|
});
|
|
test("declared record inputs preserve opaque references without opening message codecs", async () => {
|
|
const target = referenceFromWire("obj:board");
|
|
const type = {
|
|
kind: "record",
|
|
fields: {
|
|
target: { kind: "object-ref", expectation: { kind: "atom", atomId: "board" } },
|
|
x: scalar("double"),
|
|
note: { kind: "optional", value: scalar("string") },
|
|
},
|
|
};
|
|
const encoded = encodeQxValue(type, { target, x: 12 }, {});
|
|
const decoded = decodeQxValue(type, encoded, {});
|
|
assert.ok(decoded.target.equals(target));
|
|
assert.equal(decoded.x, 12);
|
|
assert.equal(decoded.note, null);
|
|
assert.throws(() => JSON.stringify(decoded), /cannot be serialized/);
|
|
assert.throws(() => encodeQxValue(type, { target, x: 12, hidden: target }, {}), /Unexpected/);
|
|
assert.throws(() => encodeQxValue(type, { x: 12 }, {}), /Missing/);
|
|
assert.throws(() => encodeQxValue(type, { target: "obj:board", x: 12 }, {}), /opaque object reference/);
|
|
const handler = bindQxHandler(
|
|
{ inputType: type, outputType: unit, ports: {} },
|
|
async (context) => {
|
|
assert.ok(context.input.target.equals(target));
|
|
assert.equal(context.input.x, 12);
|
|
},
|
|
{},
|
|
);
|
|
await handler({ objectId: target, inputProto: encoded.kind.value.fields });
|
|
});
|
|
test("typed sessions rebind ports and cancellation to each acquired invocation", async () => {
|
|
const first = new AbortController(),
|
|
second = new AbortController();
|
|
let closed = false;
|
|
const context = (value, signal) => ({
|
|
objectId: referenceFromWire("obj:owner"),
|
|
inputProto: {},
|
|
signal,
|
|
state: () => ({ live: async () => liveValue(jsToProtoValue(value)) }),
|
|
});
|
|
const raw = {
|
|
...context(1n, first.signal),
|
|
openSession: async () => ({
|
|
id: "session:test",
|
|
run: (work) => work(context(2n, second.signal)),
|
|
close: async () => {
|
|
closed = true;
|
|
},
|
|
}),
|
|
};
|
|
const handler = bindQxHandler(
|
|
{
|
|
inputType: unit,
|
|
outputType: unit,
|
|
ports: { counter: { kind: "state", id: "counter", valueType: scalar("int64"), primitives: ["read"] } },
|
|
},
|
|
async (bound) => {
|
|
assert.equal(bound.signal, first.signal);
|
|
const session = await bound.openSession();
|
|
await session.run(async (next) => {
|
|
assert.equal(next.signal, second.signal);
|
|
assert.equal(await next.ports.counter.get(), 2n);
|
|
assert.equal(next.openSession, undefined);
|
|
});
|
|
await session.close();
|
|
},
|
|
{},
|
|
);
|
|
await handler(raw);
|
|
assert.equal(closed, true);
|
|
});
|
|
test("binding codecs round trip nested bytes, 64-bit integers, nulls, and references", () => {
|
|
const values = [
|
|
[scalar("int64"), -(2n ** 63n)],
|
|
[scalar("uint64"), 2n ** 64n - 1n],
|
|
[scalar("bytes"), new Uint8Array([0, 255])],
|
|
[{ kind: "list", value: { kind: "optional", value: scalar("int64") } }, [null, 2n ** 60n]],
|
|
[{ kind: "object-ref", expectation: { kind: "atom", atomId: "thing" } }, referenceFromWire("obj:thing")],
|
|
];
|
|
for (const [type, value] of values) assert.deepEqual(decodeQxValue(type, encodeQxValue(type, value, {}), {}), value);
|
|
});
|
|
|
|
test("opaque references pass declared RPC boundaries but cannot enter ordinary data", () => {
|
|
const reference = referenceFromWire("obj:thing");
|
|
const type = { kind: "object-ref", expectation: { kind: "atom", atomId: "thing" } };
|
|
const roundtrip = decodeQxValue(type, encodeQxValue(type, reference, {}), {});
|
|
assert.equal(reference.equals(roundtrip), true);
|
|
assert.equal(reference.equals(referenceFromWire("obj:other")), false);
|
|
assert.throws(() => JSON.stringify({ nested: [reference] }), /cannot be serialized/);
|
|
assert.throws(() => String(reference), /cannot be coerced/);
|
|
assert.throws(() => encodeQxValue(type, "obj:thing", {}), /opaque/);
|
|
assert.throws(() => encodeQxValue(scalar("string"), reference, {}), /Managed references/);
|
|
const message = { kind: "message", descriptorId: "Payload" };
|
|
assert.throws(
|
|
() => encodeQxValue(message, { nested: reference }, { Payload: { encode: jsToProtoValue } }),
|
|
/Managed references/,
|
|
);
|
|
assert.throws(
|
|
() => encodeQxValue(message, {}, { Payload: { encode: () => jsToProtoValue(reference) } }),
|
|
/Managed references/,
|
|
);
|
|
assert.throws(
|
|
() => decodeQxValue(message, jsToProtoValue(reference), { Payload: { decode: () => ({}) } }),
|
|
/Managed references/,
|
|
);
|
|
});
|
|
test("typed state and interface ports preserve declared values and exact operation IDs", async () => {
|
|
const spec = {
|
|
inputType: scalar("int64"),
|
|
outputType: scalar("bytes"),
|
|
ports: {
|
|
data: { kind: "state", id: "state-id", valueType: scalar("int64"), primitives: ["read", "write"] },
|
|
reader: {
|
|
kind: "interface",
|
|
id: "interface-id",
|
|
operations: { "payload.get": { id: "get-id", inputType: unit, outputType: scalar("bytes") } },
|
|
},
|
|
},
|
|
};
|
|
let written;
|
|
const handler = bindQxHandler(
|
|
spec,
|
|
async ({ input, ports }) => {
|
|
assert.equal(input, 2n ** 60n);
|
|
assert.equal(await ports.data.get(), 9n);
|
|
assert.deepEqual((await ports.data.live()).$quixosValue, jsToProtoValue(9n));
|
|
assert.ok(ports.reader.objectId.equals(referenceFromWire("obj:reader")));
|
|
assert.deepEqual((await ports.reader.live["payload.get"]()).$quixosValue, jsToProtoValue(new Uint8Array([7])));
|
|
await ports.data.set(input);
|
|
return ports.reader["payload.get"]();
|
|
},
|
|
{},
|
|
);
|
|
const result = await handler({
|
|
inputProto: { value: jsToProtoValue(2n ** 60n) },
|
|
objectId: "obj",
|
|
state: (id) => {
|
|
assert.equal(id, "state-id");
|
|
return {
|
|
live: async () => liveValue(jsToProtoValue(9n)),
|
|
set: async (value) => {
|
|
written = value;
|
|
},
|
|
};
|
|
},
|
|
interface: (id) => {
|
|
assert.equal(id, "interface-id");
|
|
return {
|
|
objectId: referenceFromWire("obj:reader"),
|
|
live: async (operation, input) => {
|
|
assert.equal(operation, "get-id");
|
|
assert.deepEqual(input, {});
|
|
return liveValue(jsToProtoValue(new Uint8Array([7])));
|
|
},
|
|
};
|
|
},
|
|
});
|
|
assert.equal(written.$quixosValue.kind.value, String(2n ** 60n));
|
|
assert.deepEqual(result.$quixosValue.kind.value, new Uint8Array([7]));
|
|
});
|
|
test("external message bindings and derived event types are used at the boundary", async () => {
|
|
const message = { kind: "message", descriptorId: "Payload" };
|
|
const messages = { Payload: { encode: jsToProtoValue, decode: protoValueToJs } };
|
|
const handler = bindQxHandler(
|
|
{ inputType: message, outputType: { kind: "builtin", name: "watch-handle" }, eventType: message, ports: {} },
|
|
{ kind: "derived", get: ({ input }) => ({ value: input.title }) },
|
|
messages,
|
|
);
|
|
assert.equal(handler.kind, "derived");
|
|
const result = await handler.get({ objectId: "obj", inputProto: { title: jsToProtoValue("hello") } });
|
|
assert.deepEqual(protoValueToJs(result.$quixosValue), { value: "hello" });
|
|
assert.throws(() => encodeQxValue(message, {}, {}), /Missing message binding/);
|
|
});
|