@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.
- package/README.md +224 -99
- package/docs/README.md +40 -35
- package/docs/api-manifest.json +26 -10
- package/docs/api-reference.md +173 -29
- package/docs/docs-web-manifest.json +8 -4
- package/docs/platform-manifest.json +33 -4
- package/docs/plugin-module-platform.md +391 -0
- package/docs/releases/0.2.14.md +241 -0
- package/docs/releases/0.2.15.md +118 -0
- package/docs/testing-platform.md +602 -0
- package/package.json +11 -1
- package/packages/bundler/src/client-boundary.ts +2 -0
- package/packages/client/src/plugins.mjs +811 -0
- package/packages/client/src/plugins.ts +26 -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/plugins.ts +1225 -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,2357 @@
|
|
|
1
|
+
// packages/server/src/testing.ts
|
|
2
|
+
import {
|
|
3
|
+
randomUUID
|
|
4
|
+
} from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
isDeepStrictEqual
|
|
7
|
+
} from "node:util";
|
|
8
|
+
|
|
9
|
+
// packages/server/src/security.ts
|
|
10
|
+
var DEFAULT_BODY_LIMIT = 1024 * 1024;
|
|
11
|
+
function assertSafeMiddlewareTarget(target, originalUrl, kind) {
|
|
12
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`BCP Framework: middleware ${kind} only supports http: and https: URLs.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
if (target.username || target.password) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`BCP Framework: middleware ${kind} URLs must not contain credentials.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (kind === "rewrite" && target.origin !== originalUrl.origin) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"BCP Framework: middleware rewrites must stay on the same origin."
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// packages/server/src/middleware.ts
|
|
30
|
+
var middlewareRedirectTargets = /* @__PURE__ */ new WeakMap();
|
|
31
|
+
async function executeMiddlewarePipeline(module, input, downstream) {
|
|
32
|
+
const initialUrl = input.url instanceof URL ? new URL(
|
|
33
|
+
input.url.href
|
|
34
|
+
) : new URL(
|
|
35
|
+
input.url
|
|
36
|
+
);
|
|
37
|
+
const method = input.method ?? "GET";
|
|
38
|
+
const initialHeaders = new Headers(
|
|
39
|
+
input.headers
|
|
40
|
+
);
|
|
41
|
+
if (!module || isFrameworkInternalPath(
|
|
42
|
+
initialUrl.pathname
|
|
43
|
+
) || !matchesMiddleware(
|
|
44
|
+
initialUrl.pathname,
|
|
45
|
+
module.config?.matcher
|
|
46
|
+
)) {
|
|
47
|
+
return downstream({
|
|
48
|
+
url: initialUrl,
|
|
49
|
+
method,
|
|
50
|
+
headers: initialHeaders
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const handlers = getMiddlewareHandlers(
|
|
54
|
+
module
|
|
55
|
+
);
|
|
56
|
+
if (handlers.length === 0) {
|
|
57
|
+
return downstream({
|
|
58
|
+
url: initialUrl,
|
|
59
|
+
method,
|
|
60
|
+
headers: initialHeaders
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const current = {
|
|
64
|
+
url: initialUrl,
|
|
65
|
+
headers: initialHeaders
|
|
66
|
+
};
|
|
67
|
+
const context = {
|
|
68
|
+
state: /* @__PURE__ */ Object.create(
|
|
69
|
+
null
|
|
70
|
+
)
|
|
71
|
+
};
|
|
72
|
+
const dispatch = async (index) => {
|
|
73
|
+
if (index >= handlers.length) {
|
|
74
|
+
return downstream({
|
|
75
|
+
url: new URL(
|
|
76
|
+
current.url.href
|
|
77
|
+
),
|
|
78
|
+
method,
|
|
79
|
+
headers: new Headers(
|
|
80
|
+
current.headers
|
|
81
|
+
)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const handler = handlers[index];
|
|
85
|
+
const request = createMiddlewareRequest(
|
|
86
|
+
current.url,
|
|
87
|
+
method,
|
|
88
|
+
current.headers
|
|
89
|
+
);
|
|
90
|
+
let nextPromise = null;
|
|
91
|
+
const runNext = () => {
|
|
92
|
+
if (nextPromise) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"BCP Framework: middleware next() may only be called once per handler."
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
nextPromise = dispatch(
|
|
98
|
+
index + 1
|
|
99
|
+
);
|
|
100
|
+
return nextPromise;
|
|
101
|
+
};
|
|
102
|
+
const invoke = handler;
|
|
103
|
+
const result = await invoke(
|
|
104
|
+
request,
|
|
105
|
+
runNext,
|
|
106
|
+
context
|
|
107
|
+
);
|
|
108
|
+
if (result === void 0) {
|
|
109
|
+
return nextPromise ? await nextPromise : runNext();
|
|
110
|
+
}
|
|
111
|
+
if (result instanceof Response) {
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
if (result.type === "redirect") {
|
|
115
|
+
const target = new URL(
|
|
116
|
+
result.url,
|
|
117
|
+
current.url
|
|
118
|
+
);
|
|
119
|
+
assertSafeMiddlewareTarget(
|
|
120
|
+
target,
|
|
121
|
+
current.url,
|
|
122
|
+
"redirect"
|
|
123
|
+
);
|
|
124
|
+
const headers2 = new Headers(
|
|
125
|
+
result.headers
|
|
126
|
+
);
|
|
127
|
+
headers2.set(
|
|
128
|
+
"Location",
|
|
129
|
+
target.href
|
|
130
|
+
);
|
|
131
|
+
const response2 = new Response(
|
|
132
|
+
null,
|
|
133
|
+
{
|
|
134
|
+
status: result.status,
|
|
135
|
+
headers: headers2
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
middlewareRedirectTargets.set(
|
|
139
|
+
response2,
|
|
140
|
+
target
|
|
141
|
+
);
|
|
142
|
+
return response2;
|
|
143
|
+
}
|
|
144
|
+
if (result.type === "rewrite") {
|
|
145
|
+
if (nextPromise) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"BCP Framework: rewrite() must be returned before middleware calls next()."
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const target = new URL(
|
|
151
|
+
result.url,
|
|
152
|
+
current.url
|
|
153
|
+
);
|
|
154
|
+
assertSafeMiddlewareTarget(
|
|
155
|
+
target,
|
|
156
|
+
current.url,
|
|
157
|
+
"rewrite"
|
|
158
|
+
);
|
|
159
|
+
current.url = target;
|
|
160
|
+
current.headers = mergeHeaders(
|
|
161
|
+
current.headers,
|
|
162
|
+
result.requestHeaders
|
|
163
|
+
);
|
|
164
|
+
const response2 = await runNext();
|
|
165
|
+
return applyHeadersToResponse(
|
|
166
|
+
response2,
|
|
167
|
+
result.responseHeaders
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
current.headers = mergeHeaders(
|
|
171
|
+
current.headers,
|
|
172
|
+
result.requestHeaders
|
|
173
|
+
);
|
|
174
|
+
const response = nextPromise ? await nextPromise : await runNext();
|
|
175
|
+
return applyHeadersToResponse(
|
|
176
|
+
response,
|
|
177
|
+
result.responseHeaders
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
return dispatch(0);
|
|
181
|
+
}
|
|
182
|
+
function matchesMiddleware(pathname, matcher) {
|
|
183
|
+
if (matcher === void 0) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
const matchers = Array.isArray(
|
|
187
|
+
matcher
|
|
188
|
+
) ? matcher : [
|
|
189
|
+
matcher
|
|
190
|
+
];
|
|
191
|
+
if (matchers.length === 0) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return matchers.some(
|
|
195
|
+
(value) => {
|
|
196
|
+
if (value instanceof RegExp) {
|
|
197
|
+
value.lastIndex = 0;
|
|
198
|
+
return value.test(
|
|
199
|
+
pathname
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return matchPathPattern(
|
|
203
|
+
pathname,
|
|
204
|
+
value
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
function getMiddlewareHandlers(module) {
|
|
210
|
+
const value = module.middleware ?? module.default;
|
|
211
|
+
if (!value) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
return Array.isArray(
|
|
215
|
+
value
|
|
216
|
+
) ? [
|
|
217
|
+
...value
|
|
218
|
+
] : [
|
|
219
|
+
value
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
function createMiddlewareRequest(url, method, headers2) {
|
|
223
|
+
const requestUrl = new URL(
|
|
224
|
+
url.href
|
|
225
|
+
);
|
|
226
|
+
const requestHeaders = new Headers(
|
|
227
|
+
headers2
|
|
228
|
+
);
|
|
229
|
+
return {
|
|
230
|
+
url: requestUrl.href,
|
|
231
|
+
method,
|
|
232
|
+
headers: requestHeaders,
|
|
233
|
+
nextUrl: requestUrl,
|
|
234
|
+
cookies: createCookieStore(
|
|
235
|
+
requestHeaders.get(
|
|
236
|
+
"cookie"
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function applyHeadersToResponse(response, additions) {
|
|
242
|
+
let hasAdditions = false;
|
|
243
|
+
additions.forEach(
|
|
244
|
+
() => {
|
|
245
|
+
hasAdditions = true;
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
if (!hasAdditions) {
|
|
249
|
+
return response;
|
|
250
|
+
}
|
|
251
|
+
const headers2 = new Headers(
|
|
252
|
+
response.headers
|
|
253
|
+
);
|
|
254
|
+
additions.forEach(
|
|
255
|
+
(value, key) => {
|
|
256
|
+
headers2.set(
|
|
257
|
+
key,
|
|
258
|
+
value
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
const wrapped = new Response(
|
|
263
|
+
response.body,
|
|
264
|
+
{
|
|
265
|
+
status: response.status,
|
|
266
|
+
statusText: response.statusText,
|
|
267
|
+
headers: headers2
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
const redirectTarget = middlewareRedirectTargets.get(
|
|
271
|
+
response
|
|
272
|
+
);
|
|
273
|
+
if (redirectTarget) {
|
|
274
|
+
middlewareRedirectTargets.set(
|
|
275
|
+
wrapped,
|
|
276
|
+
redirectTarget
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return wrapped;
|
|
280
|
+
}
|
|
281
|
+
function matchPathPattern(pathname, pattern) {
|
|
282
|
+
if (pattern === "*" || pattern === "/:path*") {
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
if (!pattern.startsWith(
|
|
286
|
+
"/"
|
|
287
|
+
)) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`BCP Framework: middleware matcher "${pattern}" must start with "/".`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const escaped = pattern.split("/").filter(
|
|
293
|
+
Boolean
|
|
294
|
+
).map(
|
|
295
|
+
(segment) => {
|
|
296
|
+
if (segment === "*" || /^:[A-Za-z_][A-Za-z0-9_]*\*$/.test(
|
|
297
|
+
segment
|
|
298
|
+
)) {
|
|
299
|
+
return "(?:.*)?";
|
|
300
|
+
}
|
|
301
|
+
if (/^:[A-Za-z_][A-Za-z0-9_]*$/.test(
|
|
302
|
+
segment
|
|
303
|
+
)) {
|
|
304
|
+
return "[^/]+";
|
|
305
|
+
}
|
|
306
|
+
return escapeRegExp(
|
|
307
|
+
segment
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
if (escaped.length === 0) {
|
|
312
|
+
return pathname === "/";
|
|
313
|
+
}
|
|
314
|
+
const source = `^/${escaped.join("/")}/?$`;
|
|
315
|
+
return new RegExp(
|
|
316
|
+
source
|
|
317
|
+
).test(
|
|
318
|
+
pathname
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
function createCookieStore(cookieHeader) {
|
|
322
|
+
const values = /* @__PURE__ */ new Map();
|
|
323
|
+
if (cookieHeader) {
|
|
324
|
+
for (const part of cookieHeader.split(";")) {
|
|
325
|
+
const separator = part.indexOf("=");
|
|
326
|
+
if (separator < 0) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const name = part.slice(
|
|
330
|
+
0,
|
|
331
|
+
separator
|
|
332
|
+
).trim();
|
|
333
|
+
const rawValue = part.slice(
|
|
334
|
+
separator + 1
|
|
335
|
+
).trim();
|
|
336
|
+
if (!name) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
values.set(
|
|
341
|
+
name,
|
|
342
|
+
decodeURIComponent(
|
|
343
|
+
rawValue
|
|
344
|
+
)
|
|
345
|
+
);
|
|
346
|
+
} catch {
|
|
347
|
+
values.set(
|
|
348
|
+
name,
|
|
349
|
+
rawValue
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
get(name) {
|
|
356
|
+
return values.get(
|
|
357
|
+
name
|
|
358
|
+
) ?? null;
|
|
359
|
+
},
|
|
360
|
+
has(name) {
|
|
361
|
+
return values.has(
|
|
362
|
+
name
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function mergeHeaders(base, additions) {
|
|
368
|
+
const result = new Headers(
|
|
369
|
+
base
|
|
370
|
+
);
|
|
371
|
+
additions.forEach(
|
|
372
|
+
(value, key) => {
|
|
373
|
+
result.set(
|
|
374
|
+
key,
|
|
375
|
+
value
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
function isFrameworkInternalPath(pathname) {
|
|
382
|
+
return pathname === "/_bcp" || pathname.startsWith(
|
|
383
|
+
"/_bcp/"
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
function escapeRegExp(value) {
|
|
387
|
+
return value.replace(
|
|
388
|
+
/[.*+?^${}()|[\]\\]/g,
|
|
389
|
+
"\\$&"
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// packages/server/src/session.ts
|
|
394
|
+
import {
|
|
395
|
+
createHmac,
|
|
396
|
+
timingSafeEqual
|
|
397
|
+
} from "node:crypto";
|
|
398
|
+
|
|
399
|
+
// packages/server/src/request-context.ts
|
|
400
|
+
import {
|
|
401
|
+
AsyncLocalStorage
|
|
402
|
+
} from "node:async_hooks";
|
|
403
|
+
var requestStorage = new AsyncLocalStorage();
|
|
404
|
+
async function headers() {
|
|
405
|
+
const context = getRequestContext();
|
|
406
|
+
return new Headers(
|
|
407
|
+
context.headers
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
function getRequestContext() {
|
|
411
|
+
const context = requestStorage.getStore();
|
|
412
|
+
if (!context) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"BCP Framework: server request APIs can only be used while handling a request."
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return context;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// packages/server/src/session.ts
|
|
421
|
+
var DEFAULT_EXPIRES_IN = 60 * 60 * 12;
|
|
422
|
+
var MAX_TOKEN_LENGTH = 16 * 1024;
|
|
423
|
+
var MINIMUM_SECRET_BYTES = 32;
|
|
424
|
+
async function createSessionToken(payload, options = {}) {
|
|
425
|
+
assertPayload(
|
|
426
|
+
payload
|
|
427
|
+
);
|
|
428
|
+
const secret = resolveSessionSecret(
|
|
429
|
+
options.secret
|
|
430
|
+
);
|
|
431
|
+
const expiresIn = resolveExpiresIn(
|
|
432
|
+
options.expiresIn
|
|
433
|
+
);
|
|
434
|
+
const issuer = resolveOptionalIssuer(
|
|
435
|
+
options.issuer
|
|
436
|
+
);
|
|
437
|
+
const audience = options.audience === void 0 ? void 0 : normalizeAudience(
|
|
438
|
+
options.audience
|
|
439
|
+
);
|
|
440
|
+
const now = Math.floor(
|
|
441
|
+
Date.now() / 1e3
|
|
442
|
+
);
|
|
443
|
+
const header = {
|
|
444
|
+
alg: "HS256",
|
|
445
|
+
typ: "JWT"
|
|
446
|
+
};
|
|
447
|
+
const claims = {
|
|
448
|
+
...payload,
|
|
449
|
+
iat: now,
|
|
450
|
+
exp: now + expiresIn
|
|
451
|
+
};
|
|
452
|
+
if (issuer !== void 0) {
|
|
453
|
+
claims.iss = issuer;
|
|
454
|
+
}
|
|
455
|
+
if (audience !== void 0) {
|
|
456
|
+
claims.aud = audience;
|
|
457
|
+
}
|
|
458
|
+
const encodedHeader = encodeJson(
|
|
459
|
+
header
|
|
460
|
+
);
|
|
461
|
+
const encodedPayload = encodeJson(
|
|
462
|
+
claims
|
|
463
|
+
);
|
|
464
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
465
|
+
const signature = signHs256(
|
|
466
|
+
signingInput,
|
|
467
|
+
secret
|
|
468
|
+
);
|
|
469
|
+
return `${signingInput}.${signature}`;
|
|
470
|
+
}
|
|
471
|
+
function resolveSessionSecret(explicitSecret) {
|
|
472
|
+
const secret = explicitSecret ?? process.env.BCP_SESSION_SECRET;
|
|
473
|
+
if (!secret) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
"BCP Framework: BCP_SESSION_SECRET is required for session tokens."
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (Buffer.byteLength(
|
|
479
|
+
secret,
|
|
480
|
+
"utf8"
|
|
481
|
+
) < MINIMUM_SECRET_BYTES) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`BCP Framework: session secret must be at least ${MINIMUM_SECRET_BYTES} bytes.`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
return secret;
|
|
487
|
+
}
|
|
488
|
+
function resolveExpiresIn(value) {
|
|
489
|
+
const expiresIn = value ?? DEFAULT_EXPIRES_IN;
|
|
490
|
+
if (!Number.isFinite(
|
|
491
|
+
expiresIn
|
|
492
|
+
) || expiresIn <= 0) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
"BCP Framework: session expiresIn must be a positive finite number of seconds."
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return Math.floor(
|
|
498
|
+
expiresIn
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
function resolveOptionalIssuer(value) {
|
|
502
|
+
if (value === void 0) {
|
|
503
|
+
return void 0;
|
|
504
|
+
}
|
|
505
|
+
assertNonEmptyString(
|
|
506
|
+
value,
|
|
507
|
+
"issuer"
|
|
508
|
+
);
|
|
509
|
+
return value;
|
|
510
|
+
}
|
|
511
|
+
function assertPayload(payload) {
|
|
512
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(
|
|
513
|
+
payload
|
|
514
|
+
)) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
"BCP Framework: session payload must be a plain object."
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function assertNonEmptyString(value, label) {
|
|
521
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`BCP Framework: session ${label} must be a non-empty string.`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function normalizeAudience(value) {
|
|
528
|
+
if (typeof value === "string") {
|
|
529
|
+
assertNonEmptyString(
|
|
530
|
+
value,
|
|
531
|
+
"audience"
|
|
532
|
+
);
|
|
533
|
+
return value;
|
|
534
|
+
}
|
|
535
|
+
if (!Array.isArray(
|
|
536
|
+
value
|
|
537
|
+
) || value.length === 0) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"BCP Framework: session audience must contain at least one value."
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
for (const audience of value) {
|
|
543
|
+
assertNonEmptyString(
|
|
544
|
+
audience,
|
|
545
|
+
"audience"
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return [
|
|
549
|
+
...value
|
|
550
|
+
];
|
|
551
|
+
}
|
|
552
|
+
function encodeJson(value) {
|
|
553
|
+
return Buffer.from(
|
|
554
|
+
JSON.stringify(
|
|
555
|
+
value
|
|
556
|
+
),
|
|
557
|
+
"utf8"
|
|
558
|
+
).toString(
|
|
559
|
+
"base64url"
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
function signHs256(signingInput, secret) {
|
|
563
|
+
return createHmac(
|
|
564
|
+
"sha256",
|
|
565
|
+
secret
|
|
566
|
+
).update(
|
|
567
|
+
signingInput,
|
|
568
|
+
"utf8"
|
|
569
|
+
).digest(
|
|
570
|
+
"base64url"
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// packages/server/src/testing.ts
|
|
575
|
+
function createTestApp(options) {
|
|
576
|
+
if (!options || typeof options.handler !== "function") {
|
|
577
|
+
throw new TypeError(
|
|
578
|
+
"BCP Testing: createTestApp requires a request handler."
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const baseUrl = normalizeBaseUrl(
|
|
582
|
+
options.baseUrl ?? "http://bcp.test"
|
|
583
|
+
);
|
|
584
|
+
const initialHeaders = new Headers(
|
|
585
|
+
options.headers
|
|
586
|
+
);
|
|
587
|
+
const defaultHeaders = new Headers(
|
|
588
|
+
initialHeaders
|
|
589
|
+
);
|
|
590
|
+
const initialCookies = {
|
|
591
|
+
...options.cookies ?? {}
|
|
592
|
+
};
|
|
593
|
+
const cookieJar = new Map(
|
|
594
|
+
Object.entries(initialCookies)
|
|
595
|
+
);
|
|
596
|
+
const app = {
|
|
597
|
+
baseUrl,
|
|
598
|
+
async request(path, requestOptions = {}) {
|
|
599
|
+
const method = normalizeMethod(
|
|
600
|
+
requestOptions.method ?? "GET"
|
|
601
|
+
);
|
|
602
|
+
const headers2 = new Headers(
|
|
603
|
+
defaultHeaders
|
|
604
|
+
);
|
|
605
|
+
for (const [name, value] of new Headers(
|
|
606
|
+
requestOptions.headers
|
|
607
|
+
)) {
|
|
608
|
+
headers2.set(name, value);
|
|
609
|
+
}
|
|
610
|
+
const requestCookies = new Map(cookieJar);
|
|
611
|
+
for (const [name, value] of Object.entries(
|
|
612
|
+
requestOptions.cookies ?? {}
|
|
613
|
+
)) {
|
|
614
|
+
requestCookies.set(name, value);
|
|
615
|
+
}
|
|
616
|
+
if (requestCookies.size > 0) {
|
|
617
|
+
headers2.set(
|
|
618
|
+
"Cookie",
|
|
619
|
+
serializeCookies(
|
|
620
|
+
requestCookies
|
|
621
|
+
)
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
let body = requestOptions.body ?? null;
|
|
625
|
+
if (requestOptions.json !== void 0) {
|
|
626
|
+
if (requestOptions.body !== void 0 && requestOptions.body !== null) {
|
|
627
|
+
throw new TypeError(
|
|
628
|
+
"BCP Testing: request cannot specify both body and json."
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
body = JSON.stringify(
|
|
632
|
+
requestOptions.json
|
|
633
|
+
);
|
|
634
|
+
if (!headers2.has("Content-Type")) {
|
|
635
|
+
headers2.set(
|
|
636
|
+
"Content-Type",
|
|
637
|
+
"application/json"
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (method === "GET" || method === "HEAD") {
|
|
642
|
+
body = null;
|
|
643
|
+
}
|
|
644
|
+
const request = new Request(
|
|
645
|
+
resolveTestUrl(
|
|
646
|
+
baseUrl,
|
|
647
|
+
path
|
|
648
|
+
),
|
|
649
|
+
{
|
|
650
|
+
method,
|
|
651
|
+
headers: headers2,
|
|
652
|
+
body,
|
|
653
|
+
signal: requestOptions.signal
|
|
654
|
+
}
|
|
655
|
+
);
|
|
656
|
+
const response = await options.handler(
|
|
657
|
+
request
|
|
658
|
+
);
|
|
659
|
+
if (!(response instanceof Response)) {
|
|
660
|
+
throw new TypeError(
|
|
661
|
+
"BCP Testing: request handler must return a Response."
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
updateCookieJar(
|
|
665
|
+
cookieJar,
|
|
666
|
+
response.headers
|
|
667
|
+
);
|
|
668
|
+
return response;
|
|
669
|
+
},
|
|
670
|
+
get(path, requestOptions = {}) {
|
|
671
|
+
return app.request(
|
|
672
|
+
path,
|
|
673
|
+
{
|
|
674
|
+
...requestOptions,
|
|
675
|
+
method: "GET"
|
|
676
|
+
}
|
|
677
|
+
);
|
|
678
|
+
},
|
|
679
|
+
post(path, requestOptions = {}) {
|
|
680
|
+
return app.request(
|
|
681
|
+
path,
|
|
682
|
+
{
|
|
683
|
+
...requestOptions,
|
|
684
|
+
method: "POST"
|
|
685
|
+
}
|
|
686
|
+
);
|
|
687
|
+
},
|
|
688
|
+
put(path, requestOptions = {}) {
|
|
689
|
+
return app.request(
|
|
690
|
+
path,
|
|
691
|
+
{
|
|
692
|
+
...requestOptions,
|
|
693
|
+
method: "PUT"
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
},
|
|
697
|
+
patch(path, requestOptions = {}) {
|
|
698
|
+
return app.request(
|
|
699
|
+
path,
|
|
700
|
+
{
|
|
701
|
+
...requestOptions,
|
|
702
|
+
method: "PATCH"
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
},
|
|
706
|
+
delete(path, requestOptions = {}) {
|
|
707
|
+
return app.request(
|
|
708
|
+
path,
|
|
709
|
+
{
|
|
710
|
+
...requestOptions,
|
|
711
|
+
method: "DELETE"
|
|
712
|
+
}
|
|
713
|
+
);
|
|
714
|
+
},
|
|
715
|
+
setCookie(name, value) {
|
|
716
|
+
cookieJar.set(
|
|
717
|
+
normalizeCookieName(name),
|
|
718
|
+
String(value)
|
|
719
|
+
);
|
|
720
|
+
},
|
|
721
|
+
clearCookie(name) {
|
|
722
|
+
cookieJar.delete(
|
|
723
|
+
normalizeCookieName(name)
|
|
724
|
+
);
|
|
725
|
+
},
|
|
726
|
+
cookies() {
|
|
727
|
+
return Object.fromEntries(
|
|
728
|
+
cookieJar
|
|
729
|
+
);
|
|
730
|
+
},
|
|
731
|
+
setHeader(name, value) {
|
|
732
|
+
defaultHeaders.set(
|
|
733
|
+
normalizeHeaderName(name),
|
|
734
|
+
String(value)
|
|
735
|
+
);
|
|
736
|
+
},
|
|
737
|
+
deleteHeader(name) {
|
|
738
|
+
defaultHeaders.delete(
|
|
739
|
+
normalizeHeaderName(name)
|
|
740
|
+
);
|
|
741
|
+
},
|
|
742
|
+
headers() {
|
|
743
|
+
return new Headers(
|
|
744
|
+
defaultHeaders
|
|
745
|
+
);
|
|
746
|
+
},
|
|
747
|
+
reset() {
|
|
748
|
+
cookieJar.clear();
|
|
749
|
+
for (const [name, value] of Object.entries(
|
|
750
|
+
initialCookies
|
|
751
|
+
)) {
|
|
752
|
+
cookieJar.set(name, value);
|
|
753
|
+
}
|
|
754
|
+
for (const name of [...defaultHeaders.keys()]) {
|
|
755
|
+
defaultHeaders.delete(name);
|
|
756
|
+
}
|
|
757
|
+
for (const [name, value] of initialHeaders) {
|
|
758
|
+
defaultHeaders.set(name, value);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
return app;
|
|
763
|
+
}
|
|
764
|
+
function createRouteTestHandler(routeModule, options = {}) {
|
|
765
|
+
if (!routeModule || typeof routeModule !== "object") {
|
|
766
|
+
throw new TypeError(
|
|
767
|
+
"BCP Testing: route module must be an object."
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
return async (request) => {
|
|
771
|
+
const method = normalizeMethod(
|
|
772
|
+
request.method
|
|
773
|
+
);
|
|
774
|
+
const handler = routeModule[method] ?? (method === "HEAD" ? routeModule.GET : void 0);
|
|
775
|
+
if (!handler) {
|
|
776
|
+
const allow = knownRouteMethods.filter(
|
|
777
|
+
(candidate) => typeof routeModule[candidate] === "function"
|
|
778
|
+
).join(", ");
|
|
779
|
+
return new Response(
|
|
780
|
+
null,
|
|
781
|
+
{
|
|
782
|
+
status: 405,
|
|
783
|
+
headers: allow ? {
|
|
784
|
+
Allow: allow
|
|
785
|
+
} : void 0
|
|
786
|
+
}
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
const context = options.context ? await options.context(
|
|
790
|
+
request
|
|
791
|
+
) : void 0;
|
|
792
|
+
const value = await handler(
|
|
793
|
+
request,
|
|
794
|
+
context
|
|
795
|
+
);
|
|
796
|
+
return normalizeRouteResponse(
|
|
797
|
+
value,
|
|
798
|
+
method
|
|
799
|
+
);
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function expectResponse(response) {
|
|
803
|
+
if (!(response instanceof Response)) {
|
|
804
|
+
throw new TypeError(
|
|
805
|
+
"BCP Testing: expectResponse requires a Response."
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
const expectation = {
|
|
809
|
+
response,
|
|
810
|
+
status(expected) {
|
|
811
|
+
if (response.status !== expected) {
|
|
812
|
+
throw new Error(
|
|
813
|
+
`BCP Testing: expected response status ${expected}, received ${response.status}.`
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
return expectation;
|
|
817
|
+
},
|
|
818
|
+
header(name, expected) {
|
|
819
|
+
const actual = response.headers.get(name);
|
|
820
|
+
if (typeof expected === "string" ? actual !== expected : !expected.test(
|
|
821
|
+
actual ?? ""
|
|
822
|
+
)) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
`BCP Testing: response header "${name}" did not match. Received ${JSON.stringify(actual)}.`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return expectation;
|
|
828
|
+
},
|
|
829
|
+
async text(expected) {
|
|
830
|
+
const actual = await response.clone().text();
|
|
831
|
+
if (typeof expected === "string" ? actual !== expected : !expected.test(actual)) {
|
|
832
|
+
throw new Error(
|
|
833
|
+
`BCP Testing: response text did not match. Received ${JSON.stringify(actual)}.`
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
return expectation;
|
|
837
|
+
},
|
|
838
|
+
async json(expected) {
|
|
839
|
+
const actual = await response.clone().json();
|
|
840
|
+
if (!isDeepStrictEqual(actual, expected)) {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`BCP Testing: response JSON did not match. Expected ${safeJson(expected)}, received ${safeJson(actual)}.`
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
return expectation;
|
|
846
|
+
},
|
|
847
|
+
async jsonMatches(expected) {
|
|
848
|
+
const actual = await response.clone().json();
|
|
849
|
+
if (!actual || typeof actual !== "object" || Array.isArray(actual)) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
"BCP Testing: response JSON is not an object."
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
855
|
+
if (!isDeepStrictEqual(
|
|
856
|
+
actual[key],
|
|
857
|
+
value
|
|
858
|
+
)) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
`BCP Testing: response JSON key "${key}" did not match.`
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return expectation;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
return expectation;
|
|
868
|
+
}
|
|
869
|
+
function createFakeClock(initialValue = 0) {
|
|
870
|
+
assertFinite(
|
|
871
|
+
initialValue,
|
|
872
|
+
"fake clock initial value"
|
|
873
|
+
);
|
|
874
|
+
let current = initialValue;
|
|
875
|
+
return {
|
|
876
|
+
now() {
|
|
877
|
+
return current;
|
|
878
|
+
},
|
|
879
|
+
value() {
|
|
880
|
+
return current;
|
|
881
|
+
},
|
|
882
|
+
set(value) {
|
|
883
|
+
assertFinite(
|
|
884
|
+
value,
|
|
885
|
+
"fake clock value"
|
|
886
|
+
);
|
|
887
|
+
current = value;
|
|
888
|
+
return current;
|
|
889
|
+
},
|
|
890
|
+
advance(milliseconds) {
|
|
891
|
+
assertFinite(
|
|
892
|
+
milliseconds,
|
|
893
|
+
"fake clock advance"
|
|
894
|
+
);
|
|
895
|
+
current += milliseconds;
|
|
896
|
+
return current;
|
|
897
|
+
},
|
|
898
|
+
reset() {
|
|
899
|
+
current = initialValue;
|
|
900
|
+
return current;
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function createSequenceIdFactory(prefix = "test", start = 1) {
|
|
905
|
+
const normalizedPrefix = String(prefix).trim();
|
|
906
|
+
if (!normalizedPrefix) {
|
|
907
|
+
throw new TypeError(
|
|
908
|
+
"BCP Testing: id prefix must be a non-empty string."
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (!Number.isInteger(start) || start < 0) {
|
|
912
|
+
throw new TypeError(
|
|
913
|
+
"BCP Testing: id sequence start must be a non-negative integer."
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
let sequence = start;
|
|
917
|
+
return () => `${normalizedPrefix}-${sequence++}`;
|
|
918
|
+
}
|
|
919
|
+
async function createTestAuthSession(user, options = {}) {
|
|
920
|
+
if (!user || typeof user.id !== "string" && typeof user.id !== "number") {
|
|
921
|
+
throw new TypeError(
|
|
922
|
+
"BCP Testing: auth user requires a string or number id."
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
const idFactory = options.idFactory ?? randomUUID;
|
|
926
|
+
const sid = normalizeNonEmpty(
|
|
927
|
+
options.sid ?? idFactory(),
|
|
928
|
+
"session id"
|
|
929
|
+
);
|
|
930
|
+
const cookieName = normalizeCookieName(
|
|
931
|
+
options.cookieName ?? "bcp_session"
|
|
932
|
+
);
|
|
933
|
+
const expiresIn = positiveInteger(
|
|
934
|
+
options.expiresIn ?? 60 * 60 * 12,
|
|
935
|
+
"session expiresIn"
|
|
936
|
+
);
|
|
937
|
+
const createdAt = Math.floor(
|
|
938
|
+
Date.now() / 1e3
|
|
939
|
+
);
|
|
940
|
+
const expiresAt = createdAt + expiresIn;
|
|
941
|
+
const payload = {
|
|
942
|
+
sid,
|
|
943
|
+
user,
|
|
944
|
+
...options.data === void 0 ? {} : {
|
|
945
|
+
data: options.data
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
const token = await createSessionToken(
|
|
949
|
+
payload,
|
|
950
|
+
{
|
|
951
|
+
secret: options.secret,
|
|
952
|
+
expiresIn,
|
|
953
|
+
issuer: options.issuer,
|
|
954
|
+
audience: options.audience
|
|
955
|
+
}
|
|
956
|
+
);
|
|
957
|
+
if (options.store) {
|
|
958
|
+
await options.store.set({
|
|
959
|
+
sid,
|
|
960
|
+
userId: String(user.id),
|
|
961
|
+
createdAt,
|
|
962
|
+
expiresAt,
|
|
963
|
+
lastSeenAt: createdAt
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
return {
|
|
967
|
+
sid,
|
|
968
|
+
user,
|
|
969
|
+
...options.data === void 0 ? {} : {
|
|
970
|
+
data: options.data
|
|
971
|
+
},
|
|
972
|
+
token,
|
|
973
|
+
cookieName,
|
|
974
|
+
cookieHeader: `${cookieName}=${token}`,
|
|
975
|
+
createdAt,
|
|
976
|
+
expiresAt
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
async function withTestTransaction(database, callback) {
|
|
980
|
+
if (!database || typeof database.transaction !== "function") {
|
|
981
|
+
throw new TypeError(
|
|
982
|
+
"BCP Testing: withTestTransaction requires a transactional database."
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
if (typeof callback !== "function") {
|
|
986
|
+
throw new TypeError(
|
|
987
|
+
"BCP Testing: transaction callback must be a function."
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
const marker = /* @__PURE__ */ Symbol("bcp-test-rollback");
|
|
991
|
+
let completed = false;
|
|
992
|
+
let value;
|
|
993
|
+
try {
|
|
994
|
+
await database.transaction(
|
|
995
|
+
async (transaction) => {
|
|
996
|
+
value = await callback(
|
|
997
|
+
transaction
|
|
998
|
+
);
|
|
999
|
+
completed = true;
|
|
1000
|
+
throw new TestRollbackSignal(
|
|
1001
|
+
marker
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
if (!(error instanceof TestRollbackSignal) || error.marker !== marker) {
|
|
1007
|
+
throw error;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (!completed) {
|
|
1011
|
+
throw new Error(
|
|
1012
|
+
"BCP Testing: rollback transaction did not complete the test callback."
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
return value;
|
|
1016
|
+
}
|
|
1017
|
+
async function runTestMiddleware(module, input, downstream) {
|
|
1018
|
+
return executeMiddlewarePipeline(
|
|
1019
|
+
module,
|
|
1020
|
+
input,
|
|
1021
|
+
async (execution) => {
|
|
1022
|
+
const request = new Request(
|
|
1023
|
+
execution.url,
|
|
1024
|
+
{
|
|
1025
|
+
method: execution.method ?? "GET",
|
|
1026
|
+
headers: execution.headers
|
|
1027
|
+
}
|
|
1028
|
+
);
|
|
1029
|
+
return downstream ? downstream(request) : new Response(
|
|
1030
|
+
null,
|
|
1031
|
+
{
|
|
1032
|
+
status: 204
|
|
1033
|
+
}
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
function createJobTestHarness(queue) {
|
|
1039
|
+
if (!queue) {
|
|
1040
|
+
throw new TypeError(
|
|
1041
|
+
"BCP Testing: job harness requires a queue."
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
const harness = {
|
|
1045
|
+
queue,
|
|
1046
|
+
async drain(options = {}) {
|
|
1047
|
+
const maxJobs = positiveInteger(
|
|
1048
|
+
options.maxJobs ?? 1e3,
|
|
1049
|
+
"maxJobs"
|
|
1050
|
+
);
|
|
1051
|
+
let processed = 0;
|
|
1052
|
+
while (processed < maxJobs && !options.signal?.aborted) {
|
|
1053
|
+
const didProcess = await queue.processNext(
|
|
1054
|
+
options.signal
|
|
1055
|
+
);
|
|
1056
|
+
if (!didProcess) {
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
processed += 1;
|
|
1060
|
+
}
|
|
1061
|
+
return processed;
|
|
1062
|
+
},
|
|
1063
|
+
async records(name) {
|
|
1064
|
+
const records = await queue.list();
|
|
1065
|
+
return name ? records.filter(
|
|
1066
|
+
(record) => record.name === name
|
|
1067
|
+
) : records;
|
|
1068
|
+
},
|
|
1069
|
+
async count(options = {}) {
|
|
1070
|
+
const records = await harness.records(
|
|
1071
|
+
options.name
|
|
1072
|
+
);
|
|
1073
|
+
return records.filter(
|
|
1074
|
+
(record) => !options.state || record.state === options.state
|
|
1075
|
+
).length;
|
|
1076
|
+
},
|
|
1077
|
+
async expectCount(expected, options = {}) {
|
|
1078
|
+
const actual = await harness.count(options);
|
|
1079
|
+
if (actual !== expected) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`BCP Testing: expected ${expected} jobs, received ${actual}.`
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
return harness;
|
|
1087
|
+
}
|
|
1088
|
+
function createWorkflowTestHarness(workflow) {
|
|
1089
|
+
if (!workflow) {
|
|
1090
|
+
throw new TypeError(
|
|
1091
|
+
"BCP Testing: workflow harness requires a workflow."
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
const harness = {
|
|
1095
|
+
workflow,
|
|
1096
|
+
async startAndRun(input, options = {}) {
|
|
1097
|
+
const run = await workflow.start(
|
|
1098
|
+
input,
|
|
1099
|
+
{
|
|
1100
|
+
id: options.id
|
|
1101
|
+
}
|
|
1102
|
+
);
|
|
1103
|
+
return harness.runUntilIdle(
|
|
1104
|
+
run.id,
|
|
1105
|
+
options
|
|
1106
|
+
);
|
|
1107
|
+
},
|
|
1108
|
+
async runUntilIdle(id, options = {}) {
|
|
1109
|
+
const maxPasses = positiveInteger(
|
|
1110
|
+
options.maxPasses ?? 100,
|
|
1111
|
+
"workflow maxPasses"
|
|
1112
|
+
);
|
|
1113
|
+
let run = await requireWorkflowRun(
|
|
1114
|
+
workflow,
|
|
1115
|
+
id
|
|
1116
|
+
);
|
|
1117
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
1118
|
+
if (isWorkflowTerminal(run.state)) {
|
|
1119
|
+
return run;
|
|
1120
|
+
}
|
|
1121
|
+
if (run.state === "waiting") {
|
|
1122
|
+
if (!options.forceWaiting) {
|
|
1123
|
+
return run;
|
|
1124
|
+
}
|
|
1125
|
+
run = await workflow.resume(
|
|
1126
|
+
id,
|
|
1127
|
+
{
|
|
1128
|
+
force: true
|
|
1129
|
+
}
|
|
1130
|
+
);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
run = await workflow.run(id);
|
|
1134
|
+
}
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
`BCP Testing: workflow "${id}" did not become idle within ${maxPasses} passes.`
|
|
1137
|
+
);
|
|
1138
|
+
},
|
|
1139
|
+
async expectState(id, expected) {
|
|
1140
|
+
const run = await requireWorkflowRun(
|
|
1141
|
+
workflow,
|
|
1142
|
+
id
|
|
1143
|
+
);
|
|
1144
|
+
if (run.state !== expected) {
|
|
1145
|
+
throw new Error(
|
|
1146
|
+
`BCP Testing: expected workflow "${id}" state ${expected}, received ${run.state}.`
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
return run;
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
return harness;
|
|
1153
|
+
}
|
|
1154
|
+
function createOutboxTestHarness(store, dispatcher) {
|
|
1155
|
+
if (!store || !dispatcher) {
|
|
1156
|
+
throw new TypeError(
|
|
1157
|
+
"BCP Testing: outbox harness requires a store and dispatcher."
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
store,
|
|
1162
|
+
dispatcher,
|
|
1163
|
+
async dispatchUntilIdle(maxBatches = 100) {
|
|
1164
|
+
const limit = positiveInteger(
|
|
1165
|
+
maxBatches,
|
|
1166
|
+
"outbox maxBatches"
|
|
1167
|
+
);
|
|
1168
|
+
let published = 0;
|
|
1169
|
+
for (let batch = 0; batch < limit; batch += 1) {
|
|
1170
|
+
const count = await dispatcher.dispatchBatch();
|
|
1171
|
+
published += count;
|
|
1172
|
+
if (count === 0) {
|
|
1173
|
+
return published;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return published;
|
|
1177
|
+
},
|
|
1178
|
+
async events(type) {
|
|
1179
|
+
const events = await store.list();
|
|
1180
|
+
return type ? events.filter(
|
|
1181
|
+
(event) => event.type === type
|
|
1182
|
+
) : events;
|
|
1183
|
+
},
|
|
1184
|
+
stats() {
|
|
1185
|
+
return store.stats();
|
|
1186
|
+
},
|
|
1187
|
+
async expectState(id, expected) {
|
|
1188
|
+
const event = await store.get(id);
|
|
1189
|
+
if (!event) {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`BCP Testing: outbox event "${id}" does not exist.`
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
if (event.state !== expected) {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
`BCP Testing: expected outbox event "${id}" state ${expected}, received ${event.state}.`
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return event;
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
function createRealtimeTestSocket() {
|
|
1204
|
+
const sentMessages = [];
|
|
1205
|
+
const messageListeners = /* @__PURE__ */ new Set();
|
|
1206
|
+
const closeListeners = /* @__PURE__ */ new Set();
|
|
1207
|
+
const errorListeners = /* @__PURE__ */ new Set();
|
|
1208
|
+
let closed = false;
|
|
1209
|
+
let closeCode;
|
|
1210
|
+
let closeReason;
|
|
1211
|
+
const socket = {
|
|
1212
|
+
get closed() {
|
|
1213
|
+
return closed;
|
|
1214
|
+
},
|
|
1215
|
+
get closeCode() {
|
|
1216
|
+
return closeCode;
|
|
1217
|
+
},
|
|
1218
|
+
get closeReason() {
|
|
1219
|
+
return closeReason;
|
|
1220
|
+
},
|
|
1221
|
+
send(data) {
|
|
1222
|
+
if (closed) {
|
|
1223
|
+
throw new Error(
|
|
1224
|
+
"BCP Testing: realtime test socket is closed."
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
sentMessages.push(
|
|
1228
|
+
String(data)
|
|
1229
|
+
);
|
|
1230
|
+
},
|
|
1231
|
+
async close(code = 1e3, reason = "server closed") {
|
|
1232
|
+
if (closed) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
closed = true;
|
|
1236
|
+
closeCode = code;
|
|
1237
|
+
closeReason = reason;
|
|
1238
|
+
await notify(
|
|
1239
|
+
closeListeners,
|
|
1240
|
+
(listener) => listener()
|
|
1241
|
+
);
|
|
1242
|
+
},
|
|
1243
|
+
onMessage(listener) {
|
|
1244
|
+
messageListeners.add(listener);
|
|
1245
|
+
return () => {
|
|
1246
|
+
messageListeners.delete(listener);
|
|
1247
|
+
};
|
|
1248
|
+
},
|
|
1249
|
+
onClose(listener) {
|
|
1250
|
+
closeListeners.add(listener);
|
|
1251
|
+
return () => {
|
|
1252
|
+
closeListeners.delete(listener);
|
|
1253
|
+
};
|
|
1254
|
+
},
|
|
1255
|
+
onError(listener) {
|
|
1256
|
+
errorListeners.add(listener);
|
|
1257
|
+
return () => {
|
|
1258
|
+
errorListeners.delete(listener);
|
|
1259
|
+
};
|
|
1260
|
+
},
|
|
1261
|
+
async receive(message) {
|
|
1262
|
+
if (closed) {
|
|
1263
|
+
throw new Error(
|
|
1264
|
+
"BCP Testing: cannot receive on a closed realtime test socket."
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
const raw = typeof message === "string" ? message : JSON.stringify(message);
|
|
1268
|
+
await notify(
|
|
1269
|
+
messageListeners,
|
|
1270
|
+
(listener) => listener(raw)
|
|
1271
|
+
);
|
|
1272
|
+
},
|
|
1273
|
+
async closeFromClient(code = 1e3, reason = "client closed") {
|
|
1274
|
+
if (closed) {
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
closed = true;
|
|
1278
|
+
closeCode = code;
|
|
1279
|
+
closeReason = reason;
|
|
1280
|
+
await notify(
|
|
1281
|
+
closeListeners,
|
|
1282
|
+
(listener) => listener()
|
|
1283
|
+
);
|
|
1284
|
+
},
|
|
1285
|
+
async fail(error) {
|
|
1286
|
+
await notify(
|
|
1287
|
+
errorListeners,
|
|
1288
|
+
(listener) => listener(error)
|
|
1289
|
+
);
|
|
1290
|
+
},
|
|
1291
|
+
sent() {
|
|
1292
|
+
return [
|
|
1293
|
+
...sentMessages
|
|
1294
|
+
];
|
|
1295
|
+
},
|
|
1296
|
+
messages() {
|
|
1297
|
+
return sentMessages.map(
|
|
1298
|
+
(message) => JSON.parse(message)
|
|
1299
|
+
);
|
|
1300
|
+
},
|
|
1301
|
+
clear() {
|
|
1302
|
+
sentMessages.length = 0;
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
return socket;
|
|
1306
|
+
}
|
|
1307
|
+
function createRealtimeTestHarness(hub) {
|
|
1308
|
+
if (!hub) {
|
|
1309
|
+
throw new TypeError(
|
|
1310
|
+
"BCP Testing: realtime harness requires a hub."
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
const harness = {
|
|
1314
|
+
hub,
|
|
1315
|
+
async connect(options = {}) {
|
|
1316
|
+
const socket = createRealtimeTestSocket();
|
|
1317
|
+
const connection = await hub.attachSocket(
|
|
1318
|
+
socket,
|
|
1319
|
+
options
|
|
1320
|
+
);
|
|
1321
|
+
return {
|
|
1322
|
+
socket,
|
|
1323
|
+
connection
|
|
1324
|
+
};
|
|
1325
|
+
},
|
|
1326
|
+
expectEvent(socket, event, channel) {
|
|
1327
|
+
const normalizedEvent = normalizeNonEmpty(
|
|
1328
|
+
event,
|
|
1329
|
+
"realtime event"
|
|
1330
|
+
);
|
|
1331
|
+
const messages = socket.messages();
|
|
1332
|
+
const found = [...messages].reverse().find(
|
|
1333
|
+
(message) => message.type === "event" && message.event === normalizedEvent && (channel === void 0 || message.channel === channel)
|
|
1334
|
+
);
|
|
1335
|
+
if (!found || !found.id || !found.channel || found.timestamp === void 0) {
|
|
1336
|
+
throw new Error(
|
|
1337
|
+
`BCP Testing: realtime event "${normalizedEvent}" was not sent.`
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
id: found.id,
|
|
1342
|
+
channel: found.channel,
|
|
1343
|
+
event: normalizedEvent,
|
|
1344
|
+
payload: found.payload,
|
|
1345
|
+
timestamp: found.timestamp,
|
|
1346
|
+
sourceId: found.sourceId,
|
|
1347
|
+
excludeConnectionId: found.excludeConnectionId
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
return harness;
|
|
1352
|
+
}
|
|
1353
|
+
async function readSseEvents(response, options = {}) {
|
|
1354
|
+
if (!(response instanceof Response)) {
|
|
1355
|
+
throw new TypeError(
|
|
1356
|
+
"BCP Testing: readSseEvents requires a Response."
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
if (!response.body) {
|
|
1360
|
+
return [];
|
|
1361
|
+
}
|
|
1362
|
+
const limit = positiveInteger(
|
|
1363
|
+
options.limit ?? 1,
|
|
1364
|
+
"SSE event limit"
|
|
1365
|
+
);
|
|
1366
|
+
const timeoutMs = positiveInteger(
|
|
1367
|
+
options.timeoutMs ?? 1e3,
|
|
1368
|
+
"SSE timeoutMs"
|
|
1369
|
+
);
|
|
1370
|
+
const reader = response.body.getReader();
|
|
1371
|
+
const decoder = new TextDecoder();
|
|
1372
|
+
const events = [];
|
|
1373
|
+
let buffer = "";
|
|
1374
|
+
try {
|
|
1375
|
+
while (events.length < limit) {
|
|
1376
|
+
const result = await readWithTimeout(
|
|
1377
|
+
reader,
|
|
1378
|
+
timeoutMs
|
|
1379
|
+
);
|
|
1380
|
+
if (result.done) {
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
buffer += decoder.decode(
|
|
1384
|
+
result.value,
|
|
1385
|
+
{
|
|
1386
|
+
stream: true
|
|
1387
|
+
}
|
|
1388
|
+
);
|
|
1389
|
+
let boundary = buffer.indexOf("\n\n");
|
|
1390
|
+
while (boundary >= 0) {
|
|
1391
|
+
const frame = buffer.slice(
|
|
1392
|
+
0,
|
|
1393
|
+
boundary
|
|
1394
|
+
);
|
|
1395
|
+
buffer = buffer.slice(
|
|
1396
|
+
boundary + 2
|
|
1397
|
+
);
|
|
1398
|
+
const event = parseSseFrame(
|
|
1399
|
+
frame
|
|
1400
|
+
);
|
|
1401
|
+
if (event) {
|
|
1402
|
+
events.push(event);
|
|
1403
|
+
if (events.length >= limit) {
|
|
1404
|
+
break;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
boundary = buffer.indexOf("\n\n");
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
} finally {
|
|
1411
|
+
await reader.cancel().catch(
|
|
1412
|
+
() => void 0
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
return events;
|
|
1416
|
+
}
|
|
1417
|
+
var TestRollbackSignal = class extends Error {
|
|
1418
|
+
constructor(marker) {
|
|
1419
|
+
super("BCP Testing rollback");
|
|
1420
|
+
this.marker = marker;
|
|
1421
|
+
this.name = "BcpTestRollbackSignal";
|
|
1422
|
+
}
|
|
1423
|
+
marker;
|
|
1424
|
+
};
|
|
1425
|
+
var knownRouteMethods = [
|
|
1426
|
+
"GET",
|
|
1427
|
+
"POST",
|
|
1428
|
+
"PUT",
|
|
1429
|
+
"PATCH",
|
|
1430
|
+
"DELETE",
|
|
1431
|
+
"HEAD",
|
|
1432
|
+
"OPTIONS"
|
|
1433
|
+
];
|
|
1434
|
+
function normalizeRouteResponse(value, method) {
|
|
1435
|
+
if (value instanceof Response) {
|
|
1436
|
+
if (method !== "HEAD") {
|
|
1437
|
+
return value;
|
|
1438
|
+
}
|
|
1439
|
+
return new Response(
|
|
1440
|
+
null,
|
|
1441
|
+
{
|
|
1442
|
+
status: value.status,
|
|
1443
|
+
statusText: value.statusText,
|
|
1444
|
+
headers: value.headers
|
|
1445
|
+
}
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
if (value === void 0 || value === null) {
|
|
1449
|
+
return new Response(
|
|
1450
|
+
null,
|
|
1451
|
+
{
|
|
1452
|
+
status: 204
|
|
1453
|
+
}
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
if (typeof value === "string") {
|
|
1457
|
+
return new Response(value);
|
|
1458
|
+
}
|
|
1459
|
+
return Response.json(value);
|
|
1460
|
+
}
|
|
1461
|
+
async function requireWorkflowRun(workflow, id) {
|
|
1462
|
+
const run = await workflow.get(id);
|
|
1463
|
+
if (!run) {
|
|
1464
|
+
throw new Error(
|
|
1465
|
+
`BCP Testing: workflow run "${id}" does not exist.`
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
return run;
|
|
1469
|
+
}
|
|
1470
|
+
function isWorkflowTerminal(state) {
|
|
1471
|
+
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "compensated";
|
|
1472
|
+
}
|
|
1473
|
+
async function notify(listeners, invoke) {
|
|
1474
|
+
for (const listener of [
|
|
1475
|
+
...listeners
|
|
1476
|
+
]) {
|
|
1477
|
+
await invoke(listener);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
function parseSseFrame(frame) {
|
|
1481
|
+
const lines = frame.split(/\r?\n/);
|
|
1482
|
+
let id;
|
|
1483
|
+
let event;
|
|
1484
|
+
const data = [];
|
|
1485
|
+
for (const line of lines) {
|
|
1486
|
+
if (!line || line.startsWith(":") || line.startsWith("retry:")) {
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
if (line.startsWith("id:")) {
|
|
1490
|
+
id = line.slice(3).trimStart();
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (line.startsWith("event:")) {
|
|
1494
|
+
event = line.slice(6).trimStart();
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
if (line.startsWith("data:")) {
|
|
1498
|
+
data.push(
|
|
1499
|
+
line.slice(5).trimStart()
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (id === void 0 && event === void 0 && data.length === 0) {
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
const rawData = data.join("\n");
|
|
1507
|
+
let parsed;
|
|
1508
|
+
if (rawData) {
|
|
1509
|
+
try {
|
|
1510
|
+
parsed = JSON.parse(rawData);
|
|
1511
|
+
} catch {
|
|
1512
|
+
parsed = rawData;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
id,
|
|
1517
|
+
event,
|
|
1518
|
+
data: parsed,
|
|
1519
|
+
rawData: rawData || void 0
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
function readWithTimeout(reader, timeoutMs) {
|
|
1523
|
+
return new Promise(
|
|
1524
|
+
(resolve, reject) => {
|
|
1525
|
+
const timer = setTimeout(
|
|
1526
|
+
() => {
|
|
1527
|
+
reject(
|
|
1528
|
+
new Error(
|
|
1529
|
+
`BCP Testing: timed out waiting for SSE data after ${timeoutMs}ms.`
|
|
1530
|
+
)
|
|
1531
|
+
);
|
|
1532
|
+
},
|
|
1533
|
+
timeoutMs
|
|
1534
|
+
);
|
|
1535
|
+
reader.read().then(
|
|
1536
|
+
(value) => {
|
|
1537
|
+
clearTimeout(timer);
|
|
1538
|
+
resolve(value);
|
|
1539
|
+
},
|
|
1540
|
+
(error) => {
|
|
1541
|
+
clearTimeout(timer);
|
|
1542
|
+
reject(error);
|
|
1543
|
+
}
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
function updateCookieJar(jar, headers2) {
|
|
1549
|
+
const extended = headers2;
|
|
1550
|
+
const values = typeof extended.getSetCookie === "function" ? extended.getSetCookie() : headers2.get("set-cookie") ? [
|
|
1551
|
+
headers2.get("set-cookie")
|
|
1552
|
+
] : [];
|
|
1553
|
+
for (const value of values) {
|
|
1554
|
+
const first = value.split(";", 1)[0] ?? "";
|
|
1555
|
+
const separator = first.indexOf("=");
|
|
1556
|
+
if (separator <= 0) {
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
const name = first.slice(0, separator).trim();
|
|
1560
|
+
const cookieValue = first.slice(separator + 1).trim();
|
|
1561
|
+
const lower = value.toLowerCase();
|
|
1562
|
+
if (cookieValue === "" || lower.includes("max-age=0") || lower.includes("expires=thu, 01 jan 1970")) {
|
|
1563
|
+
jar.delete(name);
|
|
1564
|
+
} else {
|
|
1565
|
+
jar.set(name, cookieValue);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
function serializeCookies(cookies2) {
|
|
1570
|
+
return Array.from(
|
|
1571
|
+
cookies2,
|
|
1572
|
+
([name, value]) => `${name}=${value}`
|
|
1573
|
+
).join("; ");
|
|
1574
|
+
}
|
|
1575
|
+
function normalizeMethod(value) {
|
|
1576
|
+
const method = String(value).trim().toUpperCase();
|
|
1577
|
+
if (!method) {
|
|
1578
|
+
throw new TypeError(
|
|
1579
|
+
"BCP Testing: request method must be a non-empty string."
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
return method;
|
|
1583
|
+
}
|
|
1584
|
+
function normalizeBaseUrl(value) {
|
|
1585
|
+
let url;
|
|
1586
|
+
try {
|
|
1587
|
+
url = new URL(value);
|
|
1588
|
+
} catch {
|
|
1589
|
+
throw new TypeError(
|
|
1590
|
+
"BCP Testing: baseUrl must be an absolute URL."
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
return url.href.endsWith("/") ? url.href : `${url.href}/`;
|
|
1594
|
+
}
|
|
1595
|
+
function resolveTestUrl(baseUrl, path) {
|
|
1596
|
+
const value = String(path ?? "").trim();
|
|
1597
|
+
if (!value) {
|
|
1598
|
+
return baseUrl;
|
|
1599
|
+
}
|
|
1600
|
+
try {
|
|
1601
|
+
return new URL(value).href;
|
|
1602
|
+
} catch {
|
|
1603
|
+
return new URL(
|
|
1604
|
+
value.replace(/^\//, ""),
|
|
1605
|
+
baseUrl
|
|
1606
|
+
).href;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
function normalizeCookieName(value) {
|
|
1610
|
+
const name = normalizeNonEmpty(
|
|
1611
|
+
value,
|
|
1612
|
+
"cookie name"
|
|
1613
|
+
);
|
|
1614
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
|
|
1615
|
+
throw new TypeError(
|
|
1616
|
+
"BCP Testing: cookie name contains invalid characters."
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
return name;
|
|
1620
|
+
}
|
|
1621
|
+
function normalizeHeaderName(value) {
|
|
1622
|
+
return normalizeNonEmpty(
|
|
1623
|
+
value,
|
|
1624
|
+
"header name"
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
function normalizeNonEmpty(value, field) {
|
|
1628
|
+
const text = String(value ?? "").trim();
|
|
1629
|
+
if (!text) {
|
|
1630
|
+
throw new TypeError(
|
|
1631
|
+
`BCP Testing: ${field} must be a non-empty string.`
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
return text;
|
|
1635
|
+
}
|
|
1636
|
+
function positiveInteger(value, field) {
|
|
1637
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1638
|
+
throw new TypeError(
|
|
1639
|
+
`BCP Testing: ${field} must be a positive integer.`
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
return value;
|
|
1643
|
+
}
|
|
1644
|
+
function assertFinite(value, field) {
|
|
1645
|
+
if (!Number.isFinite(value)) {
|
|
1646
|
+
throw new TypeError(
|
|
1647
|
+
`BCP Testing: ${field} must be finite.`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
function safeJson(value) {
|
|
1652
|
+
try {
|
|
1653
|
+
return JSON.stringify(value);
|
|
1654
|
+
} catch {
|
|
1655
|
+
return String(value);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// packages/server/src/page-guard.ts
|
|
1660
|
+
async function executePageGuardFunctions(guards, params, requestUrl) {
|
|
1661
|
+
if (guards.length === 0) {
|
|
1662
|
+
return emptyGuardExecution();
|
|
1663
|
+
}
|
|
1664
|
+
const guardData = {};
|
|
1665
|
+
for (const definition of guards) {
|
|
1666
|
+
const result = await definition.guard({
|
|
1667
|
+
params: {
|
|
1668
|
+
...params
|
|
1669
|
+
},
|
|
1670
|
+
searchParams: new URLSearchParams(
|
|
1671
|
+
requestUrl.searchParams
|
|
1672
|
+
),
|
|
1673
|
+
guardData: {
|
|
1674
|
+
...guardData
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
if (result instanceof Response) {
|
|
1678
|
+
return {
|
|
1679
|
+
hasGuard: true,
|
|
1680
|
+
data: guardData,
|
|
1681
|
+
response: result
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
if (result === void 0) {
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
assertGuardData(
|
|
1688
|
+
result,
|
|
1689
|
+
definition.filePath
|
|
1690
|
+
);
|
|
1691
|
+
Object.assign(
|
|
1692
|
+
guardData,
|
|
1693
|
+
result
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
return {
|
|
1697
|
+
hasGuard: true,
|
|
1698
|
+
data: guardData,
|
|
1699
|
+
response: null
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
function emptyGuardExecution() {
|
|
1703
|
+
return {
|
|
1704
|
+
hasGuard: false,
|
|
1705
|
+
data: {},
|
|
1706
|
+
response: null
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
function assertGuardData(value, label) {
|
|
1710
|
+
if (value === null || Array.isArray(
|
|
1711
|
+
value
|
|
1712
|
+
) || typeof value !== "object") {
|
|
1713
|
+
throw new Error(
|
|
1714
|
+
`BCP Framework: guard "${label}" must return a plain object, undefined, or a Response.`
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
const prototype = Object.getPrototypeOf(
|
|
1718
|
+
value
|
|
1719
|
+
);
|
|
1720
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1721
|
+
throw new Error(
|
|
1722
|
+
`BCP Framework: guard "${label}" must return a plain object, undefined, or a Response.`
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1726
|
+
validateJsonValue(
|
|
1727
|
+
value,
|
|
1728
|
+
"$",
|
|
1729
|
+
seen,
|
|
1730
|
+
label
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
function validateJsonValue(value, location, seen, guardLabel) {
|
|
1734
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (typeof value === "number") {
|
|
1738
|
+
if (Number.isFinite(
|
|
1739
|
+
value
|
|
1740
|
+
)) {
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
throwInvalidGuardData(
|
|
1744
|
+
guardLabel,
|
|
1745
|
+
location,
|
|
1746
|
+
"numbers must be finite"
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
if (typeof value !== "object") {
|
|
1750
|
+
throwInvalidGuardData(
|
|
1751
|
+
guardLabel,
|
|
1752
|
+
location,
|
|
1753
|
+
`unsupported ${typeof value} value`
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
const objectValue = value;
|
|
1757
|
+
if (seen.has(objectValue)) {
|
|
1758
|
+
throwInvalidGuardData(
|
|
1759
|
+
guardLabel,
|
|
1760
|
+
location,
|
|
1761
|
+
"circular references are not supported"
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
seen.add(
|
|
1765
|
+
objectValue
|
|
1766
|
+
);
|
|
1767
|
+
try {
|
|
1768
|
+
if (Array.isArray(value)) {
|
|
1769
|
+
value.forEach(
|
|
1770
|
+
(item, index) => {
|
|
1771
|
+
validateJsonValue(
|
|
1772
|
+
item,
|
|
1773
|
+
`${location}[${index}]`,
|
|
1774
|
+
seen,
|
|
1775
|
+
guardLabel
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const prototype = Object.getPrototypeOf(
|
|
1782
|
+
value
|
|
1783
|
+
);
|
|
1784
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1785
|
+
throwInvalidGuardData(
|
|
1786
|
+
guardLabel,
|
|
1787
|
+
location,
|
|
1788
|
+
"only plain objects and arrays are supported"
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
for (const [
|
|
1792
|
+
key,
|
|
1793
|
+
item
|
|
1794
|
+
] of Object.entries(
|
|
1795
|
+
value
|
|
1796
|
+
)) {
|
|
1797
|
+
validateJsonValue(
|
|
1798
|
+
item,
|
|
1799
|
+
`${location}.${key}`,
|
|
1800
|
+
seen,
|
|
1801
|
+
guardLabel
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
} finally {
|
|
1805
|
+
seen.delete(
|
|
1806
|
+
objectValue
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
function throwInvalidGuardData(guardLabel, location, reason) {
|
|
1811
|
+
throw new Error(
|
|
1812
|
+
`BCP Framework: guard "${guardLabel}" returned non-serializable data at ${location}: ${reason}.`
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// packages/server/src/form-action.ts
|
|
1817
|
+
var PAGE_ACTION_METHODS = [
|
|
1818
|
+
"POST",
|
|
1819
|
+
"PUT",
|
|
1820
|
+
"PATCH",
|
|
1821
|
+
"DELETE"
|
|
1822
|
+
];
|
|
1823
|
+
var ACTION_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
1824
|
+
var BLOCKED_ACTION_NAMES = /* @__PURE__ */ new Set([
|
|
1825
|
+
"default",
|
|
1826
|
+
"__proto__",
|
|
1827
|
+
"prototype",
|
|
1828
|
+
"constructor"
|
|
1829
|
+
]);
|
|
1830
|
+
function normalizePageActionMethod(value) {
|
|
1831
|
+
const method = (value ?? "POST").trim().toUpperCase();
|
|
1832
|
+
if (!PAGE_ACTION_METHODS.includes(
|
|
1833
|
+
method
|
|
1834
|
+
)) {
|
|
1835
|
+
throw new Error(
|
|
1836
|
+
`BCP Framework: unsupported form action method "${method || String(value)}". Use POST, PUT, PATCH, or DELETE.`
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
return method;
|
|
1840
|
+
}
|
|
1841
|
+
function assertPageActionName(name) {
|
|
1842
|
+
if (!ACTION_NAME_PATTERN.test(
|
|
1843
|
+
name
|
|
1844
|
+
) || BLOCKED_ACTION_NAMES.has(
|
|
1845
|
+
name
|
|
1846
|
+
)) {
|
|
1847
|
+
throw new Error(
|
|
1848
|
+
`BCP Framework: invalid form action name "${name}".`
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
async function executePageActionFunction(action, actionName, method, formData, params, requestUrl, guardData = {}, hasGuard = false, label = "actions.ts") {
|
|
1853
|
+
assertPageActionName(
|
|
1854
|
+
actionName
|
|
1855
|
+
);
|
|
1856
|
+
const result = await action(
|
|
1857
|
+
formData,
|
|
1858
|
+
{
|
|
1859
|
+
params: {
|
|
1860
|
+
...params
|
|
1861
|
+
},
|
|
1862
|
+
searchParams: new URLSearchParams(
|
|
1863
|
+
requestUrl.searchParams
|
|
1864
|
+
),
|
|
1865
|
+
guardData: {
|
|
1866
|
+
...guardData
|
|
1867
|
+
},
|
|
1868
|
+
method
|
|
1869
|
+
}
|
|
1870
|
+
);
|
|
1871
|
+
if (result instanceof Response) {
|
|
1872
|
+
return {
|
|
1873
|
+
hasGuard,
|
|
1874
|
+
guardData: {
|
|
1875
|
+
...guardData
|
|
1876
|
+
},
|
|
1877
|
+
data: null,
|
|
1878
|
+
response: result
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
const data = result === void 0 ? null : result;
|
|
1882
|
+
assertActionData(
|
|
1883
|
+
data,
|
|
1884
|
+
`${label}#${actionName}`
|
|
1885
|
+
);
|
|
1886
|
+
return {
|
|
1887
|
+
hasGuard,
|
|
1888
|
+
guardData: {
|
|
1889
|
+
...guardData
|
|
1890
|
+
},
|
|
1891
|
+
data,
|
|
1892
|
+
response: null
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
function assertActionData(value, actionLabel = "action") {
|
|
1896
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1897
|
+
validateJsonValue2(
|
|
1898
|
+
value,
|
|
1899
|
+
"$",
|
|
1900
|
+
seen,
|
|
1901
|
+
actionLabel
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
function validateJsonValue2(value, location, seen, actionLabel) {
|
|
1905
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
if (typeof value === "number") {
|
|
1909
|
+
if (Number.isFinite(
|
|
1910
|
+
value
|
|
1911
|
+
)) {
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
throwInvalidActionData(
|
|
1915
|
+
actionLabel,
|
|
1916
|
+
location,
|
|
1917
|
+
"numbers must be finite"
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
if (typeof value !== "object") {
|
|
1921
|
+
throwInvalidActionData(
|
|
1922
|
+
actionLabel,
|
|
1923
|
+
location,
|
|
1924
|
+
`unsupported ${typeof value} value`
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
const objectValue = value;
|
|
1928
|
+
if (seen.has(objectValue)) {
|
|
1929
|
+
throwInvalidActionData(
|
|
1930
|
+
actionLabel,
|
|
1931
|
+
location,
|
|
1932
|
+
"circular references are not supported"
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
seen.add(
|
|
1936
|
+
objectValue
|
|
1937
|
+
);
|
|
1938
|
+
try {
|
|
1939
|
+
if (Array.isArray(value)) {
|
|
1940
|
+
value.forEach(
|
|
1941
|
+
(item, index) => {
|
|
1942
|
+
validateJsonValue2(
|
|
1943
|
+
item,
|
|
1944
|
+
`${location}[${index}]`,
|
|
1945
|
+
seen,
|
|
1946
|
+
actionLabel
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
);
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
const prototype = Object.getPrototypeOf(
|
|
1953
|
+
value
|
|
1954
|
+
);
|
|
1955
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1956
|
+
throwInvalidActionData(
|
|
1957
|
+
actionLabel,
|
|
1958
|
+
location,
|
|
1959
|
+
"only plain objects and arrays are supported"
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
for (const [
|
|
1963
|
+
key,
|
|
1964
|
+
item
|
|
1965
|
+
] of Object.entries(
|
|
1966
|
+
value
|
|
1967
|
+
)) {
|
|
1968
|
+
validateJsonValue2(
|
|
1969
|
+
item,
|
|
1970
|
+
`${location}.${key}`,
|
|
1971
|
+
seen,
|
|
1972
|
+
actionLabel
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
} finally {
|
|
1976
|
+
seen.delete(
|
|
1977
|
+
objectValue
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
function throwInvalidActionData(actionLabel, location, reason) {
|
|
1982
|
+
throw new Error(
|
|
1983
|
+
`BCP Framework: action "${actionLabel}" returned non-serializable data at ${location}: ${reason}.`
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// packages/server/src/page-loader.ts
|
|
1988
|
+
var GUARD_DATA_HEADER = "x-bcp-guard-data";
|
|
1989
|
+
async function executePageLoaderFunction(loader, params, requestUrl, label = "loader", explicitGuardData, explicitHasGuard) {
|
|
1990
|
+
const headerGuardData = explicitGuardData === void 0 ? await readGuardDataHeader() : null;
|
|
1991
|
+
const guardData = explicitGuardData ?? headerGuardData?.data ?? {};
|
|
1992
|
+
const hasGuard = explicitHasGuard ?? headerGuardData?.hasGuard ?? false;
|
|
1993
|
+
const result = await loader({
|
|
1994
|
+
params: {
|
|
1995
|
+
...params
|
|
1996
|
+
},
|
|
1997
|
+
searchParams: new URLSearchParams(
|
|
1998
|
+
requestUrl.searchParams
|
|
1999
|
+
),
|
|
2000
|
+
guardData: {
|
|
2001
|
+
...guardData
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
if (result instanceof Response) {
|
|
2005
|
+
return createExecution({
|
|
2006
|
+
hasPageLoader: true,
|
|
2007
|
+
hasGuard,
|
|
2008
|
+
loaderData: void 0,
|
|
2009
|
+
guardData,
|
|
2010
|
+
response: result
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
assertLoaderData(
|
|
2014
|
+
result,
|
|
2015
|
+
label
|
|
2016
|
+
);
|
|
2017
|
+
return createExecution({
|
|
2018
|
+
hasPageLoader: true,
|
|
2019
|
+
hasGuard,
|
|
2020
|
+
loaderData: result,
|
|
2021
|
+
guardData,
|
|
2022
|
+
response: null
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
function createExecution(input) {
|
|
2026
|
+
const data = {
|
|
2027
|
+
__bcp_server_data: true,
|
|
2028
|
+
hasPageLoader: input.hasPageLoader,
|
|
2029
|
+
hasGuard: input.hasGuard,
|
|
2030
|
+
loaderData: input.loaderData,
|
|
2031
|
+
guardData: {
|
|
2032
|
+
...input.guardData
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
return {
|
|
2036
|
+
hasLoader: input.hasPageLoader || input.hasGuard,
|
|
2037
|
+
hasPageLoader: input.hasPageLoader,
|
|
2038
|
+
hasGuard: input.hasGuard,
|
|
2039
|
+
data,
|
|
2040
|
+
loaderData: input.loaderData,
|
|
2041
|
+
guardData: data.guardData,
|
|
2042
|
+
response: input.response
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
async function readGuardDataHeader() {
|
|
2046
|
+
let requestHeaders;
|
|
2047
|
+
try {
|
|
2048
|
+
requestHeaders = await headers();
|
|
2049
|
+
} catch {
|
|
2050
|
+
return null;
|
|
2051
|
+
}
|
|
2052
|
+
const encoded = requestHeaders.get(
|
|
2053
|
+
GUARD_DATA_HEADER
|
|
2054
|
+
);
|
|
2055
|
+
if (!encoded) {
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
2058
|
+
let parsed;
|
|
2059
|
+
try {
|
|
2060
|
+
parsed = JSON.parse(
|
|
2061
|
+
Buffer.from(
|
|
2062
|
+
encoded,
|
|
2063
|
+
"base64url"
|
|
2064
|
+
).toString(
|
|
2065
|
+
"utf8"
|
|
2066
|
+
)
|
|
2067
|
+
);
|
|
2068
|
+
} catch {
|
|
2069
|
+
throw new Error(
|
|
2070
|
+
"BCP Framework: invalid internal guard data header."
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
assertGuardHeaderData(
|
|
2074
|
+
parsed
|
|
2075
|
+
);
|
|
2076
|
+
return {
|
|
2077
|
+
hasGuard: true,
|
|
2078
|
+
data: parsed
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
function assertGuardHeaderData(value) {
|
|
2082
|
+
if (value === null || Array.isArray(
|
|
2083
|
+
value
|
|
2084
|
+
) || typeof value !== "object") {
|
|
2085
|
+
throw new Error(
|
|
2086
|
+
"BCP Framework: invalid internal guard data header."
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
const prototype = Object.getPrototypeOf(
|
|
2090
|
+
value
|
|
2091
|
+
);
|
|
2092
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
2093
|
+
throw new Error(
|
|
2094
|
+
"BCP Framework: invalid internal guard data header."
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function assertLoaderData(value, loaderLabel) {
|
|
2099
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2100
|
+
validateJsonValue3(
|
|
2101
|
+
value,
|
|
2102
|
+
"$",
|
|
2103
|
+
seen,
|
|
2104
|
+
loaderLabel
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
function validateJsonValue3(value, location, seen, loaderLabel) {
|
|
2108
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (typeof value === "number") {
|
|
2112
|
+
if (Number.isFinite(
|
|
2113
|
+
value
|
|
2114
|
+
)) {
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
throwInvalidLoaderData(
|
|
2118
|
+
loaderLabel,
|
|
2119
|
+
location,
|
|
2120
|
+
"numbers must be finite"
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
if (typeof value !== "object") {
|
|
2124
|
+
throwInvalidLoaderData(
|
|
2125
|
+
loaderLabel,
|
|
2126
|
+
location,
|
|
2127
|
+
`unsupported ${typeof value} value`
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
const objectValue = value;
|
|
2131
|
+
if (seen.has(objectValue)) {
|
|
2132
|
+
throwInvalidLoaderData(
|
|
2133
|
+
loaderLabel,
|
|
2134
|
+
location,
|
|
2135
|
+
"circular references are not supported"
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
seen.add(
|
|
2139
|
+
objectValue
|
|
2140
|
+
);
|
|
2141
|
+
try {
|
|
2142
|
+
if (Array.isArray(value)) {
|
|
2143
|
+
value.forEach(
|
|
2144
|
+
(item, index) => {
|
|
2145
|
+
validateJsonValue3(
|
|
2146
|
+
item,
|
|
2147
|
+
`${location}[${index}]`,
|
|
2148
|
+
seen,
|
|
2149
|
+
loaderLabel
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
2152
|
+
);
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const prototype = Object.getPrototypeOf(
|
|
2156
|
+
value
|
|
2157
|
+
);
|
|
2158
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
2159
|
+
throwInvalidLoaderData(
|
|
2160
|
+
loaderLabel,
|
|
2161
|
+
location,
|
|
2162
|
+
"only plain objects and arrays are supported"
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
for (const [
|
|
2166
|
+
key,
|
|
2167
|
+
item
|
|
2168
|
+
] of Object.entries(
|
|
2169
|
+
value
|
|
2170
|
+
)) {
|
|
2171
|
+
validateJsonValue3(
|
|
2172
|
+
item,
|
|
2173
|
+
`${location}.${key}`,
|
|
2174
|
+
seen,
|
|
2175
|
+
loaderLabel
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
} finally {
|
|
2179
|
+
seen.delete(
|
|
2180
|
+
objectValue
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
function throwInvalidLoaderData(loaderLabel, location, reason) {
|
|
2185
|
+
throw new Error(
|
|
2186
|
+
`BCP Framework: loader "${loaderLabel}" returned non-serializable data at ${location}: ${reason}.`
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// packages/server/src/testing-page.ts
|
|
2191
|
+
async function runTestPageLoader(loader, options = {}) {
|
|
2192
|
+
assertFunction(
|
|
2193
|
+
loader,
|
|
2194
|
+
"page loader"
|
|
2195
|
+
);
|
|
2196
|
+
return executePageLoaderFunction(
|
|
2197
|
+
loader,
|
|
2198
|
+
{
|
|
2199
|
+
...options.params ?? {}
|
|
2200
|
+
},
|
|
2201
|
+
resolveTestPageUrl(
|
|
2202
|
+
options.url
|
|
2203
|
+
),
|
|
2204
|
+
"bcp/testing loader",
|
|
2205
|
+
{
|
|
2206
|
+
...options.guardData ?? {}
|
|
2207
|
+
},
|
|
2208
|
+
options.hasGuard ?? Object.keys(
|
|
2209
|
+
options.guardData ?? {}
|
|
2210
|
+
).length > 0
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
async function runTestPageGuards(guards, options = {}) {
|
|
2214
|
+
const list = Array.isArray(guards) ? guards : [guards];
|
|
2215
|
+
const definitions = list.map(
|
|
2216
|
+
(candidate, index) => {
|
|
2217
|
+
if (typeof candidate === "object" && candidate !== null && "guard" in candidate) {
|
|
2218
|
+
const definition = candidate;
|
|
2219
|
+
assertFunction(
|
|
2220
|
+
definition.guard,
|
|
2221
|
+
`page guard ${index + 1}`
|
|
2222
|
+
);
|
|
2223
|
+
return {
|
|
2224
|
+
filePath: definition.filePath || `${options.label ?? "bcp/testing guard"}#${index + 1}`,
|
|
2225
|
+
guard: definition.guard
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
assertFunction(
|
|
2229
|
+
candidate,
|
|
2230
|
+
`page guard ${index + 1}`
|
|
2231
|
+
);
|
|
2232
|
+
return {
|
|
2233
|
+
filePath: `${options.label ?? "bcp/testing guard"}#${index + 1}`,
|
|
2234
|
+
guard: candidate
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
);
|
|
2238
|
+
return executePageGuardFunctions(
|
|
2239
|
+
definitions,
|
|
2240
|
+
{
|
|
2241
|
+
...options.params ?? {}
|
|
2242
|
+
},
|
|
2243
|
+
resolveTestPageUrl(
|
|
2244
|
+
options.url
|
|
2245
|
+
)
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
async function runTestPageAction(action, options = {}) {
|
|
2249
|
+
assertFunction(
|
|
2250
|
+
action,
|
|
2251
|
+
"page action"
|
|
2252
|
+
);
|
|
2253
|
+
if (options.formData && options.fields) {
|
|
2254
|
+
throw new TypeError(
|
|
2255
|
+
"BCP Testing: page action test cannot specify both formData and fields."
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
const actionName = normalizeText(
|
|
2259
|
+
options.actionName ?? "action",
|
|
2260
|
+
"page action name"
|
|
2261
|
+
);
|
|
2262
|
+
const method = normalizePageActionMethod(
|
|
2263
|
+
options.method ?? "POST"
|
|
2264
|
+
);
|
|
2265
|
+
const formData = options.formData ?? createTestFormData(
|
|
2266
|
+
options.fields ?? {}
|
|
2267
|
+
);
|
|
2268
|
+
return executePageActionFunction(
|
|
2269
|
+
action,
|
|
2270
|
+
actionName,
|
|
2271
|
+
method,
|
|
2272
|
+
formData,
|
|
2273
|
+
{
|
|
2274
|
+
...options.params ?? {}
|
|
2275
|
+
},
|
|
2276
|
+
resolveTestPageUrl(
|
|
2277
|
+
options.url
|
|
2278
|
+
),
|
|
2279
|
+
{
|
|
2280
|
+
...options.guardData ?? {}
|
|
2281
|
+
},
|
|
2282
|
+
options.hasGuard ?? Object.keys(
|
|
2283
|
+
options.guardData ?? {}
|
|
2284
|
+
).length > 0,
|
|
2285
|
+
options.label ?? "bcp/testing actions"
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
function createTestFormData(fields = {}) {
|
|
2289
|
+
const formData = new FormData();
|
|
2290
|
+
for (const [name, rawValue] of Object.entries(fields)) {
|
|
2291
|
+
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
2292
|
+
for (const value of values) {
|
|
2293
|
+
formData.append(
|
|
2294
|
+
name,
|
|
2295
|
+
value
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
return formData;
|
|
2300
|
+
}
|
|
2301
|
+
function resolveTestPageUrl(value) {
|
|
2302
|
+
if (value instanceof URL) {
|
|
2303
|
+
return new URL(
|
|
2304
|
+
value.href
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
const candidate = value ?? "http://bcp.test/";
|
|
2308
|
+
try {
|
|
2309
|
+
return new URL(
|
|
2310
|
+
candidate
|
|
2311
|
+
);
|
|
2312
|
+
} catch {
|
|
2313
|
+
return new URL(
|
|
2314
|
+
String(candidate).replace(
|
|
2315
|
+
/^\//,
|
|
2316
|
+
""
|
|
2317
|
+
),
|
|
2318
|
+
"http://bcp.test/"
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
function assertFunction(value, label) {
|
|
2323
|
+
if (typeof value !== "function") {
|
|
2324
|
+
throw new TypeError(
|
|
2325
|
+
`BCP Testing: ${label} must be a function.`
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
function normalizeText(value, label) {
|
|
2330
|
+
const text = String(value ?? "").trim();
|
|
2331
|
+
if (!text) {
|
|
2332
|
+
throw new TypeError(
|
|
2333
|
+
`BCP Testing: ${label} must be a non-empty string.`
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
return text;
|
|
2337
|
+
}
|
|
2338
|
+
export {
|
|
2339
|
+
createFakeClock,
|
|
2340
|
+
createJobTestHarness,
|
|
2341
|
+
createOutboxTestHarness,
|
|
2342
|
+
createRealtimeTestHarness,
|
|
2343
|
+
createRealtimeTestSocket,
|
|
2344
|
+
createRouteTestHandler,
|
|
2345
|
+
createSequenceIdFactory,
|
|
2346
|
+
createTestApp,
|
|
2347
|
+
createTestAuthSession,
|
|
2348
|
+
createTestFormData,
|
|
2349
|
+
createWorkflowTestHarness,
|
|
2350
|
+
expectResponse,
|
|
2351
|
+
readSseEvents,
|
|
2352
|
+
runTestMiddleware,
|
|
2353
|
+
runTestPageAction,
|
|
2354
|
+
runTestPageGuards,
|
|
2355
|
+
runTestPageLoader,
|
|
2356
|
+
withTestTransaction
|
|
2357
|
+
};
|