fix(soak): multi-hole round transitions, token refresh, dashboard wiring
- Session loop now handles round_over by clicking #ss-next-btn (the
scoresheet modal button) instead of exiting early. Waits for next
round or game_over before continuing.
- SessionPool detects expired tokens on acquire and re-logins
automatically, writing fresh credentials to .env.stresstest.
- Added 2s post-game delay before goto('/') so the server can process
game completion before WebSocket disconnect.
- Wired dashboard metrics (games_completed, moves_total, errors),
activity log entries, and player tiles for both populate and stress
scenarios.
- Bumped screencast resolution to 960x540 and set headless viewport
to 960x800 for better click-to-watch framing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,8 +59,8 @@ export class Screencaster {
|
|||||||
await client.send('Page.startScreencast', {
|
await client.send('Page.startScreencast', {
|
||||||
format: opts.format ?? 'jpeg',
|
format: opts.format ?? 'jpeg',
|
||||||
quality: opts.quality ?? 60,
|
quality: opts.quality ?? 60,
|
||||||
maxWidth: opts.maxWidth ?? 640,
|
maxWidth: opts.maxWidth ?? 960,
|
||||||
maxHeight: opts.maxHeight ?? 360,
|
maxHeight: opts.maxHeight ?? 540,
|
||||||
everyNthFrame: opts.everyNthFrame ?? 2,
|
everyNthFrame: opts.everyNthFrame ?? 2,
|
||||||
});
|
});
|
||||||
this.logger.info('screencast_started', { sessionKey });
|
this.logger.info('screencast_started', { sessionKey });
|
||||||
|
|||||||
@@ -266,12 +266,49 @@ export class SessionPool {
|
|||||||
const context = await targetBrowser.newContext({
|
const context = await targetBrowser.newContext({
|
||||||
...this.opts.contextOptions,
|
...this.opts.contextOptions,
|
||||||
baseURL: this.opts.targetUrl,
|
baseURL: this.opts.targetUrl,
|
||||||
...(useHeaded ? { viewport: { width: 960, height: 900 } } : {}),
|
viewport: useHeaded
|
||||||
|
? { width: 960, height: 900 }
|
||||||
|
: { width: 960, height: 800 },
|
||||||
});
|
});
|
||||||
await this.injectAuth(context, account);
|
await this.injectAuth(context, account);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(this.opts.targetUrl);
|
await page.goto(this.opts.targetUrl);
|
||||||
|
|
||||||
|
// Verify the token is valid — if expired, re-login and reload
|
||||||
|
const controlsVisible = await page
|
||||||
|
.waitForSelector('#lobby-game-controls:not(.hidden)', {
|
||||||
|
state: 'attached',
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!controlsVisible) {
|
||||||
|
this.opts.logger.warn('token_expired_relogin', { account: account.key });
|
||||||
|
const freshToken = await loginAccount(
|
||||||
|
this.opts.targetUrl,
|
||||||
|
account.username,
|
||||||
|
account.password,
|
||||||
|
);
|
||||||
|
account.token = freshToken;
|
||||||
|
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: freshToken, username: account.username },
|
||||||
|
);
|
||||||
|
await page.goto(this.opts.targetUrl);
|
||||||
|
await page.waitForSelector('#lobby-game-controls:not(.hidden)', {
|
||||||
|
state: 'attached',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Best-effort tile placement. window.moveTo is often a no-op on
|
// Best-effort tile placement. window.moveTo is often a no-op on
|
||||||
// modern Chromium (especially under Wayland), so we don't rely on
|
// modern Chromium (especially under Wayland), so we don't rely on
|
||||||
// it — the viewport sized above is what the user actually sees.
|
// it — the viewport sized above is what the user actually sees.
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ body {
|
|||||||
}
|
}
|
||||||
#video-frame {
|
#video-frame {
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 960px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 70vh;
|
max-height: 80vh;
|
||||||
|
object-fit: contain;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,15 @@ async function runRoom(
|
|||||||
let completed = 0;
|
let completed = 0;
|
||||||
const errors: ScenarioError[] = [];
|
const errors: ScenarioError[] = [];
|
||||||
|
|
||||||
|
// Send player list for dashboard tiles
|
||||||
|
ctx.dashboard.update(roomId, {
|
||||||
|
players: sessions.map((s) => ({ key: s.key, score: null, isActive: false })),
|
||||||
|
});
|
||||||
|
|
||||||
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
|
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
|
||||||
if (ctx.signal.aborted) break;
|
if (ctx.signal.aborted) break;
|
||||||
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
|
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
|
||||||
|
ctx.dashboard.log('info', `${roomId}: starting game ${gameNum + 1}/${cfg.gamesPerRoom}`);
|
||||||
ctx.logger.info('game_start', { room: roomId, game: gameNum + 1 });
|
ctx.logger.info('game_start', { room: roomId, game: gameNum + 1 });
|
||||||
|
|
||||||
const result = await runOneMultiplayerGame(ctx, sessions, {
|
const result = await runOneMultiplayerGame(ctx, sessions, {
|
||||||
@@ -66,6 +72,9 @@ async function runRoom(
|
|||||||
|
|
||||||
if (result.completed) {
|
if (result.completed) {
|
||||||
completed++;
|
completed++;
|
||||||
|
ctx.dashboard.incrementMetric('games_completed');
|
||||||
|
ctx.dashboard.incrementMetric('moves_total', result.turns);
|
||||||
|
ctx.dashboard.log('info', `${roomId}: game ${gameNum + 1} complete — ${result.turns} turns, ${(result.durationMs / 1000).toFixed(1)}s`);
|
||||||
ctx.logger.info('game_complete', {
|
ctx.logger.info('game_complete', {
|
||||||
room: roomId,
|
room: roomId,
|
||||||
game: gameNum + 1,
|
game: gameNum + 1,
|
||||||
@@ -73,6 +82,8 @@ async function runRoom(
|
|||||||
durationMs: result.durationMs,
|
durationMs: result.durationMs,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
ctx.dashboard.incrementMetric('errors');
|
||||||
|
ctx.dashboard.log('error', `${roomId}: game ${gameNum + 1} failed — ${result.error}`);
|
||||||
errors.push({
|
errors.push({
|
||||||
room: roomId,
|
room: roomId,
|
||||||
reason: 'game_failed',
|
reason: 'game_failed',
|
||||||
|
|||||||
@@ -53,7 +53,28 @@ export async function runOneMultiplayerGame(
|
|||||||
// After the first game ends each session is parked on the
|
// After the first game ends each session is parked on the
|
||||||
// game_over screen, which hides the lobby's Create Room button.
|
// game_over screen, which hides the lobby's Create Room button.
|
||||||
// goto('/') bounces them back; localStorage-cached auth persists.
|
// goto('/') bounces them back; localStorage-cached auth persists.
|
||||||
await Promise.all(sessions.map((s) => s.bot.goto('/')));
|
// We must wait for auth hydration to unhide #lobby-game-controls.
|
||||||
|
await Promise.all(
|
||||||
|
sessions.map(async (s) => {
|
||||||
|
await s.bot.goto('/');
|
||||||
|
try {
|
||||||
|
await s.page.waitForSelector('#create-room-btn', {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Auth may have been lost — re-login via the page
|
||||||
|
const html = await s.page.content().catch(() => '');
|
||||||
|
ctx.logger.warn('lobby_not_ready', {
|
||||||
|
session: s.key,
|
||||||
|
hasControls: html.includes('lobby-game-controls'),
|
||||||
|
hasHidden: html.includes('lobby-game-controls" class="hidden"') ||
|
||||||
|
html.includes("lobby-game-controls' class='hidden'"),
|
||||||
|
});
|
||||||
|
throw new Error(`lobby not ready for ${s.key} after goto('/')`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Use a unique coordinator key per game-start so Deferreds don't
|
// Use a unique coordinator key per game-start so Deferreds don't
|
||||||
// carry stale room codes from previous games. The coordinator's
|
// carry stale room codes from previous games. The coordinator's
|
||||||
@@ -90,12 +111,36 @@ export async function runOneMultiplayerGame(
|
|||||||
|
|
||||||
async function sessionLoop(sessionIdx: number): Promise<void> {
|
async function sessionLoop(sessionIdx: number): Promise<void> {
|
||||||
const session = sessions[sessionIdx];
|
const session = sessions[sessionIdx];
|
||||||
|
const isHost = sessionIdx === 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
if (ctx.signal.aborted) return;
|
if (ctx.signal.aborted) return;
|
||||||
if (Date.now() - start > maxDuration) return;
|
if (Date.now() - start > maxDuration) return;
|
||||||
|
|
||||||
const phase = await session.bot.getGamePhase();
|
const phase = await session.bot.getGamePhase();
|
||||||
if (phase === 'game_over' || phase === 'round_over') return;
|
if (phase === 'game_over') return;
|
||||||
|
|
||||||
|
if (phase === 'round_over') {
|
||||||
|
if (isHost) {
|
||||||
|
await sleep(1500);
|
||||||
|
// The scoresheet modal uses #ss-next-btn; the side panel uses #next-round-btn.
|
||||||
|
// Try both — the visible one gets clicked.
|
||||||
|
const ssBtn = session.page.locator('#ss-next-btn');
|
||||||
|
const sideBtn = session.page.locator('#next-round-btn');
|
||||||
|
const clicked = await ssBtn.click({ timeout: 3000 }).then(() => 'ss').catch(() => null)
|
||||||
|
|| await sideBtn.click({ timeout: 3000 }).then(() => 'side').catch(() => null);
|
||||||
|
ctx.logger.info('round_advance', { room: opts.roomId, session: session.key, clicked });
|
||||||
|
} else {
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
// Wait for the next round to actually start (or game_over on last round)
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const p = await session.bot.getGamePhase();
|
||||||
|
if (p === 'game_over' || p === 'playing' || p === 'initial_flip') break;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
ctx.heartbeat(opts.roomId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (await session.bot.isMyTurn()) {
|
if (await session.bot.isMyTurn()) {
|
||||||
await session.bot.playTurn();
|
await session.bot.playTurn();
|
||||||
@@ -104,6 +149,11 @@ export async function runOneMultiplayerGame(
|
|||||||
ctx.dashboard.update(opts.roomId, {
|
ctx.dashboard.update(opts.roomId, {
|
||||||
currentPlayer: session.account.username,
|
currentPlayer: session.account.username,
|
||||||
moves: turnCounts.reduce((a, b) => a + b, 0),
|
moves: turnCounts.reduce((a, b) => a + b, 0),
|
||||||
|
players: sessions.map((s, j) => ({
|
||||||
|
key: s.key,
|
||||||
|
score: null,
|
||||||
|
isActive: j === sessionIdx,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
const thinkMs = randomInt(opts.thinkTimeMs[0], opts.thinkTimeMs[1]);
|
const thinkMs = randomInt(opts.thinkTimeMs[0], opts.thinkTimeMs[1]);
|
||||||
await sleep(thinkMs);
|
await sleep(thinkMs);
|
||||||
@@ -115,8 +165,12 @@ export async function runOneMultiplayerGame(
|
|||||||
|
|
||||||
await Promise.all(sessions.map((_, i) => sessionLoop(i)));
|
await Promise.all(sessions.map((_, i) => sessionLoop(i)));
|
||||||
|
|
||||||
|
// Let the server finish processing game completion (stats, DB update)
|
||||||
|
// before we navigate away and kill the WebSocket connections.
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
const totalTurns = turnCounts.reduce((a, b) => a + b, 0);
|
const totalTurns = turnCounts.reduce((a, b) => a + b, 0);
|
||||||
ctx.dashboard.update(opts.roomId, { phase: 'round_over' });
|
ctx.dashboard.update(opts.roomId, { phase: 'game_over' });
|
||||||
return {
|
return {
|
||||||
completed: true,
|
completed: true,
|
||||||
turns: totalTurns,
|
turns: totalTurns,
|
||||||
|
|||||||
@@ -50,10 +50,15 @@ async function runStressRoom(
|
|||||||
let chaosFired = 0;
|
let chaosFired = 0;
|
||||||
const errors: ScenarioError[] = [];
|
const errors: ScenarioError[] = [];
|
||||||
|
|
||||||
|
ctx.dashboard.update(roomId, {
|
||||||
|
players: sessions.map((s) => ({ key: s.key, score: null, isActive: false })),
|
||||||
|
});
|
||||||
|
|
||||||
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
|
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
|
||||||
if (ctx.signal.aborted) break;
|
if (ctx.signal.aborted) break;
|
||||||
|
|
||||||
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
|
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
|
||||||
|
ctx.dashboard.log('info', `${roomId}: starting game ${gameNum + 1}/${cfg.gamesPerRoom}`);
|
||||||
|
|
||||||
// Background chaos loop — runs concurrently with the game turn loop.
|
// Background chaos loop — runs concurrently with the game turn loop.
|
||||||
// Delay the first tick by 3 seconds so room creation + joiners + game
|
// Delay the first tick by 3 seconds so room creation + joiners + game
|
||||||
@@ -90,12 +95,17 @@ async function runStressRoom(
|
|||||||
|
|
||||||
if (result.completed) {
|
if (result.completed) {
|
||||||
completed++;
|
completed++;
|
||||||
|
ctx.dashboard.incrementMetric('games_completed');
|
||||||
|
ctx.dashboard.incrementMetric('moves_total', result.turns);
|
||||||
|
ctx.dashboard.log('info', `${roomId}: game ${gameNum + 1} complete — ${result.turns} turns`);
|
||||||
ctx.logger.info('game_complete', {
|
ctx.logger.info('game_complete', {
|
||||||
room: roomId,
|
room: roomId,
|
||||||
game: gameNum + 1,
|
game: gameNum + 1,
|
||||||
turns: result.turns,
|
turns: result.turns,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
ctx.dashboard.incrementMetric('errors');
|
||||||
|
ctx.dashboard.log('error', `${roomId}: game ${gameNum + 1} failed — ${result.error}`);
|
||||||
errors.push({
|
errors.push({
|
||||||
room: roomId,
|
room: roomId,
|
||||||
reason: 'game_failed',
|
reason: 'game_failed',
|
||||||
|
|||||||
Reference in New Issue
Block a user