@statewalker/webrun-streams-conformance 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2026 statewalker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @statewalker/webrun-streams-conformance
2
+
3
+ Conformance suite for `Duplex` / `Connect` / `Serve` adapters in the `webrun-streams-*` family. Every adapter ships a one-line test file that calls `describeDuplexAdapter(name, makePair)` with its own pair factory.
4
+
5
+ ## Levels asserted
6
+
7
+ - **L0** Envelope round-trip via an echo handler for body sizes empty / 1 KiB / 1 MiB / 10 MiB.
8
+ - **L1** N concurrent calls (default 10) with correct per-call body identity.
9
+ - **L2** Half-close — caller exhausts input; handler keeps yielding response chunks.
10
+ - **L3** Mid-stream cancellation — caller `.return()`s output; handler's `finally` runs.
11
+ - **L4** Error propagation — handler `throw`s; caller sees `message` + `stack` + custom fields preserved.
12
+ - **L5** Transport teardown — calling the `serve` teardown twice resolves rather than throwing, and closing the pair after a completed call resolves cleanly. (It does *not* assert what an in-flight call does when the transport closes underneath it; that is deliberately left to each adapter.)
13
+
14
+ ## Reference loopback
15
+
16
+ `makeLoopbackPair()` returns a `ConnectServePair` whose `call` invokes the registered `handler` directly with no transport. The suite must pass green against the loopback — this is the self-test that the assertions are correctly formulated.
17
+
18
+ ## Usage
19
+
20
+ ```ts
21
+ import { describeDuplexAdapter } from "@statewalker/webrun-streams-conformance";
22
+ import { makeMyAdapterPair } from "./make-pair.js";
23
+
24
+ describeDuplexAdapter("my-adapter", makeMyAdapterPair);
25
+ ```
26
+
27
+ `makeMyAdapterPair` is a `MakePair` — an async factory returning a
28
+ `ConnectServePair` (`connect()`, `serve(handler)`, `close()`). It is called
29
+ once per test case, so each gets a fresh transport.
30
+
31
+ A third argument tunes the suite:
32
+
33
+ | Option | Default | Effect |
34
+ | --- | --- | --- |
35
+ | `concurrency` | `10` | How many concurrent calls L1 runs. |
36
+ | `skipHugeBody` | `false` | Drop L0's 10 MiB case, for transports that rate-limit. |
37
+
38
+ ## License
39
+
40
+ MIT
@@ -0,0 +1,14 @@
1
+ import type { MakePair } from "./loopback.js";
2
+ export interface DescribeDuplexAdapterOptions {
3
+ /** Number of concurrent calls for L1. Default 10. */
4
+ concurrency?: number;
5
+ /** Bypass the 10 MiB L0 case (some transports rate-limit). Default false. */
6
+ skipHugeBody?: boolean;
7
+ }
8
+ /**
9
+ * Runs every conformance level (L0–L5) against the supplied `ConnectServePair`
10
+ * factory. Each adapter package in the `webrun-streams-*` family invokes this
11
+ * from its own one-line Vitest file.
12
+ */
13
+ export declare function describeDuplexAdapter(name: string, makePair: MakePair, opts?: DescribeDuplexAdapterOptions): void;
14
+ //# sourceMappingURL=describe-duplex-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"describe-duplex-adapter.d.ts","sourceRoot":"","sources":["../src/describe-duplex-adapter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,MAAM,WAAW,4BAA4B;IAC3C,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,QAAQ,EAClB,IAAI,GAAE,4BAAiC,GACtC,IAAI,CAmMN"}
@@ -0,0 +1,3 @@
1
+ export { type DescribeDuplexAdapterOptions, describeDuplexAdapter, } from "./describe-duplex-adapter.js";
2
+ export { type ConnectServePair, type MakePair, makeLoopbackPair } from "./loopback.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,4BAA4B,EACjC,qBAAqB,GACtB,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,QAAQ,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,255 @@
1
+ import { collectBytes } from "@statewalker/webrun-streams";
2
+ import { describe, expect, it } from "vitest";
3
+ //#region src/describe-duplex-adapter.ts
4
+ /**
5
+ * Runs every conformance level (L0–L5) against the supplied `ConnectServePair`
6
+ * factory. Each adapter package in the `webrun-streams-*` family invokes this
7
+ * from its own one-line Vitest file.
8
+ */
9
+ function describeDuplexAdapter(name, makePair, opts = {}) {
10
+ const concurrency = opts.concurrency ?? 10;
11
+ describe(`${name} — Duplex conformance`, () => {
12
+ describe("L0: envelope round-trip", () => {
13
+ const cases = [
14
+ {
15
+ label: "empty",
16
+ size: 0
17
+ },
18
+ {
19
+ label: "1 KiB",
20
+ size: 1024
21
+ },
22
+ {
23
+ label: "1 MiB",
24
+ size: 1048576
25
+ }
26
+ ];
27
+ if (!opts.skipHugeBody) cases.push({
28
+ label: "10 MiB",
29
+ size: 10485760
30
+ });
31
+ for (const { label, size } of cases) it(`round-trips ${label} body via echo handler`, async () => {
32
+ const pair = await makePair();
33
+ try {
34
+ await pair.serve(echoHandler);
35
+ const { call, close } = await pair.connect();
36
+ try {
37
+ const input = randomBytes(size);
38
+ const out = call([input]);
39
+ const received = await collectBytes(out);
40
+ expect(received.byteLength).toBe(size);
41
+ expect(bytesEqual(received, input)).toBe(true);
42
+ } finally {
43
+ await close();
44
+ }
45
+ } finally {
46
+ await pair.close();
47
+ }
48
+ });
49
+ });
50
+ describe("L1: concurrent calls", () => {
51
+ it(`completes ${concurrency} concurrent calls with correct per-call bodies`, async () => {
52
+ const pair = await makePair();
53
+ try {
54
+ await pair.serve(echoHandler);
55
+ const { call, close } = await pair.connect();
56
+ try {
57
+ const calls = Array.from({ length: concurrency }, async (_, i) => {
58
+ const body = new TextEncoder().encode(`body-${i}-${"x".repeat(64)}`);
59
+ const out = call([body]);
60
+ return {
61
+ i,
62
+ received: await collectBytes(out),
63
+ body
64
+ };
65
+ });
66
+ const results = await Promise.all(calls);
67
+ for (const { i, received, body } of results) {
68
+ expect(received.byteLength, `call ${i}`).toBe(body.byteLength);
69
+ expect(bytesEqual(received, body), `call ${i}`).toBe(true);
70
+ }
71
+ } finally {
72
+ await close();
73
+ }
74
+ } finally {
75
+ await pair.close();
76
+ }
77
+ });
78
+ });
79
+ describe("L2: half-close", () => {
80
+ it("response continues yielding after input exhausts", async () => {
81
+ const pair = await makePair();
82
+ try {
83
+ await pair.serve(async function* lateResponder(input) {
84
+ for await (const _ of input);
85
+ yield new TextEncoder().encode("a");
86
+ await delay(30);
87
+ yield new TextEncoder().encode("b");
88
+ await delay(30);
89
+ yield new TextEncoder().encode("c");
90
+ });
91
+ const { call, close } = await pair.connect();
92
+ try {
93
+ const out = call((async function* () {
94
+ yield new TextEncoder().encode("ping");
95
+ })());
96
+ const text = new TextDecoder().decode(await collectBytes(out));
97
+ expect(text).toBe("abc");
98
+ } finally {
99
+ await close();
100
+ }
101
+ } finally {
102
+ await pair.close();
103
+ }
104
+ });
105
+ });
106
+ describe("L3: mid-stream cancellation", () => {
107
+ it("propagates caller .return() to handler", async () => {
108
+ const pair = await makePair();
109
+ try {
110
+ let handlerCleanupRan = false;
111
+ await pair.serve(async function* unboundedResponder() {
112
+ try {
113
+ while (true) {
114
+ yield new TextEncoder().encode("tick");
115
+ await delay(10);
116
+ }
117
+ } finally {
118
+ handlerCleanupRan = true;
119
+ }
120
+ });
121
+ const { call, close } = await pair.connect();
122
+ try {
123
+ const out = call([/* @__PURE__ */ new Uint8Array(0)]);
124
+ let count = 0;
125
+ for await (const _ of out) {
126
+ count++;
127
+ if (count >= 3) break;
128
+ }
129
+ await delay(50);
130
+ expect(count).toBe(3);
131
+ expect(handlerCleanupRan).toBe(true);
132
+ } finally {
133
+ await close();
134
+ }
135
+ } finally {
136
+ await pair.close();
137
+ }
138
+ });
139
+ });
140
+ describe("L4: error propagation", () => {
141
+ it("preserves message, custom fields, and stack across the wire", async () => {
142
+ const pair = await makePair();
143
+ try {
144
+ await pair.serve(async function* failingHandler() {
145
+ const err = /* @__PURE__ */ new Error("intentional failure");
146
+ Object.assign(err, {
147
+ status: 418,
148
+ code: "TEAPOT"
149
+ });
150
+ throw err;
151
+ });
152
+ const { call, close } = await pair.connect();
153
+ try {
154
+ const out = call([/* @__PURE__ */ new Uint8Array(0)]);
155
+ await expect(async () => {
156
+ for await (const _ of out);
157
+ }).rejects.toMatchObject({
158
+ message: "intentional failure",
159
+ status: 418,
160
+ code: "TEAPOT"
161
+ });
162
+ try {
163
+ for await (const _ of call([/* @__PURE__ */ new Uint8Array(0)]));
164
+ } catch (caught) {
165
+ expect(typeof caught.stack).toBe("string");
166
+ expect((caught.stack ?? "").length).toBeGreaterThan(0);
167
+ }
168
+ } finally {
169
+ await close();
170
+ }
171
+ } finally {
172
+ await pair.close();
173
+ }
174
+ });
175
+ });
176
+ describe("L5: transport teardown", () => {
177
+ it("idempotent serve teardown", async () => {
178
+ const pair = await makePair();
179
+ try {
180
+ const teardown = await pair.serve(echoHandler);
181
+ await teardown();
182
+ await expect(teardown()).resolves.toBeUndefined();
183
+ } finally {
184
+ await pair.close();
185
+ }
186
+ });
187
+ it("pair close after operation completes without throwing", async () => {
188
+ const pair = await makePair();
189
+ await pair.serve(echoHandler);
190
+ const { call, close } = await pair.connect();
191
+ const out = call([new TextEncoder().encode("x")]);
192
+ await collectBytes(out);
193
+ await close();
194
+ await expect(pair.close()).resolves.toBeUndefined();
195
+ });
196
+ });
197
+ });
198
+ }
199
+ const echoHandler = async function* echo(input) {
200
+ for await (const chunk of input) yield chunk;
201
+ };
202
+ function randomBytes(size) {
203
+ const out = new Uint8Array(size);
204
+ for (let i = 0; i < size; i++) out[i] = i * 2654435761 & 255;
205
+ return out;
206
+ }
207
+ function bytesEqual(a, b) {
208
+ if (a.byteLength !== b.byteLength) return false;
209
+ for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
210
+ return true;
211
+ }
212
+ function delay(ms) {
213
+ return new Promise((r) => setTimeout(r, ms));
214
+ }
215
+ //#endregion
216
+ //#region src/loopback.ts
217
+ /**
218
+ * Loopback pair: `call` invokes the registered `handler` directly, no
219
+ * transport. The conformance suite must pass against this — it self-validates
220
+ * that the assertions are correctly formulated independent of any wire
221
+ * protocol or `emulateMux` behaviour.
222
+ */
223
+ const makeLoopbackPair = async () => {
224
+ let handler = null;
225
+ let closed = false;
226
+ const call = (input) => {
227
+ if (closed) return (async function* () {
228
+ throw new Error("loopback: pair closed");
229
+ })();
230
+ if (!handler) return (async function* () {
231
+ throw new Error("loopback: no handler registered");
232
+ })();
233
+ return handler(input);
234
+ };
235
+ return {
236
+ async connect() {
237
+ return {
238
+ call,
239
+ async close() {}
240
+ };
241
+ },
242
+ async serve(h) {
243
+ handler = h;
244
+ return async () => {
245
+ if (handler === h) handler = null;
246
+ };
247
+ },
248
+ async close() {
249
+ closed = true;
250
+ handler = null;
251
+ }
252
+ };
253
+ };
254
+ //#endregion
255
+ export { describeDuplexAdapter, makeLoopbackPair };
@@ -0,0 +1,22 @@
1
+ import type { Duplex } from "@statewalker/webrun-streams";
2
+ /**
3
+ * The shape every adapter test factory returns. The suite drives `connect`
4
+ * for each test case and uses `serve` to register the handler.
5
+ */
6
+ export interface ConnectServePair {
7
+ connect(): Promise<{
8
+ call: Duplex;
9
+ close: () => Promise<void>;
10
+ }>;
11
+ serve(handler: Duplex): Promise<() => Promise<void>>;
12
+ close(): Promise<void>;
13
+ }
14
+ export type MakePair = () => Promise<ConnectServePair>;
15
+ /**
16
+ * Loopback pair: `call` invokes the registered `handler` directly, no
17
+ * transport. The conformance suite must pass against this — it self-validates
18
+ * that the assertions are correctly formulated independent of any wire
19
+ * protocol or `emulateMux` behaviour.
20
+ */
21
+ export declare const makeLoopbackPair: MakePair;
22
+ //# sourceMappingURL=loopback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loopback.d.ts","sourceRoot":"","sources":["../src/loopback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAE1D;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC,CAAC;IACjE,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACrD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAEvD;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,QAwC9B,CAAC"}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@statewalker/webrun-streams-conformance",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Conformance test suite for Duplex/Connect/Serve adapters in the webrun-streams-* family",
7
+ "homepage": "https://github.com/statewalker/webrun-wire",
8
+ "author": {
9
+ "name": "Mikhail Kotelnikov",
10
+ "email": "mikhail.kotelnikov@gmail.com"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git@github.com:statewalker/webrun-wire.git"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.ts"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "dependencies": {
25
+ "vitest": "^4.1.10",
26
+ "@statewalker/webrun-streams": "0.1.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^26.2.0",
30
+ "rimraf": "^6.1.3",
31
+ "rolldown": "^1.2.4",
32
+ "typescript": "^7.0.2"
33
+ },
34
+ "sideEffects": false,
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
40
+ "test": "vitest run",
41
+ "lint": "biome check src tests"
42
+ }
43
+ }
@@ -0,0 +1,241 @@
1
+ import { collectBytes, type Duplex } from "@statewalker/webrun-streams";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { MakePair } from "./loopback.js";
4
+
5
+ export interface DescribeDuplexAdapterOptions {
6
+ /** Number of concurrent calls for L1. Default 10. */
7
+ concurrency?: number;
8
+ /** Bypass the 10 MiB L0 case (some transports rate-limit). Default false. */
9
+ skipHugeBody?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Runs every conformance level (L0–L5) against the supplied `ConnectServePair`
14
+ * factory. Each adapter package in the `webrun-streams-*` family invokes this
15
+ * from its own one-line Vitest file.
16
+ */
17
+ export function describeDuplexAdapter(
18
+ name: string,
19
+ makePair: MakePair,
20
+ opts: DescribeDuplexAdapterOptions = {},
21
+ ): void {
22
+ const concurrency = opts.concurrency ?? 10;
23
+
24
+ describe(`${name} — Duplex conformance`, () => {
25
+ describe("L0: envelope round-trip", () => {
26
+ const cases: Array<{ label: string; size: number }> = [
27
+ { label: "empty", size: 0 },
28
+ { label: "1 KiB", size: 1024 },
29
+ { label: "1 MiB", size: 1024 * 1024 },
30
+ ];
31
+ if (!opts.skipHugeBody) cases.push({ label: "10 MiB", size: 10 * 1024 * 1024 });
32
+
33
+ for (const { label, size } of cases) {
34
+ it(`round-trips ${label} body via echo handler`, async () => {
35
+ const pair = await makePair();
36
+ try {
37
+ await pair.serve(echoHandler);
38
+ const { call, close } = await pair.connect();
39
+ try {
40
+ const input = randomBytes(size);
41
+ const out = call([input]);
42
+ const received = await collectBytes(out);
43
+ expect(received.byteLength).toBe(size);
44
+ expect(bytesEqual(received, input)).toBe(true);
45
+ } finally {
46
+ await close();
47
+ }
48
+ } finally {
49
+ await pair.close();
50
+ }
51
+ });
52
+ }
53
+ });
54
+
55
+ describe("L1: concurrent calls", () => {
56
+ it(`completes ${concurrency} concurrent calls with correct per-call bodies`, async () => {
57
+ const pair = await makePair();
58
+ try {
59
+ await pair.serve(echoHandler);
60
+ const { call, close } = await pair.connect();
61
+ try {
62
+ const calls = Array.from({ length: concurrency }, async (_, i) => {
63
+ const body = new TextEncoder().encode(`body-${i}-${"x".repeat(64)}`);
64
+ const out = call([body]);
65
+ const received = await collectBytes(out);
66
+ return { i, received, body };
67
+ });
68
+ const results = await Promise.all(calls);
69
+ for (const { i, received, body } of results) {
70
+ expect(received.byteLength, `call ${i}`).toBe(body.byteLength);
71
+ expect(bytesEqual(received, body), `call ${i}`).toBe(true);
72
+ }
73
+ } finally {
74
+ await close();
75
+ }
76
+ } finally {
77
+ await pair.close();
78
+ }
79
+ });
80
+ });
81
+
82
+ describe("L2: half-close", () => {
83
+ it("response continues yielding after input exhausts", async () => {
84
+ const pair = await makePair();
85
+ try {
86
+ await pair.serve(async function* lateResponder(input) {
87
+ // Drain input.
88
+ for await (const _ of input) {
89
+ /* discard */
90
+ }
91
+ // Then yield over time.
92
+ yield new TextEncoder().encode("a");
93
+ await delay(30);
94
+ yield new TextEncoder().encode("b");
95
+ await delay(30);
96
+ yield new TextEncoder().encode("c");
97
+ });
98
+ const { call, close } = await pair.connect();
99
+ try {
100
+ const out = call(
101
+ (async function* () {
102
+ yield new TextEncoder().encode("ping");
103
+ })(),
104
+ );
105
+ const text = new TextDecoder().decode(await collectBytes(out));
106
+ expect(text).toBe("abc");
107
+ } finally {
108
+ await close();
109
+ }
110
+ } finally {
111
+ await pair.close();
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("L3: mid-stream cancellation", () => {
117
+ it("propagates caller .return() to handler", async () => {
118
+ const pair = await makePair();
119
+ try {
120
+ let handlerCleanupRan = false;
121
+ await pair.serve(async function* unboundedResponder() {
122
+ try {
123
+ while (true) {
124
+ yield new TextEncoder().encode("tick");
125
+ await delay(10);
126
+ }
127
+ } finally {
128
+ handlerCleanupRan = true;
129
+ }
130
+ });
131
+ const { call, close } = await pair.connect();
132
+ try {
133
+ const out = call([new Uint8Array(0)]);
134
+ let count = 0;
135
+ for await (const _ of out) {
136
+ count++;
137
+ if (count >= 3) break;
138
+ }
139
+ // Give the handler a moment to observe the cancellation.
140
+ await delay(50);
141
+ expect(count).toBe(3);
142
+ expect(handlerCleanupRan).toBe(true);
143
+ } finally {
144
+ await close();
145
+ }
146
+ } finally {
147
+ await pair.close();
148
+ }
149
+ });
150
+ });
151
+
152
+ describe("L4: error propagation", () => {
153
+ it("preserves message, custom fields, and stack across the wire", async () => {
154
+ const pair = await makePair();
155
+ try {
156
+ await pair.serve(async function* failingHandler() {
157
+ const err = new Error("intentional failure");
158
+ Object.assign(err, { status: 418, code: "TEAPOT" });
159
+ if ((0 as number) === 0) throw err;
160
+ yield new Uint8Array(0);
161
+ });
162
+ const { call, close } = await pair.connect();
163
+ try {
164
+ const out = call([new Uint8Array(0)]);
165
+ await expect(async () => {
166
+ for await (const _ of out) {
167
+ /* drain */
168
+ }
169
+ }).rejects.toMatchObject({
170
+ message: "intentional failure",
171
+ status: 418,
172
+ code: "TEAPOT",
173
+ });
174
+ // Stack must be a non-empty string (modulo loopback returning the
175
+ // same Error instance, native bridges reconstructing it).
176
+ try {
177
+ for await (const _ of call([new Uint8Array(0)])) {
178
+ /* drain */
179
+ }
180
+ } catch (caught) {
181
+ expect(typeof (caught as Error).stack).toBe("string");
182
+ expect(((caught as Error).stack ?? "").length).toBeGreaterThan(0);
183
+ }
184
+ } finally {
185
+ await close();
186
+ }
187
+ } finally {
188
+ await pair.close();
189
+ }
190
+ });
191
+ });
192
+
193
+ describe("L5: transport teardown", () => {
194
+ it("idempotent serve teardown", async () => {
195
+ const pair = await makePair();
196
+ try {
197
+ const teardown = await pair.serve(echoHandler);
198
+ await teardown();
199
+ await expect(teardown()).resolves.toBeUndefined();
200
+ } finally {
201
+ await pair.close();
202
+ }
203
+ });
204
+
205
+ it("pair close after operation completes without throwing", async () => {
206
+ const pair = await makePair();
207
+ await pair.serve(echoHandler);
208
+ const { call, close } = await pair.connect();
209
+ const out = call([new TextEncoder().encode("x")]);
210
+ await collectBytes(out);
211
+ await close();
212
+ await expect(pair.close()).resolves.toBeUndefined();
213
+ });
214
+ });
215
+ });
216
+ }
217
+
218
+ const echoHandler: Duplex = async function* echo(input) {
219
+ for await (const chunk of input) yield chunk;
220
+ };
221
+
222
+ function randomBytes(size: number): Uint8Array {
223
+ const out = new Uint8Array(size);
224
+ // Cheap deterministic-but-non-trivial fill — full crypto.getRandomValues is
225
+ // capped at 64 KiB per call in browsers, and conformance just needs the
226
+ // bytes to round-trip, not be cryptographically random.
227
+ for (let i = 0; i < size; i++) out[i] = (i * 2654435761) & 0xff;
228
+ return out;
229
+ }
230
+
231
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
232
+ if (a.byteLength !== b.byteLength) return false;
233
+ for (let i = 0; i < a.byteLength; i++) {
234
+ if (a[i] !== b[i]) return false;
235
+ }
236
+ return true;
237
+ }
238
+
239
+ function delay(ms: number): Promise<void> {
240
+ return new Promise((r) => setTimeout(r, ms));
241
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ type DescribeDuplexAdapterOptions,
3
+ describeDuplexAdapter,
4
+ } from "./describe-duplex-adapter.js";
5
+ export { type ConnectServePair, type MakePair, makeLoopbackPair } from "./loopback.js";
@@ -0,0 +1,61 @@
1
+ import type { Duplex } from "@statewalker/webrun-streams";
2
+
3
+ /**
4
+ * The shape every adapter test factory returns. The suite drives `connect`
5
+ * for each test case and uses `serve` to register the handler.
6
+ */
7
+ export interface ConnectServePair {
8
+ connect(): Promise<{ call: Duplex; close: () => Promise<void> }>;
9
+ serve(handler: Duplex): Promise<() => Promise<void>>;
10
+ close(): Promise<void>;
11
+ }
12
+
13
+ export type MakePair = () => Promise<ConnectServePair>;
14
+
15
+ /**
16
+ * Loopback pair: `call` invokes the registered `handler` directly, no
17
+ * transport. The conformance suite must pass against this — it self-validates
18
+ * that the assertions are correctly formulated independent of any wire
19
+ * protocol or `emulateMux` behaviour.
20
+ */
21
+ export const makeLoopbackPair: MakePair = async () => {
22
+ let handler: Duplex | null = null;
23
+ let closed = false;
24
+
25
+ const call: Duplex = (input) => {
26
+ if (closed) {
27
+ return (async function* () {
28
+ if ((0 as number) === 0) throw new Error("loopback: pair closed");
29
+ yield new Uint8Array(0);
30
+ })();
31
+ }
32
+ if (!handler) {
33
+ return (async function* () {
34
+ if ((0 as number) === 0) throw new Error("loopback: no handler registered");
35
+ yield new Uint8Array(0);
36
+ })();
37
+ }
38
+ return handler(input);
39
+ };
40
+
41
+ return {
42
+ async connect() {
43
+ return {
44
+ call,
45
+ async close() {
46
+ /* no-op for loopback; pair.close handles teardown */
47
+ },
48
+ };
49
+ },
50
+ async serve(h) {
51
+ handler = h;
52
+ return async () => {
53
+ if (handler === h) handler = null;
54
+ };
55
+ },
56
+ async close() {
57
+ closed = true;
58
+ handler = null;
59
+ },
60
+ };
61
+ };