Files
golfgame/tests/soak/core/session-pool.ts
adlee-was-taken 3bc0270eb9 feat(soak): SessionPool — seed, login, acquire contexts
Owns BrowserContexts, seeds via POST /api/auth/register with the
invite code on cold start, warm-starts via localStorage injection of
the cached JWT, falls back to POST /api/auth/login if the token is
rejected. Exposes acquire(n) for scenarios.

Infrastructure changes needed to import the real GolfBot class from
tests/e2e/bot/ without the Task-10 structural-interface workaround:
 - Add @playwright/test as devDep so value-imports in e2e/bot/*.ts
   resolve at runtime (Page/Locator/expect are pulled even as types)
 - Remove rootDir from tsconfig so TS follows cross-package imports;
   add a paths entry so TS can resolve @playwright/test from the soak
   package's node_modules when compiling files under tests/e2e/bot
 - Drop the local GolfBot structural interface + its placeholder
   GamePhase/StartGameOptions/TurnResult types; re-export the real
   class from types.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:19:39 -04:00

306 lines
9.6 KiB
TypeScript

/**
* 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<string> {
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(() => '<no body>');
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<string> {
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(() => '<no body>');
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<Browser['newContext']>[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<Account[]> {
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<Account[]> {
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<Session[]> {
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<void> {
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<void> {
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;
}
}
}