kolu
Docs

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