@chidchanun/bcp 0.2.13 → 0.2.15

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.
@@ -0,0 +1,1884 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+ import {
5
+ isDeepStrictEqual,
6
+ } from "node:util";
7
+
8
+ import type {
9
+ AuthSessionStore,
10
+ } from "./auth-session-store.js";
11
+ import type {
12
+ AuthUser,
13
+ } from "./auth.js";
14
+ import type {
15
+ BackgroundJobQueue,
16
+ JobRecord,
17
+ JobState,
18
+ } from "./jobs.js";
19
+ import {
20
+ executeMiddlewarePipeline,
21
+ type MiddlewareExecutionInput,
22
+ type MiddlewareModule,
23
+ } from "./middleware.js";
24
+ import type {
25
+ OutboxDispatcher,
26
+ OutboxEventRecord,
27
+ OutboxEventState,
28
+ OutboxStats,
29
+ OutboxStore,
30
+ } from "./events.js";
31
+ import type {
32
+ RealtimeConnectOptions,
33
+ RealtimeConnection,
34
+ RealtimeEnvelope,
35
+ RealtimeHub,
36
+ RealtimeSocket,
37
+ } from "./realtime.js";
38
+ import {
39
+ createSessionToken,
40
+ type SessionTokenOptions,
41
+ } from "./session.js";
42
+ import type {
43
+ Workflow,
44
+ WorkflowRunRecord,
45
+ WorkflowRunState,
46
+ } from "./workflow.js";
47
+ import type {
48
+ TransactionDatabase,
49
+ } from "../../client/src/database.js";
50
+
51
+ export type TestRequestHandler = (
52
+ request: Request
53
+ ) => Response | Promise<Response>;
54
+
55
+ export interface TestRequestOptions {
56
+ method?: string;
57
+ headers?: HeadersInit;
58
+ body?: BodyInit | null;
59
+ json?: unknown;
60
+ cookies?: Record<string, string>;
61
+ signal?: AbortSignal;
62
+ }
63
+
64
+ export interface TestAppOptions {
65
+ handler: TestRequestHandler;
66
+ baseUrl?: string;
67
+ headers?: HeadersInit;
68
+ cookies?: Record<string, string>;
69
+ }
70
+
71
+ export interface TestApp {
72
+ readonly baseUrl: string;
73
+ request(
74
+ path: string,
75
+ options?: TestRequestOptions
76
+ ): Promise<Response>;
77
+ get(
78
+ path: string,
79
+ options?: Omit<TestRequestOptions, "method">
80
+ ): Promise<Response>;
81
+ post(
82
+ path: string,
83
+ options?: Omit<TestRequestOptions, "method">
84
+ ): Promise<Response>;
85
+ put(
86
+ path: string,
87
+ options?: Omit<TestRequestOptions, "method">
88
+ ): Promise<Response>;
89
+ patch(
90
+ path: string,
91
+ options?: Omit<TestRequestOptions, "method">
92
+ ): Promise<Response>;
93
+ delete(
94
+ path: string,
95
+ options?: Omit<TestRequestOptions, "method">
96
+ ): Promise<Response>;
97
+ setCookie(name: string, value: string): void;
98
+ clearCookie(name: string): void;
99
+ cookies(): Record<string, string>;
100
+ setHeader(name: string, value: string): void;
101
+ deleteHeader(name: string): void;
102
+ headers(): Headers;
103
+ reset(): void;
104
+ }
105
+
106
+ export type TestRouteMethod =
107
+ | "GET"
108
+ | "POST"
109
+ | "PUT"
110
+ | "PATCH"
111
+ | "DELETE"
112
+ | "HEAD"
113
+ | "OPTIONS";
114
+
115
+ export type TestRouteFunction = (
116
+ request: Request,
117
+ context?: unknown
118
+ ) => unknown | Promise<unknown>;
119
+
120
+ export type TestRouteModule =
121
+ Partial<Record<TestRouteMethod, TestRouteFunction>>;
122
+
123
+ export interface TestRouteHandlerOptions {
124
+ context?: (
125
+ request: Request
126
+ ) => unknown | Promise<unknown>;
127
+ }
128
+
129
+ export interface TestResponseExpectation {
130
+ readonly response: Response;
131
+ status(expected: number): TestResponseExpectation;
132
+ header(
133
+ name: string,
134
+ expected: string | RegExp
135
+ ): TestResponseExpectation;
136
+ text(expected: string | RegExp): Promise<TestResponseExpectation>;
137
+ json(expected: unknown): Promise<TestResponseExpectation>;
138
+ jsonMatches(
139
+ expected: Record<string, unknown>
140
+ ): Promise<TestResponseExpectation>;
141
+ }
142
+
143
+ export interface FakeClock {
144
+ now(): number;
145
+ value(): number;
146
+ set(value: number): number;
147
+ advance(milliseconds: number): number;
148
+ reset(): number;
149
+ }
150
+
151
+ export interface CreateTestAuthSessionOptions<TData extends object = Record<string, never>>
152
+ extends SessionTokenOptions {
153
+ cookieName?: string;
154
+ sid?: string;
155
+ data?: TData;
156
+ store?: AuthSessionStore;
157
+ idFactory?: () => string;
158
+ }
159
+
160
+ export interface TestAuthSession<TUser extends AuthUser, TData extends object> {
161
+ sid: string;
162
+ user: TUser;
163
+ data?: TData;
164
+ token: string;
165
+ cookieName: string;
166
+ cookieHeader: string;
167
+ createdAt: number;
168
+ expiresAt: number;
169
+ }
170
+
171
+ export interface TestTransactionDatabase {
172
+ transaction<T>(
173
+ callback: (
174
+ database: TransactionDatabase
175
+ ) => Promise<T>
176
+ ): Promise<T>;
177
+ }
178
+
179
+ export interface JobTestHarness {
180
+ readonly queue: BackgroundJobQueue;
181
+ drain(options?: {
182
+ maxJobs?: number;
183
+ signal?: AbortSignal;
184
+ }): Promise<number>;
185
+ records(name?: string): Promise<JobRecord[]>;
186
+ count(options?: {
187
+ name?: string;
188
+ state?: JobState;
189
+ }): Promise<number>;
190
+ expectCount(
191
+ expected: number,
192
+ options?: {
193
+ name?: string;
194
+ state?: JobState;
195
+ }
196
+ ): Promise<void>;
197
+ }
198
+
199
+ export interface WorkflowTestHarness<TInput = unknown> {
200
+ readonly workflow: Workflow<TInput>;
201
+ startAndRun(
202
+ input: TInput,
203
+ options?: {
204
+ id?: string;
205
+ forceWaiting?: boolean;
206
+ maxPasses?: number;
207
+ }
208
+ ): Promise<WorkflowRunRecord<TInput>>;
209
+ runUntilIdle(
210
+ id: string,
211
+ options?: {
212
+ forceWaiting?: boolean;
213
+ maxPasses?: number;
214
+ }
215
+ ): Promise<WorkflowRunRecord<TInput>>;
216
+ expectState(
217
+ id: string,
218
+ expected: WorkflowRunState
219
+ ): Promise<WorkflowRunRecord<TInput>>;
220
+ }
221
+
222
+ export interface OutboxTestHarness {
223
+ readonly store: OutboxStore;
224
+ readonly dispatcher: OutboxDispatcher;
225
+ dispatchUntilIdle(
226
+ maxBatches?: number
227
+ ): Promise<number>;
228
+ events(type?: string): Promise<OutboxEventRecord[]>;
229
+ stats(): Promise<OutboxStats>;
230
+ expectState(
231
+ id: string,
232
+ expected: OutboxEventState
233
+ ): Promise<OutboxEventRecord>;
234
+ }
235
+
236
+ export interface RealtimeTestSocket
237
+ extends RealtimeSocket {
238
+ readonly closed: boolean;
239
+ readonly closeCode: number | undefined;
240
+ readonly closeReason: string | undefined;
241
+ receive(
242
+ message: string | Record<string, unknown>
243
+ ): Promise<void>;
244
+ closeFromClient(
245
+ code?: number,
246
+ reason?: string
247
+ ): Promise<void>;
248
+ fail(error: unknown): Promise<void>;
249
+ sent(): string[];
250
+ messages<T = unknown>(): T[];
251
+ clear(): void;
252
+ }
253
+
254
+ export interface RealtimeTestConnection<TUser = unknown> {
255
+ socket: RealtimeTestSocket;
256
+ connection: RealtimeConnection<TUser>;
257
+ }
258
+
259
+ export interface RealtimeTestHarness<TUser = unknown> {
260
+ readonly hub: RealtimeHub<TUser>;
261
+ connect<TData = unknown>(
262
+ options?: Omit<
263
+ RealtimeConnectOptions<TUser, TData>,
264
+ "socket"
265
+ >
266
+ ): Promise<RealtimeTestConnection<TUser>>;
267
+ expectEvent<TPayload = unknown>(
268
+ socket: RealtimeTestSocket,
269
+ event: string,
270
+ channel?: string
271
+ ): RealtimeEnvelope<TPayload>;
272
+ }
273
+
274
+ export interface ParsedSseEvent<TData = unknown> {
275
+ id?: string;
276
+ event?: string;
277
+ data?: TData;
278
+ rawData?: string;
279
+ }
280
+
281
+ export function createTestApp(
282
+ options: TestAppOptions
283
+ ): TestApp {
284
+ if (
285
+ !options ||
286
+ typeof options.handler !== "function"
287
+ ) {
288
+ throw new TypeError(
289
+ "BCP Testing: createTestApp requires a request handler."
290
+ );
291
+ }
292
+
293
+ const baseUrl =
294
+ normalizeBaseUrl(
295
+ options.baseUrl ?? "http://bcp.test"
296
+ );
297
+ const initialHeaders =
298
+ new Headers(
299
+ options.headers
300
+ );
301
+ const defaultHeaders =
302
+ new Headers(
303
+ initialHeaders
304
+ );
305
+ const initialCookies = {
306
+ ...(options.cookies ?? {}),
307
+ };
308
+ const cookieJar =
309
+ new Map<string, string>(
310
+ Object.entries(initialCookies)
311
+ );
312
+
313
+ const app: TestApp = {
314
+ baseUrl,
315
+
316
+ async request(path, requestOptions = {}) {
317
+ const method =
318
+ normalizeMethod(
319
+ requestOptions.method ?? "GET"
320
+ );
321
+ const headers =
322
+ new Headers(
323
+ defaultHeaders
324
+ );
325
+
326
+ for (
327
+ const [name, value]
328
+ of new Headers(
329
+ requestOptions.headers
330
+ )
331
+ ) {
332
+ headers.set(name, value);
333
+ }
334
+
335
+ const requestCookies =
336
+ new Map(cookieJar);
337
+
338
+ for (
339
+ const [name, value]
340
+ of Object.entries(
341
+ requestOptions.cookies ?? {}
342
+ )
343
+ ) {
344
+ requestCookies.set(name, value);
345
+ }
346
+
347
+ if (requestCookies.size > 0) {
348
+ headers.set(
349
+ "Cookie",
350
+ serializeCookies(
351
+ requestCookies
352
+ )
353
+ );
354
+ }
355
+
356
+ let body =
357
+ requestOptions.body ?? null;
358
+
359
+ if (
360
+ requestOptions.json !== undefined
361
+ ) {
362
+ if (
363
+ requestOptions.body !== undefined &&
364
+ requestOptions.body !== null
365
+ ) {
366
+ throw new TypeError(
367
+ "BCP Testing: request cannot specify both body and json."
368
+ );
369
+ }
370
+ body =
371
+ JSON.stringify(
372
+ requestOptions.json
373
+ );
374
+ if (!headers.has("Content-Type")) {
375
+ headers.set(
376
+ "Content-Type",
377
+ "application/json"
378
+ );
379
+ }
380
+ }
381
+
382
+ if (
383
+ method === "GET" ||
384
+ method === "HEAD"
385
+ ) {
386
+ body = null;
387
+ }
388
+
389
+ const request =
390
+ new Request(
391
+ resolveTestUrl(
392
+ baseUrl,
393
+ path
394
+ ),
395
+ {
396
+ method,
397
+ headers,
398
+ body,
399
+ signal:
400
+ requestOptions.signal,
401
+ }
402
+ );
403
+ const response =
404
+ await options.handler(
405
+ request
406
+ );
407
+
408
+ if (!(response instanceof Response)) {
409
+ throw new TypeError(
410
+ "BCP Testing: request handler must return a Response."
411
+ );
412
+ }
413
+
414
+ updateCookieJar(
415
+ cookieJar,
416
+ response.headers
417
+ );
418
+
419
+ return response;
420
+ },
421
+
422
+ get(path, requestOptions = {}) {
423
+ return app.request(
424
+ path,
425
+ {
426
+ ...requestOptions,
427
+ method: "GET",
428
+ }
429
+ );
430
+ },
431
+
432
+ post(path, requestOptions = {}) {
433
+ return app.request(
434
+ path,
435
+ {
436
+ ...requestOptions,
437
+ method: "POST",
438
+ }
439
+ );
440
+ },
441
+
442
+ put(path, requestOptions = {}) {
443
+ return app.request(
444
+ path,
445
+ {
446
+ ...requestOptions,
447
+ method: "PUT",
448
+ }
449
+ );
450
+ },
451
+
452
+ patch(path, requestOptions = {}) {
453
+ return app.request(
454
+ path,
455
+ {
456
+ ...requestOptions,
457
+ method: "PATCH",
458
+ }
459
+ );
460
+ },
461
+
462
+ delete(path, requestOptions = {}) {
463
+ return app.request(
464
+ path,
465
+ {
466
+ ...requestOptions,
467
+ method: "DELETE",
468
+ }
469
+ );
470
+ },
471
+
472
+ setCookie(name, value) {
473
+ cookieJar.set(
474
+ normalizeCookieName(name),
475
+ String(value)
476
+ );
477
+ },
478
+
479
+ clearCookie(name) {
480
+ cookieJar.delete(
481
+ normalizeCookieName(name)
482
+ );
483
+ },
484
+
485
+ cookies() {
486
+ return Object.fromEntries(
487
+ cookieJar
488
+ );
489
+ },
490
+
491
+ setHeader(name, value) {
492
+ defaultHeaders.set(
493
+ normalizeHeaderName(name),
494
+ String(value)
495
+ );
496
+ },
497
+
498
+ deleteHeader(name) {
499
+ defaultHeaders.delete(
500
+ normalizeHeaderName(name)
501
+ );
502
+ },
503
+
504
+ headers() {
505
+ return new Headers(
506
+ defaultHeaders
507
+ );
508
+ },
509
+
510
+ reset() {
511
+ cookieJar.clear();
512
+ for (
513
+ const [name, value]
514
+ of Object.entries(
515
+ initialCookies
516
+ )
517
+ ) {
518
+ cookieJar.set(name, value);
519
+ }
520
+ for (const name of [...defaultHeaders.keys()]) {
521
+ defaultHeaders.delete(name);
522
+ }
523
+ for (const [name, value] of initialHeaders) {
524
+ defaultHeaders.set(name, value);
525
+ }
526
+ },
527
+ };
528
+
529
+ return app;
530
+ }
531
+
532
+ export function createRouteTestHandler(
533
+ routeModule: TestRouteModule,
534
+ options: TestRouteHandlerOptions = {}
535
+ ): TestRequestHandler {
536
+ if (!routeModule || typeof routeModule !== "object") {
537
+ throw new TypeError(
538
+ "BCP Testing: route module must be an object."
539
+ );
540
+ }
541
+
542
+ return async request => {
543
+ const method =
544
+ normalizeMethod(
545
+ request.method
546
+ ) as TestRouteMethod;
547
+ const handler =
548
+ routeModule[method] ??
549
+ (
550
+ method === "HEAD"
551
+ ? routeModule.GET
552
+ : undefined
553
+ );
554
+
555
+ if (!handler) {
556
+ const allow =
557
+ knownRouteMethods
558
+ .filter(candidate =>
559
+ typeof routeModule[candidate] ===
560
+ "function"
561
+ )
562
+ .join(", ");
563
+ return new Response(
564
+ null,
565
+ {
566
+ status: 405,
567
+ headers:
568
+ allow
569
+ ? {
570
+ Allow: allow,
571
+ }
572
+ : undefined,
573
+ }
574
+ );
575
+ }
576
+
577
+ const context =
578
+ options.context
579
+ ? await options.context(
580
+ request
581
+ )
582
+ : undefined;
583
+ const value =
584
+ await handler(
585
+ request,
586
+ context
587
+ );
588
+
589
+ return normalizeRouteResponse(
590
+ value,
591
+ method
592
+ );
593
+ };
594
+ }
595
+
596
+ export function expectResponse(
597
+ response: Response
598
+ ): TestResponseExpectation {
599
+ if (!(response instanceof Response)) {
600
+ throw new TypeError(
601
+ "BCP Testing: expectResponse requires a Response."
602
+ );
603
+ }
604
+
605
+ const expectation:
606
+ TestResponseExpectation = {
607
+ response,
608
+
609
+ status(expected) {
610
+ if (response.status !== expected) {
611
+ throw new Error(
612
+ `BCP Testing: expected response status ${expected}, received ${response.status}.`
613
+ );
614
+ }
615
+ return expectation;
616
+ },
617
+
618
+ header(name, expected) {
619
+ const actual =
620
+ response.headers.get(name);
621
+ if (
622
+ typeof expected === "string"
623
+ ? actual !== expected
624
+ : !expected.test(
625
+ actual ?? ""
626
+ )
627
+ ) {
628
+ throw new Error(
629
+ `BCP Testing: response header "${name}" did not match. Received ${JSON.stringify(actual)}.`
630
+ );
631
+ }
632
+ return expectation;
633
+ },
634
+
635
+ async text(expected) {
636
+ const actual =
637
+ await response.clone().text();
638
+ if (
639
+ typeof expected === "string"
640
+ ? actual !== expected
641
+ : !expected.test(actual)
642
+ ) {
643
+ throw new Error(
644
+ `BCP Testing: response text did not match. Received ${JSON.stringify(actual)}.`
645
+ );
646
+ }
647
+ return expectation;
648
+ },
649
+
650
+ async json(expected) {
651
+ const actual =
652
+ await response.clone().json();
653
+ if (!isDeepStrictEqual(actual, expected)) {
654
+ throw new Error(
655
+ `BCP Testing: response JSON did not match. Expected ${safeJson(expected)}, received ${safeJson(actual)}.`
656
+ );
657
+ }
658
+ return expectation;
659
+ },
660
+
661
+ async jsonMatches(expected) {
662
+ const actual =
663
+ await response.clone().json();
664
+ if (
665
+ !actual ||
666
+ typeof actual !== "object" ||
667
+ Array.isArray(actual)
668
+ ) {
669
+ throw new Error(
670
+ "BCP Testing: response JSON is not an object."
671
+ );
672
+ }
673
+ for (
674
+ const [key, value]
675
+ of Object.entries(expected)
676
+ ) {
677
+ if (
678
+ !isDeepStrictEqual(
679
+ (actual as Record<string, unknown>)[key],
680
+ value
681
+ )
682
+ ) {
683
+ throw new Error(
684
+ `BCP Testing: response JSON key "${key}" did not match.`
685
+ );
686
+ }
687
+ }
688
+ return expectation;
689
+ },
690
+ };
691
+
692
+ return expectation;
693
+ }
694
+
695
+ export function createFakeClock(
696
+ initialValue = 0
697
+ ): FakeClock {
698
+ assertFinite(
699
+ initialValue,
700
+ "fake clock initial value"
701
+ );
702
+ let current =
703
+ initialValue;
704
+
705
+ return {
706
+ now() {
707
+ return current;
708
+ },
709
+ value() {
710
+ return current;
711
+ },
712
+ set(value) {
713
+ assertFinite(
714
+ value,
715
+ "fake clock value"
716
+ );
717
+ current = value;
718
+ return current;
719
+ },
720
+ advance(milliseconds) {
721
+ assertFinite(
722
+ milliseconds,
723
+ "fake clock advance"
724
+ );
725
+ current += milliseconds;
726
+ return current;
727
+ },
728
+ reset() {
729
+ current = initialValue;
730
+ return current;
731
+ },
732
+ };
733
+ }
734
+
735
+ export function createSequenceIdFactory(
736
+ prefix = "test",
737
+ start = 1
738
+ ): () => string {
739
+ const normalizedPrefix =
740
+ String(prefix).trim();
741
+ if (!normalizedPrefix) {
742
+ throw new TypeError(
743
+ "BCP Testing: id prefix must be a non-empty string."
744
+ );
745
+ }
746
+ if (!Number.isInteger(start) || start < 0) {
747
+ throw new TypeError(
748
+ "BCP Testing: id sequence start must be a non-negative integer."
749
+ );
750
+ }
751
+ let sequence = start;
752
+
753
+ return () =>
754
+ `${normalizedPrefix}-${sequence++}`;
755
+ }
756
+
757
+ export async function createTestAuthSession<
758
+ TUser extends AuthUser,
759
+ TData extends object = Record<string, never>
760
+ >(
761
+ user: TUser,
762
+ options:
763
+ CreateTestAuthSessionOptions<TData> = {}
764
+ ): Promise<TestAuthSession<TUser, TData>> {
765
+ if (
766
+ !user ||
767
+ (
768
+ typeof user.id !== "string" &&
769
+ typeof user.id !== "number"
770
+ )
771
+ ) {
772
+ throw new TypeError(
773
+ "BCP Testing: auth user requires a string or number id."
774
+ );
775
+ }
776
+
777
+ const idFactory =
778
+ options.idFactory ?? randomUUID;
779
+ const sid =
780
+ normalizeNonEmpty(
781
+ options.sid ?? idFactory(),
782
+ "session id"
783
+ );
784
+ const cookieName =
785
+ normalizeCookieName(
786
+ options.cookieName ?? "bcp_session"
787
+ );
788
+ const expiresIn =
789
+ positiveInteger(
790
+ options.expiresIn ?? 60 * 60 * 12,
791
+ "session expiresIn"
792
+ );
793
+ const createdAt =
794
+ Math.floor(
795
+ Date.now() / 1000
796
+ );
797
+ const expiresAt =
798
+ createdAt + expiresIn;
799
+ const payload = {
800
+ sid,
801
+ user,
802
+ ...(options.data === undefined
803
+ ? {}
804
+ : {
805
+ data: options.data,
806
+ }),
807
+ };
808
+ const token =
809
+ await createSessionToken(
810
+ payload,
811
+ {
812
+ secret:
813
+ options.secret,
814
+ expiresIn,
815
+ issuer:
816
+ options.issuer,
817
+ audience:
818
+ options.audience,
819
+ }
820
+ );
821
+
822
+ if (options.store) {
823
+ await options.store.set({
824
+ sid,
825
+ userId:
826
+ String(user.id),
827
+ createdAt,
828
+ expiresAt,
829
+ lastSeenAt:
830
+ createdAt,
831
+ });
832
+ }
833
+
834
+ return {
835
+ sid,
836
+ user,
837
+ ...(options.data === undefined
838
+ ? {}
839
+ : {
840
+ data: options.data,
841
+ }),
842
+ token,
843
+ cookieName,
844
+ cookieHeader:
845
+ `${cookieName}=${token}`,
846
+ createdAt,
847
+ expiresAt,
848
+ };
849
+ }
850
+
851
+ export async function withTestTransaction<T>(
852
+ database: TestTransactionDatabase,
853
+ callback: (
854
+ transaction: TransactionDatabase
855
+ ) => Promise<T>
856
+ ): Promise<T> {
857
+ if (
858
+ !database ||
859
+ typeof database.transaction !== "function"
860
+ ) {
861
+ throw new TypeError(
862
+ "BCP Testing: withTestTransaction requires a transactional database."
863
+ );
864
+ }
865
+ if (typeof callback !== "function") {
866
+ throw new TypeError(
867
+ "BCP Testing: transaction callback must be a function."
868
+ );
869
+ }
870
+
871
+ const marker =
872
+ Symbol("bcp-test-rollback");
873
+ let completed = false;
874
+ let value:
875
+ T | undefined;
876
+
877
+ try {
878
+ await database.transaction(
879
+ async transaction => {
880
+ value =
881
+ await callback(
882
+ transaction
883
+ );
884
+ completed = true;
885
+ throw new TestRollbackSignal(
886
+ marker
887
+ );
888
+ }
889
+ );
890
+ } catch (error) {
891
+ if (
892
+ !(error instanceof TestRollbackSignal) ||
893
+ error.marker !== marker
894
+ ) {
895
+ throw error;
896
+ }
897
+ }
898
+
899
+ if (!completed) {
900
+ throw new Error(
901
+ "BCP Testing: rollback transaction did not complete the test callback."
902
+ );
903
+ }
904
+
905
+ return value as T;
906
+ }
907
+
908
+ export async function runTestMiddleware(
909
+ module: MiddlewareModule | null | undefined,
910
+ input: MiddlewareExecutionInput,
911
+ downstream?: (
912
+ request: Request
913
+ ) => Response | Promise<Response>
914
+ ): Promise<Response> {
915
+ return executeMiddlewarePipeline(
916
+ module,
917
+ input,
918
+ async execution => {
919
+ const request =
920
+ new Request(
921
+ execution.url,
922
+ {
923
+ method:
924
+ execution.method ?? "GET",
925
+ headers:
926
+ execution.headers,
927
+ }
928
+ );
929
+ return downstream
930
+ ? downstream(request)
931
+ : new Response(
932
+ null,
933
+ {
934
+ status: 204,
935
+ }
936
+ );
937
+ }
938
+ );
939
+ }
940
+
941
+ export function createJobTestHarness(
942
+ queue: BackgroundJobQueue
943
+ ): JobTestHarness {
944
+ if (!queue) {
945
+ throw new TypeError(
946
+ "BCP Testing: job harness requires a queue."
947
+ );
948
+ }
949
+
950
+ const harness: JobTestHarness = {
951
+ queue,
952
+
953
+ async drain(options = {}) {
954
+ const maxJobs =
955
+ positiveInteger(
956
+ options.maxJobs ?? 1_000,
957
+ "maxJobs"
958
+ );
959
+ let processed = 0;
960
+
961
+ while (
962
+ processed < maxJobs &&
963
+ !options.signal?.aborted
964
+ ) {
965
+ const didProcess =
966
+ await queue.processNext(
967
+ options.signal
968
+ );
969
+ if (!didProcess) {
970
+ break;
971
+ }
972
+ processed += 1;
973
+ }
974
+
975
+ return processed;
976
+ },
977
+
978
+ async records(name) {
979
+ const records =
980
+ await queue.list();
981
+ return name
982
+ ? records.filter(
983
+ record =>
984
+ record.name === name
985
+ )
986
+ : records;
987
+ },
988
+
989
+ async count(options = {}) {
990
+ const records =
991
+ await harness.records(
992
+ options.name
993
+ );
994
+ return records.filter(
995
+ record =>
996
+ !options.state ||
997
+ record.state === options.state
998
+ ).length;
999
+ },
1000
+
1001
+ async expectCount(
1002
+ expected,
1003
+ options = {}
1004
+ ) {
1005
+ const actual =
1006
+ await harness.count(options);
1007
+ if (actual !== expected) {
1008
+ throw new Error(
1009
+ `BCP Testing: expected ${expected} jobs, received ${actual}.`
1010
+ );
1011
+ }
1012
+ },
1013
+ };
1014
+
1015
+ return harness;
1016
+ }
1017
+
1018
+ export function createWorkflowTestHarness<TInput>(
1019
+ workflow: Workflow<TInput>
1020
+ ): WorkflowTestHarness<TInput> {
1021
+ if (!workflow) {
1022
+ throw new TypeError(
1023
+ "BCP Testing: workflow harness requires a workflow."
1024
+ );
1025
+ }
1026
+
1027
+ const harness:
1028
+ WorkflowTestHarness<TInput> = {
1029
+ workflow,
1030
+
1031
+ async startAndRun(
1032
+ input,
1033
+ options = {}
1034
+ ) {
1035
+ const run =
1036
+ await workflow.start(
1037
+ input,
1038
+ {
1039
+ id: options.id,
1040
+ }
1041
+ );
1042
+ return harness.runUntilIdle(
1043
+ run.id,
1044
+ options
1045
+ );
1046
+ },
1047
+
1048
+ async runUntilIdle(
1049
+ id,
1050
+ options = {}
1051
+ ) {
1052
+ const maxPasses =
1053
+ positiveInteger(
1054
+ options.maxPasses ?? 100,
1055
+ "workflow maxPasses"
1056
+ );
1057
+ let run =
1058
+ await requireWorkflowRun(
1059
+ workflow,
1060
+ id
1061
+ );
1062
+
1063
+ for (
1064
+ let pass = 0;
1065
+ pass < maxPasses;
1066
+ pass += 1
1067
+ ) {
1068
+ if (isWorkflowTerminal(run.state)) {
1069
+ return run;
1070
+ }
1071
+
1072
+ if (run.state === "waiting") {
1073
+ if (!options.forceWaiting) {
1074
+ return run;
1075
+ }
1076
+ run =
1077
+ await workflow.resume(
1078
+ id,
1079
+ {
1080
+ force: true,
1081
+ }
1082
+ );
1083
+ continue;
1084
+ }
1085
+
1086
+ run =
1087
+ await workflow.run(id);
1088
+ }
1089
+
1090
+ throw new Error(
1091
+ `BCP Testing: workflow "${id}" did not become idle within ${maxPasses} passes.`
1092
+ );
1093
+ },
1094
+
1095
+ async expectState(
1096
+ id,
1097
+ expected
1098
+ ) {
1099
+ const run =
1100
+ await requireWorkflowRun(
1101
+ workflow,
1102
+ id
1103
+ );
1104
+ if (run.state !== expected) {
1105
+ throw new Error(
1106
+ `BCP Testing: expected workflow "${id}" state ${expected}, received ${run.state}.`
1107
+ );
1108
+ }
1109
+ return run;
1110
+ },
1111
+ };
1112
+
1113
+ return harness;
1114
+ }
1115
+
1116
+ export function createOutboxTestHarness(
1117
+ store: OutboxStore,
1118
+ dispatcher: OutboxDispatcher
1119
+ ): OutboxTestHarness {
1120
+ if (!store || !dispatcher) {
1121
+ throw new TypeError(
1122
+ "BCP Testing: outbox harness requires a store and dispatcher."
1123
+ );
1124
+ }
1125
+
1126
+ return {
1127
+ store,
1128
+ dispatcher,
1129
+
1130
+ async dispatchUntilIdle(
1131
+ maxBatches = 100
1132
+ ) {
1133
+ const limit =
1134
+ positiveInteger(
1135
+ maxBatches,
1136
+ "outbox maxBatches"
1137
+ );
1138
+ let published = 0;
1139
+
1140
+ for (
1141
+ let batch = 0;
1142
+ batch < limit;
1143
+ batch += 1
1144
+ ) {
1145
+ const count =
1146
+ await dispatcher.dispatchBatch();
1147
+ published += count;
1148
+ if (count === 0) {
1149
+ return published;
1150
+ }
1151
+ }
1152
+
1153
+ return published;
1154
+ },
1155
+
1156
+ async events(type) {
1157
+ const events =
1158
+ await store.list();
1159
+ return type
1160
+ ? events.filter(
1161
+ event =>
1162
+ event.type === type
1163
+ )
1164
+ : events;
1165
+ },
1166
+
1167
+ stats() {
1168
+ return store.stats();
1169
+ },
1170
+
1171
+ async expectState(
1172
+ id,
1173
+ expected
1174
+ ) {
1175
+ const event =
1176
+ await store.get(id);
1177
+ if (!event) {
1178
+ throw new Error(
1179
+ `BCP Testing: outbox event "${id}" does not exist.`
1180
+ );
1181
+ }
1182
+ if (event.state !== expected) {
1183
+ throw new Error(
1184
+ `BCP Testing: expected outbox event "${id}" state ${expected}, received ${event.state}.`
1185
+ );
1186
+ }
1187
+ return event;
1188
+ },
1189
+ };
1190
+ }
1191
+
1192
+ export function createRealtimeTestSocket():
1193
+ RealtimeTestSocket {
1194
+ const sentMessages:
1195
+ string[] = [];
1196
+ const messageListeners =
1197
+ new Set<(
1198
+ data: string
1199
+ ) => void | Promise<void>>();
1200
+ const closeListeners =
1201
+ new Set<() => void | Promise<void>>();
1202
+ const errorListeners =
1203
+ new Set<(
1204
+ error: unknown
1205
+ ) => void | Promise<void>>();
1206
+ let closed = false;
1207
+ let closeCode:
1208
+ number | undefined;
1209
+ let closeReason:
1210
+ string | undefined;
1211
+
1212
+ const socket:
1213
+ RealtimeTestSocket = {
1214
+ get closed() {
1215
+ return closed;
1216
+ },
1217
+ get closeCode() {
1218
+ return closeCode;
1219
+ },
1220
+ get closeReason() {
1221
+ return closeReason;
1222
+ },
1223
+
1224
+ send(data) {
1225
+ if (closed) {
1226
+ throw new Error(
1227
+ "BCP Testing: realtime test socket is closed."
1228
+ );
1229
+ }
1230
+ sentMessages.push(
1231
+ String(data)
1232
+ );
1233
+ },
1234
+
1235
+ async close(
1236
+ code = 1000,
1237
+ reason = "server closed"
1238
+ ) {
1239
+ if (closed) {
1240
+ return;
1241
+ }
1242
+ closed = true;
1243
+ closeCode = code;
1244
+ closeReason = reason;
1245
+ await notify(
1246
+ closeListeners,
1247
+ listener => listener()
1248
+ );
1249
+ },
1250
+
1251
+ onMessage(listener) {
1252
+ messageListeners.add(listener);
1253
+ return () => {
1254
+ messageListeners.delete(listener);
1255
+ };
1256
+ },
1257
+
1258
+ onClose(listener) {
1259
+ closeListeners.add(listener);
1260
+ return () => {
1261
+ closeListeners.delete(listener);
1262
+ };
1263
+ },
1264
+
1265
+ onError(listener) {
1266
+ errorListeners.add(listener);
1267
+ return () => {
1268
+ errorListeners.delete(listener);
1269
+ };
1270
+ },
1271
+
1272
+ async receive(message) {
1273
+ if (closed) {
1274
+ throw new Error(
1275
+ "BCP Testing: cannot receive on a closed realtime test socket."
1276
+ );
1277
+ }
1278
+ const raw =
1279
+ typeof message === "string"
1280
+ ? message
1281
+ : JSON.stringify(message);
1282
+ await notify(
1283
+ messageListeners,
1284
+ listener =>
1285
+ listener(raw)
1286
+ );
1287
+ },
1288
+
1289
+ async closeFromClient(
1290
+ code = 1000,
1291
+ reason = "client closed"
1292
+ ) {
1293
+ if (closed) {
1294
+ return;
1295
+ }
1296
+ closed = true;
1297
+ closeCode = code;
1298
+ closeReason = reason;
1299
+ await notify(
1300
+ closeListeners,
1301
+ listener => listener()
1302
+ );
1303
+ },
1304
+
1305
+ async fail(error) {
1306
+ await notify(
1307
+ errorListeners,
1308
+ listener =>
1309
+ listener(error)
1310
+ );
1311
+ },
1312
+
1313
+ sent() {
1314
+ return [
1315
+ ...sentMessages,
1316
+ ];
1317
+ },
1318
+
1319
+ messages<T = unknown>() {
1320
+ return sentMessages.map(
1321
+ message =>
1322
+ JSON.parse(message) as T
1323
+ );
1324
+ },
1325
+
1326
+ clear() {
1327
+ sentMessages.length = 0;
1328
+ },
1329
+ };
1330
+
1331
+ return socket;
1332
+ }
1333
+
1334
+ export function createRealtimeTestHarness<TUser = unknown>(
1335
+ hub: RealtimeHub<TUser>
1336
+ ): RealtimeTestHarness<TUser> {
1337
+ if (!hub) {
1338
+ throw new TypeError(
1339
+ "BCP Testing: realtime harness requires a hub."
1340
+ );
1341
+ }
1342
+
1343
+ const harness:
1344
+ RealtimeTestHarness<TUser> = {
1345
+ hub,
1346
+
1347
+ async connect<TData = unknown>(
1348
+ options: Omit<
1349
+ RealtimeConnectOptions<TUser, TData>,
1350
+ "socket"
1351
+ > = {}
1352
+ ): Promise<RealtimeTestConnection<TUser>> {
1353
+ const socket =
1354
+ createRealtimeTestSocket();
1355
+ const connection =
1356
+ await hub.attachSocket(
1357
+ socket,
1358
+ options
1359
+ );
1360
+ return {
1361
+ socket,
1362
+ connection,
1363
+ };
1364
+ },
1365
+
1366
+ expectEvent<TPayload = unknown>(
1367
+ socket: RealtimeTestSocket,
1368
+ event: string,
1369
+ channel?: string
1370
+ ): RealtimeEnvelope<TPayload> {
1371
+ const normalizedEvent =
1372
+ normalizeNonEmpty(
1373
+ event,
1374
+ "realtime event"
1375
+ );
1376
+ const messages =
1377
+ socket.messages<
1378
+ {
1379
+ type?: string;
1380
+ id?: string;
1381
+ channel?: string;
1382
+ event?: string;
1383
+ payload?: unknown;
1384
+ timestamp?: number;
1385
+ sourceId?: string;
1386
+ excludeConnectionId?: string;
1387
+ }
1388
+ >();
1389
+ const found =
1390
+ [...messages]
1391
+ .reverse()
1392
+ .find(message =>
1393
+ message.type === "event" &&
1394
+ message.event === normalizedEvent &&
1395
+ (
1396
+ channel === undefined ||
1397
+ message.channel === channel
1398
+ )
1399
+ );
1400
+
1401
+ if (
1402
+ !found ||
1403
+ !found.id ||
1404
+ !found.channel ||
1405
+ found.timestamp === undefined
1406
+ ) {
1407
+ throw new Error(
1408
+ `BCP Testing: realtime event "${normalizedEvent}" was not sent.`
1409
+ );
1410
+ }
1411
+
1412
+ return {
1413
+ id: found.id,
1414
+ channel: found.channel,
1415
+ event: normalizedEvent,
1416
+ payload:
1417
+ found.payload as TPayload,
1418
+ timestamp: found.timestamp,
1419
+ sourceId: found.sourceId,
1420
+ excludeConnectionId:
1421
+ found.excludeConnectionId,
1422
+ };
1423
+ },
1424
+ };
1425
+
1426
+ return harness;
1427
+ }
1428
+
1429
+ export async function readSseEvents<TData = unknown>(
1430
+ response: Response,
1431
+ options: {
1432
+ limit?: number;
1433
+ timeoutMs?: number;
1434
+ } = {}
1435
+ ): Promise<ParsedSseEvent<TData>[]> {
1436
+ if (!(response instanceof Response)) {
1437
+ throw new TypeError(
1438
+ "BCP Testing: readSseEvents requires a Response."
1439
+ );
1440
+ }
1441
+ if (!response.body) {
1442
+ return [];
1443
+ }
1444
+
1445
+ const limit =
1446
+ positiveInteger(
1447
+ options.limit ?? 1,
1448
+ "SSE event limit"
1449
+ );
1450
+ const timeoutMs =
1451
+ positiveInteger(
1452
+ options.timeoutMs ?? 1_000,
1453
+ "SSE timeoutMs"
1454
+ );
1455
+ const reader =
1456
+ response.body.getReader();
1457
+ const decoder =
1458
+ new TextDecoder();
1459
+ const events:
1460
+ ParsedSseEvent<TData>[] = [];
1461
+ let buffer = "";
1462
+
1463
+ try {
1464
+ while (events.length < limit) {
1465
+ const result =
1466
+ await readWithTimeout(
1467
+ reader,
1468
+ timeoutMs
1469
+ );
1470
+ if (result.done) {
1471
+ break;
1472
+ }
1473
+ buffer +=
1474
+ decoder.decode(
1475
+ result.value,
1476
+ {
1477
+ stream: true,
1478
+ }
1479
+ );
1480
+
1481
+ let boundary =
1482
+ buffer.indexOf("\n\n");
1483
+ while (boundary >= 0) {
1484
+ const frame =
1485
+ buffer.slice(
1486
+ 0,
1487
+ boundary
1488
+ );
1489
+ buffer =
1490
+ buffer.slice(
1491
+ boundary + 2
1492
+ );
1493
+ const event =
1494
+ parseSseFrame<TData>(
1495
+ frame
1496
+ );
1497
+ if (event) {
1498
+ events.push(event);
1499
+ if (events.length >= limit) {
1500
+ break;
1501
+ }
1502
+ }
1503
+ boundary =
1504
+ buffer.indexOf("\n\n");
1505
+ }
1506
+ }
1507
+ } finally {
1508
+ await reader.cancel().catch(
1509
+ () => undefined
1510
+ );
1511
+ }
1512
+
1513
+ return events;
1514
+ }
1515
+
1516
+ class TestRollbackSignal
1517
+ extends Error {
1518
+ constructor(
1519
+ readonly marker: symbol
1520
+ ) {
1521
+ super("BCP Testing rollback");
1522
+ this.name =
1523
+ "BcpTestRollbackSignal";
1524
+ }
1525
+ }
1526
+
1527
+ const knownRouteMethods:
1528
+ TestRouteMethod[] = [
1529
+ "GET",
1530
+ "POST",
1531
+ "PUT",
1532
+ "PATCH",
1533
+ "DELETE",
1534
+ "HEAD",
1535
+ "OPTIONS",
1536
+ ];
1537
+
1538
+ function normalizeRouteResponse(
1539
+ value: unknown,
1540
+ method: TestRouteMethod
1541
+ ): Response {
1542
+ if (value instanceof Response) {
1543
+ if (method !== "HEAD") {
1544
+ return value;
1545
+ }
1546
+ return new Response(
1547
+ null,
1548
+ {
1549
+ status: value.status,
1550
+ statusText:
1551
+ value.statusText,
1552
+ headers:
1553
+ value.headers,
1554
+ }
1555
+ );
1556
+ }
1557
+ if (value === undefined || value === null) {
1558
+ return new Response(
1559
+ null,
1560
+ {
1561
+ status: 204,
1562
+ }
1563
+ );
1564
+ }
1565
+ if (typeof value === "string") {
1566
+ return new Response(value);
1567
+ }
1568
+ return Response.json(value);
1569
+ }
1570
+
1571
+ async function requireWorkflowRun<TInput>(
1572
+ workflow: Workflow<TInput>,
1573
+ id: string
1574
+ ): Promise<WorkflowRunRecord<TInput>> {
1575
+ const run =
1576
+ await workflow.get(id);
1577
+ if (!run) {
1578
+ throw new Error(
1579
+ `BCP Testing: workflow run "${id}" does not exist.`
1580
+ );
1581
+ }
1582
+ return run;
1583
+ }
1584
+
1585
+ function isWorkflowTerminal(
1586
+ state: WorkflowRunState
1587
+ ): boolean {
1588
+ return state === "succeeded" ||
1589
+ state === "failed" ||
1590
+ state === "cancelled" ||
1591
+ state === "compensated";
1592
+ }
1593
+
1594
+ async function notify<T>(
1595
+ listeners: Set<T>,
1596
+ invoke: (
1597
+ listener: T
1598
+ ) => void | Promise<void>
1599
+ ): Promise<void> {
1600
+ for (const listener of [
1601
+ ...listeners,
1602
+ ]) {
1603
+ await invoke(listener);
1604
+ }
1605
+ }
1606
+
1607
+ function parseSseFrame<TData>(
1608
+ frame: string
1609
+ ): ParsedSseEvent<TData> | null {
1610
+ const lines =
1611
+ frame.split(/\r?\n/);
1612
+ let id:
1613
+ string | undefined;
1614
+ let event:
1615
+ string | undefined;
1616
+ const data:
1617
+ string[] = [];
1618
+
1619
+ for (const line of lines) {
1620
+ if (
1621
+ !line ||
1622
+ line.startsWith(":") ||
1623
+ line.startsWith("retry:")
1624
+ ) {
1625
+ continue;
1626
+ }
1627
+ if (line.startsWith("id:")) {
1628
+ id =
1629
+ line.slice(3).trimStart();
1630
+ continue;
1631
+ }
1632
+ if (line.startsWith("event:")) {
1633
+ event =
1634
+ line.slice(6).trimStart();
1635
+ continue;
1636
+ }
1637
+ if (line.startsWith("data:")) {
1638
+ data.push(
1639
+ line.slice(5).trimStart()
1640
+ );
1641
+ }
1642
+ }
1643
+
1644
+ if (
1645
+ id === undefined &&
1646
+ event === undefined &&
1647
+ data.length === 0
1648
+ ) {
1649
+ return null;
1650
+ }
1651
+
1652
+ const rawData =
1653
+ data.join("\n");
1654
+ let parsed:
1655
+ TData | undefined;
1656
+ if (rawData) {
1657
+ try {
1658
+ parsed =
1659
+ JSON.parse(rawData) as TData;
1660
+ } catch {
1661
+ parsed =
1662
+ rawData as unknown as TData;
1663
+ }
1664
+ }
1665
+
1666
+ return {
1667
+ id,
1668
+ event,
1669
+ data: parsed,
1670
+ rawData:
1671
+ rawData || undefined,
1672
+ };
1673
+ }
1674
+
1675
+ function readWithTimeout(
1676
+ reader:
1677
+ ReadableStreamDefaultReader<Uint8Array>,
1678
+ timeoutMs: number
1679
+ ): Promise<ReadableStreamReadResult<Uint8Array>> {
1680
+ return new Promise(
1681
+ (resolve, reject) => {
1682
+ const timer =
1683
+ setTimeout(
1684
+ () => {
1685
+ reject(
1686
+ new Error(
1687
+ `BCP Testing: timed out waiting for SSE data after ${timeoutMs}ms.`
1688
+ )
1689
+ );
1690
+ },
1691
+ timeoutMs
1692
+ );
1693
+ reader.read().then(
1694
+ value => {
1695
+ clearTimeout(timer);
1696
+ resolve(value);
1697
+ },
1698
+ error => {
1699
+ clearTimeout(timer);
1700
+ reject(error);
1701
+ }
1702
+ );
1703
+ }
1704
+ );
1705
+ }
1706
+
1707
+ function updateCookieJar(
1708
+ jar: Map<string, string>,
1709
+ headers: Headers
1710
+ ): void {
1711
+ const extended =
1712
+ headers as Headers & {
1713
+ getSetCookie?: () => string[];
1714
+ };
1715
+ const values =
1716
+ typeof extended.getSetCookie === "function"
1717
+ ? extended.getSetCookie()
1718
+ : (
1719
+ headers.get("set-cookie")
1720
+ ? [
1721
+ headers.get("set-cookie") as string,
1722
+ ]
1723
+ : []
1724
+ );
1725
+
1726
+ for (const value of values) {
1727
+ const first =
1728
+ value.split(";", 1)[0] ?? "";
1729
+ const separator =
1730
+ first.indexOf("=");
1731
+ if (separator <= 0) {
1732
+ continue;
1733
+ }
1734
+ const name =
1735
+ first.slice(0, separator).trim();
1736
+ const cookieValue =
1737
+ first.slice(separator + 1).trim();
1738
+ const lower =
1739
+ value.toLowerCase();
1740
+ if (
1741
+ cookieValue === "" ||
1742
+ lower.includes("max-age=0") ||
1743
+ lower.includes("expires=thu, 01 jan 1970")
1744
+ ) {
1745
+ jar.delete(name);
1746
+ } else {
1747
+ jar.set(name, cookieValue);
1748
+ }
1749
+ }
1750
+ }
1751
+
1752
+ function serializeCookies(
1753
+ cookies: Map<string, string>
1754
+ ): string {
1755
+ return Array.from(
1756
+ cookies,
1757
+ ([name, value]) =>
1758
+ `${name}=${value}`
1759
+ ).join("; ");
1760
+ }
1761
+
1762
+ function normalizeMethod(
1763
+ value: string
1764
+ ): string {
1765
+ const method =
1766
+ String(value).trim().toUpperCase();
1767
+ if (!method) {
1768
+ throw new TypeError(
1769
+ "BCP Testing: request method must be a non-empty string."
1770
+ );
1771
+ }
1772
+ return method;
1773
+ }
1774
+
1775
+ function normalizeBaseUrl(
1776
+ value: string
1777
+ ): string {
1778
+ let url:
1779
+ URL;
1780
+ try {
1781
+ url = new URL(value);
1782
+ } catch {
1783
+ throw new TypeError(
1784
+ "BCP Testing: baseUrl must be an absolute URL."
1785
+ );
1786
+ }
1787
+ return url.href.endsWith("/")
1788
+ ? url.href
1789
+ : `${url.href}/`;
1790
+ }
1791
+
1792
+ function resolveTestUrl(
1793
+ baseUrl: string,
1794
+ path: string
1795
+ ): string {
1796
+ const value =
1797
+ String(path ?? "").trim();
1798
+ if (!value) {
1799
+ return baseUrl;
1800
+ }
1801
+ try {
1802
+ return new URL(value).href;
1803
+ } catch {
1804
+ return new URL(
1805
+ value.replace(/^\//, ""),
1806
+ baseUrl
1807
+ ).href;
1808
+ }
1809
+ }
1810
+
1811
+ function normalizeCookieName(
1812
+ value: string
1813
+ ): string {
1814
+ const name =
1815
+ normalizeNonEmpty(
1816
+ value,
1817
+ "cookie name"
1818
+ );
1819
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
1820
+ throw new TypeError(
1821
+ "BCP Testing: cookie name contains invalid characters."
1822
+ );
1823
+ }
1824
+ return name;
1825
+ }
1826
+
1827
+ function normalizeHeaderName(
1828
+ value: string
1829
+ ): string {
1830
+ return normalizeNonEmpty(
1831
+ value,
1832
+ "header name"
1833
+ );
1834
+ }
1835
+
1836
+ function normalizeNonEmpty(
1837
+ value: unknown,
1838
+ field: string
1839
+ ): string {
1840
+ const text =
1841
+ String(value ?? "").trim();
1842
+ if (!text) {
1843
+ throw new TypeError(
1844
+ `BCP Testing: ${field} must be a non-empty string.`
1845
+ );
1846
+ }
1847
+ return text;
1848
+ }
1849
+
1850
+ function positiveInteger(
1851
+ value: number,
1852
+ field: string
1853
+ ): number {
1854
+ if (
1855
+ !Number.isInteger(value) ||
1856
+ value <= 0
1857
+ ) {
1858
+ throw new TypeError(
1859
+ `BCP Testing: ${field} must be a positive integer.`
1860
+ );
1861
+ }
1862
+ return value;
1863
+ }
1864
+
1865
+ function assertFinite(
1866
+ value: number,
1867
+ field: string
1868
+ ): void {
1869
+ if (!Number.isFinite(value)) {
1870
+ throw new TypeError(
1871
+ `BCP Testing: ${field} must be finite.`
1872
+ );
1873
+ }
1874
+ }
1875
+
1876
+ function safeJson(
1877
+ value: unknown
1878
+ ): string {
1879
+ try {
1880
+ return JSON.stringify(value);
1881
+ } catch {
1882
+ return String(value);
1883
+ }
1884
+ }