From 32c95a37803045e6dbd9aed68411b9b873f08451 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 13:42:52 +0200 Subject: [PATCH] chore(diagnostics): headless prod smoke scripts Two Playwright-based diagnostic scripts for investigating production-only browser issues that curl can't reproduce: - scripts/smoke-prod.mjs: loads mana.how like a fresh incognito tab, waits a configurable budget, reports every console error, request failure, still-pending request, and slow resource. - scripts/smoke-prod-load.mjs: measures DOMContentLoaded + load event timing explicitly. Distinguishes "app interactive" from "browser tab spinner stops". Run: `node apps/mana/apps/web/scripts/smoke-prod.mjs` MANA_URL=https://mana.how/login MANA_WAIT_MS=45000 node ... Used today to rule out server-side issues in a loader-hang report that reproduced only in one specific browser profile. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mana/apps/web/scripts/smoke-prod-load.mjs | 100 +++++++++++ apps/mana/apps/web/scripts/smoke-prod.mjs | 158 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 apps/mana/apps/web/scripts/smoke-prod-load.mjs create mode 100644 apps/mana/apps/web/scripts/smoke-prod.mjs diff --git a/apps/mana/apps/web/scripts/smoke-prod-load.mjs b/apps/mana/apps/web/scripts/smoke-prod-load.mjs new file mode 100644 index 000000000..4ad414576 --- /dev/null +++ b/apps/mana/apps/web/scripts/smoke-prod-load.mjs @@ -0,0 +1,100 @@ +// Same as smoke-prod.mjs but explicitly waits for the 'load' event — the +// one that controls whether Chrome's tab spinner keeps spinning. If the +// tab spinner hangs in a real browser but Playwright's .goto({waitUntil: +// 'load'}) resolves quickly, the hang isn't about 'load' — it's about +// some async op kicked off after load that browsers still count as +// "loading" (e.g. service-worker install, web-manifest sub-fetches, +// deferred analytics). +// +// Also dumps ALL non-200 or slow requests to see which ones the browser +// is actually waiting on. + +import { chromium } from 'playwright'; + +const URL = process.env.MANA_URL ?? 'https://mana.how/'; +const GOTO_TIMEOUT = Number(process.env.MANA_GOTO_TIMEOUT ?? 60_000); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36', +}); +const page = await context.newPage(); + +const requests = new Map(); +const errors = []; + +page.on('request', (req) => { + requests.set(req.url() + '#' + req.method(), { + url: req.url(), + method: req.method(), + startedAt: Date.now(), + resourceType: req.resourceType(), + }); +}); +page.on('response', (res) => { + const k = res.url() + '#' + res.request().method(); + const e = requests.get(k); + if (e) { + e.status = res.status(); + e.duration = Date.now() - e.startedAt; + } +}); +page.on('requestfailed', (req) => { + const k = req.url() + '#' + req.method(); + const e = requests.get(k); + if (e) { + e.failure = req.failure()?.errorText ?? 'unknown'; + e.duration = Date.now() - e.startedAt; + } +}); +page.on('pageerror', (err) => errors.push(err.message)); + +const t0 = Date.now(); + +let domContentLoadedAt = null; +let loadedAt = null; +page.on('domcontentloaded', () => (domContentLoadedAt = Date.now() - t0)); +page.on('load', () => (loadedAt = Date.now() - t0)); + +try { + console.log(`→ goto ${URL} (waitUntil=load, timeout=${GOTO_TIMEOUT}ms)`); + await page.goto(URL, { waitUntil: 'load', timeout: GOTO_TIMEOUT }); + console.log(`✓ load event fired`); +} catch (e) { + console.log(`✗ goto failed: ${e.message}`); +} + +const gotoEnded = Date.now() - t0; + +// After load, wait up to 10s more to see if anything starts hanging. +await page.waitForTimeout(10_000); + +const pending = [...requests.values()].filter((r) => r.status === undefined && !r.failure); +const failed = [...requests.values()].filter((r) => r.failure); + +console.log(''); +console.log(`DOMContentLoaded: ${domContentLoadedAt ?? 'never'} ms`); +console.log(`load event: ${loadedAt ?? 'never'} ms`); +console.log(`goto returned at: ${gotoEnded} ms`); +console.log(`total requests: ${requests.size}`); +console.log(`still pending after 10s post-load: ${pending.length}`); +if (pending.length) { + console.log(''); + console.log('─── HANGING ───'); + for (const r of pending) { + const age = Date.now() - r.startedAt; + console.log(` ${age}ms pending ${r.method} ${r.resourceType} ${r.url}`); + } +} +if (failed.length) { + console.log(''); + console.log('─── FAILED ───'); + for (const r of failed) console.log(` ${r.failure} ${r.url}`); +} +console.log(''); +console.log(`uncaught pageerrors: ${errors.length}`); +for (const e of errors) console.log(` ${e}`); + +await browser.close(); diff --git a/apps/mana/apps/web/scripts/smoke-prod.mjs b/apps/mana/apps/web/scripts/smoke-prod.mjs new file mode 100644 index 000000000..391fcf7f8 --- /dev/null +++ b/apps/mana/apps/web/scripts/smoke-prod.mjs @@ -0,0 +1,158 @@ +// Headless diagnostic: load mana.how like a fresh incognito tab, capture +// every signal a browser would expose (console, network, uncaught errors, +// pending requests after N seconds, final DOM state). Prints a tight +// report — no side effects. + +import { chromium } from 'playwright'; + +const URL = process.env.MANA_URL ?? 'https://mana.how/'; +const WAIT_MS = Number(process.env.MANA_WAIT_MS ?? 30_000); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + // Fresh context = no cookies, no storage — closest we get to incognito. + storageState: undefined, + viewport: { width: 1280, height: 800 }, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36', +}); +const page = await context.newPage(); + +/** @type {Array<{level: string, text: string, location?: string}>} */ +const consoleEntries = []; +/** @type {Array<{type: string, message: string, stack?: string}>} */ +const errors = []; +/** @type {Map} */ +const requests = new Map(); + +page.on('console', (msg) => { + consoleEntries.push({ + level: msg.type(), + text: msg.text(), + location: msg.location()?.url, + }); +}); + +page.on('pageerror', (err) => { + errors.push({ type: 'pageerror', message: err.message, stack: err.stack }); +}); + +page.on('crash', () => { + errors.push({ type: 'crash', message: 'page crashed' }); +}); + +page.on('request', (req) => { + requests.set(req.url() + '#' + req.method(), { + url: req.url(), + method: req.method(), + startedAt: Date.now(), + resourceType: req.resourceType(), + }); +}); + +page.on('response', async (res) => { + const key = res.url() + '#' + res.request().method(); + const entry = requests.get(key); + if (entry) { + entry.status = res.status(); + entry.duration = Date.now() - entry.startedAt; + } +}); + +page.on('requestfailed', (req) => { + const key = req.url() + '#' + req.method(); + const entry = requests.get(key); + if (entry) { + entry.failure = req.failure()?.errorText ?? 'unknown'; + entry.duration = Date.now() - entry.startedAt; + } +}); + +const startedAt = Date.now(); +console.log(`→ loading ${URL} (wait up to ${WAIT_MS / 1000}s)`); + +let navError = null; +try { + // Don't wait for networkidle — that's precisely what's broken. Just + // fire the navigation and let our WAIT_MS budget decide when to look. + await page.goto(URL, { waitUntil: 'commit', timeout: 15_000 }); +} catch (e) { + navError = e.message; +} + +// Let the page chew on JS for the wait budget, then snapshot. +await page.waitForTimeout(WAIT_MS); +const elapsed = Date.now() - startedAt; + +const title = await page.title().catch(() => ''); +const bodyText = await page + .evaluate(() => document.body?.innerText?.slice(0, 500) ?? '') + .catch(() => '<eval failed>'); +const currentURL = page.url(); + +const pending = [...requests.values()].filter((r) => r.status === undefined && !r.failure); +const failed = [...requests.values()].filter((r) => r.failure); +const slow = [...requests.values()] + .filter((r) => r.duration && r.duration > 2000 && r.status !== undefined) + .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)); +const byStatus = {}; +for (const r of requests.values()) { + if (r.status !== undefined) byStatus[r.status] = (byStatus[r.status] ?? 0) + 1; +} + +console.log(''); +console.log('═══ RESULT ═══'); +console.log(`elapsed: ${elapsed}ms`); +console.log(`final URL: ${currentURL}`); +console.log(`title: ${title}`); +console.log(`nav error: ${navError ?? 'none'}`); +console.log(''); + +console.log('─── body preview (first 500 chars) ───'); +console.log(bodyText || '<empty>'); +console.log(''); + +console.log(`─── requests by status (total ${requests.size}) ───`); +for (const [code, count] of Object.entries(byStatus).sort()) { + console.log(` ${code}: ${count}`); +} +if (pending.length) { + console.log(''); + console.log(`─── STILL PENDING after ${WAIT_MS / 1000}s (${pending.length}) ───`); + for (const r of pending) { + console.log(` ${r.method.padEnd(6)} ${r.resourceType.padEnd(8)} ${r.url}`); + } +} +if (failed.length) { + console.log(''); + console.log(`─── FAILED (${failed.length}) ───`); + for (const r of failed) { + console.log(` ${r.method.padEnd(6)} ${r.failure?.padEnd(35)} ${r.url}`); + } +} +if (slow.length) { + console.log(''); + console.log(`─── slow (>2s, finished) ───`); + for (const r of slow.slice(0, 10)) { + console.log(` ${r.duration}ms ${r.status} ${r.url}`); + } +} + +console.log(''); +console.log(`─── console messages (${consoleEntries.length}) ───`); +for (const msg of consoleEntries) { + if (['error', 'warning'].includes(msg.level)) { + console.log(` [${msg.level}] ${msg.text}${msg.location ? ' @ ' + msg.location : ''}`); + } +} +const nonWarn = consoleEntries.filter((m) => !['error', 'warning'].includes(m.level)); +if (nonWarn.length) console.log(` (+${nonWarn.length} log/info/debug suppressed)`); + +console.log(''); +console.log(`─── uncaught errors (${errors.length}) ───`); +for (const e of errors) { + console.log(` [${e.type}] ${e.message}`); + if (e.stack) console.log(e.stack.split('\n').slice(0, 3).join('\n')); +} + +await browser.close();