@znight/graphql 0.2.1 → 0.3.0

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/dist/testing.mjs CHANGED
@@ -1,6 +1,651 @@
1
- import { n as endpointOf, r as executorOf } from "./plugin-CR_wVfRU.mjs";
2
- import { parseSseStream } from "@znight/http";
3
- import { resolveMockFn } from "@znight/testing";
1
+ import { n as endpointOf, r as executorOf, t as GraphQLPlugin } from "./plugin-q4oz7d6s.mjs";
2
+ import { HttpPlugin, NotFoundException, parseSseStream } from "@znight/http";
3
+ import { TestApp, defineTestMetadata, resolveMockFn, testToken } from "@znight/testing";
4
+ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
5
+ // src/conformance.ts
6
+ /**
7
+ * Describes the conformance scenarios of a GraphQL driver (RFC 0009 §12): queries, mutations,
8
+ * field guards and HTTP guards, loaders batching, request-scoped providers, result mappers,
9
+ * masking, introspection, limits, the `graphql:operation` hook, `POST` bodies other than JSON and
10
+ * mutations over `GET` refused (CSRF) and, over SSE, subscriptions (events, completion, client
11
+ * abort, `maxActive`, shutdown whatever the plugin order).
12
+ *
13
+ * ```ts
14
+ * import { describeDriverConformance } from '@znight/graphql/testing'
15
+ * import { yogaDriver } from '@znight/graphql/yoga'
16
+ * import { afterAll, beforeAll, describe, it } from 'bun:test'
17
+ *
18
+ * describeDriverConformance({ driver: () => yogaDriver(), test: { describe, it, beforeAll, afterAll } })
19
+ * ```
20
+ */
21
+ function describeDriverConformance(options) {
22
+ const { test } = options;
23
+ const adapters = Object.entries(options.adapters ?? { default: void 0 });
24
+ const name = options.driver()?.name ?? "builtin";
25
+ test.describe(`GraphQL driver conformance: ${name}`, () => {
26
+ for (const [adapterName, adapter] of adapters) test.describe(`${adapterName} adapter`, () => {
27
+ describeOperations(options, name, adapter);
28
+ if (options.subscriptions !== false) describeSubscriptions(options, adapter);
29
+ });
30
+ });
31
+ }
32
+ const OBSERVED = Symbol("conformance.observed");
33
+ var Catalog = class {
34
+ #pubsub;
35
+ #books = [
36
+ {
37
+ id: "1",
38
+ title: "Dune",
39
+ authorId: "herbert"
40
+ },
41
+ {
42
+ id: "2",
43
+ title: "Hyperion",
44
+ authorId: "simmons"
45
+ },
46
+ {
47
+ id: "3",
48
+ title: "Children of Dune",
49
+ authorId: "herbert"
50
+ }
51
+ ];
52
+ [OBSERVED] = {
53
+ batches: [],
54
+ ended: [],
55
+ sessions: 0
56
+ };
57
+ constructor(pubsub) {
58
+ this.#pubsub = pubsub;
59
+ }
60
+ books() {
61
+ return this.#books;
62
+ }
63
+ book(args) {
64
+ const book = this.#books.find((candidate) => candidate.id === args.id);
65
+ if (book === void 0) throw new NotFoundException(`No book ${args.id}`);
66
+ return book;
67
+ }
68
+ secret() {
69
+ throw new Error("postgres://admin:secret@db");
70
+ }
71
+ async addBook(args) {
72
+ const book = {
73
+ id: String(this.#books.length + 1),
74
+ title: args.title,
75
+ authorId: "herbert"
76
+ };
77
+ this.#books.push(book);
78
+ await this.#pubsub.publish("bookAdded", book);
79
+ return book;
80
+ }
81
+ bookAdded(ctx) {
82
+ return this.#pubsub.subscribe("bookAdded", { signal: ctx.signal });
83
+ }
84
+ async *countdown(args) {
85
+ for (let n = args.from; n > 0; n--) yield await Promise.resolve(n);
86
+ }
87
+ async *forever(ctx) {
88
+ const signal = ctx.signal;
89
+ try {
90
+ for (let n = 1; !signal.aborted; n++) {
91
+ yield n;
92
+ await new Promise((resolve) => {
93
+ const timer = setTimeout(resolve, 10);
94
+ signal.addEventListener("abort", () => {
95
+ clearTimeout(timer);
96
+ resolve();
97
+ }, { once: true });
98
+ });
99
+ }
100
+ } finally {
101
+ this[OBSERVED].ended.push("forever");
102
+ }
103
+ }
104
+ };
105
+ var AuthorsById = class {
106
+ #catalog;
107
+ constructor(catalog) {
108
+ this.#catalog = catalog;
109
+ }
110
+ load(keys) {
111
+ this.#catalog[OBSERVED].batches.push([...keys]);
112
+ return Promise.resolve(keys.map((id) => ({
113
+ id,
114
+ name: id === "herbert" ? "Frank Herbert" : "Dan Simmons"
115
+ })));
116
+ }
117
+ };
118
+ /** Request-scoped: one per HTTP request, shared by the guard and the resolver of an operation. */
119
+ var Session = class {
120
+ id;
121
+ user;
122
+ constructor(http, catalog) {
123
+ this.id = ++catalog[OBSERVED].sessions;
124
+ this.user = http.header("x-user");
125
+ }
126
+ };
127
+ var Viewer = class {
128
+ #session;
129
+ constructor(session) {
130
+ this.#session = session;
131
+ }
132
+ me() {
133
+ return {
134
+ id: this.#session.id,
135
+ user: this.#session.user ?? null
136
+ };
137
+ }
138
+ };
139
+ /** A field guard: editors only, read from the request-scoped session. */
140
+ var EditorGuard = class {
141
+ #session;
142
+ constructor(session) {
143
+ this.#session = session;
144
+ }
145
+ canResolve() {
146
+ return this.#session.user === "editor";
147
+ }
148
+ };
149
+ /** An HTTP guard on the endpoint (`http.guards`). */
150
+ var Gate = class {
151
+ canActivate(ctx) {
152
+ return ctx.header("x-block") !== "yes";
153
+ }
154
+ };
155
+ /** Errors as data (RFC 0008): a missing book is a `NotFound` of `BookResult`. */
156
+ var Rejections = class {
157
+ static types = ["NotFound"];
158
+ toResult(error) {
159
+ return error instanceof NotFoundException ? {
160
+ __typename: "NotFound",
161
+ message: error.message
162
+ } : void 0;
163
+ }
164
+ };
165
+ /** The binding of a resolver method (what the compiler generates for `@Query()`, `@ResolveField()`...). */
166
+ function resolver(cls, method, operation, invoke, extra = {}) {
167
+ return {
168
+ token: testToken(cls),
169
+ method,
170
+ operation,
171
+ guards: [],
172
+ meta: {},
173
+ invoke,
174
+ ...extra
175
+ };
176
+ }
177
+ const TYPES = [
178
+ {
179
+ kind: "object",
180
+ name: "Author",
181
+ fields: [{
182
+ name: "name",
183
+ type: "String!"
184
+ }]
185
+ },
186
+ {
187
+ kind: "object",
188
+ name: "Book",
189
+ fields: [
190
+ {
191
+ name: "id",
192
+ type: "ID!"
193
+ },
194
+ {
195
+ name: "title",
196
+ type: "String!"
197
+ },
198
+ {
199
+ name: "author",
200
+ type: "Author!",
201
+ resolver: resolver(Catalog, "books", "field", ((_r, parent, _a, ctx) => ctx.loader(testToken(AuthorsById)).load(parent.authorId)), { loaders: [testToken(AuthorsById)] })
202
+ }
203
+ ]
204
+ },
205
+ {
206
+ kind: "object",
207
+ name: "NotFound",
208
+ fields: [{
209
+ name: "message",
210
+ type: "String!"
211
+ }]
212
+ },
213
+ {
214
+ kind: "union",
215
+ name: "BookResult",
216
+ members: ["Book", "NotFound"],
217
+ discriminator: { by: "typename" }
218
+ },
219
+ {
220
+ kind: "object",
221
+ name: "Me",
222
+ fields: [{
223
+ name: "id",
224
+ type: "Int!"
225
+ }, {
226
+ name: "user",
227
+ type: "String"
228
+ }]
229
+ },
230
+ {
231
+ kind: "object",
232
+ name: "Query",
233
+ fields: [
234
+ {
235
+ name: "books",
236
+ type: "[Book!]!",
237
+ resolver: resolver(Catalog, "books", "query", ((r) => r.books()))
238
+ },
239
+ {
240
+ name: "book",
241
+ type: "BookResult!",
242
+ args: [{
243
+ name: "id",
244
+ type: "ID!"
245
+ }],
246
+ resolver: resolver(Catalog, "book", "query", ((r, _p, a) => ({
247
+ __typename: "Book",
248
+ ...r.book(a)
249
+ })))
250
+ },
251
+ {
252
+ name: "secret",
253
+ type: "String",
254
+ resolver: resolver(Catalog, "secret", "query", ((r) => r.secret()))
255
+ },
256
+ {
257
+ name: "me",
258
+ type: "Me!",
259
+ resolver: resolver(Viewer, "me", "query", ((r) => r.me()))
260
+ }
261
+ ]
262
+ },
263
+ {
264
+ kind: "object",
265
+ name: "Mutation",
266
+ fields: [{
267
+ name: "addBook",
268
+ type: "Book!",
269
+ args: [{
270
+ name: "title",
271
+ type: "String!"
272
+ }],
273
+ resolver: resolver(Catalog, "addBook", "mutation", ((r, _p, a) => r.addBook(a)), { guards: [testToken(EditorGuard)] })
274
+ }]
275
+ },
276
+ {
277
+ kind: "object",
278
+ name: "Subscription",
279
+ fields: [
280
+ {
281
+ name: "bookAdded",
282
+ type: "Book!",
283
+ resolver: resolver(Catalog, "bookAdded", "subscription", ((r, _p, _a, c) => r.bookAdded(c)))
284
+ },
285
+ {
286
+ name: "countdown",
287
+ type: "Int!",
288
+ args: [{
289
+ name: "from",
290
+ type: "Int!"
291
+ }],
292
+ resolver: resolver(Catalog, "countdown", "subscription", ((r, _p, a) => r.countdown(a)))
293
+ },
294
+ {
295
+ name: "forever",
296
+ type: "Int!",
297
+ resolver: resolver(Catalog, "forever", "subscription", ((r, _p, _a, c) => r.forever(c)))
298
+ }
299
+ ]
300
+ }
301
+ ];
302
+ function conformanceMetadata() {
303
+ return defineTestMetadata({
304
+ providers: [
305
+ {
306
+ class: Catalog,
307
+ deps: ["@znight/graphql#PubSub"]
308
+ },
309
+ {
310
+ class: AuthorsById,
311
+ deps: [Catalog]
312
+ },
313
+ {
314
+ class: Session,
315
+ scope: "request",
316
+ deps: ["@znight/http#HttpContext", Catalog]
317
+ },
318
+ {
319
+ class: Viewer,
320
+ scope: "request",
321
+ deps: [Session]
322
+ },
323
+ {
324
+ class: EditorGuard,
325
+ scope: "request",
326
+ deps: [Session]
327
+ },
328
+ Gate,
329
+ Rejections
330
+ ],
331
+ externals: [
332
+ {
333
+ token: "@znight/graphql#GraphQLContext",
334
+ scope: "request",
335
+ providedBy: "GraphQLPlugin"
336
+ },
337
+ {
338
+ token: "@znight/graphql#PubSub",
339
+ providedBy: "GraphQLPlugin"
340
+ },
341
+ {
342
+ token: "@znight/graphql#GraphQLPlugin",
343
+ providedBy: "GraphQLPlugin"
344
+ },
345
+ {
346
+ token: "@znight/http#HttpContext",
347
+ scope: "request",
348
+ providedBy: "HttpPlugin"
349
+ },
350
+ {
351
+ token: "@znight/http#RequestId",
352
+ scope: "request",
353
+ providedBy: "HttpPlugin"
354
+ }
355
+ ],
356
+ extensions: { graphql: {
357
+ version: 1,
358
+ schemaHash: "c0nf0rmance00000",
359
+ roots: {
360
+ query: "Query",
361
+ mutation: "Mutation",
362
+ subscription: "Subscription"
363
+ },
364
+ types: [...TYPES].sort((a, b) => a.name < b.name ? -1 : 1)
365
+ } }
366
+ });
367
+ }
368
+ /** The drain timeout of `HttpPlugin` in the suite: a shutdown waiting for it is a failure. */
369
+ const DRAIN_TIMEOUT_MS = 5e3;
370
+ /**
371
+ * Boots the conformance application on `adapter` with `driver`, `GraphQLPlugin` after `HttpPlugin`
372
+ * (or before it, `graphqlFirst`).
373
+ */
374
+ function boot(options, adapter, graphqlFirst = false) {
375
+ const driver = options.driver();
376
+ const http = HttpPlugin.configure({
377
+ port: 0,
378
+ host: "127.0.0.1",
379
+ drainTimeoutMs: DRAIN_TIMEOUT_MS,
380
+ ...adapter === void 0 ? {} : { adapter: adapter() }
381
+ });
382
+ const graphql = GraphQLPlugin.configure({
383
+ introspection: false,
384
+ resultMappers: [Rejections],
385
+ limits: {
386
+ maxAliases: 3,
387
+ maxDepth: 4
388
+ },
389
+ http: { guards: [Gate] },
390
+ subscriptions: { maxActive: 2 },
391
+ ...driver === void 0 ? {} : { driver }
392
+ });
393
+ return TestApp.create({
394
+ metadata: conformanceMetadata(),
395
+ plugins: graphqlFirst ? [graphql, http] : [http, graphql],
396
+ logger: {
397
+ debug() {},
398
+ info() {},
399
+ warn() {},
400
+ error() {}
401
+ }
402
+ });
403
+ }
404
+ function observed(app) {
405
+ return app.get(testToken(Catalog))[OBSERVED];
406
+ }
407
+ function graphql(app) {
408
+ return app.app.getPlugin("graphql");
409
+ }
410
+ async function until(condition, what, timeoutMs = 2e3) {
411
+ const started = Date.now();
412
+ while (!condition()) {
413
+ if (Date.now() - started > timeoutMs) throw new Error(`conformance: ${what} did not happen within ${timeoutMs} ms`);
414
+ await new Promise((resolve) => setTimeout(resolve, 5));
415
+ }
416
+ }
417
+ function codes(result) {
418
+ return (result.errors ?? []).map((error) => error.extensions?.["code"]);
419
+ }
420
+ function result(response) {
421
+ return {
422
+ ..."data" in response ? { data: response.data } : {},
423
+ ...response.errors === void 0 ? {} : { errors: response.errors }
424
+ };
425
+ }
426
+ function describeOperations(options, driverName, adapter) {
427
+ const { test } = options;
428
+ let app;
429
+ const events = [];
430
+ test.beforeAll(async () => {
431
+ app = await boot(options, adapter);
432
+ app.app.hooks.on("graphql:operation", (event) => {
433
+ events.push({
434
+ driver: event.driver,
435
+ transport: event.transport,
436
+ operationName: event.operationName
437
+ });
438
+ });
439
+ });
440
+ test.afterAll(async () => {
441
+ await app?.close();
442
+ });
443
+ test.it("executes queries, with field resolvers batched through a loader", async () => {
444
+ const response = await executeOperation(app, { query: "{ books { title author { name } } }" });
445
+ strictEqual(response.status, 200);
446
+ deepStrictEqual(result(response), { data: { books: [
447
+ {
448
+ title: "Dune",
449
+ author: { name: "Frank Herbert" }
450
+ },
451
+ {
452
+ title: "Hyperion",
453
+ author: { name: "Dan Simmons" }
454
+ },
455
+ {
456
+ title: "Children of Dune",
457
+ author: { name: "Frank Herbert" }
458
+ }
459
+ ] } });
460
+ deepStrictEqual(observed(app).batches.at(-1), ["herbert", "simmons"]);
461
+ });
462
+ test.it("executes mutations behind a field guard reading a request-scoped provider", async () => {
463
+ const denied = await executeOperation(app, { query: "mutation { addBook(title: \"Ilium\") { title } }" });
464
+ strictEqual(denied.status, 200);
465
+ deepStrictEqual(denied.data, null);
466
+ deepStrictEqual(codes(denied), ["FORBIDDEN"]);
467
+ const added = await executeOperation(app, {
468
+ query: "mutation Add($title: String!) { addBook(title: $title) { title author { name } } }",
469
+ variables: { title: "Ilium" },
470
+ headers: { "x-user": "editor" }
471
+ });
472
+ deepStrictEqual(result(added), { data: { addBook: {
473
+ title: "Ilium",
474
+ author: { name: "Frank Herbert" }
475
+ } } });
476
+ });
477
+ test.it("creates request-scoped providers once per request", async () => {
478
+ const first = await executeOperation(app, {
479
+ query: "{ a: me { id } b: me { id user } }",
480
+ headers: { "x-user": "ada" }
481
+ });
482
+ const second = await executeOperation(app, { query: "{ me { id } }" });
483
+ ok(first.data !== void 0 && first.data !== null && second.data !== void 0 && second.data !== null);
484
+ strictEqual(first.data.a.id, first.data.b.id);
485
+ strictEqual(first.data.b.user, "ada");
486
+ ok(second.data.me.id > first.data.a.id);
487
+ });
488
+ test.it("turns errors into data with result mappers, and masks unexpected errors", async () => {
489
+ const missing = await executeOperation(app, { query: "{ book(id: \"404\") { __typename ... on NotFound { message } ... on Book { title } } }" });
490
+ deepStrictEqual(result(missing), { data: { book: {
491
+ __typename: "NotFound",
492
+ message: "No book 404"
493
+ } } });
494
+ const secret = await executeOperation(app, { query: "{ secret }" });
495
+ strictEqual(secret.status, 200);
496
+ deepStrictEqual(result(secret), {
497
+ data: { secret: null },
498
+ errors: [{
499
+ message: "Unexpected error.",
500
+ locations: [{
501
+ line: 1,
502
+ column: 3
503
+ }],
504
+ path: ["secret"],
505
+ extensions: { code: "INTERNAL_SERVER_ERROR" }
506
+ }]
507
+ });
508
+ ok(!secret.body.includes("postgres"), "the original error leaked");
509
+ });
510
+ test.it("refuses introspection, documents past the limits and invalid documents (400)", async () => {
511
+ for (const [query, code] of [
512
+ ["{ __schema { queryType { name } } }", "GRAPHQL_VALIDATION_FAILED"],
513
+ ["{ a: me { id } b: me { id } c: me { id } d: me { id } }", "QUERY_TOO_COMPLEX"],
514
+ ["{ books { author { name } author2: author { name } } }", void 0],
515
+ ["{ nope }", "GRAPHQL_VALIDATION_FAILED"],
516
+ ["{ books", "GRAPHQL_PARSE_FAILED"]
517
+ ]) {
518
+ const response = await executeOperation(app, { query });
519
+ if (code === void 0) {
520
+ strictEqual(response.status, 200, query);
521
+ continue;
522
+ }
523
+ strictEqual(response.status, 400, query);
524
+ strictEqual(response.data, void 0, query);
525
+ deepStrictEqual([...new Set(codes(response))], [code], query);
526
+ }
527
+ });
528
+ test.it("runs behind the HTTP guards of the endpoint", async () => {
529
+ const blocked = await executeOperation(app, {
530
+ query: "{ books { title } }",
531
+ headers: { "x-block": "yes" }
532
+ });
533
+ strictEqual(blocked.status, 403);
534
+ strictEqual(blocked.data, void 0);
535
+ });
536
+ test.it("reports every executed operation to the graphql:operation hook, with the driver", async () => {
537
+ events.length = 0;
538
+ await executeOperation(app, { query: "query Named { books { id } }" });
539
+ deepStrictEqual(events, [{
540
+ driver: driverName,
541
+ transport: "http",
542
+ operationName: "Named"
543
+ }]);
544
+ });
545
+ test.it("refuses POST bodies other than JSON (415): a cross-site form never runs an operation", async () => {
546
+ const mutation = "mutation { addBook(title: \"Forged\") { id } }";
547
+ const before = await executeOperation(app, { query: "{ books { id } }" });
548
+ for (const [type, body] of [
549
+ ["application/x-www-form-urlencoded", `query=${encodeURIComponent(mutation)}`],
550
+ ["multipart/form-data; boundary=x", `--x\r\ncontent-disposition: form-data; name="query"\r\n\r\n${mutation}\r\n--x--\r\n`],
551
+ ["text/plain", JSON.stringify({ query: mutation })],
552
+ ["application/graphql", mutation]
553
+ ]) {
554
+ const response = await app.inject({
555
+ method: "POST",
556
+ url: "/graphql",
557
+ headers: {
558
+ "content-type": type,
559
+ "x-user": "editor"
560
+ },
561
+ body
562
+ });
563
+ strictEqual(response.status, 415, type);
564
+ deepStrictEqual(response.json(), { errors: [{
565
+ message: "Send GraphQL requests as application/json (content-type: application/json)",
566
+ extensions: { code: "BAD_REQUEST" }
567
+ }] }, type);
568
+ }
569
+ const after = await executeOperation(app, { query: "{ books { id } }" });
570
+ strictEqual(after.data?.books.length, before.data?.books.length);
571
+ });
572
+ test.it("refuses mutations over GET (405, allow: POST)", async () => {
573
+ const before = await executeOperation(app, { query: "{ books { id } }" });
574
+ const response = await app.inject({
575
+ method: "GET",
576
+ url: "/graphql",
577
+ query: { query: "mutation { addBook(title: \"Fetched\") { id } }" },
578
+ headers: { "x-user": "editor" }
579
+ });
580
+ strictEqual(response.status, 405);
581
+ strictEqual(response.headers["allow"], "POST");
582
+ const after = await executeOperation(app, { query: "{ books { id } }" });
583
+ strictEqual(after.data?.books.length, before.data?.books.length);
584
+ });
585
+ }
586
+ function describeSubscriptions(options, adapter) {
587
+ const { test } = options;
588
+ let app;
589
+ test.beforeAll(async () => {
590
+ app = await boot(options, adapter);
591
+ });
592
+ test.afterAll(async () => {
593
+ await app?.close();
594
+ });
595
+ test.it("streams the events of a subscription until the client leaves", async () => {
596
+ const events = await subscribeOperation(app, { query: "subscription { bookAdded { title } }" });
597
+ strictEqual(events.status, 200);
598
+ await executeOperation(app, {
599
+ query: "mutation { addBook(title: \"Endymion\") { id } }",
600
+ headers: { "x-user": "editor" }
601
+ });
602
+ deepStrictEqual((await events.next()).value, { data: { bookAdded: { title: "Endymion" } } });
603
+ await events.return();
604
+ await until(() => graphql(app).activeSubscriptions === 0, "the end of the subscription");
605
+ });
606
+ test.it("completes a finite subscription", async () => {
607
+ const events = await subscribeOperation(app, { query: "subscription { countdown(from: 2) }" });
608
+ deepStrictEqual(await drain(events), [{ data: { countdown: 2 } }, { data: { countdown: 1 } }]);
609
+ });
610
+ test.it("ends the source when the client aborts, and caps the active subscriptions", async () => {
611
+ const ended = observed(app).ended;
612
+ ended.length = 0;
613
+ const first = await subscribeOperation(app, { query: "subscription { forever }" });
614
+ const second = await subscribeOperation(app, { query: "subscription { forever }" });
615
+ deepStrictEqual((await first.next()).value, { data: { forever: 1 } });
616
+ deepStrictEqual((await second.next()).value, { data: { forever: 1 } });
617
+ const third = await subscribeOperation(app, { query: "subscription { forever }" });
618
+ const refused = await third.next();
619
+ deepStrictEqual(codes(refused.value ?? {}), ["SERVICE_UNAVAILABLE"]);
620
+ await third.return();
621
+ await first.return();
622
+ await second.return();
623
+ await until(() => ended.length === 2, "the end of both sources");
624
+ await until(() => graphql(app).activeSubscriptions === 0, "the end of the subscriptions");
625
+ });
626
+ for (const graphqlFirst of [false, true]) {
627
+ const order = graphqlFirst ? "GraphQLPlugin before HttpPlugin" : "HttpPlugin before GraphQLPlugin";
628
+ test.it(`ends the subscriptions when the application stops, without waiting for the drain (${order})`, async () => {
629
+ const stopping = await boot(options, adapter, graphqlFirst);
630
+ const ended = observed(stopping).ended;
631
+ const events = await subscribeOperation(stopping, { query: "subscription { forever }" });
632
+ deepStrictEqual((await events.next()).value, { data: { forever: 1 } });
633
+ const rest = drain(events);
634
+ const started = Date.now();
635
+ await stopping.close();
636
+ const elapsed = Date.now() - started;
637
+ ok(elapsed < DRAIN_TIMEOUT_MS / 2, `the shutdown waited ${elapsed} ms for the drain`);
638
+ await until(() => ended.includes("forever"), "the end of the source on shutdown");
639
+ await rest;
640
+ strictEqual(graphql(stopping).activeSubscriptions, 0);
641
+ });
642
+ }
643
+ }
644
+ async function drain(events) {
645
+ const results = [];
646
+ for (let step = await events.next(); step.done !== true; step = await events.next()) results.push(step.value);
647
+ return results;
648
+ }
4
649
  // src/testing.ts
5
650
  const DEFAULT_TIMEOUT_MS = 2e3;
6
651
  const RESPONSE_ACCEPT = "application/graphql-response+json, application/json;q=0.9";
@@ -301,6 +946,6 @@ var SingleResult = class {
301
946
  return Promise.resolve();
302
947
  }
303
948
  };
304
- export { executeOperation, fakeLoader, subscribeOperation };
949
+ export { describeDriverConformance, executeOperation, fakeLoader, subscribeOperation };
305
950
 
306
951
  //# sourceMappingURL=testing.mjs.map