@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.
@@ -0,0 +1,602 @@
1
+ # Testing Platform
2
+
3
+ BCP `0.2.14` adds the server-only `bcp/testing` entrypoint for framework-native testing without forcing Jest, Vitest or another test runner.
4
+
5
+ The APIs work with Node's built-in `node:test`, but they are assertion-runner neutral and can also be used from other test runners.
6
+
7
+ ## Import
8
+
9
+ ```ts
10
+ import {
11
+ createRouteTestHandler,
12
+ createTestApp,
13
+ expectResponse,
14
+ runTestPageAction,
15
+ runTestPageGuards,
16
+ runTestPageLoader,
17
+ } from "bcp/testing";
18
+ ```
19
+
20
+ `bcp/testing` is server-only and must not be imported from browser/client bundles.
21
+
22
+ ## Request harness
23
+
24
+ `createTestApp()` wraps a Request -> Response handler and provides request helpers, default headers and an in-memory cookie jar.
25
+
26
+ ```ts
27
+ const app =
28
+ createTestApp({
29
+ handler:
30
+ async request => {
31
+ return Response.json({
32
+ method:
33
+ request.method,
34
+ });
35
+ },
36
+ });
37
+
38
+ const response =
39
+ await app.get("/api/health");
40
+
41
+ await expectResponse(response)
42
+ .status(200)
43
+ .json({
44
+ method: "GET",
45
+ });
46
+ ```
47
+
48
+ Available request methods:
49
+
50
+ ```text
51
+ request()
52
+ get()
53
+ post()
54
+ put()
55
+ patch()
56
+ delete()
57
+ ```
58
+
59
+ JSON request bodies can be sent with:
60
+
61
+ ```ts
62
+ await app.post(
63
+ "/api/users",
64
+ {
65
+ json: {
66
+ name: "BCP",
67
+ },
68
+ }
69
+ );
70
+ ```
71
+
72
+ When a response contains `Set-Cookie`, the test app updates its cookie jar and sends those cookies on later requests.
73
+
74
+ ```ts
75
+ app.setCookie(
76
+ "theme",
77
+ "dark"
78
+ );
79
+
80
+ app.cookies();
81
+ app.clearCookie("theme");
82
+ ```
83
+
84
+ Default headers can also be changed between requests with `setHeader()` / `deleteHeader()`. Call `app.reset()` to restore the initial headers and cookies.
85
+
86
+ ## API route handler testing
87
+
88
+ `createRouteTestHandler()` turns a BCP-style route module into a Request -> Response handler.
89
+
90
+ ```ts
91
+ const handler =
92
+ createRouteTestHandler({
93
+ GET() {
94
+ return {
95
+ ok: true,
96
+ };
97
+ },
98
+
99
+ async POST(request) {
100
+ return Response.json({
101
+ received:
102
+ await request.json(),
103
+ });
104
+ },
105
+ });
106
+ ```
107
+
108
+ Supported route method exports:
109
+
110
+ ```text
111
+ GET
112
+ POST
113
+ PUT
114
+ PATCH
115
+ DELETE
116
+ HEAD
117
+ OPTIONS
118
+ ```
119
+
120
+ Missing methods return `405 Method Not Allowed` with an `Allow` header.
121
+
122
+ Non-Response route values are normalized for tests:
123
+
124
+ ```text
125
+ object/array/value -> Response.json(value)
126
+ string -> text Response
127
+ undefined/null -> 204 Response
128
+ ```
129
+
130
+ A custom route context can be supplied through `createRouteTestHandler(route, { context })`.
131
+
132
+ ## Page guard testing
133
+
134
+ `runTestPageGuards()` calls the real `executePageGuardFunctions()` runtime.
135
+
136
+ ```ts
137
+ const result =
138
+ await runTestPageGuards(
139
+ [
140
+ ({ params }) => ({
141
+ userId:
142
+ params.userId,
143
+ }),
144
+ ({ guardData }) => ({
145
+ allowed:
146
+ guardData.userId ===
147
+ "42",
148
+ }),
149
+ ],
150
+ {
151
+ params: {
152
+ userId: "42",
153
+ },
154
+ url:
155
+ "/users/42?mode=edit",
156
+ }
157
+ );
158
+ ```
159
+
160
+ Guard data is chained in the same order as production execution. A guard `Response` such as a redirect stops the chain and is returned in `result.response`.
161
+
162
+ The helper accepts either guard functions directly or `PageGuardDefinition` objects when a specific label/file path is useful.
163
+
164
+ ## Page loader testing
165
+
166
+ `runTestPageLoader()` calls the production `executePageLoaderFunction()` path and provides params, search params and guard data.
167
+
168
+ ```ts
169
+ const result =
170
+ await runTestPageLoader(
171
+ async ({
172
+ params,
173
+ searchParams,
174
+ guardData,
175
+ }) => ({
176
+ id: params.id,
177
+ tab:
178
+ searchParams.get(
179
+ "tab"
180
+ ),
181
+ role:
182
+ guardData.role,
183
+ }),
184
+ {
185
+ params: {
186
+ id: "7",
187
+ },
188
+ url:
189
+ "/users/7?tab=profile",
190
+ guardData: {
191
+ role: "admin",
192
+ },
193
+ }
194
+ );
195
+ ```
196
+
197
+ The result is the actual `PageLoaderExecution`, including `loaderData`, `guardData`, `hasPageLoader`, `hasGuard` and a possible Response short-circuit.
198
+
199
+ ## Form action testing
200
+
201
+ `runTestPageAction()` calls the real `executePageActionFunction()` runtime.
202
+
203
+ ```ts
204
+ const result =
205
+ await runTestPageAction(
206
+ async (
207
+ formData,
208
+ context
209
+ ) => ({
210
+ name:
211
+ formData.get(
212
+ "name"
213
+ ),
214
+ method:
215
+ context.method,
216
+ userId:
217
+ context.params.userId,
218
+ }),
219
+ {
220
+ actionName:
221
+ "updateUser",
222
+ method: "PATCH",
223
+ params: {
224
+ userId: "42",
225
+ },
226
+ fields: {
227
+ name: "BCP",
228
+ tag: [
229
+ "framework",
230
+ "testing",
231
+ ],
232
+ },
233
+ }
234
+ );
235
+ ```
236
+
237
+ Use `fields` for simple form construction or provide an existing `FormData` through `formData`. Specifying both is rejected.
238
+
239
+ `createTestFormData()` is also exported for repeated/multipart-like field setup:
240
+
241
+ ```ts
242
+ const formData =
243
+ createTestFormData({
244
+ role: [
245
+ "admin",
246
+ "developer",
247
+ ],
248
+ });
249
+ ```
250
+
251
+ Action method validation, JSON-safe action data validation and Response short-circuit behavior all come from the production Form Action runtime.
252
+
253
+ ## Response assertions
254
+
255
+ `expectResponse()` provides lightweight assertions without coupling BCP to a test framework.
256
+
257
+ ```ts
258
+ await expectResponse(response)
259
+ .status(200)
260
+ .header(
261
+ "content-type",
262
+ /application\/json/
263
+ )
264
+ .json({
265
+ ok: true,
266
+ });
267
+ ```
268
+
269
+ Available assertions:
270
+
271
+ ```text
272
+ status()
273
+ header()
274
+ text()
275
+ json()
276
+ jsonMatches()
277
+ ```
278
+
279
+ `jsonMatches()` checks only the provided object keys.
280
+
281
+ ## Signed auth sessions
282
+
283
+ `createTestAuthSession()` creates the same signed HS256 session token used by BCP Auth.
284
+
285
+ ```ts
286
+ const session =
287
+ await createTestAuthSession(
288
+ {
289
+ id: 42,
290
+ role: "admin",
291
+ },
292
+ {
293
+ secret:
294
+ process.env.BCP_SESSION_SECRET,
295
+ }
296
+ );
297
+
298
+ app.setCookie(
299
+ session.cookieName,
300
+ session.token
301
+ );
302
+ ```
303
+
304
+ When an `AuthSessionStore` is provided, the helper also writes the matching server-side session record.
305
+
306
+ Supported options include `sid`, session data, `expiresIn`, issuer, audience, custom cookie name, custom ID factory and `AuthSessionStore`.
307
+
308
+ This allows auth/role/permission route tests to use real BCP session verification rather than a fake user header.
309
+
310
+ ## Rollback database tests
311
+
312
+ Use `withTestTransaction()` to run a test inside a real BCP database transaction and force rollback after the callback succeeds.
313
+
314
+ ```ts
315
+ const result =
316
+ await withTestTransaction(
317
+ db,
318
+ async tx => {
319
+ await tx.execute(
320
+ "INSERT INTO users (name) VALUES (?)",
321
+ ["Test User"]
322
+ );
323
+
324
+ return tx.query(
325
+ "SELECT * FROM users"
326
+ );
327
+ }
328
+ );
329
+ ```
330
+
331
+ The callback result is returned to the test, but BCP deliberately throws an internal rollback signal before the transaction can commit. If the callback itself throws, the original error is preserved.
332
+
333
+ This helper assumes the configured database adapter rolls back a transaction when its callback rejects, which is the BCP database adapter contract.
334
+
335
+ ## Middleware tests
336
+
337
+ `runTestMiddleware()` executes the real Middleware System v2 onion pipeline.
338
+
339
+ ```ts
340
+ const response =
341
+ await runTestMiddleware(
342
+ middlewareModule,
343
+ {
344
+ url:
345
+ "http://bcp.test/dashboard",
346
+ },
347
+ request =>
348
+ Response.json({
349
+ header:
350
+ request.headers.get(
351
+ "x-middleware"
352
+ ),
353
+ })
354
+ );
355
+ ```
356
+
357
+ This supports matcher logic, request/response headers, redirects, rewrites, middleware state and `await next()` behavior through the framework runtime.
358
+
359
+ ## Fake clock
360
+
361
+ Use `createFakeClock()` when testing jobs, schedulers, workflows or other time-sensitive code.
362
+
363
+ ```ts
364
+ const clock =
365
+ createFakeClock(1_000);
366
+
367
+ clock.now();
368
+ clock.advance(500);
369
+ clock.set(10_000);
370
+ clock.reset();
371
+ ```
372
+
373
+ Pass `clock.now` to runtime options that accept a `now` function.
374
+
375
+ ## Deterministic IDs
376
+
377
+ ```ts
378
+ const idFactory =
379
+ createSequenceIdFactory(
380
+ "job",
381
+ 1
382
+ );
383
+
384
+ idFactory(); // job-1
385
+ idFactory(); // job-2
386
+ ```
387
+
388
+ This is useful for exact event/job/run assertions.
389
+
390
+ ## Background job harness
391
+
392
+ ```ts
393
+ const jobs =
394
+ createJobTestHarness(
395
+ queue
396
+ );
397
+
398
+ await jobs.drain();
399
+
400
+ await jobs.expectCount(
401
+ 1,
402
+ {
403
+ name: "email.welcome",
404
+ state: "succeeded",
405
+ }
406
+ );
407
+ ```
408
+
409
+ Available helpers are `drain()`, `records()`, `count()` and `expectCount()`.
410
+
411
+ `drain()` processes currently eligible jobs synchronously by calling the queue's real `processNext()` API until the queue is idle or `maxJobs` is reached. Delayed jobs remain pending until the queue clock reaches their `availableAt` value.
412
+
413
+ ## Workflow harness
414
+
415
+ ```ts
416
+ const workflows =
417
+ createWorkflowTestHarness(
418
+ onboarding
419
+ );
420
+
421
+ const run =
422
+ await workflows.startAndRun({
423
+ userId: 42,
424
+ });
425
+
426
+ await workflows.expectState(
427
+ run.id,
428
+ "succeeded"
429
+ );
430
+ ```
431
+
432
+ `runUntilIdle()` continues a workflow until it becomes terminal or reaches a persisted waiting state. For delay-oriented tests, `forceWaiting: true` can resume waiting workflows immediately.
433
+
434
+ ## Transactional outbox harness
435
+
436
+ ```ts
437
+ const events =
438
+ createOutboxTestHarness(
439
+ outboxStore,
440
+ dispatcher
441
+ );
442
+
443
+ await events.dispatchUntilIdle();
444
+
445
+ await events.expectState(
446
+ eventId,
447
+ "published"
448
+ );
449
+ ```
450
+
451
+ The harness uses the real `OutboxStore` and `OutboxDispatcher`; it does not bypass leases, retry logic or delivery state transitions.
452
+
453
+ `dispatchUntilIdle()` stops when a batch publishes zero events, so retry-delayed failures are not artificially time-skipped.
454
+
455
+ ## Realtime test socket
456
+
457
+ `createRealtimeTestSocket()` implements the provider-neutral `RealtimeSocket` contract entirely in memory.
458
+
459
+ ```ts
460
+ const socket =
461
+ createRealtimeTestSocket();
462
+
463
+ await realtime.attachSocket(
464
+ socket
465
+ );
466
+
467
+ await socket.receive({
468
+ type: "join",
469
+ channel: "orders:42",
470
+ });
471
+ ```
472
+
473
+ Inspect server-to-client messages with `socket.sent()` / `socket.messages()`. Simulate client close/error events with `closeFromClient()` and `fail()`.
474
+
475
+ ## Realtime harness
476
+
477
+ ```ts
478
+ const realtimeTest =
479
+ createRealtimeTestHarness(
480
+ realtime
481
+ );
482
+
483
+ const {
484
+ socket,
485
+ connection,
486
+ } =
487
+ await realtimeTest.connect();
488
+
489
+ await connection.join(
490
+ "orders:42"
491
+ );
492
+
493
+ await realtime.broadcast(
494
+ "orders:42",
495
+ "order.updated",
496
+ {
497
+ status: "paid",
498
+ }
499
+ );
500
+
501
+ const event =
502
+ realtimeTest.expectEvent(
503
+ socket,
504
+ "order.updated",
505
+ "orders:42"
506
+ );
507
+ ```
508
+
509
+ The harness uses the actual `RealtimeHub`, channel membership and broker dispatch path.
510
+
511
+ ## SSE testing
512
+
513
+ `readSseEvents()` reads structured events from a Server-Sent Events response.
514
+
515
+ ```ts
516
+ const response =
517
+ realtime.sse(
518
+ "workflow:42"
519
+ );
520
+
521
+ await realtime.broadcast(
522
+ "workflow:42",
523
+ "workflow.progress",
524
+ {
525
+ progress: 50,
526
+ }
527
+ );
528
+
529
+ const [event] =
530
+ await readSseEvents(
531
+ response,
532
+ {
533
+ limit: 1,
534
+ timeoutMs: 1_000,
535
+ }
536
+ );
537
+ ```
538
+
539
+ SSE comments and retry frames are ignored; `data:` is JSON-decoded when possible.
540
+
541
+ ## Test runner neutrality
542
+
543
+ BCP does not depend on Jest or Vitest.
544
+
545
+ Recommended built-in setup:
546
+
547
+ ```ts
548
+ import assert from "node:assert/strict";
549
+ import {
550
+ test,
551
+ } from "node:test";
552
+ ```
553
+
554
+ All `bcp/testing` helpers can also be used from another runner.
555
+
556
+ ## Production boundary
557
+
558
+ `bcp/testing` is intended for tests and development tooling. It is server-only and the BCP client-boundary validator rejects it from pages/client islands.
559
+
560
+ Applications should avoid importing `bcp/testing` from production request modules unless the deployed code intentionally includes a test-only route or diagnostic surface.
561
+
562
+ ## 0.2.14 scope
563
+
564
+ Included:
565
+
566
+ ```text
567
+ Request/Response test app
568
+ cookie jar and default headers
569
+ API route module testing
570
+ page guard harness
571
+ page loader harness
572
+ form action harness
573
+ FormData helper
574
+ response assertions
575
+ real signed BCP auth sessions
576
+ optional AuthSessionStore registration
577
+ rollback transaction helper
578
+ real middleware pipeline runner
579
+ fake clock
580
+ deterministic ID factory
581
+ background job harness
582
+ workflow harness
583
+ outbox harness
584
+ fake realtime socket
585
+ realtime harness
586
+ SSE reader
587
+ compiled testing.mjs package runtime
588
+ ```
589
+
590
+ Not included in this milestone:
591
+
592
+ ```text
593
+ browser DOM testing
594
+ Playwright wrapper
595
+ React component renderer
596
+ snapshot framework
597
+ coverage runner
598
+ Jest/Vitest dependency
599
+ containerized database provisioning
600
+ ```
601
+
602
+ Those can be layered on top without changing the base `bcp/testing` contract.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -82,6 +82,11 @@
82
82
  "browser": "./packages/client/src/server-only.browser.mjs",
83
83
  "default": "./packages/client/src/realtime.mjs"
84
84
  },
85
+ "./testing": {
86
+ "types": "./packages/client/src/testing.ts",
87
+ "browser": "./packages/client/src/server-only.browser.mjs",
88
+ "default": "./packages/client/src/testing.mjs"
89
+ },
85
90
  "./observability": {
86
91
  "types": "./packages/client/src/observability.ts",
87
92
  "browser": "./packages/client/src/server-only.browser.mjs",
@@ -32,6 +32,7 @@ const SERVER_ONLY_IMPORTS =
32
32
  "bcp/workflow",
33
33
  "bcp/events",
34
34
  "bcp/realtime",
35
+ "bcp/testing",
35
36
  "bcp/observability",
36
37
  ]);
37
38