/** * 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; } } }