@dreamboard-games/testing 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,516 @@
1
+ import type {
2
+ HostPlayerGameplayView,
3
+ HostSessionContext,
4
+ HostSessionSnapshot,
5
+ SeatAssignment,
6
+ } from "@dreamboard/api-client";
7
+ import type { ReducerBundleContract } from "@dreamboard/reducer-contract/bundle";
8
+ import type * as Wire from "@dreamboard/reducer-contract/wire";
9
+ import type { PluginStateSnapshot } from "@dreamboard/ui-sdk/reducer";
10
+ import type {
11
+ PluginSessionState,
12
+ RuntimeAPI,
13
+ SubmissionError,
14
+ ValidationResult,
15
+ } from "@dreamboard/ui-sdk";
16
+ import {
17
+ createUnifiedSessionStore,
18
+ type GameplayViewport,
19
+ type SessionContext,
20
+ type SSEManagerLike,
21
+ } from "@dreamboard/ui-host-runtime/runtime";
22
+
23
+ type ReducerStaticProjection = Wire.BoardStaticProjection;
24
+ type ReducerBundleLike = Pick<
25
+ ReducerBundleContract,
26
+ "projectSeatsDynamic" | "projectStatic" | "validateInput" | "dispatch"
27
+ >;
28
+
29
+ type BaseStateArtifact = {
30
+ snapshot: Wire.ReducerSessionState;
31
+ fingerprint: {
32
+ players: number;
33
+ };
34
+ };
35
+
36
+ export type CreateTestRuntimeOptions = {
37
+ baseId: string;
38
+ baseStates: Record<string, BaseStateArtifact>;
39
+ bundle: ReducerBundleLike;
40
+ phase?: string;
41
+ playerIds?: readonly string[];
42
+ sessionId?: string;
43
+ userId?: string | null;
44
+ gameId?: string;
45
+ displayNameByPlayerId?: Record<string, string>;
46
+ };
47
+
48
+ export type CreatedTestRuntime = {
49
+ runtime: RuntimeAPI & {
50
+ getSnapshot(): PluginStateSnapshot;
51
+ subscribeToState(
52
+ listener: (state: PluginStateSnapshot) => void,
53
+ ): () => void;
54
+ _subscribeToSessionState(
55
+ listener: (state: PluginSessionState) => void,
56
+ ): () => void;
57
+ };
58
+ getSnapshot(): PluginStateSnapshot;
59
+ players(): readonly string[];
60
+ seat(index: number): string;
61
+ submit(
62
+ playerId: string,
63
+ interactionId: string,
64
+ params?: unknown,
65
+ ): Promise<void>;
66
+ validate(
67
+ playerId: string,
68
+ interactionId: string,
69
+ params?: unknown,
70
+ ): Promise<ValidationResult>;
71
+ setControllingPlayer(playerId: string): void;
72
+ };
73
+
74
+ function cloneState<T>(value: T): T {
75
+ return structuredClone(value);
76
+ }
77
+
78
+ function createSubmissionError(
79
+ errorCode: string | undefined,
80
+ message: string | undefined,
81
+ ): SubmissionError {
82
+ const error = new Error(message ?? "Interaction rejected") as SubmissionError;
83
+ error.name = "SubmissionError";
84
+ error.errorCode = errorCode;
85
+ return error;
86
+ }
87
+
88
+ function readFlowState(state: Wire.ReducerSessionState): {
89
+ currentPhase: string | null;
90
+ activePlayers: string[];
91
+ } {
92
+ const flow = ((
93
+ state.domain as
94
+ | { flow?: { currentPhase?: string; activePlayers?: string[] } }
95
+ | undefined
96
+ )?.flow ?? {}) as {
97
+ currentPhase?: string;
98
+ activePlayers?: string[];
99
+ };
100
+ return {
101
+ currentPhase: flow.currentPhase ?? null,
102
+ activePlayers: Array.isArray(flow.activePlayers) ? flow.activePlayers : [],
103
+ };
104
+ }
105
+
106
+ function buildSeatAssignments(
107
+ playerIds: readonly string[],
108
+ userId: string | null,
109
+ displayNameByPlayerId: Record<string, string> | undefined,
110
+ ): SeatAssignment[] {
111
+ const actor =
112
+ userId != null ? { kind: "AUTH_USER" as const, id: userId } : undefined;
113
+ return playerIds.map((playerId) => ({
114
+ playerId,
115
+ controllerActor: actor,
116
+ displayName: displayNameByPlayerId?.[playerId] ?? playerId,
117
+ }));
118
+ }
119
+
120
+ function resolvePlayerIds(options: {
121
+ baseState: BaseStateArtifact;
122
+ explicitPlayerIds?: readonly string[];
123
+ }): string[] {
124
+ if (options.explicitPlayerIds && options.explicitPlayerIds.length > 0) {
125
+ return [...options.explicitPlayerIds];
126
+ }
127
+ return Array.from(
128
+ { length: options.baseState.fingerprint.players },
129
+ (_, index) => `player-${index + 1}`,
130
+ );
131
+ }
132
+
133
+ // Stub SSE manager for the in-memory store. `createTestRuntime` never
134
+ // connects to a live backend; updates are delivered through
135
+ // `applyGameplaySnapshotLocal(...)` instead.
136
+ function createStubSseManager(): SSEManagerLike {
137
+ return {
138
+ connect: () => undefined,
139
+ disconnect: () => undefined,
140
+ on: () => () => undefined,
141
+ onAnyMessage: () => () => undefined,
142
+ };
143
+ }
144
+
145
+ function buildGameplaySnapshot(options: {
146
+ state: Wire.ReducerSessionState;
147
+ bundle: ReducerBundleLike;
148
+ staticProjection: ReducerStaticProjection | null;
149
+ playerId: string;
150
+ version: number;
151
+ expectedPhase: string | undefined;
152
+ baseId: string;
153
+ }): HostPlayerGameplayView {
154
+ const projection = options.bundle.projectSeatsDynamic({
155
+ state: options.state,
156
+ playerIds: [options.playerId],
157
+ });
158
+
159
+ const flow = readFlowState(options.state);
160
+ if (options.expectedPhase && flow.currentPhase !== options.expectedPhase) {
161
+ throw new Error(
162
+ `Expected base '${options.baseId}' to be in phase '${options.expectedPhase}', received '${
163
+ flow.currentPhase ?? "null"
164
+ }'.`,
165
+ );
166
+ }
167
+
168
+ const seats = projection.seats ?? {};
169
+ const seat = seats[options.playerId];
170
+
171
+ return {
172
+ version: options.version,
173
+ actionSetVersion: `${options.version}:test`,
174
+ playerId: options.playerId,
175
+ activePlayers: flow.activePlayers,
176
+ currentPhase: flow.currentPhase ?? "",
177
+ currentStage: projection.currentStage ?? "",
178
+ stageSeats: projection.stageSeats ?? [],
179
+ view: JSON.stringify(seat?.view ?? null),
180
+ availableInteractions:
181
+ (seat?.availableInteractions as HostPlayerGameplayView["availableInteractions"]) ??
182
+ [],
183
+ zones: (seat?.zones as HostPlayerGameplayView["zones"]) ?? {},
184
+ boardStatic: options.staticProjection
185
+ ? JSON.stringify(options.staticProjection.view)
186
+ : undefined,
187
+ boardStaticHash: options.staticProjection?.hash,
188
+ };
189
+ }
190
+
191
+ function createHostContext(options: {
192
+ sessionId: string;
193
+ userId: string;
194
+ gameId: string;
195
+ phase: HostSessionContext["phase"];
196
+ hostActor: { kind: "AUTH_USER"; id: string };
197
+ switchablePlayerIds: string[];
198
+ }): HostSessionContext {
199
+ return {
200
+ sessionId: options.sessionId,
201
+ shortCode: options.sessionId,
202
+ phase: options.phase,
203
+ status: "active",
204
+ hostActor: options.hostActor,
205
+ gameSource: {
206
+ kind: "USER_COMPILED",
207
+ ownerUserId: options.userId,
208
+ gameId: options.gameId,
209
+ compiledResultId: "00000000-0000-0000-0000-000000000000",
210
+ },
211
+ switchablePlayerIds: options.switchablePlayerIds,
212
+ history: {
213
+ entries: [],
214
+ currentIndex: -1,
215
+ canGoBack: false,
216
+ canGoForward: false,
217
+ },
218
+ };
219
+ }
220
+
221
+ function createRuntimeSessionContext(options: {
222
+ sessionId: string;
223
+ userId: string;
224
+ gameId: string;
225
+ hostActor: { kind: "AUTH_USER"; id: string };
226
+ switchablePlayerIds: string[];
227
+ seats: SeatAssignment[];
228
+ canStart: boolean;
229
+ }): SessionContext {
230
+ return {
231
+ identity: {
232
+ sessionId: options.sessionId,
233
+ shortCode: options.sessionId,
234
+ gameId: options.gameId,
235
+ },
236
+ userId: options.userId,
237
+ seats: options.seats,
238
+ canStart: options.canStart,
239
+ hostActor: options.hostActor,
240
+ switchablePlayerIds: options.switchablePlayerIds,
241
+ history: {
242
+ entries: [],
243
+ currentIndex: -1,
244
+ canGoBack: false,
245
+ canGoForward: false,
246
+ },
247
+ };
248
+ }
249
+
250
+ function gameplayViewportFromSnapshot(
251
+ snapshot: HostPlayerGameplayView,
252
+ ): GameplayViewport {
253
+ return {
254
+ version: snapshot.version,
255
+ actionSetVersion: snapshot.actionSetVersion,
256
+ activePlayers: snapshot.activePlayers,
257
+ currentPhase: snapshot.currentPhase,
258
+ currentStage: snapshot.currentStage,
259
+ stageSeats: snapshot.stageSeats,
260
+ simultaneousPhase: snapshot.simultaneousPhase ?? null,
261
+ view: JSON.parse(snapshot.view) as GameplayViewport["view"],
262
+ availableInteractions: snapshot.availableInteractions,
263
+ zones: snapshot.zones,
264
+ boardStatic: snapshot.boardStatic
265
+ ? (JSON.parse(snapshot.boardStatic) as Record<string, unknown>)
266
+ : null,
267
+ boardStaticHash: snapshot.boardStaticHash ?? null,
268
+ };
269
+ }
270
+
271
+ // Reuses `packages/ui-host-runtime`'s unified session store so the
272
+ // `getPluginSnapshot()` projection and `applyGameplaySnapshotLocal(...)`
273
+ // reducer running inside workspace tests are the same code paths
274
+ // running inside the host app. Prevents snapshot-shape drift between
275
+ // host plugin UI and authored UI tests.
276
+ export function createTestRuntime(
277
+ options: CreateTestRuntimeOptions,
278
+ ): CreatedTestRuntime {
279
+ const baseState = options.baseStates[options.baseId];
280
+ if (!baseState) {
281
+ throw new Error(`Unknown test base '${options.baseId}'.`);
282
+ }
283
+
284
+ let currentState = cloneState(baseState.snapshot);
285
+ const playerIds = resolvePlayerIds({
286
+ baseState,
287
+ explicitPlayerIds: options.playerIds,
288
+ });
289
+ const userId = options.userId ?? "test-user";
290
+ const sessionId = options.sessionId ?? "test-session";
291
+ const gameId = options.gameId ?? "test-game";
292
+ const seats = buildSeatAssignments(
293
+ playerIds,
294
+ userId,
295
+ options.displayNameByPlayerId,
296
+ );
297
+ const storeApi = createUnifiedSessionStore({
298
+ createSseManager: createStubSseManager,
299
+ // Test runtimes keep `userId` present so the unified store derives
300
+ // controllable player ids from seat assignments whenever the
301
+ // incoming snapshot omits `controllablePlayerIds`.
302
+ fallbackToAllSeatsWhenUserIdMissing: false,
303
+ });
304
+
305
+ const hostActor = { kind: "AUTH_USER" as const, id: userId };
306
+ const lobbySnapshot: HostSessionSnapshot = {
307
+ type: "lobby",
308
+ context: createHostContext({
309
+ sessionId,
310
+ userId,
311
+ gameId,
312
+ phase: "lobby",
313
+ hostActor,
314
+ switchablePlayerIds: playerIds,
315
+ }),
316
+ lobby: {
317
+ seats,
318
+ canStart: true,
319
+ hostActor,
320
+ },
321
+ };
322
+ const setSessionState = (input: {
323
+ selectedPlayerId: string;
324
+ gameplay?: HostPlayerGameplayView;
325
+ }): void => {
326
+ const context = createRuntimeSessionContext({
327
+ sessionId,
328
+ userId,
329
+ gameId,
330
+ hostActor,
331
+ switchablePlayerIds: playerIds,
332
+ seats,
333
+ canStart: true,
334
+ });
335
+ storeApi.setState((state) => ({
336
+ ...state,
337
+ session: input.gameplay
338
+ ? {
339
+ type: "gameplay",
340
+ context,
341
+ perspective: { playerId: input.selectedPlayerId },
342
+ gameplay: gameplayViewportFromSnapshot(input.gameplay),
343
+ }
344
+ : {
345
+ type: "lobby",
346
+ context,
347
+ preferredPlayerId: input.selectedPlayerId || null,
348
+ },
349
+ connection: { ...state.connection, error: null },
350
+ activity: {
351
+ ...state.activity,
352
+ syncId: state.activity.syncId + 1,
353
+ lastSyncTimestamp: Date.now(),
354
+ },
355
+ }));
356
+ };
357
+
358
+ setSessionState({
359
+ selectedPlayerId: playerIds[0] ?? "",
360
+ });
361
+
362
+ let version = 0;
363
+ let currentPlayerId = playerIds[0] ?? "";
364
+ const staticProjection = options.bundle.projectStatic?.() ?? null;
365
+
366
+ const applyCurrentState = (): void => {
367
+ version += 1;
368
+ const snapshot = buildGameplaySnapshot({
369
+ state: currentState,
370
+ bundle: options.bundle,
371
+ staticProjection,
372
+ playerId: currentPlayerId,
373
+ version,
374
+ expectedPhase: version === 1 ? options.phase : undefined,
375
+ baseId: options.baseId,
376
+ });
377
+ setSessionState({
378
+ selectedPlayerId: currentPlayerId,
379
+ gameplay: snapshot,
380
+ });
381
+ };
382
+
383
+ applyCurrentState();
384
+
385
+ const stateListeners = new Set<(state: PluginStateSnapshot) => void>();
386
+ const sessionListeners = new Set<(state: PluginSessionState) => void>();
387
+
388
+ let lastPluginSnapshot = storeApi.getState().getPluginSnapshot();
389
+ let lastSessionState: PluginSessionState = {
390
+ ...lastPluginSnapshot.session,
391
+ status: "ready",
392
+ };
393
+
394
+ storeApi.subscribe((state, previous) => {
395
+ const nextPluginSnapshot = state.getPluginSnapshot();
396
+ if (nextPluginSnapshot !== lastPluginSnapshot) {
397
+ lastPluginSnapshot = nextPluginSnapshot;
398
+ for (const listener of stateListeners) {
399
+ listener(nextPluginSnapshot);
400
+ }
401
+ }
402
+ const previousSession = previous.getPluginSnapshot().session;
403
+ const nextSession = nextPluginSnapshot.session;
404
+ const controllingPlayerChanged =
405
+ nextSession.controllingPlayerId !== previousSession.controllingPlayerId;
406
+ const controllableIdsChanged =
407
+ nextSession.controllablePlayerIds.join("\0") !==
408
+ previousSession.controllablePlayerIds.join("\0");
409
+ if (controllingPlayerChanged || controllableIdsChanged) {
410
+ lastSessionState = {
411
+ ...nextPluginSnapshot.session,
412
+ status: "ready",
413
+ };
414
+ for (const listener of sessionListeners) {
415
+ listener(lastSessionState);
416
+ }
417
+ }
418
+ });
419
+
420
+ const validate = async (
421
+ playerId: string,
422
+ interactionId: string,
423
+ params: unknown = {},
424
+ ): Promise<ValidationResult> => {
425
+ const result = await options.bundle.validateInput({
426
+ state: currentState,
427
+ input: {
428
+ kind: "interaction",
429
+ playerId,
430
+ interactionId,
431
+ params: params as Wire.JsonValue,
432
+ },
433
+ });
434
+ return {
435
+ valid: result.valid,
436
+ errorCode: result.errorCode,
437
+ message: result.message,
438
+ };
439
+ };
440
+
441
+ const submit = async (
442
+ playerId: string,
443
+ interactionId: string,
444
+ params: unknown = {},
445
+ ): Promise<void> => {
446
+ const validation = await validate(playerId, interactionId, params);
447
+ if (!validation.valid) {
448
+ throw createSubmissionError(validation.errorCode, validation.message);
449
+ }
450
+ const result = await options.bundle.dispatch({
451
+ state: currentState,
452
+ input: {
453
+ kind: "interaction",
454
+ playerId,
455
+ interactionId,
456
+ params: params as Wire.JsonValue,
457
+ },
458
+ });
459
+ if (result.kind === "reject") {
460
+ throw createSubmissionError(result.errorCode, result.message);
461
+ }
462
+ currentState = cloneState(result.state);
463
+ applyCurrentState();
464
+ };
465
+
466
+ const setControllingPlayer = (playerId: string): void => {
467
+ if (!playerIds.includes(playerId)) {
468
+ throw new Error(`Unknown controlling player '${playerId}'.`);
469
+ }
470
+ currentPlayerId = playerId;
471
+ storeApi.getState().selectPlayer(playerId);
472
+ applyCurrentState();
473
+ };
474
+
475
+ const runtime = {
476
+ validateInteraction: validate,
477
+ submitInteraction: submit,
478
+ getSessionState: (): PluginSessionState => ({ ...lastSessionState }),
479
+ disconnect: () => undefined,
480
+ switchPlayer: (playerId: string) => {
481
+ setControllingPlayer(playerId);
482
+ },
483
+ getSnapshot: (): PluginStateSnapshot => lastPluginSnapshot,
484
+ subscribeToState: (listener: (state: PluginStateSnapshot) => void) => {
485
+ stateListeners.add(listener);
486
+ return () => {
487
+ stateListeners.delete(listener);
488
+ };
489
+ },
490
+ _subscribeToSessionState: (
491
+ listener: (state: PluginSessionState) => void,
492
+ ) => {
493
+ sessionListeners.add(listener);
494
+ return () => {
495
+ sessionListeners.delete(listener);
496
+ };
497
+ },
498
+ };
499
+
500
+ return {
501
+ runtime,
502
+ getSnapshot: () => lastPluginSnapshot,
503
+ players: () => [...playerIds],
504
+ seat: (index: number) => {
505
+ if (!Number.isInteger(index) || index < 0 || index >= playerIds.length) {
506
+ throw new Error(
507
+ `seat(${index}) is out of range; base '${options.baseId}' has ${playerIds.length} player(s).`,
508
+ );
509
+ }
510
+ return playerIds[index]!;
511
+ },
512
+ submit,
513
+ validate,
514
+ setControllingPlayer,
515
+ };
516
+ }
@@ -0,0 +1,114 @@
1
+ export type TestRunner = "reducer" | "embedded" | "browser";
2
+
3
+ export type InteractionDescriptorLike = {
4
+ interactionId?: string;
5
+ surface?: string;
6
+ kind?: string;
7
+ available?: boolean;
8
+ unavailableReason?: string;
9
+ context?: {
10
+ to?: string;
11
+ };
12
+ } & Record<string, unknown>;
13
+
14
+ export type SnapshotMatcherHandler = (
15
+ name: string | undefined,
16
+ actual: unknown,
17
+ ) => void;
18
+
19
+ export type RejectionExpectation = {
20
+ errorCode?: string;
21
+ message?: string | RegExp;
22
+ };
23
+
24
+ export type ExpectMatchers = {
25
+ toBe: (expected: unknown) => void;
26
+ toEqual: (expected: unknown) => void;
27
+ toMatchObject: (expected: Record<string, unknown>) => void;
28
+ toBeDefined: () => void;
29
+ toBeUndefined: () => void;
30
+ toBeNull: () => void;
31
+ toContain: (expected: unknown) => void;
32
+ toContainEqual: (expected: unknown) => void;
33
+ toHaveLength: (expected: number) => void;
34
+ toBeGreaterThanOrEqual: (expected: number) => void;
35
+ toThrow: (predicate?: string | RegExp | ((error: Error) => boolean)) => void;
36
+ toMatchSnapshot: (filename?: string) => void;
37
+ toRejectWith: (expected: RejectionExpectation) => Promise<void>;
38
+ toHaveInteraction: (
39
+ interactionId: string,
40
+ opts?: Partial<InteractionDescriptorLike>,
41
+ ) => void;
42
+ toBeGatedBy: (reason: string, opts?: { interactionId?: string }) => void;
43
+ toBeActiveFor: (playerId: string, opts?: { interactionId?: string }) => void;
44
+ not: {
45
+ toHaveInteraction: (interactionId: string) => void;
46
+ };
47
+ };
48
+
49
+ export type ExpectFn = (actual: unknown) => ExpectMatchers;
50
+
51
+ export interface BaseContext<PlayerId extends string = string> {
52
+ game: {
53
+ start(): Promise<void>;
54
+ submit(
55
+ playerId: PlayerId,
56
+ interactionId: string,
57
+ params?: unknown,
58
+ ): Promise<void>;
59
+ };
60
+ players(): readonly PlayerId[];
61
+ seat(index: number): PlayerId;
62
+ }
63
+
64
+ export interface SharedScenarioContext<
65
+ PlayerId extends string = string,
66
+ StateName extends string = string,
67
+ View = unknown,
68
+ Descriptor extends InteractionDescriptorLike = InteractionDescriptorLike,
69
+ > extends BaseContext<PlayerId> {
70
+ state(): StateName;
71
+ view(playerId: PlayerId): View;
72
+ interactions(playerId: PlayerId): readonly Descriptor[];
73
+ expect: ExpectFn;
74
+ }
75
+
76
+ export interface BaseDefinition {
77
+ id: string;
78
+ seed?: number;
79
+ players?: number;
80
+ setupProfileId?: string | null;
81
+ extends?: string;
82
+ setup: (ctx: BaseContext) => void | Promise<void>;
83
+ }
84
+
85
+ export interface ScenarioDefinition<
86
+ Runners extends readonly TestRunner[] = readonly ["reducer"],
87
+ PhaseName extends string = string,
88
+ StageName extends string = string,
89
+ > {
90
+ id: string;
91
+ description?: string;
92
+ from: string;
93
+ runners?: Runners;
94
+ phase?: PhaseName;
95
+ stage?: StageName;
96
+ when: (ctx: SharedScenarioContext) => void | Promise<void>;
97
+ then: (ctx: SharedScenarioContext) => void | Promise<void>;
98
+ }
99
+
100
+ export function defineBase<const Definition extends BaseDefinition>(
101
+ definition: Definition,
102
+ ): Definition {
103
+ return definition;
104
+ }
105
+
106
+ export function defineScenario<
107
+ const Runners extends readonly TestRunner[] = readonly ["reducer"],
108
+ const PhaseName extends string = string,
109
+ const StageName extends string = string,
110
+ >(
111
+ definition: ScenarioDefinition<Runners, PhaseName, StageName>,
112
+ ): ScenarioDefinition<Runners, PhaseName, StageName> {
113
+ return definition;
114
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./definitions.js";
2
+ export * from "./create-expect-api.js";
3
+ export * from "./create-test-runtime.js";