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",
|
"ws": "^8.16.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/ws": "^8.5.0",
|
"@types/ws": "^8.5.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
@@ -74,6 +75,8 @@
|
|||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
"@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-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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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@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=="],
|
"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';
|
import type { BrowserContext, Page } from 'playwright-core';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// GolfBot structural interface
|
// GolfBot — real class from tests/e2e/bot/
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
import type { GolfBot } from '../../e2e/bot/golf-bot';
|
||||||
* Structural interface for the real `GolfBot` class from
|
export type { GolfBot };
|
||||||
* `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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Accounts & sessions
|
// Accounts & sessions
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"ws": "^8.16.0"
|
"ws": "^8.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.40.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"@types/ws": "^8.5.0",
|
"@types/ws": "^8.5.0",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
"declaration": false,
|
"declaration": false,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": ".",
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@soak/*": ["./*"],
|
"@soak/*": ["./*"],
|
||||||
"@bot/*": ["../e2e/bot/*"]
|
"@bot/*": ["../e2e/bot/*"],
|
||||||
|
"@playwright/test": ["./node_modules/@playwright/test"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["**/*.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user