How to test a surface
directDispatch is the identity transport: it takes the served surface itself
and invokes its handlers in-process, with no socket and no subprocess. That makes
it the tool for testing a surface — the face you drive in the test is the exact
face a socket consumer would hold.
Build an in-process client
Hand directDispatch the value implementSurface returned (it needs only the
bound handlers, so pass the runtime bare) and wrap it in buildSurfaceFace for
the nested client.surface.<member>.<verb> addressing. Nothing is finalized and
nothing is re-prefixed — a tag carries its own route. directDispatch is also the
one dispatch surfaceClient accepts without a watchdog, because with no transport
there is nothing that could half-open.
The face is deliberately structural: each leaf types as unknown, because
per-member precision lives in the spec-derived hooks a Solid consumer gets, and a
second precise mapped type over the same spec is the union-budget blow-up the
framework avoids. So a test names the shape of each member it uses once — as a
UnaryEffect<I, O, E> or a StreamingProcedure<I, O> — after which every call
site is fully typed.
function makeTestClient() {
const runtime = implementSurface(surface, {
cells: { load: { store: inMemoryStore(ZERO) } },
collections: {
processes: {
readAll: () => table,
upsert: (pid, proc) => {
table.set(pid, proc);
},
remove: (pid) => {
table.delete(pid);
},
},
},
streams: {
nodeLog: {
source: (nodeId) =>
Stream.succeed({
kind: "snapshot" as const,
text: `opened ${nodeId}`,
done: false,
}),
},
},
events: { autosave: {} },
procedures: {
proc: {
kill: ({ input, ctx }) =>
Effect.sync(() => {
ctx.collections.processes.remove(input.pid);
return { ok: true };
}),
},
},
});
// The wire face, in-process. `implementSurface` already returns everything a
// dispatch needs, so pass the runtime bare — `directDispatch` is the one
// dispatch `surfaceClient` accepts without a watchdog.
return buildSurfaceFace(surface, directDispatch(runtime));
}
Assert on a snapshot
A cell or stream read opens with a snapshot and then sends deltas, as an Effect
Stream. So the current value is the first frame, and Stream.runHead is the
one-shot read — it interrupts the subscription as soon as that frame lands. Wrap
it in a helper that fails on an empty stream, so a broken read fails the test
instead of asserting on undefined. (firstFrameOrThrow in
@kolu/surface/first-frame is that helper, already written: it takes the member
Stream and gives you an Effect that fails with your message on an empty one.)
const load = await snapshot(
loadGet(undefined),
"load cell yielded no snapshot frame — link failure",
);
expect(load).toEqual(ZERO);
Drive a procedure and assert the effect
A procedure call is an Effect, so a test runs it the same way it runs any other
— a test is a process edge. Call one, then read the affected cell or collection
back and assert the new snapshot.
// A procedure call is an `Effect` — a description. `await` on one yields the
// description itself and dispatches NOTHING, so the run is what makes it happen.
await Effect.runPromise(kill({ pid: 4321 }));
const alive = await snapshot(
keys(undefined),
"processes keys yielded no snapshot frame — link failure",
);
expect(alive).not.toContain(4321);
Read a run of stream frames
When the value under test is a stream that emits over time, run it and collect the
frames you care about. Stream.takeUntil ends it on the terminal frame — the
Stream-native break — and ending the stream finalizes the subscription, so
there is no signal to thread and none to forget. Because there is no socket,
delivery is deterministic: no timers, no flushing.
const frames: LogFrame[] = [];
await Effect.runPromise(
Stream.runForEach(
// `takeUntil` ends the stream on the terminal frame, which finalizes the
// subscription — the Stream-native `break`.
Stream.takeUntil(nodeLog("node-1"), (frame) => frame.done),
(frame) => Effect.sync(() => frames.push(frame)),
),
);
expect(frames[0]?.kind).toBe("snapshot");