diff --git a/tests/soak/core/screencaster.ts b/tests/soak/core/screencaster.ts index 2c3725d..74b0b21 100644 --- a/tests/soak/core/screencaster.ts +++ b/tests/soak/core/screencaster.ts @@ -59,8 +59,8 @@ export class Screencaster { await client.send('Page.startScreencast', { format: opts.format ?? 'jpeg', quality: opts.quality ?? 60, - maxWidth: opts.maxWidth ?? 640, - maxHeight: opts.maxHeight ?? 360, + maxWidth: opts.maxWidth ?? 960, + maxHeight: opts.maxHeight ?? 540, everyNthFrame: opts.everyNthFrame ?? 2, }); this.logger.info('screencast_started', { sessionKey }); diff --git a/tests/soak/core/session-pool.ts b/tests/soak/core/session-pool.ts index b4e63c1..78826eb 100644 --- a/tests/soak/core/session-pool.ts +++ b/tests/soak/core/session-pool.ts @@ -266,12 +266,49 @@ export class SessionPool { const context = await targetBrowser.newContext({ ...this.opts.contextOptions, 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); const page = await context.newPage(); 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 // modern Chromium (especially under Wayland), so we don't rely on // it — the viewport sized above is what the user actually sees. diff --git a/tests/soak/dashboard/dashboard.css b/tests/soak/dashboard/dashboard.css index 8914eb8..97f10f5 100644 --- a/tests/soak/dashboard/dashboard.css +++ b/tests/soak/dashboard/dashboard.css @@ -167,7 +167,9 @@ body { } #video-frame { display: block; + width: 960px; max-width: 100%; - max-height: 70vh; + max-height: 80vh; + object-fit: contain; border: 1px solid var(--border); } diff --git a/tests/soak/scenarios/populate.ts b/tests/soak/scenarios/populate.ts index bb6dcc4..600638c 100644 --- a/tests/soak/scenarios/populate.ts +++ b/tests/soak/scenarios/populate.ts @@ -50,9 +50,15 @@ async function runRoom( let completed = 0; 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++) { if (ctx.signal.aborted) break; 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 }); const result = await runOneMultiplayerGame(ctx, sessions, { @@ -66,6 +72,9 @@ async function runRoom( if (result.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', { room: roomId, game: gameNum + 1, @@ -73,6 +82,8 @@ async function runRoom( durationMs: result.durationMs, }); } else { + ctx.dashboard.incrementMetric('errors'); + ctx.dashboard.log('error', `${roomId}: game ${gameNum + 1} failed — ${result.error}`); errors.push({ room: roomId, reason: 'game_failed', diff --git a/tests/soak/scenarios/shared/multiplayer-game.ts b/tests/soak/scenarios/shared/multiplayer-game.ts index 187a567..fce50df 100644 --- a/tests/soak/scenarios/shared/multiplayer-game.ts +++ b/tests/soak/scenarios/shared/multiplayer-game.ts @@ -53,7 +53,28 @@ export async function runOneMultiplayerGame( // After the first game ends each session is parked on the // game_over screen, which hides the lobby's Create Room button. // 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 // carry stale room codes from previous games. The coordinator's @@ -90,12 +111,36 @@ export async function runOneMultiplayerGame( async function sessionLoop(sessionIdx: number): Promise { const session = sessions[sessionIdx]; + const isHost = sessionIdx === 0; while (true) { if (ctx.signal.aborted) return; if (Date.now() - start > maxDuration) return; 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()) { await session.bot.playTurn(); @@ -104,6 +149,11 @@ export async function runOneMultiplayerGame( ctx.dashboard.update(opts.roomId, { currentPlayer: session.account.username, 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]); await sleep(thinkMs); @@ -115,8 +165,12 @@ export async function runOneMultiplayerGame( 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); - ctx.dashboard.update(opts.roomId, { phase: 'round_over' }); + ctx.dashboard.update(opts.roomId, { phase: 'game_over' }); return { completed: true, turns: totalTurns, diff --git a/tests/soak/scenarios/stress.ts b/tests/soak/scenarios/stress.ts index c4e713d..932ca17 100644 --- a/tests/soak/scenarios/stress.ts +++ b/tests/soak/scenarios/stress.ts @@ -50,10 +50,15 @@ async function runStressRoom( let chaosFired = 0; 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++) { if (ctx.signal.aborted) break; 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. // Delay the first tick by 3 seconds so room creation + joiners + game @@ -90,12 +95,17 @@ async function runStressRoom( if (result.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', { room: roomId, game: gameNum + 1, turns: result.turns, }); } else { + ctx.dashboard.incrementMetric('errors'); + ctx.dashboard.log('error', `${roomId}: game ${gameNum + 1} failed — ${result.error}`); errors.push({ room: roomId, reason: 'game_failed',