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:
20
tests/soak/core/deferred.ts
Normal file
20
tests/soak/core/deferred.ts
Normal 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
185
tests/soak/core/types.ts
Normal 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;
|
||||||
|
}
|
||||||
24
tests/soak/tests/deferred.test.ts
Normal file
24
tests/soak/tests/deferred.test.ts
Normal file
@@ -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<string>();
|
||||||
|
d.resolve('hello');
|
||||||
|
await expect(d.promise).resolves.toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects with the given error', async () => {
|
||||||
|
const d = deferred<string>();
|
||||||
|
const err = new Error('boom');
|
||||||
|
d.reject(err);
|
||||||
|
await expect(d.promise).rejects.toBe(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores second resolve calls', async () => {
|
||||||
|
const d = deferred<number>();
|
||||||
|
d.resolve(1);
|
||||||
|
d.resolve(2);
|
||||||
|
await expect(d.promise).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user