diff --git a/tests/soak/core/deferred.ts b/tests/soak/core/deferred.ts new file mode 100644 index 0000000..4e235ad --- /dev/null +++ b/tests/soak/core/deferred.ts @@ -0,0 +1,20 @@ +/** + * Promise deferred primitive — lets external code resolve or reject + * a promise. Used by RoomCoordinator for host→joiners handoff. + */ + +export interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} + +export function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/tests/soak/core/types.ts b/tests/soak/core/types.ts new file mode 100644 index 0000000..6a13b8b --- /dev/null +++ b/tests/soak/core/types.ts @@ -0,0 +1,185 @@ +/** + * Core type definitions for the soak harness. + * + * Contracts here are consumed by runner.ts, SessionPool, scenarios, + * and the dashboard. Keep this file small and stable. + */ + +import type { BrowserContext, Page } from 'playwright-core'; + +// ============================================================================= +// GolfBot structural interface +// ============================================================================= + +/** + * Structural interface for the real `GolfBot` class from + * `tests/e2e/bot/golf-bot.ts`. We can't type-import the real class + * because (a) it lives outside this package's `rootDir`, and (b) it + * imports `@playwright/test` which isn't in this package's deps. + * + * Instead we declare the narrow public contract the soak harness + * actually calls. `SessionPool` constructs the real class at runtime + * via a dynamic require and casts it to this interface. When golf-bot + * gains new methods the harness wants, add them here — TypeScript will + * flag drift at the first call site. + */ + +export type GamePhase = + | 'lobby' + | 'waiting_for_flip' + | 'playing' + | 'round_over' + | 'game_over' + | 'unknown'; + +export interface StartGameOptions { + holes?: number; + decks?: number; + initialFlips?: number; + flipMode?: 'never' | 'always' | 'endgame'; + knockPenalty?: boolean; + jokerMode?: 'none' | 'standard' | 'lucky-swing' | 'eagle-eye'; +} + +export interface TurnResult { + success: boolean; + action: string; + details?: Record; + error?: string; +} + +export interface GolfBot { + readonly page: Page; + goto(url?: string): Promise; + createGame(playerName: string): Promise; + joinGame(roomCode: string, playerName: string): Promise; + addCPU(profileName?: string): Promise; + startGame(options?: StartGameOptions): Promise; + isMyTurn(): Promise; + waitForMyTurn(timeout?: number): Promise; + getGamePhase(): Promise; + getGameState(): Promise>; + playTurn(): Promise; + completeInitialFlips(): Promise; + isFrozen(timeout?: number): Promise; + takeScreenshot(label: string): Promise; + getConsoleErrors?(): string[]; +} + +// ============================================================================= +// Accounts & sessions +// ============================================================================= + +export interface Account { + /** Stable key used in logs, e.g. "soak_00". */ + key: string; + username: string; + password: string; + /** JWT returned from /api/auth/login, may be refreshed by SessionPool. */ + token: string; +} + +export interface Session { + account: Account; + context: BrowserContext; + page: Page; + bot: GolfBot; + /** Convenience mirror of account.key. */ + key: string; +} + +// ============================================================================= +// Scenarios +// ============================================================================= + +export interface ScenarioNeeds { + /** Total number of authenticated sessions the scenario requires. */ + accounts: number; + /** How many rooms to partition sessions into (default: 1). */ + rooms?: number; + /** CPUs to add per room (default: 0). */ + cpusPerRoom?: number; +} + +/** Free-form per-scenario config merged with CLI flags. */ +export type ScenarioConfig = Record; + +export interface ScenarioError { + room: string; + reason: string; + detail?: string; + timestamp: number; +} + +export interface ScenarioResult { + gamesCompleted: number; + errors: ScenarioError[]; + durationMs: number; + customMetrics?: Record; +} + +export interface ScenarioContext { + /** Merged config: CLI flags → env → scenario defaults → runner defaults. */ + config: ScenarioConfig; + /** Pre-authenticated sessions; ordered. */ + sessions: Session[]; + coordinator: RoomCoordinatorApi; + dashboard: DashboardReporter; + logger: Logger; + signal: AbortSignal; + /** Reset the per-room watchdog. Call at each progress point. */ + heartbeat(roomId: string): void; +} + +export interface Scenario { + name: string; + description: string; + defaultConfig: ScenarioConfig; + needs: ScenarioNeeds; + run(ctx: ScenarioContext): Promise; +} + +// ============================================================================= +// Room coordination +// ============================================================================= + +export interface RoomCoordinatorApi { + announce(roomId: string, code: string): void; + await(roomId: string, timeoutMs?: number): Promise; +} + +// ============================================================================= +// Dashboard reporter +// ============================================================================= + +export interface RoomState { + phase?: string; + currentPlayer?: string; + hole?: number; + totalHoles?: number; + game?: number; + totalGames?: number; + moves?: number; + players?: Array<{ key: string; score: number | null; isActive: boolean }>; + message?: string; +} + +export interface DashboardReporter { + update(roomId: string, state: Partial): void; + log(level: 'info' | 'warn' | 'error', msg: string, meta?: object): void; + incrementMetric(name: string, by?: number): void; +} + +// ============================================================================= +// Logger +// ============================================================================= + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface Logger { + debug(msg: string, meta?: object): void; + info(msg: string, meta?: object): void; + warn(msg: string, meta?: object): void; + error(msg: string, meta?: object): void; + child(meta: object): Logger; +} diff --git a/tests/soak/tests/deferred.test.ts b/tests/soak/tests/deferred.test.ts new file mode 100644 index 0000000..c0977fa --- /dev/null +++ b/tests/soak/tests/deferred.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { deferred } from '../core/deferred'; + +describe('deferred', () => { + it('resolves with the given value', async () => { + const d = deferred(); + d.resolve('hello'); + await expect(d.promise).resolves.toBe('hello'); + }); + + it('rejects with the given error', async () => { + const d = deferred(); + const err = new Error('boom'); + d.reject(err); + await expect(d.promise).rejects.toBe(err); + }); + + it('ignores second resolve calls', async () => { + const d = deferred(); + d.resolve(1); + d.resolve(2); + await expect(d.promise).resolves.toBe(1); + }); +});