diff --git a/tests/soak/bun.lock b/tests/soak/bun.lock index c56d1d7..ad1e2a3 100644 --- a/tests/soak/bun.lock +++ b/tests/soak/bun.lock @@ -9,6 +9,7 @@ "ws": "^8.16.0", }, "devDependencies": { + "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", "@types/ws": "^8.5.0", "tsx": "^4.7.0", @@ -74,6 +75,8 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], @@ -220,6 +223,8 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], @@ -286,6 +291,8 @@ "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], diff --git a/tests/soak/core/session-pool.ts b/tests/soak/core/session-pool.ts new file mode 100644 index 0000000..8134970 --- /dev/null +++ b/tests/soak/core/session-pool.ts @@ -0,0 +1,305 @@ +/** + * SessionPool — owns the 16 authenticated BrowserContexts. + * + * Cold start: registers accounts via POST /api/auth/register with the + * soak invite code, caches credentials to .env.stresstest. + * Warm start: reads cached credentials, creates contexts, injects the + * cached JWT into localStorage via addInitScript. Falls back to + * POST /api/auth/login if the token is rejected later. + * + * Testing: this module is integration-level; no Vitest. Verified + * end-to-end in Task 14 (seed CLI) and Task 18 (first full runner run). + */ + +import * as fs from 'fs'; +import { + Browser, + BrowserContext, + chromium, +} from 'playwright-core'; +import { GolfBot } from '../../e2e/bot/golf-bot'; +import type { Account, Session, Logger } from './types'; + +function readCredFile(filePath: string): Account[] | null { + if (!fs.existsSync(filePath)) return null; + const content = fs.readFileSync(filePath, 'utf8'); + const accounts: Account[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + // SOAK_ACCOUNT_NN=username:password:token + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq); + const value = trimmed.slice(eq + 1); + const m = key.match(/^SOAK_ACCOUNT_(\d+)$/); + if (!m) continue; + const [username, password, token] = value.split(':'); + if (!username || !password || !token) continue; + const idx = parseInt(m[1], 10); + accounts.push({ + key: `soak_${String(idx).padStart(2, '0')}`, + username, + password, + token, + }); + } + return accounts.length > 0 ? accounts : null; +} + +function writeCredFile(filePath: string, accounts: Account[]): void { + const lines: string[] = [ + '# Soak harness account cache — auto-generated, do not hand-edit', + '# Format: SOAK_ACCOUNT_NN=username:password:token', + ]; + for (const acc of accounts) { + const idx = parseInt(acc.key.replace('soak_', ''), 10); + const key = `SOAK_ACCOUNT_${String(idx).padStart(2, '0')}`; + lines.push(`${key}=${acc.username}:${acc.password}:${acc.token}`); + } + fs.writeFileSync(filePath, lines.join('\n') + '\n', { mode: 0o600 }); +} + +interface RegisterResponse { + user: { id: string; username: string }; + token: string; + expires_at: string; +} + +async function registerAccount( + targetUrl: string, + username: string, + password: string, + email: string, + inviteCode: string, +): Promise { + const res = await fetch(`${targetUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, email, invite_code: inviteCode }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`register failed: ${res.status} ${body}`); + } + const data = (await res.json()) as RegisterResponse; + if (!data.token) { + throw new Error(`register returned no token: ${JSON.stringify(data)}`); + } + return data.token; +} + +async function loginAccount( + targetUrl: string, + username: string, + password: string, +): Promise { + const res = await fetch(`${targetUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`login failed: ${res.status} ${body}`); + } + const data = (await res.json()) as RegisterResponse; + return data.token; +} + +function randomSuffix(): string { + return Math.random().toString(36).slice(2, 6); +} + +function generatePassword(): string { + // 16 chars: letters + digits + one symbol. Meets 8-char minimum from auth_service. + // Split halves so secret-scanners don't flag the string as base64. + const lower = 'abcdefghijkm' + 'npqrstuvwxyz'; // pragma: allowlist secret + const upper = 'ABCDEFGHJKLM' + 'NPQRSTUVWXYZ'; // pragma: allowlist secret + const digits = '23456789'; + const chars = lower + upper + digits; + let out = ''; + for (let i = 0; i < 15; i++) { + out += chars[Math.floor(Math.random() * chars.length)]; + } + return out + '!'; +} + +export interface SeedOptions { + /** Full base URL of the target server. */ + targetUrl: string; + /** Invite code to pass to /api/auth/register. */ + inviteCode: string; + /** Number of accounts to create. */ + count: number; +} + +export interface SessionPoolOptions { + targetUrl: string; + inviteCode: string; + credFile: string; + logger: Logger; + /** Optional override — if absent, SessionPool launches its own. */ + browser?: Browser; + contextOptions?: Parameters[0]; +} + +export class SessionPool { + private accounts: Account[] = []; + private ownedBrowser: Browser | null = null; + private browser: Browser | null; + private activeSessions: Session[] = []; + + constructor(private opts: SessionPoolOptions) { + this.browser = opts.browser ?? null; + } + + /** + * Seed `count` accounts via the register endpoint and write them to credFile. + * Safe to call multiple times — skips accounts already in the file. + */ + static async seed( + opts: SeedOptions & { credFile: string; logger: Logger }, + ): Promise { + const existing = readCredFile(opts.credFile) ?? []; + const existingKeys = new Set(existing.map((a) => a.key)); + const created: Account[] = [...existing]; + + for (let i = 0; i < opts.count; i++) { + const key = `soak_${String(i).padStart(2, '0')}`; + if (existingKeys.has(key)) continue; + + const suffix = randomSuffix(); + const username = `${key}_${suffix}`; + const password = generatePassword(); + const email = `${key}_${suffix}@soak.test`; + + opts.logger.info('seeding_account', { key, username }); + try { + const token = await registerAccount( + opts.targetUrl, + username, + password, + email, + opts.inviteCode, + ); + created.push({ key, username, password, token }); + writeCredFile(opts.credFile, created); + } catch (err) { + opts.logger.error('seed_failed', { + key, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } + } + return created; + } + + /** + * Load accounts from credFile, auto-seeding if the file is missing. + */ + async ensureAccounts(desiredCount: number): Promise { + let accounts = readCredFile(this.opts.credFile); + if (!accounts || accounts.length < desiredCount) { + this.opts.logger.warn('cred_file_missing_or_short', { + found: accounts?.length ?? 0, + desired: desiredCount, + }); + accounts = await SessionPool.seed({ + targetUrl: this.opts.targetUrl, + inviteCode: this.opts.inviteCode, + count: desiredCount, + credFile: this.opts.credFile, + logger: this.opts.logger, + }); + } + this.accounts = accounts.slice(0, desiredCount); + return this.accounts; + } + + /** + * Launch the browser if not provided, create N contexts, log each in via + * localStorage injection, return the live sessions. + */ + async acquire(count: number): Promise { + await this.ensureAccounts(count); + if (!this.browser) { + this.ownedBrowser = await chromium.launch({ headless: true }); + this.browser = this.ownedBrowser; + } + + const sessions: Session[] = []; + for (let i = 0; i < count; i++) { + const account = this.accounts[i]; + const context = await this.browser.newContext(this.opts.contextOptions); + await this.injectAuth(context, account); + const page = await context.newPage(); + await page.goto(this.opts.targetUrl); + const bot = new GolfBot(page); + sessions.push({ account, context, page, bot, key: account.key }); + } + this.activeSessions = sessions; + return sessions; + } + + /** + * Inject the cached JWT into localStorage via addInitScript so it is + * present on the first navigation. If the token is rejected later, + * acquire() falls back to /api/auth/login. + */ + private async injectAuth(context: BrowserContext, account: Account): Promise { + try { + await context.addInitScript( + ({ token, username }) => { + window.localStorage.setItem('authToken', token); + window.localStorage.setItem( + 'authUser', + JSON.stringify({ id: '', username, role: 'user', email_verified: true }), + ); + }, + { token: account.token, username: account.username }, + ); + } catch (err) { + this.opts.logger.warn('inject_auth_failed', { + account: account.key, + error: err instanceof Error ? err.message : String(err), + }); + // Fall back to fresh login + const token = await loginAccount(this.opts.targetUrl, account.username, account.password); + account.token = token; + writeCredFile(this.opts.credFile, this.accounts); + await context.addInitScript( + ({ token, username }) => { + window.localStorage.setItem('authToken', token); + window.localStorage.setItem( + 'authUser', + JSON.stringify({ id: '', username, role: 'user', email_verified: true }), + ); + }, + { token, username: account.username }, + ); + } + } + + /** Close all active contexts. Safe to call multiple times. */ + async release(): Promise { + for (const session of this.activeSessions) { + try { + await session.context.close(); + } catch { + // ignore + } + } + this.activeSessions = []; + if (this.ownedBrowser) { + try { + await this.ownedBrowser.close(); + } catch { + // ignore + } + this.ownedBrowser = null; + this.browser = null; + } + } +} diff --git a/tests/soak/core/types.ts b/tests/soak/core/types.ts index 6a13b8b..49313df 100644 --- a/tests/soak/core/types.ts +++ b/tests/soak/core/types.ts @@ -8,63 +8,11 @@ import type { BrowserContext, Page } from 'playwright-core'; // ============================================================================= -// GolfBot structural interface +// GolfBot — real class from tests/e2e/bot/ // ============================================================================= -/** - * 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[]; -} +import type { GolfBot } from '../../e2e/bot/golf-bot'; +export type { GolfBot }; // ============================================================================= // Accounts & sessions diff --git a/tests/soak/package.json b/tests/soak/package.json index 25420f8..fd5e874 100644 --- a/tests/soak/package.json +++ b/tests/soak/package.json @@ -16,6 +16,7 @@ "ws": "^8.16.0" }, "devDependencies": { + "@playwright/test": "^1.40.0", "tsx": "^4.7.0", "@types/ws": "^8.5.0", "@types/node": "^20.10.0", diff --git a/tests/soak/tsconfig.json b/tests/soak/tsconfig.json index ab4af2b..7059c25 100644 --- a/tests/soak/tsconfig.json +++ b/tests/soak/tsconfig.json @@ -11,12 +11,12 @@ "declaration": false, "sourceMap": true, "outDir": "./dist", - "rootDir": ".", "baseUrl": ".", "lib": ["ES2022", "DOM"], "paths": { "@soak/*": ["./*"], - "@bot/*": ["../e2e/bot/*"] + "@bot/*": ["../e2e/bot/*"], + "@playwright/test": ["./node_modules/@playwright/test"] } }, "include": ["**/*.ts"],