@chidchanun/bcp 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ # BCP Framework 0.2.15 — Plugin & Module Platform
2
+
3
+ **Release state:** unreleased
4
+
5
+ BCP `0.2.15` adds a server-only plugin/module composition layer through `bcp/plugins`.
6
+
7
+ ## Highlights
8
+
9
+ - new `bcp/plugins` public entrypoint,
10
+ - `definePlugin()` plugin definitions,
11
+ - `defineModule()` named plugin bundles,
12
+ - `createPluginHost()` lifecycle orchestration,
13
+ - required and optional plugin dependencies,
14
+ - deterministic topological dependency ordering,
15
+ - dependency cycle and missing-dependency errors,
16
+ - setup/start/stop/dispose lifecycle hooks,
17
+ - reverse shutdown/disposal ordering,
18
+ - startup rollback when a dependent plugin fails,
19
+ - inspectable plugin runtime state,
20
+ - plugin-local config parser/schema contract,
21
+ - host-level config overrides,
22
+ - shared string/Symbol service registry,
23
+ - asynchronous in-process hook bus,
24
+ - server-only browser boundary,
25
+ - compiled `plugins.mjs` npm runtime,
26
+ - unit and prepared-package smoke coverage.
27
+
28
+ ## Public API
29
+
30
+ ```ts
31
+ import {
32
+ createPluginHookBus,
33
+ createPluginHost,
34
+ createPluginServiceRegistry,
35
+ defineModule,
36
+ definePlugin,
37
+ PluginDependencyError,
38
+ PluginLifecycleError,
39
+ } from "bcp/plugins";
40
+ ```
41
+
42
+ ## Dependency lifecycle
43
+
44
+ ```text
45
+ database
46
+
47
+ jobs
48
+
49
+ workflow
50
+ ```
51
+
52
+ Startup follows dependency order. Stop/dispose runs in reverse order.
53
+
54
+ If a plugin fails during startup, already-started plugins from that transition are stopped before the failure is propagated.
55
+
56
+ ## Modules
57
+
58
+ Modules are composition bundles rather than a second runtime:
59
+
60
+ ```ts
61
+ const backend =
62
+ defineModule({
63
+ name: "backend",
64
+ plugins: [
65
+ databasePlugin,
66
+ jobsPlugin,
67
+ workflowPlugin,
68
+ ],
69
+ });
70
+ ```
71
+
72
+ All plugins still share one host-level dependency graph.
73
+
74
+ ## Configuration
75
+
76
+ Plugin definitions can provide defaults and a parser/schema. `PluginHostOptions.configs` can override raw configuration per plugin before parsing.
77
+
78
+ Invalid configuration fails at the plugin setup boundary.
79
+
80
+ ## Extension services
81
+
82
+ `PluginServiceRegistry` provides explicit shared service lookup between plugins.
83
+
84
+ `PluginHookBus` provides awaited in-process extension hooks. It is not a durable event system; `bcp/events` remains the durable integration-event platform.
85
+
86
+ ## Package/runtime contract
87
+
88
+ The prepared npm package exposes:
89
+
90
+ ```text
91
+ bcp/plugins
92
+ types -> packages/client/src/plugins.ts
93
+ browser -> packages/client/src/server-only.browser.mjs
94
+ default -> packages/client/src/plugins.mjs
95
+ ```
96
+
97
+ `plugins.mjs` is compiled during package preparation.
98
+
99
+ ## Compatibility
100
+
101
+ `0.2.15` has no intentional breaking changes from `0.2.14`.
102
+
103
+ Existing BCP entrypoints remain supported.
104
+
105
+ ## Validation
106
+
107
+ Before publishing:
108
+
109
+ ```bash
110
+ npm run typecheck
111
+ npm run test:unit
112
+ npm run test:integration
113
+ npm run test:e2e
114
+ npm run test:package
115
+ npm run rc:check
116
+ ```
117
+
118
+ Do not tag or publish until the exact final release commit passes the full RC sequence.
@@ -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.