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>
This commit is contained in:
305
tests/soak/core/session-pool.ts
Normal file
305
tests/soak/core/session-pool.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user