@kronos-ts/test 0.1.0

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,82 @@
1
+ /**
2
+ * Create a fresh Recordings handle. Pass it into {@link testRecordingExtension}
3
+ * so the fixture and the decorators share the same backing arrays.
4
+ *
5
+ * Replaces the legacy "register testRecordings as a component, retrieve via
6
+ * configuration.getComponent" pattern (removed in Phase 8).
7
+ */
8
+ export function createRecordings() {
9
+ const recordedEvents = [];
10
+ const recordedCommands = [];
11
+ const internal = {
12
+ events() {
13
+ return [...recordedEvents];
14
+ },
15
+ commands() {
16
+ return [...recordedCommands];
17
+ },
18
+ reset() {
19
+ recordedEvents.length = 0;
20
+ recordedCommands.length = 0;
21
+ },
22
+ _push: {
23
+ event: (e) => {
24
+ recordedEvents.push(e);
25
+ },
26
+ command: (c) => {
27
+ recordedCommands.push(c);
28
+ },
29
+ },
30
+ };
31
+ return internal;
32
+ }
33
+ /**
34
+ * Native Extension that decorates the eventStore and commandBus with
35
+ * recording wrappers.
36
+ *
37
+ * **Decoration order** (Phase 6 D-62): user decorators registered AFTER this
38
+ * extension's `app.use(...)` wrap OUTSIDE the recording decorators. To land
39
+ * the recording decorators at the INNERMOST position (capturing messages
40
+ * AFTER all interceptors have enriched them), call
41
+ * `app.use(testRecordingExtension(recordings))` BEFORE applying any user
42
+ * decorators / `configureFn(app)`.
43
+ *
44
+ * The legacy enhancer used `Number.MIN_SAFE_INTEGER` numeric priority for the
45
+ * same effect; Phase 6 dropped numeric priorities — innermost = first
46
+ * registered.
47
+ */
48
+ export function testRecordingExtension(recordings) {
49
+ const push = recordings._push;
50
+ if (!push) {
51
+ throw new Error("[testRecordingExtension] Recordings handle missing internal writers — pass an instance from createRecordings().");
52
+ }
53
+ return (app) => {
54
+ // EventStore append wrapper — records events after a successful append.
55
+ app.decorate("eventStore", (inner) => {
56
+ const originalAppend = inner.append.bind(inner);
57
+ return {
58
+ ...inner,
59
+ async append(events, condition) {
60
+ const result = await originalAppend(events, condition);
61
+ for (const e of events)
62
+ push.event(e);
63
+ return result;
64
+ },
65
+ };
66
+ });
67
+ // CommandBus dispatch wrapper — records commands BEFORE dispatch (legacy
68
+ // semantics: legacy fixture inspected the raw dispatched command, not
69
+ // the post-handler outcome).
70
+ app.decorate("commandBus", (inner) => {
71
+ const originalDispatch = inner.dispatch.bind(inner);
72
+ return {
73
+ ...inner,
74
+ async dispatch(message) {
75
+ push.command(message);
76
+ return originalDispatch(message);
77
+ },
78
+ };
79
+ });
80
+ };
81
+ }
82
+ //# sourceMappingURL=recording-enhancer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recording-enhancer.js","sourceRoot":"","sources":["../src/recording-enhancer.ts"],"names":[],"mappings":"AA6BA;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,cAAc,GAAmB,EAAE,CAAA;IACzC,MAAM,gBAAgB,GAAqB,EAAE,CAAA;IAC7C,MAAM,QAAQ,GAAuB;QACnC,MAAM;YACJ,OAAO,CAAC,GAAG,cAAc,CAAC,CAAA;QAC5B,CAAC;QACD,QAAQ;YACN,OAAO,CAAC,GAAG,gBAAgB,CAAC,CAAA;QAC9B,CAAC;QACD,KAAK;YACH,cAAc,CAAC,MAAM,GAAG,CAAC,CAAA;YACzB,gBAAgB,CAAC,MAAM,GAAG,CAAC,CAAA;QAC7B,CAAC;QACD,KAAK,EAAE;YACL,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE;gBACX,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACxB,CAAC;YACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;gBACb,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YAC1B,CAAC;SACF;KACF,CAAA;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,sBAAsB,CAAC,UAAsB;IAC3D,MAAM,IAAI,GAAI,UAAiC,CAAC,KAAK,CAAA;IACrD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACb,iHAAiH,CAClH,CAAA;IACH,CAAC;IACD,OAAO,CAAC,GAAQ,EAAE,EAAE;QAClB,wEAAwE;QACxE,GAAG,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,KAAK,EAAE,EAAE;YACnC,MAAM,cAAc,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;YAC/C,OAAO;gBACL,GAAG,KAAK;gBACR,KAAK,CAAC,MAAM,CAAC,MAAmC,EAAE,SAAe;oBAC/D,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;oBACtD,KAAK,MAAM,CAAC,IAAI,MAAM;wBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;oBACrC,OAAO,MAAM,CAAA;gBACf,CAAC;aACF,CAAA;QACH,CAAC,CAAC,CAAA;QAEF,yEAAyE;QACzE,sEAAsE;QACtE,6BAA6B;QAC7B,GAAG,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,KAAK,EAAE,EAAE;YACnC,MAAM,gBAAgB,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;YACnD,OAAO;gBACL,GAAG,KAAK;gBACR,KAAK,CAAC,QAAQ,CAAC,OAAuB;oBACpC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;oBACrB,OAAO,gBAAgB,CAAC,OAAO,CAAC,CAAA;gBAClC,CAAC;aACY,CAAA;QACjB,CAAC,CAAC,CAAA;IACJ,CAAC,CAAA;AACH,CAAC"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@kronos-ts/test",
3
+ "version": "0.1.0",
4
+ "description": "Testing toolkit for Kronos — given/when/then fixtures for event-sourced slices.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "author": "Theo Emanuelsson",
8
+ "homepage": "https://github.com/KronosDB/kronos-ts/tree/main/packages/test#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/KronosDB/kronos-ts.git",
12
+ "directory": "packages/test"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/KronosDB/kronos-ts/issues"
16
+ },
17
+ "keywords": [
18
+ "kronos",
19
+ "event-sourcing",
20
+ "cqrs",
21
+ "dcb",
22
+ "typescript"
23
+ ],
24
+ "sideEffects": false,
25
+ "main": "src/index.ts",
26
+ "types": "src/index.ts",
27
+ "files": [
28
+ "dist",
29
+ "src",
30
+ "!src/**/__tests__",
31
+ "!src/**/*.test.ts",
32
+ "!src/**/*.bench.ts"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "clean": "rm -rf dist *.tsbuildinfo"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "main": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "default": "./dist/index.js"
46
+ }
47
+ }
48
+ },
49
+ "dependencies": {
50
+ "@kronos-ts/common": "workspace:*",
51
+ "@kronos-ts/app": "workspace:*",
52
+ "@kronos-ts/messaging": "workspace:*",
53
+ "@kronos-ts/modelling": "workspace:*",
54
+ "@kronos-ts/eventsourcing": "workspace:*",
55
+ "zod": "^4.3.6"
56
+ }
57
+ }
package/src/fixture.ts ADDED
@@ -0,0 +1,535 @@
1
+ import {
2
+ generateIdentifier,
3
+ emptyMetadata,
4
+ qualifiedNameToString,
5
+ type Metadata,
6
+ } from "@kronos-ts/common"
7
+ import type {
8
+ CommandDescriptor,
9
+ EventDescriptor,
10
+ EventMessage,
11
+ CommandMessage,
12
+ } from "@kronos-ts/messaging"
13
+ import { runInNewUoW } from "@kronos-ts/messaging"
14
+ import { kronos, type App, type RunningApp } from "@kronos-ts/app"
15
+ import type { EventStore } from "@kronos-ts/eventsourcing"
16
+ import type { z } from "zod"
17
+ import {
18
+ createRecordings,
19
+ testRecordingExtension,
20
+ type Recordings,
21
+ } from "./recording-enhancer.js"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Public types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ type EventPair = [EventDescriptor<any>, unknown]
28
+ type CommandPair = [CommandDescriptor<any>, unknown]
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Fixture entry point
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Creates a BDD test fixture that runs your REAL application code
36
+ * against an in-memory event store with recording decorators.
37
+ *
38
+ * Pass a configuration function that sets up the same domain
39
+ * as your production app via the kronos() App fluent surface.
40
+ *
41
+ * ```typescript
42
+ * const fixture = await createTestFixture((app) => {
43
+ * app.states(Course)
44
+ * app.commands(createCourse, subscribeStudent)
45
+ * app.queries(getCourseView, getAllCourses)
46
+ * })
47
+ *
48
+ * await fixture
49
+ * .given()
50
+ * .events([CourseCreated, { courseId: "cs-101", name: "Intro", capacity: 30 }])
51
+ * .when()
52
+ * .command(SubscribeStudent, { courseId: "cs-101", studentId: "stu-001" })
53
+ * .then()
54
+ * .expectSuccess()
55
+ * .expectEvents([StudentSubscribed, { courseId: "cs-101", studentId: "stu-001" }])
56
+ *
57
+ * await fixture.stop()
58
+ * ```
59
+ */
60
+ export async function createTestFixture(
61
+ configureFn: (app: App) => void,
62
+ ): Promise<TestFixture> {
63
+ const recordings = createRecordings()
64
+ const app = kronos({ quiet: true })
65
+
66
+ // Apply testRecordingExtension synchronously BEFORE configureFn so its
67
+ // decorators land FIRST in the registration order — Phase 6 D-62: first
68
+ // registered = innermost wrap. Recording must be innermost so it captures
69
+ // messages AFTER all interceptors have enriched them.
70
+ testRecordingExtension(recordings)(app)
71
+
72
+ // Capture the eventStore reference via an identity decorator so the BDD
73
+ // given/when paths can append events directly. The decorator runs as
74
+ // part of the user-decorator pass at .start(), so it sees whatever
75
+ // (recording-wrapped) store the framework resolved.
76
+ let capturedEventStore: EventStore | undefined
77
+ app.decorate("eventStore", (inner) => {
78
+ capturedEventStore = inner
79
+ return inner
80
+ })
81
+
82
+ configureFn(app)
83
+
84
+ const running = await app.start()
85
+
86
+ if (!capturedEventStore) {
87
+ throw new Error("Test fixture: eventStore capture failed (start() did not run probe decorator).")
88
+ }
89
+ const eventStore = capturedEventStore
90
+
91
+ return {
92
+ given() {
93
+ return new GivenPhaseImpl(running, recordings, eventStore)
94
+ },
95
+
96
+ async stop() {
97
+ await running.stop()
98
+ },
99
+ }
100
+ }
101
+
102
+ export interface TestFixture {
103
+ given(): GivenPhase
104
+ stop(): Promise<void>
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Given phase
109
+ // ---------------------------------------------------------------------------
110
+
111
+ export interface GivenPhase {
112
+ events(...pairs: EventPair[]): GivenPhase
113
+ commands(...pairs: CommandPair[]): GivenPhase
114
+ execute(fn: (app: RunningApp) => void | Promise<void>): GivenPhase
115
+ noPriorActivity(): GivenPhase
116
+ when(): WhenPhase
117
+ }
118
+
119
+ class GivenPhaseImpl implements GivenPhase {
120
+ private readonly givenEvents: EventPair[] = []
121
+ private readonly givenCommands: CommandPair[] = []
122
+ private readonly givenSetupFns: Array<(app: RunningApp) => void | Promise<void>> = []
123
+ _prerequisite: Promise<void> | undefined
124
+
125
+ constructor(
126
+ private readonly app: RunningApp,
127
+ private readonly recordings: Recordings,
128
+ private readonly eventStore: EventStore,
129
+ ) {}
130
+
131
+ events(...pairs: EventPair[]): GivenPhase {
132
+ this.givenEvents.push(...pairs)
133
+ return this
134
+ }
135
+
136
+ commands(...pairs: CommandPair[]): GivenPhase {
137
+ this.givenCommands.push(...pairs)
138
+ return this
139
+ }
140
+
141
+ execute(fn: (app: RunningApp) => void | Promise<void>): GivenPhase {
142
+ this.givenSetupFns.push(fn)
143
+ return this
144
+ }
145
+
146
+ noPriorActivity(): GivenPhase {
147
+ return this
148
+ }
149
+
150
+ when(): WhenPhase {
151
+ return new WhenPhaseImpl(
152
+ this.app, this.recordings, this.eventStore,
153
+ this.givenEvents, this.givenCommands, this.givenSetupFns,
154
+ this._prerequisite,
155
+ )
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // When phase
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export interface WhenPhase {
164
+ command<P extends z.ZodType>(descriptor: CommandDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult
165
+ event<P extends z.ZodType>(descriptor: EventDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult
166
+ nothing(): WhenResult
167
+ }
168
+
169
+ export interface WhenResult {
170
+ then(): ThenPhase
171
+ }
172
+
173
+ class WhenPhaseImpl implements WhenPhase {
174
+ constructor(
175
+ private readonly app: RunningApp,
176
+ private readonly recordings: Recordings,
177
+ private readonly eventStore: EventStore,
178
+ private readonly givenEvents: EventPair[],
179
+ private readonly givenCommands: CommandPair[],
180
+ private readonly givenSetupFns: Array<(app: RunningApp) => void | Promise<void>>,
181
+ private readonly prerequisite?: Promise<void>,
182
+ ) {}
183
+
184
+ command<P extends z.ZodType>(descriptor: CommandDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult {
185
+ const thenPhase = new ThenPhaseImpl(
186
+ this.app, this.recordings, this.eventStore,
187
+ this.givenEvents, this.givenCommands, this.givenSetupFns,
188
+ { kind: "command", descriptor, payload, metadata },
189
+ this.prerequisite,
190
+ )
191
+ return { then: () => thenPhase }
192
+ }
193
+
194
+ event<P extends z.ZodType>(descriptor: EventDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult {
195
+ const thenPhase = new ThenPhaseImpl(
196
+ this.app, this.recordings, this.eventStore,
197
+ this.givenEvents, this.givenCommands, this.givenSetupFns,
198
+ { kind: "event", descriptor, payload, metadata },
199
+ this.prerequisite,
200
+ )
201
+ return { then: () => thenPhase }
202
+ }
203
+
204
+ nothing(): WhenResult {
205
+ const thenPhase = new ThenPhaseImpl(
206
+ this.app, this.recordings, this.eventStore,
207
+ this.givenEvents, this.givenCommands, this.givenSetupFns,
208
+ { kind: "nothing" },
209
+ this.prerequisite,
210
+ )
211
+ return { then: () => thenPhase }
212
+ }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Then phase
217
+ // ---------------------------------------------------------------------------
218
+
219
+ type WhenAction =
220
+ | { kind: "command"; descriptor: CommandDescriptor<any>; payload: unknown; metadata?: Metadata }
221
+ | { kind: "event"; descriptor: EventDescriptor<any>; payload: unknown; metadata?: Metadata }
222
+ | { kind: "nothing" }
223
+
224
+ export interface ThenPhase extends PromiseLike<void> {
225
+ expectEvents(...pairs: EventPair[]): ThenPhase
226
+ expectNoEvents(): ThenPhase
227
+ expectSuccess(): ThenPhase
228
+ expectResult(expected: unknown): ThenPhase
229
+ expectResultSatisfying(fn: (result: unknown) => void): ThenPhase
230
+ expectResultPayloadSatisfying<T>(fn: (payload: T) => void): ThenPhase
231
+ expectException(messageSubstring: string): ThenPhase
232
+ expectExceptionType(errorName: string): ThenPhase
233
+ expectExceptionSatisfying(fn: (error: unknown) => void): ThenPhase
234
+ expectEventsSatisfying(fn: (events: ReadonlyArray<EventMessage>) => void): ThenPhase
235
+ expectCommands(...pairs: CommandPair[]): ThenPhase
236
+ expectNoCommands(): ThenPhase
237
+ expectCommandsSatisfying(fn: (commands: ReadonlyArray<CommandMessage>) => void): ThenPhase
238
+ expect(fn: (app: RunningApp) => void | Promise<void>): ThenPhase
239
+ await(assertion: (app: RunningApp) => void | Promise<void>, timeoutMs?: number, intervalMs?: number): ThenPhase
240
+ and(): TestFixture
241
+ }
242
+
243
+ class ThenPhaseImpl implements ThenPhase {
244
+ private readonly assertions: Array<(result: unknown, error: unknown, events: ReadonlyArray<EventMessage>) => void | Promise<void>> = []
245
+ private executionPromise: Promise<void> | null = null
246
+
247
+ constructor(
248
+ private readonly app: RunningApp,
249
+ private readonly recordings: Recordings,
250
+ private readonly eventStore: EventStore,
251
+ private readonly givenEvents: EventPair[],
252
+ private readonly givenCommands: CommandPair[],
253
+ private readonly givenSetupFns: Array<(app: RunningApp) => void | Promise<void>>,
254
+ private readonly whenAction: WhenAction,
255
+ private readonly prerequisite?: Promise<void>,
256
+ ) {}
257
+
258
+ expectEvents(...pairs: EventPair[]): ThenPhase {
259
+ this.assertions.push((_result, _error, events) => {
260
+ if (events.length !== pairs.length) {
261
+ throw new FixtureAssertionError(
262
+ `Expected ${pairs.length} event(s) but got ${events.length}.\n` +
263
+ ` Expected: [${pairs.map(([d]) => qualifiedNameToString(d.name)).join(", ")}]\n` +
264
+ ` Actual: [${events.map((e) => qualifiedNameToString(e.name)).join(", ")}]`,
265
+ )
266
+ }
267
+ for (let i = 0; i < pairs.length; i++) {
268
+ const [desc, payload] = pairs[i]!
269
+ const actual = events[i]!
270
+ const expectedName = qualifiedNameToString(desc.name)
271
+ const actualName = qualifiedNameToString(actual.name)
272
+ if (actualName !== expectedName) {
273
+ throw new FixtureAssertionError(`Event ${i}: expected "${expectedName}" but got "${actualName}"`)
274
+ }
275
+ assertDeepEqual(payload, actual.payload, `Event ${i} (${expectedName}) payload`)
276
+ }
277
+ })
278
+ return this
279
+ }
280
+
281
+ expectNoEvents(): ThenPhase {
282
+ this.assertions.push((_result, _error, events) => {
283
+ if (events.length !== 0) {
284
+ throw new FixtureAssertionError(
285
+ `Expected no events but got ${events.length}: ` + events.map((e) => qualifiedNameToString(e.name)).join(", "),
286
+ )
287
+ }
288
+ })
289
+ return this
290
+ }
291
+
292
+ expectSuccess(): ThenPhase {
293
+ this.assertions.push((_result, error) => {
294
+ if (error) throw new FixtureAssertionError(`Expected success but command failed: ${error instanceof Error ? error.message : String(error)}`)
295
+ })
296
+ return this
297
+ }
298
+
299
+ expectResult(expected: unknown): ThenPhase {
300
+ this.assertions.push((result, error) => {
301
+ if (error) throw new FixtureAssertionError(`Expected result but command failed: ${error}`)
302
+ assertDeepEqual(expected, result, "Command result")
303
+ })
304
+ return this
305
+ }
306
+
307
+ expectResultSatisfying(fn: (result: unknown) => void): ThenPhase {
308
+ this.assertions.push((result, error) => {
309
+ if (error) throw new FixtureAssertionError(`Expected result but command failed: ${error}`)
310
+ fn(result)
311
+ })
312
+ return this
313
+ }
314
+
315
+ expectResultPayloadSatisfying<T>(fn: (payload: T) => void): ThenPhase {
316
+ this.assertions.push((result, error) => {
317
+ if (error) throw new FixtureAssertionError(`Expected result but command failed: ${error}`)
318
+ fn(result as T)
319
+ })
320
+ return this
321
+ }
322
+
323
+ expectException(messageSubstring: string): ThenPhase {
324
+ this.assertions.push((_result, error) => {
325
+ if (!error) throw new FixtureAssertionError(`Expected exception containing "${messageSubstring}" but command succeeded`)
326
+ const msg = error instanceof Error ? error.message : String(error)
327
+ if (!msg.includes(messageSubstring)) {
328
+ throw new FixtureAssertionError(`Expected exception containing "${messageSubstring}" but got: "${msg}"`)
329
+ }
330
+ })
331
+ return this
332
+ }
333
+
334
+ expectExceptionType(errorName: string): ThenPhase {
335
+ this.assertions.push((_result, error) => {
336
+ if (!error) throw new FixtureAssertionError(`Expected ${errorName} but command succeeded`)
337
+ const actualName = error instanceof Error ? error.name : "Error"
338
+ if (actualName !== errorName) {
339
+ throw new FixtureAssertionError(`Expected ${errorName} but got ${actualName}: ${error instanceof Error ? error.message : error}`)
340
+ }
341
+ })
342
+ return this
343
+ }
344
+
345
+ expectExceptionSatisfying(fn: (error: unknown) => void): ThenPhase {
346
+ this.assertions.push((_result, error) => {
347
+ if (!error) throw new FixtureAssertionError("Expected exception but command succeeded")
348
+ fn(error)
349
+ })
350
+ return this
351
+ }
352
+
353
+ expectEventsSatisfying(fn: (events: ReadonlyArray<EventMessage>) => void): ThenPhase {
354
+ this.assertions.push((_result, _error, events) => { fn(events) })
355
+ return this
356
+ }
357
+
358
+ expectCommands(...pairs: CommandPair[]): ThenPhase {
359
+ this.assertions.push(() => {
360
+ const actual = this.recordings.commands()
361
+ const handlerCommands = actual.slice(1)
362
+ if (handlerCommands.length !== pairs.length) {
363
+ throw new FixtureAssertionError(
364
+ `Expected ${pairs.length} dispatched command(s) but got ${handlerCommands.length}.`)
365
+ }
366
+ for (let i = 0; i < pairs.length; i++) {
367
+ const [desc, payload] = pairs[i]!
368
+ const actualCmd = handlerCommands[i]!
369
+ const expectedName = qualifiedNameToString(desc.name)
370
+ const actualName = qualifiedNameToString(actualCmd.name)
371
+ if (actualName !== expectedName) throw new FixtureAssertionError(`Command ${i}: expected "${expectedName}" but got "${actualName}"`)
372
+ assertDeepEqual(payload, actualCmd.payload, `Command ${i} (${expectedName}) payload`)
373
+ }
374
+ })
375
+ return this
376
+ }
377
+
378
+ expectNoCommands(): ThenPhase {
379
+ this.assertions.push(() => {
380
+ const handlerCommands = this.recordings.commands().slice(1)
381
+ if (handlerCommands.length !== 0) {
382
+ throw new FixtureAssertionError(`Expected no dispatched commands but got ${handlerCommands.length}`)
383
+ }
384
+ })
385
+ return this
386
+ }
387
+
388
+ expectCommandsSatisfying(fn: (commands: ReadonlyArray<CommandMessage>) => void): ThenPhase {
389
+ this.assertions.push(() => { fn(this.recordings.commands().slice(1)) })
390
+ return this
391
+ }
392
+
393
+ expect(fn: (app: RunningApp) => void | Promise<void>): ThenPhase {
394
+ this.assertions.push(async () => { await fn(this.app) })
395
+ return this
396
+ }
397
+
398
+ await(assertion: (app: RunningApp) => void | Promise<void>, timeoutMs: number = 5000, intervalMs: number = 50): ThenPhase {
399
+ this.assertions.push(async () => {
400
+ const start = Date.now()
401
+ let lastError: unknown
402
+ while (Date.now() - start < timeoutMs) {
403
+ try { await assertion(this.app); return } catch (err) { lastError = err; await new Promise((r) => setTimeout(r, intervalMs)) }
404
+ }
405
+ throw new FixtureAssertionError(`Assertion did not pass within ${timeoutMs}ms. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`)
406
+ })
407
+ return this
408
+ }
409
+
410
+ and(): TestFixture {
411
+ const prerequisite = this.getExecutionPromise()
412
+ return {
413
+ given: () => {
414
+ const given = new GivenPhaseImpl(this.app, this.recordings, this.eventStore)
415
+ given._prerequisite = prerequisite
416
+ return given
417
+ },
418
+ stop: async () => { await this.app.stop() },
419
+ }
420
+ }
421
+
422
+ then<TResult1 = void, TResult2 = never>(
423
+ onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
424
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
425
+ ): PromiseLike<TResult1 | TResult2> {
426
+ return this.getExecutionPromise().then(onfulfilled, onrejected)
427
+ }
428
+
429
+ private getExecutionPromise(): Promise<void> {
430
+ if (!this.executionPromise) this.executionPromise = this.execute()
431
+ return this.executionPromise
432
+ }
433
+
434
+ private async execute(): Promise<void> {
435
+ if (this.prerequisite) await this.prerequisite
436
+
437
+ const eventStore = this.eventStore
438
+
439
+ // 1. Given: publish events within a UnitOfWork
440
+ if (this.givenEvents.length > 0) {
441
+ await runInNewUoW(emptyMetadata(), async () => {
442
+ const events: EventMessage[] = this.givenEvents.map(([desc, payload]) => {
443
+ const tags = desc.tags ? desc.tags(payload) : []
444
+ return { identifier: generateIdentifier(), name: desc.name, version: desc.version, payload, metadata: emptyMetadata(), timestamp: Date.now(), tags }
445
+ })
446
+ await eventStore.append(events)
447
+ })
448
+ }
449
+
450
+ // 1b. Given: run custom setup
451
+ for (const fn of this.givenSetupFns) { await fn(this.app) }
452
+
453
+ // 1c. Given: dispatch commands via the gateway (matches user-facing semantics).
454
+ // The recording decorator on the bus captures messages whether they
455
+ // arrive via gateway or direct bus dispatch.
456
+ for (const [desc, payload] of this.givenCommands) {
457
+ await this.app.commandGateway.send(desc, payload, emptyMetadata())
458
+ }
459
+
460
+ // 2. Reset recordings
461
+ this.recordings.reset()
462
+
463
+ // 3. When
464
+ let result: unknown
465
+ let error: unknown
466
+
467
+ if (this.whenAction.kind === "command") {
468
+ try {
469
+ result = await this.app.commandGateway.send(
470
+ this.whenAction.descriptor,
471
+ this.whenAction.payload,
472
+ this.whenAction.metadata ?? emptyMetadata(),
473
+ )
474
+ } catch (err) { error = err }
475
+ } else if (this.whenAction.kind === "event") {
476
+ const desc = this.whenAction.descriptor
477
+ const payload = this.whenAction.payload
478
+ const tags = desc.tags ? desc.tags(payload) : []
479
+ try {
480
+ await eventStore.append([{ identifier: generateIdentifier(), name: desc.name, version: desc.version, payload, metadata: this.whenAction.metadata ?? emptyMetadata(), timestamp: Date.now(), tags }])
481
+ } catch (err) { error = err }
482
+ }
483
+
484
+ // 4. Then
485
+ const recordedEvents = this.recordings.events()
486
+ for (const assertion of this.assertions) { await assertion(result, error, recordedEvents) }
487
+ }
488
+ }
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // Helpers
492
+ // ---------------------------------------------------------------------------
493
+
494
+ export class FixtureAssertionError extends Error {
495
+ constructor(message: string) { super(message); this.name = "FixtureAssertionError" }
496
+ }
497
+
498
+ export type FieldFilter = (fieldName: string, owner: unknown) => boolean
499
+ export const allFieldsFilter: FieldFilter = () => true
500
+ export function ignoreFields(...fieldNames: string[]): FieldFilter {
501
+ const ignored = new Set(fieldNames)
502
+ return (name) => !ignored.has(name)
503
+ }
504
+
505
+ function assertDeepEqual(expected: unknown, actual: unknown, label: string, fieldFilter: FieldFilter = allFieldsFilter): void {
506
+ const differences = deepCompare(expected, actual, "", fieldFilter)
507
+ if (differences.length > 0) throw new FixtureAssertionError(`${label} mismatch:\n${differences.map((d) => ` ${d}`).join("\n")}`)
508
+ }
509
+
510
+ function deepCompare(expected: unknown, actual: unknown, path: string, fieldFilter: FieldFilter): string[] {
511
+ if (expected === actual) return []
512
+ if (expected === null || actual === null) return [`${path || "root"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`]
513
+ if (typeof expected !== typeof actual) return [`${path || "root"}: expected type ${typeof expected}, got type ${typeof actual}`]
514
+ if (typeof expected !== "object") return [`${path || "root"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`]
515
+ if (Array.isArray(expected) !== Array.isArray(actual)) return [`${path || "root"}: expected ${Array.isArray(expected) ? "array" : "object"}, got ${Array.isArray(actual) ? "array" : "object"}`]
516
+ if (Array.isArray(expected) && Array.isArray(actual)) {
517
+ const diffs: string[] = []
518
+ for (let i = 0; i < Math.max(expected.length, actual.length); i++) {
519
+ if (i >= expected.length) diffs.push(`${path}[${i}]: unexpected extra element`)
520
+ else if (i >= actual.length) diffs.push(`${path}[${i}]: missing expected element`)
521
+ else diffs.push(...deepCompare(expected[i], actual[i], `${path}[${i}]`, fieldFilter))
522
+ }
523
+ return diffs
524
+ }
525
+ const diffs: string[] = []
526
+ const allKeys = new Set([...Object.keys(expected as any), ...Object.keys(actual as any)])
527
+ for (const key of allKeys) {
528
+ if (!fieldFilter(key, expected)) continue
529
+ const fieldPath = path ? `${path}.${key}` : key
530
+ if (!(key in (expected as any))) diffs.push(`${fieldPath}: unexpected field`)
531
+ else if (!(key in (actual as any))) diffs.push(`${fieldPath}: missing expected value`)
532
+ else diffs.push(...deepCompare((expected as any)[key], (actual as any)[key], fieldPath, fieldFilter))
533
+ }
534
+ return diffs
535
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export {
2
+ type TestFixture,
3
+ type GivenPhase,
4
+ type WhenPhase,
5
+ type WhenResult,
6
+ type ThenPhase,
7
+ type FieldFilter,
8
+ allFieldsFilter,
9
+ ignoreFields,
10
+ createTestFixture,
11
+ FixtureAssertionError,
12
+ } from "./fixture.js"
13
+
14
+ export {
15
+ type Recordings,
16
+ createRecordings,
17
+ testRecordingExtension,
18
+ } from "./recording-enhancer.js"