🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@sebspark/emulator

Package Overview
Dependencies
Maintainers
7
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sebspark/emulator - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+40
-4
dist/index.d.mts

@@ -23,2 +23,24 @@ //#region src/index.d.ts

/**
* A handle returned by `.stream()` that lets a test drive a stateful,
* externally-controlled streaming responder.
*
* - `waitForCall()` — resolves when the next request has arrived and the
* initializer has been called. Each invocation dequeues the next pending
* request in arrival order.
* - `send(modifier)` — derives a new response from `latestResponse` and
* delivers it via the transport callback. Throws if called before
* `waitForCall()` resolves.
* - `latestResponse` — the most recent response emitted for the active stream,
* or `undefined` before `waitForCall()` resolves.
*
* @template R Response type
*/
interface StreamHandle<R> {
waitForCall(): Promise<void>;
send(modifier: (prev: R) => R): Promise<void>;
readonly latestResponse: R | undefined;
/** `true` once the first `waitForCall()` has resolved. */
readonly hasBeenCalled: boolean;
}
/**
* Builder API returned by `.times(n)` or `.persist()`.

@@ -33,4 +55,8 @@ *

* - `.callback(fn)` → streaming style
* - `.stream(initializer)` → externally-driven streaming style
*
* Each returns an `execute(...)` helper for direct invocation.
*
* Note: `.pending()` and `.reset()` are on {@link MethodCall} (before lifetime
* configuration), not on this builder — they operate on the method as a whole.
*/

@@ -47,2 +73,3 @@ interface MethodCallBuilder<A, R> {

};
stream(initializer: (args: A) => R): StreamHandle<R>;
}

@@ -52,3 +79,3 @@ /**

*
* By default, `.reply` and `.callback` register **single-use** responders.
* By default, `.reply`, `.callback`, and `.stream` register **single-use** responders.
* For repeated use, chain `.times(n)` or `.persist()`.

@@ -58,2 +85,3 @@ *

* - `.callback(...)` — one-time streaming responder
* - `.stream(initializer)` — externally-driven streaming responder
* - `.times(n)` — limit responder to `n` uses

@@ -75,2 +103,3 @@ * - `.persist()` — make responder permanent (infinite uses)

};
stream(initializer: (args: A) => R): StreamHandle<R>;
times(n: number): MethodCallBuilder<A, R>;

@@ -81,2 +110,6 @@ persist(): MethodCallBuilder<A, R>;

thrice(): MethodCallBuilder<A, R>;
/** The number of responders registered for this method that have not yet been fully consumed. */
readonly pending: number;
/** Removes all registered responders for this method. */
reset(): void;
}

@@ -143,3 +176,4 @@ /**

declare const createEmulator: <T extends MethodMap>() => { [M in keyof T]: (filter?: Filter<T[M]["args"]>) => MethodCall<T[M]["args"], T[M]["resp"]> } & {
handle<M extends keyof T>(method: M, args: T[M]["args"], cb: ResponseCb<T[M]["resp"]>): Promise<void>;
handle<M extends keyof T>(method: M, args: T[M]["args"], cb: ResponseCb<T[M]["resp"]>): Promise<void>; /** Removes all registered responders across every method. */
reset(): void;
};

@@ -163,7 +197,9 @@ /**

type Disposable<T> = T & {
/** Explicit synchronous or async disposal */dispose(): void | Promise<void>;
/** Explicit synchronous or async disposal */dispose(): void | Promise<void>; /** Symbol-based sync disposal for Node 20+ */
[Symbol.dispose](): void; /** Symbol-based async disposal for Node 20+ */
[Symbol.asyncDispose]?(): Promise<void>;
};
declare const disposable: <T>(emulator: T, dispose: () => void | Promise<void>) => Disposable<T>;
//#endregion
export { Disposable, MethodMap, createEmulator, disposable };
export { Disposable, MethodMap, StreamHandle, createEmulator, disposable };
//# sourceMappingURL=index.d.mts.map
+1
-1

@@ -1,1 +0,1 @@

{"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;;;;KASK,MAAA,OAAa,IAAA,EAAM,CAAA;AAAC;;;;;;;;;AAAA,KAWpB,UAAA,OAAiB,IAAA,EAAM,CAAA;;;;;;;;;;;;;;UAkFlB,iBAAA;EACR,KAAA,CAAM,QAAA,EAAU,CAAA;IAAM,OAAA,CAAQ,GAAA,EAAK,CAAA,GAAI,OAAA,CAAQ,CAAA;EAAA;EAC/C,KAAA,CAAM,EAAA,GAAK,IAAA,EAAM,CAAA,KAAM,CAAA,GAAI,OAAA,CAAQ,CAAA;IAAO,OAAA,CAAQ,GAAA,EAAK,CAAA,GAAI,OAAA,CAAQ,CAAA;EAAA;EAEnE,QAAA,CAAS,EAAA,GAAK,IAAA,EAAM,CAAA,EAAG,EAAA,GAAK,IAAA,EAAM,CAAA;IAChC,OAAA,CAAQ,GAAA,EAAK,CAAA,EAAG,EAAA,GAAK,IAAA,EAAM,CAAA,eAAgB,OAAA;EAAA;AAAA;;;;;;;;;;;;;;;UAkBrC,UAAA;EAER,KAAA,CAAM,QAAA,EAAU,CAAA;IAAM,OAAA,CAAQ,GAAA,EAAK,CAAA,GAAI,OAAA,CAAQ,CAAA;EAAA;EAC/C,KAAA,CAAM,EAAA,GAAK,IAAA,EAAM,CAAA,KAAM,CAAA,GAAI,OAAA,CAAQ,CAAA;IAAO,OAAA,CAAQ,GAAA,EAAK,CAAA,GAAI,OAAA,CAAQ,CAAA;EAAA;EAGnE,QAAA,CAAS,EAAA,GAAK,IAAA,EAAM,CAAA,EAAG,EAAA,GAAK,IAAA,EAAM,CAAA;IAChC,OAAA,CAAQ,GAAA,EAAK,CAAA,EAAG,EAAA,GAAK,IAAA,EAAM,CAAA,eAAgB,OAAA;EAAA;EAI7C,KAAA,CAAM,CAAA,WAAY,iBAAA,CAAkB,CAAA,EAAG,CAAA;EACvC,OAAA,IAAW,iBAAA,CAAkB,CAAA,EAAG,CAAA;EAGhC,IAAA,IAAQ,iBAAA,CAAkB,CAAA,EAAG,CAAA;EAC7B,KAAA,IAAS,iBAAA,CAAkB,CAAA,EAAG,CAAA;EAC9B,MAAA,IAAU,iBAAA,CAAkB,CAAA,EAAG,CAAA;AAAA;;;;;AAnCqB;;;;;;KAiD1C,SAAA,GAAY,MAAA;EAAiB,IAAA;EAAW,IAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAoOvC,cAAA,aAA4B,SAAA,qBAyCzB,CAAA,IACV,MAAA,GAAS,MAAA,CAAO,CAAA,CAAE,CAAA,eACf,UAAA,CAAW,CAAA,CAAE,CAAA,WAAY,CAAA,CAAE,CAAA;EAEhC,MAAA,iBAAuB,CAAA,EACrB,MAAA,EAAQ,CAAA,EACR,IAAA,EAAM,CAAA,CAAE,CAAA,WACR,EAAA,EAAI,UAAA,CAAW,CAAA,CAAE,CAAA,aAChB,OAAA;AAAA;;;;;;;;;;;;;;;;;KAoBK,UAAA,MAAgB,CAAA;EA7TU,6CA+TpC,OAAA,WAAkB,OAAA;AAAA;AAAA,cAOP,UAAA,MACX,QAAA,EAAU,CAAA,EACV,OAAA,eAAsB,OAAA,WACrB,UAAA,CAAW,CAAA"}
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":"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"}

@@ -82,5 +82,55 @@ //#region src/index.ts

}
function stream(initializer) {
const queue = [];
let queueResolve = null;
let activeTransportCb = null;
let latestResponse;
let hasBeenCalled = false;
const responder = async (args, cb) => {
const initial = initializer(args);
latestResponse = initial;
activeTransportCb = cb;
await cb(initial);
await new Promise((resolve) => {
queue.push({
resolve,
transportCb: cb
});
if (queueResolve) {
const wake = queueResolve;
queueResolve = null;
wake();
}
});
};
addResponder(filter, responder, remaining);
return {
get latestResponse() {
return latestResponse;
},
get hasBeenCalled() {
return hasBeenCalled;
},
async waitForCall() {
if (queue.length === 0) await new Promise((resolve) => {
queueResolve = resolve;
});
const entry = queue.shift();
if (!entry) throw new Error("Stream queue is empty");
activeTransportCb = entry.transportCb;
hasBeenCalled = true;
entry.resolve();
},
async send(modifier) {
if (activeTransportCb === null) throw new Error("No active stream — call waitForCall() first");
const next = modifier(latestResponse);
latestResponse = next;
await activeTransportCb(next);
}
};
}
return {
reply,
callback
callback,
stream
};

@@ -104,2 +154,8 @@ };

return builder(3);
},
get pending() {
return responders.size;
},
reset() {
responders.clear();
}

@@ -132,7 +188,4 @@ };

const callResponder = async (methodName, responders, args, cb) => {
const candidates = [...responders].reverse().filter((r) => !r.filter || r.filter(args));
if (candidates.length === 0) throw new Error(`No responder found for .${methodName}(${args && JSON.stringify(args)})`);
const chosen = candidates[0];
if (!chosen) throw new Error("Chosen is not defined");
await chosen.cb(args, cb);
const [chosen] = [...responders].reverse().filter((r) => !r.filter || r.filter(args));
if (!chosen) throw new Error(`No responder found for .${methodName}(${args && JSON.stringify(args)})`);
if (chosen.remaining !== Number.POSITIVE_INFINITY) {

@@ -142,2 +195,3 @@ chosen.remaining -= 1;

}
await chosen.cb(args, cb);
};

@@ -192,2 +246,5 @@ /**

return new Proxy({}, { get(_target, prop) {
if (prop === "reset") return () => {
for (const set of responders.values()) set.clear();
};
if (prop === "handle") return async (method, args, cb) => {

@@ -194,0 +251,0 @@ const set = responders.get(method) ?? /* @__PURE__ */ new Set();

@@ -1,1 +0,1 @@

{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * A filter function that decides whether a responder should be used\n * for a given request. If the function returns `true`, the responder\n * is eligible to handle the request; otherwise it is skipped.\n *\n * @template A Arguments type for the request\n * @param args The request arguments\n * @returns `true` if this responder should handle the request\n */\ntype Filter<A> = (args: A) => boolean\n\n/**\n * Callback invoked by a responder whenever it emits a response.\n * This may be called once for simple responders, or many times\n * for streaming responders.\n *\n * @template R Response type\n * @param resp A response object of type `R`\n * @returns An arbitrary value; ignored by the emulator.\n */\ntype ResponseCb<R> = (resp: R) => unknown\n\n/**\n * The normalized internal responder form. Regardless of whether\n * the user provided a static response, a function, or a streaming\n * callback, it is converted into this form internally.\n *\n * @template A Arguments type for the request\n * @template R Response type\n * @param args The request arguments\n * @param cb A callback to deliver one or more responses\n * @returns Nothing meaningful; may be synchronous or asynchronous\n */\ntype StreamResponder<A, R> = (\n args: A,\n cb: ResponseCb<R>\n) => void | Promise<void>\n\n/**\n * The user-facing responder input form. When registering responders,\n * users can choose one of three styles:\n *\n * 1. **Static response** — provide a plain object of type `R`.\n * Example: `.reply({ ok: true })`\n *\n * 2. **Function responder** — provide a function `(args) => R | Promise<R>`\n * that computes a single response from the request arguments.\n * Example: `.reply(args => ({ ok: args.id > 0 }))`\n *\n * 3. **Streaming responder** — provide a function `(args, cb) => { ... }`\n * that can invoke `cb(r)` one or more times to emit multiple responses.\n * Example:\n * ```ts\n * .callback((args, cb) => {\n * cb({ step: 1 })\n * cb({ step: 2 })\n * })\n * ```\n *\n * All three forms are internally normalized into a {@link StreamResponder}.\n *\n * @template A Arguments type\n * @template R Response type\n */\ntype ReplyInput<A, R> =\n | R\n | ((args: A) => R | Promise<R>)\n | StreamResponder<A, R>\n\n/**\n * Internal structure representing a registered responder.\n *\n * @template A Arguments type\n * @template R Response type\n *\n * @property filter Optional {@link Filter} restricting which requests\n * this responder will handle\n * @property cb The normalized {@link StreamResponder} to invoke\n * @property remaining How many times this responder may be invoked.\n * - `Infinity` = unlimited (persist)\n * - `n` = valid for `n` calls\n * - `1` = one-shot\n */\ninterface StoredResponder<A, R> {\n filter?: Filter<A>\n cb: StreamResponder<A, R>\n remaining: number\n}\n\n/**\n * Builder API returned by `.times(n)` or `.persist()`.\n *\n * These builders let you specify how many times a responder should be used:\n * - `.times(n)` → expires after `n` uses\n * - `.persist()` → never expires\n *\n * They expose the same responder registration methods as normal:\n * - `.reply(response | fn)` → single-response style\n * - `.callback(fn)` → streaming style\n *\n * Each returns an `execute(...)` helper for direct invocation.\n */\ninterface MethodCallBuilder<A, R> {\n reply(response: R): { execute(arg: A): Promise<R> }\n reply(fn: (args: A) => R | Promise<R>): { execute(arg: A): Promise<R> }\n\n callback(fn: (args: A, cb: (resp: R) => unknown) => unknown): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n }\n}\n\n/**\n * The full API available for each method in the emulator.\n *\n * By default, `.reply` and `.callback` register **single-use** responders.\n * For repeated use, chain `.times(n)` or `.persist()`.\n *\n * - `.reply(...)` — one-time single-response responder\n * - `.callback(...)` — one-time streaming responder\n * - `.times(n)` — limit responder to `n` uses\n * - `.persist()` — make responder permanent (infinite uses)\n * - `.once()` / `.twice()` / `.thrice()` — convenience aliases for `.times(1/2/3)`\n *\n * Each registration returns an `execute(...)` helper for direct invocation.\n */\ninterface MethodCall<A, R> {\n // One-time single-response\n reply(response: R): { execute(arg: A): Promise<R> }\n reply(fn: (args: A) => R | Promise<R>): { execute(arg: A): Promise<R> }\n\n // One-time streaming\n callback(fn: (args: A, cb: (resp: R) => unknown) => unknown): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n }\n\n // Lifetime configuration\n times(n: number): MethodCallBuilder<A, R>\n persist(): MethodCallBuilder<A, R>\n\n // Aliases\n once(): MethodCallBuilder<A, R>\n twice(): MethodCallBuilder<A, R>\n thrice(): MethodCallBuilder<A, R>\n}\n\n/**\n * Describes the shape of all methods supported by an emulator.\n *\n * Each entry in the map defines:\n * - `args`: the type of the request arguments the method accepts\n * - `resp`: the type of the responses the method will emit\n *\n * The map is only used at the type level. At runtime, methods are\n * generated dynamically using a Proxy, so no actual object is required.\n */\n// biome-ignore lint/suspicious/noExplicitAny: emulator code\nexport type MethodMap = Record<string, { args: any; resp: any }>\n\n/**\n * Converts any user-supplied responder into the internal\n * streaming responder form `(args, cb) => void | Promise<void>`.\n *\n * This allows the emulator to treat all responders uniformly,\n * regardless of whether they were registered as:\n * - A static response object of type `R`\n * - A simple function `(args) => R | Promise<R>` returning a single response\n * - A streaming function `(args, cb) => { cb(r1); cb(r2); ... }`\n *\n * @template A Arguments type for the request\n * @template R Response type\n *\n * @param input The user-supplied responder or response\n * @returns A {@link StreamResponder} that invokes the callback appropriately\n */\nconst normalizeResponder = <A, R>(\n input: ReplyInput<A, R>\n): StreamResponder<A, R> => {\n if (typeof input === 'function') {\n // Either (args) => R or (args, cb) => void\n if (input.length >= 2) {\n // Already a streaming responder\n return input as StreamResponder<A, R>\n }\n // Single-response function\n return async (args, cb) => {\n const result = await (input as (args: A) => R | Promise<R>)(args)\n await cb(result)\n }\n }\n // Static response object\n return async (_args, cb) => {\n await cb(input)\n }\n}\n\n/**\n * Creates the responder registration API for a single method.\n *\n * For the given set of responders, this function returns a builder\n * `(filter?) => MethodCall<A, R>` which allows users to register responders\n * with optional filter logic.\n *\n * The returned {@link MethodCall} supports:\n * - `.reply(...)` / `.callback(...)` → one-time responders (default)\n * - `.times(n)` → responders valid for `n` calls\n * - `.persist()` → responders valid forever\n * - `.once()` / `.twice()` / `.thrice()` → shorthand for `.times(1/2/3)`\n *\n * Each of these also returns an `.execute(...)` convenience helper\n * to directly trigger the responder logic without going through `.handle`.\n *\n * Responder resolution is **strict LIFO**:\n * - The most recently added matching responder is chosen.\n * - Filters are applied, but do not affect ordering.\n *\n * @template A Arguments type for the request\n * @template R Response type\n *\n * @param responders The set of responders registered for this method\n * @returns A function `(filter?) => MethodCall<A, R>` that exposes\n * the responder registration API for this method\n */\nconst makeRequest = <A, R>(responders: Set<StoredResponder<A, R>>) => {\n function addResponder(\n filter: Filter<A> | undefined,\n input: ReplyInput<A, R>,\n remaining: number\n ) {\n responders.add({ filter, cb: normalizeResponder(input), remaining })\n }\n\n return (filter?: Filter<A>): MethodCall<A, R> => {\n const builder = (remaining: number) => {\n function reply(response: R): { execute(arg: A): Promise<R> }\n function reply(fn: (args: A) => R | Promise<R>): {\n execute(arg: A): Promise<R>\n }\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n function reply(input: any) {\n addResponder(filter, input, remaining)\n return {\n async execute(arg: A): Promise<R> {\n let result!: R\n await callResponder('reply', responders, arg, (r) => {\n result = r\n })\n return result\n },\n }\n }\n\n function callback(fn: StreamResponder<A, R>): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n } {\n addResponder(filter, fn, remaining)\n return {\n async execute(arg: A, cb: (resp: R) => unknown): Promise<void> {\n await callResponder('callback', responders, arg, cb)\n },\n }\n }\n\n return { reply, callback }\n }\n\n return {\n ...builder(1), // default: one-shot\n times(n: number) {\n return builder(n)\n },\n persist() {\n return builder(Number.POSITIVE_INFINITY)\n },\n once() {\n return builder(1)\n },\n twice() {\n return builder(2)\n },\n thrice() {\n return builder(3)\n },\n } as MethodCall<A, R>\n }\n}\n\n/**\n * Dispatch a request to the highest-priority matching responder.\n *\n * - Responders are resolved in **strict LIFO order**:\n * - The most recently registered responder that matches the request\n * (by filter, if provided) is invoked.\n * - Only one responder is invoked per request, even if others also match.\n *\n * - Streaming responders may call the provided callback multiple times.\n * - Responders created with `.reply()` / `.callback()` are single-use by default.\n * Use `.times(n)` or `.persist()` to extend their lifetime.\n *\n * @template T The full MethodMap type\n * @template M The specific method name\n *\n * @param methodName The name of the method (for error messages)\n * @param responders The set of responders registered for the method\n * @param args The request arguments to match against filters\n * @param cb Callback used to deliver one or more responses\n *\n * @throws {Error} If no matching responder is found\n */\nconst callResponder = async <T extends MethodMap, M extends keyof T>(\n methodName: string,\n responders: Set<StoredResponder<T[M]['args'], T[M]['resp']>>,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n): Promise<void> => {\n // Collect responders in insertion order, then reverse for LIFO\n const candidates = [...responders]\n .reverse()\n .filter((r) => !r.filter || r.filter(args))\n\n if (candidates.length === 0) {\n throw new Error(\n `No responder found for .${methodName}(${args && JSON.stringify(args)})`\n )\n }\n\n const chosen = candidates[0]\n if (!chosen) {\n throw new Error('Chosen is not defined')\n }\n await chosen.cb(args, cb)\n\n if (chosen.remaining !== Number.POSITIVE_INFINITY) {\n chosen.remaining -= 1\n if (chosen.remaining <= 0) {\n responders.delete(chosen)\n }\n }\n}\n\n/**\n * Create a generic emulator for a given `MethodMap`.\n *\n * - Uses a Proxy so you only need a *type* for your method map — no\n * runtime object is required.\n *\n * - For each method key in the `MethodMap`, the emulator provides:\n * - `.reply(response | fn)` — one-time static or single-response functions\n * - `.callback(fn)` — one-time streaming responders\n * - `.times(n)` — responders valid for `n` calls\n * - `.persist()` — responders valid forever\n * - `.once()` / `.twice()` / `.thrice()` — shorthands for `.times(1/2/3)`\n *\n * - Responders are matched in **strict LIFO order**:\n * - The most recently registered matching responder is invoked first.\n * - Only one responder handles a given request, even if others also match.\n * - Filters still apply: a responder is considered only if its filter\n * returns `true` (or it has no filter).\n *\n * - Provides `.handle(method, args, cb)` to manually dispatch a request\n * to the appropriate responder and stream results via the callback.\n *\n * @template T The `MethodMap` describing all methods, their args and responses\n * @returns An emulator object with per-method registration APIs and a\n * `.handle` dispatcher\n *\n * @example\n * type MyMethodMap = {\n * echo: { args: { msg: string }, resp: { echoed: string } }\n * double: { args: { n: number }, resp: { result: number } }\n * }\n *\n * const emu = createEmulator<MyMethodMap>()\n *\n * emu.echo().reply({ echoed: 'hello' }) // one-time\n * emu.echo().persist().reply({ echoed: 'always' }) // infinite\n *\n * const r1 = await emu.echo().times(2).reply(args => ({ echoed: args.msg }))\n * .execute({ msg: 'hi' })\n *\n * emu.double().callback((args, cb) => {\n * cb({ result: args.n })\n * cb({ result: args.n * 2 })\n * })\n */\nexport const createEmulator = <T extends MethodMap>() => {\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n const responders = new Map<keyof T, Set<StoredResponder<any, any>>>()\n\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n const handler: ProxyHandler<any> = {\n get(_target, prop) {\n if (prop === 'handle') {\n return async <M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void> => {\n const set = responders.get(method) ?? new Set()\n responders.set(method, set)\n return callResponder(method as string, set, args, cb)\n }\n }\n\n // Pass dispose and symbols through to the target\n if (prop === 'dispose' || typeof prop === 'symbol') {\n return Reflect.get(_target, prop)\n }\n\n // If it’s a method name\n if (typeof prop === 'string') {\n const method = prop as keyof T\n let set = responders.get(method)\n if (!set) {\n set = new Set()\n responders.set(method, set)\n }\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n return (filter?: Filter<any>) => makeRequest(set)(filter)\n }\n\n return undefined\n },\n }\n\n return new Proxy({}, handler) as {\n [M in keyof T]: (\n filter?: Filter<T[M]['args']>\n ) => MethodCall<T[M]['args'], T[M]['resp']>\n } & {\n handle<M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void>\n }\n}\n\n/**\n * Adds disposable semantics to an object `T`.\n *\n * This augments any object with cleanup capabilities that can be invoked\n * explicitly via `.dispose()` or automatically using the `using` /\n * `await using` constructs available in Node.js 20+ and modern runtimes.\n *\n * - `.dispose()` → Manual cleanup, may be sync or async.\n * - `[Symbol.dispose]()` → Enables synchronous `using`.\n * - `[Symbol.asyncDispose]()` → Enables asynchronous `await using`.\n *\n * Useful for managing emulator lifetimes, open connections, or other\n * resources that must be cleaned up deterministically.\n *\n * @template T Base object type to be extended with disposal methods\n */\nexport type Disposable<T> = T & {\n /** Explicit synchronous or async disposal */\n dispose(): void | Promise<void>\n /** Symbol-based sync disposal for Node 20+ */\n [Symbol.dispose](): void\n /** Symbol-based async disposal for Node 20+ */\n [Symbol.asyncDispose]?(): Promise<void>\n}\n\nexport const disposable = <T>(\n emulator: T,\n dispose: () => void | Promise<void>\n): Disposable<T> => {\n const _disposable = emulator as Disposable<T>\n _disposable.dispose = dispose\n _disposable[Symbol.dispose] = dispose\n _disposable[Symbol.asyncDispose] = dispose as () => Promise<void>\n\n return _disposable\n}\n"],"mappings":"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"}
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * A filter function that decides whether a responder should be used\n * for a given request. If the function returns `true`, the responder\n * is eligible to handle the request; otherwise it is skipped.\n *\n * @template A Arguments type for the request\n * @param args The request arguments\n * @returns `true` if this responder should handle the request\n */\ntype Filter<A> = (args: A) => boolean\n\n/**\n * Callback invoked by a responder whenever it emits a response.\n * This may be called once for simple responders, or many times\n * for streaming responders.\n *\n * @template R Response type\n * @param resp A response object of type `R`\n * @returns An arbitrary value; ignored by the emulator.\n */\ntype ResponseCb<R> = (resp: R) => unknown\n\n/**\n * The normalized internal responder form. Regardless of whether\n * the user provided a static response, a function, or a streaming\n * callback, it is converted into this form internally.\n *\n * @template A Arguments type for the request\n * @template R Response type\n * @param args The request arguments\n * @param cb A callback to deliver one or more responses\n * @returns Nothing meaningful; may be synchronous or asynchronous\n */\ntype StreamResponder<A, R> = (\n args: A,\n cb: ResponseCb<R>\n) => void | Promise<void>\n\n/**\n * The user-facing responder input form. When registering responders,\n * users can choose one of three styles:\n *\n * 1. **Static response** — provide a plain object of type `R`.\n * Example: `.reply({ ok: true })`\n *\n * 2. **Function responder** — provide a function `(args) => R | Promise<R>`\n * that computes a single response from the request arguments.\n * Example: `.reply(args => ({ ok: args.id > 0 }))`\n *\n * 3. **Streaming responder** — provide a function `(args, cb) => { ... }`\n * that can invoke `cb(r)` one or more times to emit multiple responses.\n * Example:\n * ```ts\n * .callback((args, cb) => {\n * cb({ step: 1 })\n * cb({ step: 2 })\n * })\n * ```\n *\n * All three forms are internally normalized into a {@link StreamResponder}.\n *\n * @template A Arguments type\n * @template R Response type\n */\ntype ReplyInput<A, R> =\n | R\n | ((args: A) => R | Promise<R>)\n | StreamResponder<A, R>\n\n/**\n * Internal structure representing a registered responder.\n *\n * @template A Arguments type\n * @template R Response type\n *\n * @property filter Optional {@link Filter} restricting which requests\n * this responder will handle\n * @property cb The normalized {@link StreamResponder} to invoke\n * @property remaining How many times this responder may be invoked.\n * - `Infinity` = unlimited (persist)\n * - `n` = valid for `n` calls\n * - `1` = one-shot\n */\ninterface StoredResponder<A, R> {\n filter?: Filter<A>\n cb: StreamResponder<A, R>\n remaining: number\n}\n\n/**\n * A handle returned by `.stream()` that lets a test drive a stateful,\n * externally-controlled streaming responder.\n *\n * - `waitForCall()` — resolves when the next request has arrived and the\n * initializer has been called. Each invocation dequeues the next pending\n * request in arrival order.\n * - `send(modifier)` — derives a new response from `latestResponse` and\n * delivers it via the transport callback. Throws if called before\n * `waitForCall()` resolves.\n * - `latestResponse` — the most recent response emitted for the active stream,\n * or `undefined` before `waitForCall()` resolves.\n *\n * @template R Response type\n */\nexport interface StreamHandle<R> {\n waitForCall(): Promise<void>\n send(modifier: (prev: R) => R): Promise<void>\n readonly latestResponse: R | undefined\n /** `true` once the first `waitForCall()` has resolved. */\n readonly hasBeenCalled: boolean\n}\n\n/**\n * Builder API returned by `.times(n)` or `.persist()`.\n *\n * These builders let you specify how many times a responder should be used:\n * - `.times(n)` → expires after `n` uses\n * - `.persist()` → never expires\n *\n * They expose the same responder registration methods as normal:\n * - `.reply(response | fn)` → single-response style\n * - `.callback(fn)` → streaming style\n * - `.stream(initializer)` → externally-driven streaming style\n *\n * Each returns an `execute(...)` helper for direct invocation.\n *\n * Note: `.pending()` and `.reset()` are on {@link MethodCall} (before lifetime\n * configuration), not on this builder — they operate on the method as a whole.\n */\ninterface MethodCallBuilder<A, R> {\n reply(response: R): { execute(arg: A): Promise<R> }\n reply(fn: (args: A) => R | Promise<R>): { execute(arg: A): Promise<R> }\n\n callback(fn: (args: A, cb: (resp: R) => unknown) => unknown): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n }\n\n stream(initializer: (args: A) => R): StreamHandle<R>\n}\n\n/**\n * The full API available for each method in the emulator.\n *\n * By default, `.reply`, `.callback`, and `.stream` register **single-use** responders.\n * For repeated use, chain `.times(n)` or `.persist()`.\n *\n * - `.reply(...)` — one-time single-response responder\n * - `.callback(...)` — one-time streaming responder\n * - `.stream(initializer)` — externally-driven streaming responder\n * - `.times(n)` — limit responder to `n` uses\n * - `.persist()` — make responder permanent (infinite uses)\n * - `.once()` / `.twice()` / `.thrice()` — convenience aliases for `.times(1/2/3)`\n *\n * Each registration returns an `execute(...)` helper for direct invocation.\n */\ninterface MethodCall<A, R> {\n // One-time single-response\n reply(response: R): { execute(arg: A): Promise<R> }\n reply(fn: (args: A) => R | Promise<R>): { execute(arg: A): Promise<R> }\n\n // One-time streaming\n callback(fn: (args: A, cb: (resp: R) => unknown) => unknown): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n }\n\n // Externally-driven streaming\n stream(initializer: (args: A) => R): StreamHandle<R>\n\n // Lifetime configuration\n times(n: number): MethodCallBuilder<A, R>\n persist(): MethodCallBuilder<A, R>\n\n // Aliases\n once(): MethodCallBuilder<A, R>\n twice(): MethodCallBuilder<A, R>\n thrice(): MethodCallBuilder<A, R>\n\n /** The number of responders registered for this method that have not yet been fully consumed. */\n readonly pending: number\n\n /** Removes all registered responders for this method. */\n reset(): void\n}\n\n/**\n * Describes the shape of all methods supported by an emulator.\n *\n * Each entry in the map defines:\n * - `args`: the type of the request arguments the method accepts\n * - `resp`: the type of the responses the method will emit\n *\n * The map is only used at the type level. At runtime, methods are\n * generated dynamically using a Proxy, so no actual object is required.\n */\n// biome-ignore lint/suspicious/noExplicitAny: emulator code\nexport type MethodMap = Record<string, { args: any; resp: any }>\n\n/**\n * Converts any user-supplied responder into the internal\n * streaming responder form `(args, cb) => void | Promise<void>`.\n *\n * This allows the emulator to treat all responders uniformly,\n * regardless of whether they were registered as:\n * - A static response object of type `R`\n * - A simple function `(args) => R | Promise<R>` returning a single response\n * - A streaming function `(args, cb) => { cb(r1); cb(r2); ... }`\n *\n * @template A Arguments type for the request\n * @template R Response type\n *\n * @param input The user-supplied responder or response\n * @returns A {@link StreamResponder} that invokes the callback appropriately\n */\nconst normalizeResponder = <A, R>(\n input: ReplyInput<A, R>\n): StreamResponder<A, R> => {\n if (typeof input === 'function') {\n // Either (args) => R or (args, cb) => void\n if (input.length >= 2) {\n // Already a streaming responder\n return input as StreamResponder<A, R>\n }\n // Single-response function\n return async (args, cb) => {\n const result = await (input as (args: A) => R | Promise<R>)(args)\n await cb(result)\n }\n }\n // Static response object\n return async (_args, cb) => {\n await cb(input)\n }\n}\n\n/**\n * Creates the responder registration API for a single method.\n *\n * For the given set of responders, this function returns a builder\n * `(filter?) => MethodCall<A, R>` which allows users to register responders\n * with optional filter logic.\n *\n * The returned {@link MethodCall} supports:\n * - `.reply(...)` / `.callback(...)` → one-time responders (default)\n * - `.times(n)` → responders valid for `n` calls\n * - `.persist()` → responders valid forever\n * - `.once()` / `.twice()` / `.thrice()` → shorthand for `.times(1/2/3)`\n *\n * Each of these also returns an `.execute(...)` convenience helper\n * to directly trigger the responder logic without going through `.handle`.\n *\n * Responder resolution is **strict LIFO**:\n * - The most recently added matching responder is chosen.\n * - Filters are applied, but do not affect ordering.\n *\n * @template A Arguments type for the request\n * @template R Response type\n *\n * @param responders The set of responders registered for this method\n * @returns A function `(filter?) => MethodCall<A, R>` that exposes\n * the responder registration API for this method\n */\nconst makeRequest = <A, R>(responders: Set<StoredResponder<A, R>>) => {\n function addResponder(\n filter: Filter<A> | undefined,\n input: ReplyInput<A, R>,\n remaining: number\n ) {\n responders.add({ filter, cb: normalizeResponder(input), remaining })\n }\n\n return (filter?: Filter<A>): MethodCall<A, R> => {\n const builder = (remaining: number) => {\n function reply(response: R): { execute(arg: A): Promise<R> }\n function reply(fn: (args: A) => R | Promise<R>): {\n execute(arg: A): Promise<R>\n }\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n function reply(input: any) {\n addResponder(filter, input, remaining)\n return {\n async execute(arg: A): Promise<R> {\n let result!: R\n await callResponder('reply', responders, arg, (r) => {\n result = r\n })\n return result\n },\n }\n }\n\n function callback(fn: StreamResponder<A, R>): {\n execute(arg: A, cb: (resp: R) => unknown): Promise<void>\n } {\n addResponder(filter, fn, remaining)\n return {\n async execute(arg: A, cb: (resp: R) => unknown): Promise<void> {\n await callResponder('callback', responders, arg, cb)\n },\n }\n }\n\n function stream(initializer: (args: A) => R): StreamHandle<R> {\n // Queue of { resolve, transportCb } entries — one per accepted request.\n const queue: Array<{\n resolve: () => void\n transportCb: ResponseCb<R>\n }> = []\n let queueResolve: (() => void) | null = null\n\n let activeTransportCb: ResponseCb<R> | null = null\n let latestResponse: R | undefined\n let hasBeenCalled = false\n\n // Register the underlying streaming responder.\n const responder: StreamResponder<A, R> = async (args, cb) => {\n const initial = initializer(args)\n latestResponse = initial\n activeTransportCb = cb\n await cb(initial)\n\n // Park until waitForCall() dequeues this entry.\n await new Promise<void>((resolve) => {\n queue.push({ resolve, transportCb: cb })\n if (queueResolve) {\n const wake = queueResolve\n queueResolve = null\n wake()\n }\n })\n }\n addResponder(filter, responder, remaining)\n\n const handle: StreamHandle<R> = {\n get latestResponse() {\n return latestResponse\n },\n get hasBeenCalled() {\n return hasBeenCalled\n },\n\n async waitForCall(): Promise<void> {\n // Wait if no request has arrived yet.\n if (queue.length === 0) {\n await new Promise<void>((resolve) => {\n queueResolve = resolve\n })\n }\n const entry = queue.shift()\n if (!entry) throw new Error('Stream queue is empty')\n activeTransportCb = entry.transportCb\n hasBeenCalled = true\n entry.resolve()\n },\n\n async send(modifier: (prev: R) => R): Promise<void> {\n if (activeTransportCb === null) {\n throw new Error('No active stream — call waitForCall() first')\n }\n const next = modifier(latestResponse as R)\n latestResponse = next\n await activeTransportCb(next)\n },\n }\n\n return handle\n }\n\n return { reply, callback, stream }\n }\n\n return {\n ...builder(1), // default: one-shot\n times(n: number) {\n return builder(n)\n },\n persist() {\n return builder(Number.POSITIVE_INFINITY)\n },\n once() {\n return builder(1)\n },\n twice() {\n return builder(2)\n },\n thrice() {\n return builder(3)\n },\n get pending() {\n return responders.size\n },\n reset() {\n responders.clear()\n },\n } as MethodCall<A, R>\n }\n}\n\n/**\n * Dispatch a request to the highest-priority matching responder.\n *\n * - Responders are resolved in **strict LIFO order**:\n * - The most recently registered responder that matches the request\n * (by filter, if provided) is invoked.\n * - Only one responder is invoked per request, even if others also match.\n *\n * - Streaming responders may call the provided callback multiple times.\n * - Responders created with `.reply()` / `.callback()` are single-use by default.\n * Use `.times(n)` or `.persist()` to extend their lifetime.\n *\n * @template T The full MethodMap type\n * @template M The specific method name\n *\n * @param methodName The name of the method (for error messages)\n * @param responders The set of responders registered for the method\n * @param args The request arguments to match against filters\n * @param cb Callback used to deliver one or more responses\n *\n * @throws {Error} If no matching responder is found\n */\nconst callResponder = async <T extends MethodMap, M extends keyof T>(\n methodName: string,\n responders: Set<StoredResponder<T[M]['args'], T[M]['resp']>>,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n): Promise<void> => {\n // Collect responders in insertion order, then reverse for LIFO\n const [chosen] = [...responders]\n .reverse()\n .filter((r) => !r.filter || r.filter(args))\n\n if (!chosen) {\n throw new Error(\n `No responder found for .${methodName}(${args && JSON.stringify(args)})`\n )\n }\n\n // Decrement before invoking so that concurrent/subsequent requests see the\n // updated count immediately — even for long-lived streaming responders whose\n // body only completes when the test calls waitForCall().\n if (chosen.remaining !== Number.POSITIVE_INFINITY) {\n chosen.remaining -= 1\n if (chosen.remaining <= 0) {\n responders.delete(chosen)\n }\n }\n\n await chosen.cb(args, cb)\n}\n\n/**\n * Create a generic emulator for a given `MethodMap`.\n *\n * - Uses a Proxy so you only need a *type* for your method map — no\n * runtime object is required.\n *\n * - For each method key in the `MethodMap`, the emulator provides:\n * - `.reply(response | fn)` — one-time static or single-response functions\n * - `.callback(fn)` — one-time streaming responders\n * - `.times(n)` — responders valid for `n` calls\n * - `.persist()` — responders valid forever\n * - `.once()` / `.twice()` / `.thrice()` — shorthands for `.times(1/2/3)`\n *\n * - Responders are matched in **strict LIFO order**:\n * - The most recently registered matching responder is invoked first.\n * - Only one responder handles a given request, even if others also match.\n * - Filters still apply: a responder is considered only if its filter\n * returns `true` (or it has no filter).\n *\n * - Provides `.handle(method, args, cb)` to manually dispatch a request\n * to the appropriate responder and stream results via the callback.\n *\n * @template T The `MethodMap` describing all methods, their args and responses\n * @returns An emulator object with per-method registration APIs and a\n * `.handle` dispatcher\n *\n * @example\n * type MyMethodMap = {\n * echo: { args: { msg: string }, resp: { echoed: string } }\n * double: { args: { n: number }, resp: { result: number } }\n * }\n *\n * const emu = createEmulator<MyMethodMap>()\n *\n * emu.echo().reply({ echoed: 'hello' }) // one-time\n * emu.echo().persist().reply({ echoed: 'always' }) // infinite\n *\n * const r1 = await emu.echo().times(2).reply(args => ({ echoed: args.msg }))\n * .execute({ msg: 'hi' })\n *\n * emu.double().callback((args, cb) => {\n * cb({ result: args.n })\n * cb({ result: args.n * 2 })\n * })\n */\nexport const createEmulator = <T extends MethodMap>() => {\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n const responders = new Map<keyof T, Set<StoredResponder<any, any>>>()\n\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n const handler: ProxyHandler<any> = {\n get(_target, prop) {\n if (prop === 'reset') {\n return () => {\n for (const set of responders.values()) {\n set.clear()\n }\n }\n }\n\n if (prop === 'handle') {\n return async <M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void> => {\n const set = responders.get(method) ?? new Set()\n responders.set(method, set)\n return callResponder(method as string, set, args, cb)\n }\n }\n\n // Pass dispose and symbols through to the target\n if (prop === 'dispose' || typeof prop === 'symbol') {\n return Reflect.get(_target, prop)\n }\n\n // If it’s a method name\n if (typeof prop === 'string') {\n const method = prop as keyof T\n let set = responders.get(method)\n if (!set) {\n set = new Set()\n responders.set(method, set)\n }\n // biome-ignore lint/suspicious/noExplicitAny: emulator code\n return (filter?: Filter<any>) => makeRequest(set)(filter)\n }\n\n return undefined\n },\n }\n\n return new Proxy({}, handler) as {\n [M in keyof T]: (\n filter?: Filter<T[M]['args']>\n ) => MethodCall<T[M]['args'], T[M]['resp']>\n } & {\n handle<M extends keyof T>(\n method: M,\n args: T[M]['args'],\n cb: ResponseCb<T[M]['resp']>\n ): Promise<void>\n /** Removes all registered responders across every method. */\n reset(): void\n }\n}\n\n/**\n * Adds disposable semantics to an object `T`.\n *\n * This augments any object with cleanup capabilities that can be invoked\n * explicitly via `.dispose()` or automatically using the `using` /\n * `await using` constructs available in Node.js 20+ and modern runtimes.\n *\n * - `.dispose()` → Manual cleanup, may be sync or async.\n * - `[Symbol.dispose]()` → Enables synchronous `using`.\n * - `[Symbol.asyncDispose]()` → Enables asynchronous `await using`.\n *\n * Useful for managing emulator lifetimes, open connections, or other\n * resources that must be cleaned up deterministically.\n *\n * @template T Base object type to be extended with disposal methods\n */\nexport type Disposable<T> = T & {\n /** Explicit synchronous or async disposal */\n dispose(): void | Promise<void>\n /** Symbol-based sync disposal for Node 20+ */\n [Symbol.dispose](): void\n /** Symbol-based async disposal for Node 20+ */\n [Symbol.asyncDispose]?(): Promise<void>\n}\n\nexport const disposable = <T>(\n emulator: T,\n dispose: () => void | Promise<void>\n): Disposable<T> => {\n const _disposable = emulator as Disposable<T>\n _disposable.dispose = dispose\n _disposable[Symbol.dispose] = dispose\n _disposable[Symbol.asyncDispose] = dispose as () => Promise<void>\n\n return _disposable\n}\n"],"mappings":"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"}
{
"name": "@sebspark/emulator",
"version": "0.1.0",
"version": "0.2.0",
"license": "Apache-2.0",

@@ -5,0 +5,0 @@ "type": "module",

+183
-28

@@ -10,3 +10,3 @@ # `@sebspark/emulator`

```
Real transport (Pub/Sub message, HTTP request, …)
Real transport (Pub/Sub message, HTTP request, WebSocket, …)

@@ -20,13 +20,14 @@

Your test ← registers responders with .reply() / .callback()
Your test ← registers responders with .reply() / .callback() / .stream()
```
## Building an emulator
---
Define a `MethodMap` that describes every operation your external system exposes, then wire up the transport to call `emulator.handle(...)`.
## Example: request/response — payment gateway
The simplest case: one request, one response. A payment gateway is a natural fit.
```ts
import { createEmulator, disposable, type Disposable } from '@sebspark/emulator'
// 1. Declare every method with its request and response types
type PaymentMethodMap = {

@@ -43,3 +44,2 @@ authorise: {

// 2. Expose a typed emulator handle
export type PaymentEmulator = Disposable<

@@ -49,3 +49,2 @@ ReturnType<typeof createEmulator<PaymentMethodMap>>

// 3. Wire up the transport
export const startPaymentEmulator = (server: HttpServer): PaymentEmulator => {

@@ -70,15 +69,10 @@ const emulator = createEmulator<PaymentMethodMap>()

## Using the emulator in tests
In tests:
The intended test pattern is **setup → execute → assert**, keeping each step explicit and local to the test. Register exactly one responder, trigger exactly one call, check the result:
```ts
it('returns an auth code on approval', async () => {
// Setup
payments.authorise().reply({ authCode: 'ABC123', status: 'approved' })
// Execute
const result = await client.authorise({ amount: 100, currency: 'SEK' })
// Assert
expect(result.authCode).toBe('ABC123')

@@ -88,4 +82,119 @@ })

The responder is consumed after the call, so a missing setup will throw immediately rather than silently reusing state from another test.
---
## Example: streaming — chatbot over WebSocket
When a single request triggers a series of responses, use a streaming responder. A WebSocket chatbot that emits tokens one at a time is a natural fit.
```ts
import { createEmulator, disposable, type Disposable } from '@sebspark/emulator'
import { WebSocketServer, type WebSocket } from 'ws'
type ChatMethodMap = {
chat: {
args: { sessionId: string; message: string }
resp: { token: string; done: boolean }
}
}
export type ChatEmulator = Disposable<
ReturnType<typeof createEmulator<ChatMethodMap>>
>
export const createChatEmulator = (port: number): ChatEmulator => {
const emulator = createEmulator<ChatMethodMap>()
const wss = new WebSocketServer({ port })
wss.on('connection', (ws: WebSocket) => {
ws.on('message', async (data) => {
const args = JSON.parse(data.toString()) as ChatMethodMap['chat']['args']
await emulator.handle('chat', args, async (resp) => {
ws.send(JSON.stringify(resp))
})
})
})
return disposable(emulator, () => wss.close())
}
```
### Fixed reply with `.callback()`
Use `.callback()` when the full sequence of tokens is known upfront:
```ts
bot.chat().callback((_args, cb) => {
cb({ token: 'Sure', done: false })
cb({ token: ', here', done: false })
cb({ token: ' you go.', done: true })
})
```
### Test-driven streaming with `.stream()`
Use `.stream(initializer)` when the test needs to **drive responses at its own pace** — for example to assert state between tokens, or simulate a correction mid-stream.
`.stream()` returns a `StreamHandle`:
| Member | Type | Description |
|---|---|---|
| `waitForCall()` | `() => Promise<void>` | Resolves when the next request has arrived and the initializer has fired |
| `send(modifier)` | `(fn: (prev) => Resp) => Promise<void>` | Derives and sends the next response from the last one |
| `latestResponse` | `Resp \| undefined` | The most recent response sent, or `undefined` before the first `waitForCall()` |
| `hasBeenCalled` | `boolean` | `true` once the first request has arrived and `waitForCall()` has resolved |
```ts
it('streams a correction mid-reply', async () => {
const stream = bot
.chat()
.stream(() => ({ token: 'Paris is in Germany.', done: false }))
const received: string[] = []
client.chat({ sessionId: 's1', message: 'Where is Paris?' }, (r) => {
received.push(r.token)
})
await stream.waitForCall()
expect(stream.latestResponse).toEqual({ token: 'Paris is in Germany.', done: false })
await stream.send(() => ({ token: 'Sorry — Paris is in France.', done: true }))
expect(received).toEqual([
'Paris is in Germany.',
'Sorry — Paris is in France.',
])
})
```
#### Sequential streams with `.times(n)`
The lifetime modifier caps how many **requests** the responder accepts. Each `waitForCall()` picks up the next one. `send()` and `latestResponse` are always scoped to the stream resolved by the most recent `waitForCall()`.
```ts
const stream = bot.chat().twice().stream(() => ({ token: 'Hello!', done: false }))
// First connection
client.chat({ sessionId: 's1', message: 'hi' }, onToken)
await stream.waitForCall()
await stream.send(() => ({ token: 'How can I help?', done: true }))
// Second connection
client.chat({ sessionId: 's2', message: 'hello' }, onToken)
await stream.waitForCall()
await stream.send(() => ({ token: 'Welcome back.', done: true }))
// Third connection → throws, responder exhausted
```
Calling `send()` before `waitForCall()` resolves throws immediately:
```ts
const stream = bot.chat().stream(() => ({ token: 'init', done: false }))
await stream.send(...) // throws: No active stream — call waitForCall() first
```
---
## API reference
### Single response — `.reply()`

@@ -108,15 +217,22 @@

Use `.callback()` when a single trigger produces multiple responses (e.g. order status updates).
```ts
payments.authorise().callback((args, cb) => {
cb({ authCode: 'PENDING', status: 'approved' })
cb({ authCode: 'SETTLED', status: 'approved' })
bot.chat().callback((_args, cb) => {
cb({ token: 'Sure', done: false })
cb({ token: ', here', done: false })
cb({ token: ' you go.', done: true })
})
```
### Externally-driven streaming — `.stream()`
See the [chatbot example](#example-streaming--chatbot-over-websocket) above. `StreamHandle<R>` is a named export if you need to type a helper:
```ts
import { type StreamHandle } from '@sebspark/emulator'
function driveStream(handle: StreamHandle<ChatResp>) { ... }
```
### Lifetime control
In most tests the one-shot default is exactly what you want. Lifetime modifiers are intended for more complex scenarios such as integration-style tests or helpers that need to serve many calls. Prefer explicit per-test setup over persistent responders wherever possible.
By default, a responder is consumed after **one use**. Control this with:

@@ -126,3 +242,3 @@

|---|---|
| `.reply(...)` / `.callback(...)` | One-time (default) |
| `.reply(...)` / `.callback(...)` / `.stream(...)` | One-time (default) |
| `.once().reply(...)` | One-time (explicit) |

@@ -140,3 +256,3 @@ | `.twice().reply(...)` | Two uses |

Responders are matched in **LIFO order** — the most recently registered matching responder wins. This makes it easy to stack overrides.
Responders are matched in **LIFO order** — the most recently registered matching responder wins.

@@ -159,4 +275,2 @@ ### Filters

The most common pattern is a persistent default with one-time overrides layered on top. Because responders resolve in LIFO order, the override is consumed first, then every subsequent request falls through to the default:
```ts

@@ -176,6 +290,5 @@ // Always decline...

If a request arrives with no matching responder registered, the emulator throws. This is intentional — it surfaces missing setup immediately rather than returning a silent default:
If a request arrives with no matching responder registered, the emulator throws immediately rather than returning a silent default:
```ts
// No responder registered
await payments.authorise(...)

@@ -187,3 +300,3 @@ // throws: No responder found for .authorise(...)

Each registration returns an `.execute()` helper for triggering the responder directly in a test without going through the transport:
Each registration returns an `.execute()` helper for triggering the responder directly without going through the transport:

@@ -199,2 +312,43 @@ ```ts

### Inspecting and resetting
#### `pending` — count unspent responders
`payments.authorise().pending` returns the number of responders currently registered for that method that have not yet been fully consumed.
This is most useful in `afterEach` to catch leftover setup — a responder registered in a test but never triggered indicates a test that didn't exercise what it intended:
```ts
afterEach(() => {
expect(payments.authorise().pending).toBe(0)
expect(payments.refund().pending).toBe(0)
})
```
A `.persist()` responder counts as 1 pending regardless of how many times it has fired.
#### `.reset()` — clear registered responders
`payments.authorise().reset()` removes all responders for that method. `payments.reset()` removes all responders across every method.
The per-method form is useful when you want to swap out a responder mid-test:
```ts
payments.authorise().persist().reply({ authCode: 'DEFAULT', status: 'approved' })
// Later in the test, replace it entirely
payments.authorise().reset()
payments.authorise().reply({ authCode: 'OVERRIDE', status: 'declined' })
```
Use the emulator-level reset for blanket teardown in `afterEach`:
```ts
afterEach(() => {
payments.reset()
})
```
---
## Cleanup

@@ -212,1 +366,2 @@

```