@sebspark/emulator
Advanced tools
+40
-4
@@ -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 +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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"} |
+63
-6
@@ -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":";;;;;;;;;;;;;;;;;AAqNA,MAAM,sBACJ,UAC0B;AAC1B,KAAI,OAAO,UAAU,YAAY;AAE/B,MAAI,MAAM,UAAU,EAElB,QAAO;AAGT,SAAO,OAAO,MAAM,OAAO;AAEzB,SAAM,GAAG,MADa,MAAsC,KAAK,CACjD;;;AAIpB,QAAO,OAAO,OAAO,OAAO;AAC1B,QAAM,GAAG,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BnB,MAAM,eAAqB,eAA2C;CACpE,SAAS,aACP,QACA,OACA,WACA;AACA,aAAW,IAAI;GAAE;GAAQ,IAAI,mBAAmB,MAAM;GAAE;GAAW,CAAC;;AAGtE,SAAQ,WAAyC;EAC/C,MAAM,WAAW,cAAsB;GAMrC,SAAS,MAAM,OAAY;AACzB,iBAAa,QAAQ,OAAO,UAAU;AACtC,WAAO,EACL,MAAM,QAAQ,KAAoB;KAChC,IAAI;AACJ,WAAM,cAAc,SAAS,YAAY,MAAM,MAAM;AACnD,eAAS;OACT;AACF,YAAO;OAEV;;GAGH,SAAS,SAAS,IAEhB;AACA,iBAAa,QAAQ,IAAI,UAAU;AACnC,WAAO,EACL,MAAM,QAAQ,KAAQ,IAAyC;AAC7D,WAAM,cAAc,YAAY,YAAY,KAAK,GAAG;OAEvD;;GAGH,SAAS,OAAO,aAA8C;IAE5D,MAAM,QAGD,EAAE;IACP,IAAI,eAAoC;IAExC,IAAI,oBAA0C;IAC9C,IAAI;IACJ,IAAI,gBAAgB;IAGpB,MAAM,YAAmC,OAAO,MAAM,OAAO;KAC3D,MAAM,UAAU,YAAY,KAAK;AACjC,sBAAiB;AACjB,yBAAoB;AACpB,WAAM,GAAG,QAAQ;AAGjB,WAAM,IAAI,SAAe,YAAY;AACnC,YAAM,KAAK;OAAE;OAAS,aAAa;OAAI,CAAC;AACxC,UAAI,cAAc;OAChB,MAAM,OAAO;AACb,sBAAe;AACf,aAAM;;OAER;;AAEJ,iBAAa,QAAQ,WAAW,UAAU;AAkC1C,WAAO;KA/BL,IAAI,iBAAiB;AACnB,aAAO;;KAET,IAAI,gBAAgB;AAClB,aAAO;;KAGT,MAAM,cAA6B;AAEjC,UAAI,MAAM,WAAW,EACnB,OAAM,IAAI,SAAe,YAAY;AACnC,sBAAe;QACf;MAEJ,MAAM,QAAQ,MAAM,OAAO;AAC3B,UAAI,CAAC,MAAO,OAAM,IAAI,MAAM,wBAAwB;AACpD,0BAAoB,MAAM;AAC1B,sBAAgB;AAChB,YAAM,SAAS;;KAGjB,MAAM,KAAK,UAAyC;AAClD,UAAI,sBAAsB,KACxB,OAAM,IAAI,MAAM,8CAA8C;MAEhE,MAAM,OAAO,SAAS,eAAoB;AAC1C,uBAAiB;AACjB,YAAM,kBAAkB,KAAK;;KAIpB;;AAGf,UAAO;IAAE;IAAO;IAAU;IAAQ;;AAGpC,SAAO;GACL,GAAG,QAAQ,EAAE;GACb,MAAM,GAAW;AACf,WAAO,QAAQ,EAAE;;GAEnB,UAAU;AACR,WAAO,QAAQ,OAAO,kBAAkB;;GAE1C,OAAO;AACL,WAAO,QAAQ,EAAE;;GAEnB,QAAQ;AACN,WAAO,QAAQ,EAAE;;GAEnB,SAAS;AACP,WAAO,QAAQ,EAAE;;GAEnB,IAAI,UAAU;AACZ,WAAO,WAAW;;GAEpB,QAAQ;AACN,eAAW,OAAO;;GAErB;;;;;;;;;;;;;;;;;;;;;;;;;AA0BL,MAAM,gBAAgB,OACpB,YACA,YACA,MACA,OACkB;CAElB,MAAM,CAAC,UAAU,CAAC,GAAG,WAAW,CAC7B,SAAS,CACT,QAAQ,MAAM,CAAC,EAAE,UAAU,EAAE,OAAO,KAAK,CAAC;AAE7C,KAAI,CAAC,OACH,OAAM,IAAI,MACR,2BAA2B,WAAW,GAAG,QAAQ,KAAK,UAAU,KAAK,CAAC,GACvE;AAMH,KAAI,OAAO,cAAc,OAAO,mBAAmB;AACjD,SAAO,aAAa;AACpB,MAAI,OAAO,aAAa,EACtB,YAAW,OAAO,OAAO;;AAI7B,OAAM,OAAO,GAAG,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgD3B,MAAa,uBAA4C;CAEvD,MAAM,6BAAa,IAAI,KAA8C;AA8CrE,QAAO,IAAI,MAAM,EAAE,EAAE,EA1CnB,IAAI,SAAS,MAAM;AACjB,MAAI,SAAS,QACX,cAAa;AACX,QAAK,MAAM,OAAO,WAAW,QAAQ,CACnC,KAAI,OAAO;;AAKjB,MAAI,SAAS,SACX,QAAO,OACL,QACA,MACA,OACkB;GAClB,MAAM,MAAM,WAAW,IAAI,OAAO,oBAAI,IAAI,KAAK;AAC/C,cAAW,IAAI,QAAQ,IAAI;AAC3B,UAAO,cAAc,QAAkB,KAAK,MAAM,GAAG;;AAKzD,MAAI,SAAS,aAAa,OAAO,SAAS,SACxC,QAAO,QAAQ,IAAI,SAAS,KAAK;AAInC,MAAI,OAAO,SAAS,UAAU;GAC5B,MAAM,SAAS;GACf,IAAI,MAAM,WAAW,IAAI,OAAO;AAChC,OAAI,CAAC,KAAK;AACR,0BAAM,IAAI,KAAK;AACf,eAAW,IAAI,QAAQ,IAAI;;AAG7B,WAAQ,WAAyB,YAAY,IAAI,CAAC,OAAO;;IAOnC,CAAC;;AAwC/B,MAAa,cACX,UACA,YACkB;CAClB,MAAM,cAAc;AACpB,aAAY,UAAU;AACtB,aAAY,OAAO,WAAW;AAC9B,aAAY,OAAO,gBAAgB;AAEnC,QAAO"} |
+1
-1
| { | ||
| "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 @@ | ||
| ``` | ||
64157
25.28%270
26.76%354
77.89%