From c307027dd0e18739681272a7bdd9bde6084710cf Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 21:01:21 -0400 Subject: [PATCH] feat(soak): --watch=tiled launches N headed host windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionPool accepts headedHostCount; when > 0 it launches a second Chromium in headed mode and creates the first N sessions in it. Joiners (sessions N..count) stay headless in the main browser. Each headed context gets a 960×900 viewport — tall enough to show the full game table (deck + opponent row + own 2×3 card grid + status area) without clipping. Horizontal tiling still fits two windows side-by-side on a 1920-wide display. window.moveTo is kept as a best-effort tile-placement hint, but viewport from newContext() is what actually sizes the window (window.resizeTo is a no-op on modern Chromium / Wayland). Verified: 1-room tiled run plays a full game cleanly; 2-room parallel tiled had one window get closed mid-run, which is consistent with a user manually dismissing a window — tiled mode is a best-effort hands-on debugging aid, not an automation mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/core/session-pool.ts | 63 +++++++++++++++++++++++++++++++-- tests/soak/runner.ts | 7 ++-- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/tests/soak/core/session-pool.ts b/tests/soak/core/session-pool.ts index 8134970..7b3318d 100644 --- a/tests/soak/core/session-pool.ts +++ b/tests/soak/core/session-pool.ts @@ -142,12 +142,20 @@ export interface SessionPoolOptions { /** Optional override — if absent, SessionPool launches its own. */ browser?: Browser; contextOptions?: Parameters[0]; + /** + * If > 0, the first `headedHostCount` sessions use a separate headed + * Chromium browser with visible windows (positioned in a 2×2 grid + * via window.moveTo). The remaining sessions stay headless. Used for + * --watch=tiled mode. + */ + headedHostCount?: number; } export class SessionPool { private accounts: Account[] = []; private ownedBrowser: Browser | null = null; private browser: Browser | null; + private headedBrowser: Browser | null = null; private activeSessions: Session[] = []; constructor(private opts: SessionPoolOptions) { @@ -221,6 +229,10 @@ export class SessionPool { /** * Launch the browser if not provided, create N contexts, log each in via * localStorage injection, return the live sessions. + * + * If `headedHostCount > 0`, launches a second headed Chromium and + * places the first `headedHostCount` sessions in it, positioning + * their windows in a 2×2 grid. The rest stay headless. */ async acquire(count: number): Promise { await this.ensureAccounts(count); @@ -229,13 +241,52 @@ export class SessionPool { this.browser = this.ownedBrowser; } + const headedCount = this.opts.headedHostCount ?? 0; + if (headedCount > 0 && !this.headedBrowser) { + this.headedBrowser = await chromium.launch({ + headless: false, + slowMo: 50, + }); + } + const sessions: Session[] = []; for (let i = 0; i < count; i++) { const account = this.accounts[i]; - const context = await this.browser.newContext(this.opts.contextOptions); + const useHeaded = i < headedCount; + const targetBrowser = useHeaded ? this.headedBrowser! : this.browser!; + // Headed host windows get a larger viewport — 960×900 fits the full + // game table (deck + opponent row + own 2×3 grid + status area) on + // a typical 1920×1080 display. Two windows side-by-side still fit + // horizontally; if the user runs more than 2 rooms in tiled mode + // the extra windows will overlap and need to be arranged manually. + const context = await targetBrowser.newContext({ + ...this.opts.contextOptions, + ...(useHeaded ? { viewport: { width: 960, height: 900 } } : {}), + }); await this.injectAuth(context, account); const page = await context.newPage(); await page.goto(this.opts.targetUrl); + + // 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. + if (useHeaded) { + const col = i % 2; + const row = Math.floor(i / 2); + const x = col * 960; + const y = row * 920; + await page + .evaluate( + ([x, y]) => { + window.moveTo(x, y); + }, + [x, y] as [number, number], + ) + .catch(() => { + // ignore — window.moveTo may be blocked + }); + } + const bot = new GolfBot(page); sessions.push({ account, context, page, bot, key: account.key }); } @@ -282,7 +333,7 @@ export class SessionPool { } } - /** Close all active contexts. Safe to call multiple times. */ + /** Close all active contexts + browsers. Safe to call multiple times. */ async release(): Promise { for (const session of this.activeSessions) { try { @@ -301,5 +352,13 @@ export class SessionPool { this.ownedBrowser = null; this.browser = null; } + if (this.headedBrowser) { + try { + await this.headedBrowser.close(); + } catch { + // ignore + } + this.headedBrowser = null; + } } } diff --git a/tests/soak/runner.ts b/tests/soak/runner.ts index 43bc432..ff13380 100644 --- a/tests/soak/runner.ts +++ b/tests/soak/runner.ts @@ -101,20 +101,17 @@ async function main(): Promise { // Build dependencies const credFile = path.resolve(__dirname, '.env.stresstest'); + const headedHostCount = watch === 'tiled' ? rooms : 0; const pool = new SessionPool({ targetUrl, inviteCode, credFile, logger, + headedHostCount, }); const coordinator = new RoomCoordinator(); const screencaster = new Screencaster(logger); - if (watch === 'tiled') { - logger.warn('tiled_not_yet_implemented'); - console.warn('Watch mode "tiled" not yet implemented (Task 24). Falling back to none.'); - } - const abortController = new AbortController(); const onSignal = (sig: string) => {