#!/usr/bin/env -S deno run -A
import {
createSession,
Identity,
IdentityCreateConfig,
Session,
} from "@commontools/identity";
import { env, waitFor } from "@commontools/integration";
import {
CellHandle,
type JSONSchema,
RuntimeClient,
type VNode,
} from "@commontools/runtime-client";
import { rendererVDOMSchema } from "@commontools/runner/schemas";
import { assertEquals, assertExists } from "@std/assert";
import { describe, it } from "@std/testing/bdd";
import { Program } from "@commontools/js-compiler";
import { render } from "@commontools/html/client";
import { MockDoc } from "@commontools/html/mock-doc";
import { WebWorkerRuntimeTransport } from "@commontools/runtime-client/transports/web-worker";
const { API_URL } = env;
// Use a deserializable key implementation in Deno,
// as we cannot currently transfer WebCrypto implementation keys
// across serialized boundary
const keyConfig: IdentityCreateConfig = {
implementation: "noble",
};
const identity = await Identity.fromPassphrase("test operator", keyConfig);
const spaceName = globalThis.crypto.randomUUID();
const TEST_PROGRAM = `///
import { Cell, NAME, pattern, UI } from "commontools";
export default pattern((_) => {
const cell = Cell.of("hello");
return {
[NAME]: "Home",
[UI]: (
home{cell}
),
};
});`;
const TEMP_PATTERN = `///
import { Default, NAME, pattern, UI } from "commontools";
interface PatternState {
count: Default;
label: Default;
}
export default pattern((state) => {
return {
[NAME]: state.label,
[UI]: (
{state && state.count > 0 ? Positive
: Non-positive
}
),
};
});
`;
describe("RuntimeClient", () => {
describe("lifecycle", () => {
it("initializes and reaches ready state", async () => {
const session = await createSession({ identity, spaceName });
const rt = await createRuntimeClient(session);
await rt.dispose();
});
});
describe("cell operations", () => {
it("creates a cell with getCell and syncs its value", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const schema = {
type: "object",
properties: {
message: { type: "string" },
count: { type: "number" },
},
} as const satisfies JSONSchema;
const cause = "test-cell-" + Date.now();
const cell = await rt.getCell<{ message: string; count: number }>(
session.space,
cause,
schema,
);
const input = { message: "hi", count: 0 };
await cell.set(input);
await cell.sync();
const value = await new Promise((resolve) => {
cell.subscribe((value) => {
resolve(value);
});
});
assertEquals(value, input);
});
it("recursively returns VNodes inline with schema-driven serialization", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(TEMP_PATTERN, {
run: true,
});
const cell = page.cell();
const value = await cell.sync() as { $UI?: VNode; $NAME?: string };
// With schema-driven serialization (asCell: true), children are resolved
// inline as VNodes rather than wrapped in CellHandle indirection.
const children = value.$UI?.children as VNode[];
const firstChild = children?.[0];
assertEquals(firstChild?.children, ["Non-positive"]);
assertEquals(firstChild?.name, "p");
});
it("resolves cell links with resolveAsCell()", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
// Create a target cell with some data
const targetSchema = {
type: "object",
properties: { value: { type: "string" } },
} as const satisfies JSONSchema;
const targetCell = await rt.getCell<{ value: string }>(
session.space,
"resolve-target-" + Date.now(),
targetSchema,
);
await targetCell.set({ value: "resolved!" });
await rt.idle();
// Create a source cell that contains a link to the target
const sourceSchema = {
type: "object",
properties: { link: { type: "object" } },
} as const satisfies JSONSchema;
const sourceCell = await rt.getCell<{ link: unknown }>(
session.space,
"resolve-source-" + Date.now(),
sourceSchema,
);
await sourceCell.set({ link: targetCell });
await rt.idle();
await sourceCell.sync();
// Get the link cell and resolve it
const linkCell = sourceCell.key("link");
const resolved = await linkCell.resolveAsCell();
// The resolved cell should point to the target
assertEquals(resolved.id(), targetCell.id());
});
it("subscribes to cell updates via subscribe()", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const schema = {
type: "object",
properties: { counter: { type: "number" } },
} as const satisfies JSONSchema;
const cell = await rt.getCell<{ counter: number }>(
session.space,
"test-sink-" + Date.now(),
schema,
);
cell.set({ counter: 0 });
await rt.idle();
await cell.sync();
const receivedValues: { counter: number }[] = [];
const cancel = cell.subscribe((value) => {
if (!value) throw new Error("cell was not synced");
receivedValues.push(value);
});
cell.set({ counter: 1 });
cell.set({ counter: 2 });
cell.set({ counter: 3 });
await waitFor(() => Promise.resolve(receivedValues.length >= 3), {
timeout: 5000,
});
cancel();
// Should have received updates (may include initial value)
assertEquals(receivedValues.length >= 3, true);
assertEquals(receivedValues[receivedValues.length - 1], { counter: 3 });
});
it("updates multiple instances of the same cell with different schema", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const schema = {
type: "string",
} as const satisfies JSONSchema;
const cause = "test-cell-" + Date.now();
const cell = await rt.getCell(
session.space,
cause,
schema,
);
const cell2 = cell.asSchema({
type: "string",
default: "default-string",
});
let _updatedValue1 = undefined;
const cancel1 = cell.subscribe((value) => {
_updatedValue1 = value;
});
let _updatedValue2 = undefined;
const cancel2 = cell2.subscribe((value) => {
_updatedValue2 = value;
});
await cell.set("my-value");
await waitFor(() => Promise.resolve(cell2.get() === "my-value"));
cancel1();
cancel2();
});
it("late subscribers receive initial value from existing subscription", async () => {
// Regression test for bug where text interpolation {value} would show blank
// when used alongside ct-input bound to the same cell. The issue was that
// late subscribers (those joining an existing subscription) would miss the
// initial value that was already sent to earlier subscribers.
//
// Fix: connection.subscribe() copies cached value from existing subscriber
// to new subscriber when joining an existing subscription.
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const schema = {
type: "object",
properties: { message: { type: "string" } },
} as const satisfies JSONSchema;
// Create a cell and set an initial value
const cell = await rt.getCell<{ message: string }>(
session.space,
"test-late-subscriber-" + Date.now(),
schema,
);
await cell.set({ message: "hello world" });
await rt.idle();
await cell.sync();
// Create two CellHandles with the SAME schema - this produces the same
// subscription key (space:id:path:schema). In the real bug, this happens
// when ct-input and text interpolation both call asSchema(stringSchema).
const cellA = cell.asSchema<{ message: string }>(schema);
const cellB = cell.asSchema<{ message: string }>(schema);
// Subscribe cellA first - this establishes the backend subscription
const valuesA: ({ message: string } | undefined)[] = [];
const cancelA = cellA.subscribe((v) => {
valuesA.push(v);
});
// Wait for initial value to arrive from backend
await waitFor(
() =>
Promise.resolve(
valuesA.length > 0 && valuesA[valuesA.length - 1] !== undefined,
),
{ timeout: 5000 },
);
// Verify cellA received the value
assertEquals(
valuesA[valuesA.length - 1],
{ message: "hello world" },
"First subscriber should receive value",
);
// Now subscribe cellB - this is the "late subscriber" that joins an
// existing subscription. Before the fix, its initial callback would
// receive undefined because no new backend request was made.
const valuesB: ({ message: string } | undefined)[] = [];
const cancelB = cellB.subscribe((v) => {
valuesB.push(v);
});
// The fix ensures cellB immediately receives the cached value
// synchronously in the subscribe() call
assertEquals(
valuesB.length,
1,
"Late subscriber should receive immediate callback",
);
assertEquals(
valuesB[0],
{ message: "hello world" },
"Late subscriber should receive cached value, not undefined",
);
// Also verify both receive subsequent updates
await cell.set({ message: "updated" });
await waitFor(
() =>
Promise.resolve(
valuesA.some((v) => v?.message === "updated") &&
valuesB.some((v) => v?.message === "updated"),
),
{ timeout: 5000 },
);
cancelA();
cancelB();
});
});
describe("page operations", () => {
it("creates a page from URL and retrieves it", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(TEST_PROGRAM, {
run: true,
});
assertExists(page.id());
});
it("starts and stops page execution", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(TEST_PROGRAM, {
run: false,
});
await page.start();
await rt.idle();
await page.stop();
});
it("removes a page", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(TEST_PROGRAM, {
run: false,
});
await rt.removePage(page.id());
await rt.synced();
// Note: getPage may still return a reference to a removed page
// because the ID still maps to a cell that existed. The removal
// affects the pages list, not the ability to lookup by ID.
});
it("gets the pages list cell", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const piecesListCell = await rt.getPiecesListCell();
assertExists(piecesListCell);
await piecesListCell.sync();
const link = piecesListCell.ref();
assertExists(link);
});
});
describe("events", () => {
it("emits console events from page execution", async () => {
const consolePattern = `///
import { NAME, pattern, UI } from "commontools";
export default pattern((_) => {
console.log('hello');
return {
[NAME]: "Home",
[UI]: (console),
};
});`;
const consoleProgram: Program = {
main: "/main.tsx",
files: [{
name: "/main.tsx",
contents: consolePattern,
}],
};
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const consoleEvents: { method: string; args: unknown[] }[] = [];
rt.on(
"console",
(
event,
) => {
consoleEvents.push(event);
},
);
await rt.createPage(consoleProgram, { run: true });
await rt.idle();
await waitFor(
() =>
Promise.resolve(
consoleEvents.length > 0 && consoleEvents[0].args[0] === "hello",
),
{
timeout: 5000,
},
);
});
});
describe("event handlers", () => {
it("sends events to stream cells without schema error", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
// Create a cell with undefined schema (simulating what happens with handler streams)
const cell = await rt.getCell(
session.space,
"test-stream-send-" + Date.now(),
undefined, // No schema - this is what causes the proxy fallback
);
cell.send({ type: "click", target: "button" });
await rt.idle();
await cell.sync();
// Verify the event was stored
const value = cell.get() as { type?: string };
assertEquals(value?.type, "click", "Event should be stored in cell");
});
it("sends events to nested stream cell paths without schema error", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
// Create a root cell that will have the structure like a process cell
const rootCell = await rt.getCell(
session.space,
"test-nested-stream-" + Date.now(),
undefined,
);
// First, set up the internal structure
rootCell.set({ internal: {} });
await rt.idle();
await rootCell.sync();
// Now get a nested cell reference to internal/__#0stream (mimicking handler stream path)
const internalCell = (rootCell as any).key("internal");
const streamCell = (internalCell as any).key("__#0stream");
streamCell.send({ type: "click" });
await rt.idle();
await rootCell.sync();
});
});
describe("html render", () => {
it("retrieves UI markup from page cell", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(TEST_PROGRAM, {
run: true,
});
const cell = page.cell();
await cell.sync();
const value = cell.get() as { $UI?: VNode; $NAME?: string };
// Verify we can access the UI markup
assertExists(value.$UI, "Cell should have $UI property");
assertEquals(value.$UI.type, "vnode");
assertEquals(value.$UI.name, "h1");
});
it("renders page UI using html render function with CellHandle", async () => {
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(TEST_PROGRAM, {
run: true,
});
const cell = page.cell();
await cell.sync();
const typedCell = cell as typeof cell & { key(k: "$UI"): typeof cell };
const uiCell = typedCell.key("$UI").asSchema(rendererVDOMSchema);
await uiCell.sync();
const mock = new MockDoc(
``,
);
const { document, renderOptions } = mock;
const root = document.getElementById("root")!;
const cancel = render(root, uiCell as any, renderOptions);
const expected = "homehello
";
await waitFor(() => Promise.resolve(root.innerHTML === expected));
assertEquals(
root.innerHTML,
expected,
"Should render the page UI correctly",
);
cancel();
});
it("renders cell values in VNode children", async () => {
// Pattern that renders a state value in the UI
const valuePattern = `///
import { Default, NAME, pattern, UI } from "commontools";
interface State {
value: Default;
}
export default pattern(({ value }) => {
return {
[NAME]: "Value Test",
[UI]: (
Value is {value}
),
};
});`;
const valueProgram: Program = {
main: "/main.tsx",
files: [{
name: "/main.tsx",
contents: valuePattern,
}],
};
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(valueProgram, {
run: true,
});
const mock = new MockDoc(
``,
);
const { document, renderOptions } = mock;
const root = document.getElementById("root")!;
const cancel = render(root, page.cell() as any, renderOptions);
await waitFor(
() => Promise.resolve(root.innerHTML.includes("Value is 10")),
{ timeout: 5000 },
);
cancel();
});
it("renders derived cell values (like nth function)", async () => {
// Pattern that uses a derived expression similar to counter's nth(state.value)
const derivedPattern = `///
import { Default, NAME, pattern, UI } from "commontools";
function formatValue(n: number): string {
return "number-" + n;
}
interface State {
value: Default;
}
export default pattern(({ value }) => {
return {
[NAME]: "Derived Test",
[UI]: (
Result: {formatValue(value)}
),
};
});`;
const derivedProgram: Program = {
main: "/main.tsx",
files: [{
name: "/main.tsx",
contents: derivedPattern,
}],
};
const session = await createSession({ identity, spaceName });
await using rt = await createRuntimeClient(session);
const page = await rt.createPage(derivedProgram, {
run: true,
});
const cell = page.cell() as CellHandle;
const mock = new MockDoc(
``,
);
const { document, renderOptions } = mock;
const root = document.getElementById("root")!;
const cancel = render(root, cell, renderOptions);
await waitFor(
() => Promise.resolve(root.innerHTML.includes("Result: number-42")),
{ timeout: 15000 },
);
cancel();
});
});
});
async function createRuntimeClient(session: Session): Promise {
// If a space identity was created, replace it with a transferrable
// key in Deno using the same derivation as Session
if (session.spaceIdentity && session.spaceName) {
session.spaceIdentity = await (
await Identity.fromPassphrase("common user", keyConfig)
).derive(session.spaceName, keyConfig);
}
const transport = await WebWorkerRuntimeTransport.connect();
const worker = await RuntimeClient.initialize(transport, {
apiUrl: new URL(API_URL),
identity: session.as,
spaceIdentity: session.spaceIdentity,
spaceDid: session.space,
spaceName: session.spaceName,
});
await worker.synced();
return worker;
}