@chidchanun/bcp 0.2.13 → 0.2.14
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/README.md +144 -98
- package/docs/README.md +46 -36
- package/docs/api-manifest.json +18 -10
- package/docs/api-reference.md +127 -29
- package/docs/docs-web-manifest.json +6 -4
- package/docs/platform-manifest.json +22 -4
- package/docs/releases/0.2.14.md +241 -0
- package/docs/testing-platform.md +602 -0
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +1 -0
- package/packages/client/src/realtime.mjs +67 -30
- package/packages/client/src/testing.mjs +2357 -0
- package/packages/client/src/testing.ts +52 -0
- package/packages/server/src/realtime.ts +95 -41
- package/packages/server/src/testing-page.ts +274 -0
- package/packages/server/src/testing.ts +1884 -0
|
@@ -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
|
+
}
|