feat(soak): core types + Deferred primitive

Establishes the Scenario/Session/Logger/DashboardReporter contracts
the rest of the harness builds on. Deferred is the building block
for RoomCoordinator's host→joiners handoff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 16:58:56 -04:00
parent 5478a4299e
commit 1565046ab7
3 changed files with 229 additions and 0 deletions

View File

@@ -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<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: unknown): void;
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

185
tests/soak/core/types.ts Normal file
View File

@@ -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<string, unknown>;
error?: string;
}
export interface GolfBot {
readonly page: Page;
goto(url?: string): Promise<void>;
createGame(playerName: string): Promise<string>;
joinGame(roomCode: string, playerName: string): Promise<void>;
addCPU(profileName?: string): Promise<void>;
startGame(options?: StartGameOptions): Promise<void>;
isMyTurn(): Promise<boolean>;
waitForMyTurn(timeout?: number): Promise<boolean>;
getGamePhase(): Promise<GamePhase>;
getGameState(): Promise<Record<string, unknown>>;
playTurn(): Promise<TurnResult>;
completeInitialFlips(): Promise<void>;
isFrozen(timeout?: number): Promise<boolean>;
takeScreenshot(label: string): Promise<Buffer>;
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<string, unknown>;
export interface ScenarioError {
room: string;
reason: string;
detail?: string;
timestamp: number;
}
export interface ScenarioResult {
gamesCompleted: number;
errors: ScenarioError[];
durationMs: number;
customMetrics?: Record<string, number>;
}
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<ScenarioResult>;
}
// =============================================================================
// Room coordination
// =============================================================================
export interface RoomCoordinatorApi {
announce(roomId: string, code: string): void;
await(roomId: string, timeoutMs?: number): Promise<string>;
}
// =============================================================================
// 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<RoomState>): 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;
}