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:
@@ -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=="],
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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[];
|
||||
}
|
||||
import type { GolfBot } from '../../e2e/bot/golf-bot';
|
||||
export type { GolfBot };
|
||||
|
||||
// =============================================================================
|
||||
// Accounts & sessions
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user