// What a free browser game really downloads — measurement harness (v2). // // For each game URL and each run: open it in a fresh, clean Chrome profile, do // NOT click anything (no consent, no play button), wait a fixed time, and record // every network request the page, its frames and its workers make, with the // compressed bytes received, plus the cookies present at 5, 10, 30 and 60 s. // One JSON file per game per run. // // node measure.cjs urls.txt out/ [waitSeconds=60] [runs=3] [parallel=3] // // urls.txt: one "labelurl" per line; # comments allowed. // Requires Playwright (PLAYWRIGHT env var may point at an install) and Chrome. // // Method notes (see STUDY-DESIGN-RESEARCH.md): // - Service workers are blocked so every request is observable. // - The user agent is the installed Chrome's own, minus "Headless", so the // version is honest and the page is not told it is headless. // - Bytes are Playwright's responseBodySize: compressed body bytes received. // - The country of the connection is recorded from Cloudflare's /cdn-cgi/trace. // // Process hygiene: every browser is tracked and closed in finally, on // SIGTERM/SIGINT, and killed on exit; each load has its own hard timeout. const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require(process.env.PLAYWRIGHT || 'playwright'); const [, , listFile, outDir, waitArg, runsArg, parArg] = process.argv; const WAIT_MS = (Number(waitArg) || 60) * 1000; const RUNS = Number(runsArg) || 3; const PARALLEL = Number(parArg) || 3; const LOAD_TIMEOUT_MS = WAIT_MS + 60_000; const CHECKPOINTS_S = [5, 10, 30, 60].filter((s) => s * 1000 <= WAIT_MS); const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const live = new Set(); async function closeAll() { await Promise.all([...live].map((b) => b.close().catch(() => {}))); live.clear(); } process.on('SIGTERM', async () => { await closeAll(); process.exit(1); }); process.on('SIGINT', async () => { await closeAll(); process.exit(1); }); process.on('exit', () => { for (const b of live) { try { b.process?.()?.kill('SIGKILL'); } catch {} } }); async function country() { try { const r = await fetch('https://www.cloudflare.com/cdn-cgi/trace'); const t = await r.text(); return (t.match(/^loc=(\w+)/m) || [])[1] || null; } catch { return null; } } async function measure(label, url, run, loc) { const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ['--disable-blink-features=AutomationControlled'] }); live.add(browser); try { const major = browser.version().split('.')[0]; const ctx = await browser.newContext({ viewport: { width: 1366, height: 768 }, locale: 'en-US', timezoneId: 'America/New_York', serviceWorkers: 'block', userAgent: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`, }); const requests = []; const started = Date.now(); ctx.on('requestfinished', async (req) => { let sizes = null; try { sizes = await req.sizes(); } catch {} requests.push({ url: req.url(), method: req.method(), type: req.resourceType(), t_ms: Date.now() - started, body_bytes: sizes ? sizes.responseBodySize : null, failed: false }); }); ctx.on('requestfailed', (req) => { requests.push({ url: req.url(), method: req.method(), type: req.resourceType(), t_ms: Date.now() - started, body_bytes: 0, failed: true, error: req.failure()?.errorText || null }); }); const page = await ctx.newPage(); let status = null, error = null; try { const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 }); status = res ? res.status() : null; } catch (e) { error = String(e.message || e).slice(0, 200); } const cookie_checkpoints = {}; for (const s of CHECKPOINTS_S) { const due = started + s * 1000 - Date.now(); if (due > 0) await page.waitForTimeout(due); const cs = await Promise.race([ctx.cookies(), new Promise((r) => setTimeout(() => r([]), 10000))]); cookie_checkpoints[s] = cs.map((c) => ({ name: c.name, domain: c.domain })); } const rest = started + WAIT_MS - Date.now(); if (rest > 0) await page.waitForTimeout(rest); // Did a game appear without any click? Look for a canvas in any frame. const frames = []; const framesDeadline = Date.now() + 10_000; // whole check, not per frame for (const f of page.frames()) { if (Date.now() > framesDeadline) { frames.push({ url: f.url().slice(0, 300), canvases: null, skipped: true }); continue; } let canvases = null; try { canvases = await Promise.race([f.evaluate(() => document.querySelectorAll('canvas').length), new Promise((r) => setTimeout(() => r(null), 3000))]); } catch {} frames.push({ url: f.url().slice(0, 300), canvases }); } const cookies = await Promise.race([ctx.cookies(), new Promise((r) => setTimeout(() => r([]), 10000))]); return { label, url, run, country: loc, chrome: browser.version(), final_url: page.url(), title: await page.title().catch(() => null), status, error, wait_s: WAIT_MS / 1000, measured_at: new Date(started).toISOString(), requests, frames, cookie_checkpoints, cookies: cookies.map((c) => ({ name: c.name, domain: c.domain, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite })) }; } finally { await browser.close().catch(() => {}); live.delete(browser); } } (async () => { fs.mkdirSync(outDir, { recursive: true }); const games = fs.readFileSync(listFile, 'utf8').split('\n').map((l) => l.trim()) .filter((l) => l && !l.startsWith('#')).map((l) => l.split('\t')); // Interleave: every game's run 1, then every game's run 2, and so on. const jobs = []; for (let run = 1; run <= RUNS; run++) for (const [label, url] of games) jobs.push({ label, url, run }); const loc = await country(); console.log(`connection country: ${loc}; ${jobs.length} loads, ${PARALLEL} at a time, ${WAIT_MS / 1000}s each`); let next = 0; async function worker() { while (next < jobs.length) { const { label, url, run } = jobs[next++]; const out = path.join(outDir, `${label}__r${run}.json`); if (fs.existsSync(out)) continue; let timer; try { const r = await Promise.race([ measure(label, url, run, loc), new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('load timeout')), LOAD_TIMEOUT_MS); }), ]); fs.writeFileSync(out, JSON.stringify(r)); const mb = r.requests.reduce((a, q) => a + (q.body_bytes || 0), 0) / 1e6; const canvas = r.frames.some((f) => f.canvases > 0); console.log(`${label} r${run}: ${r.requests.length} req, ${mb.toFixed(1)} MB, ${r.cookies.length} cookies, game ${canvas ? 'loaded' : 'NOT seen'}${r.error ? ' ERROR ' + r.error : ''}`); } catch (e) { console.log(`FAIL ${label} r${run}: ${String(e.message || e).slice(0, 160)}`); } finally { clearTimeout(timer); } } } await Promise.all(Array.from({ length: PARALLEL }, worker)); await closeAll(); process.exit(0); })();