@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 +21 -0
- package/README.md +40 -0
- package/dist/describe-duplex-adapter.d.ts +14 -0
- package/dist/describe-duplex-adapter.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +255 -0
- package/dist/loopback.d.ts +22 -0
- package/dist/loopback.d.ts.map +1 -0
- package/package.json +43 -0
- package/src/describe-duplex-adapter.ts +241 -0
- package/src/index.ts +5 -0
- package/src/loopback.ts +61 -0
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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/src/loopback.ts
ADDED
|
@@ -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
|
+
};
|