#!/usr/bin/env node // verify-online.mjs — Browser-level operational verification for a deployed service. // Used by Test-ServiceOnline.ps1. Exits 0 on success, non-zero on failure. // // Usage: // node verify-online.mjs [--screenshot ] [--timeout ] [--expect-title ] // // Checks performed: // 1. Page navigates to status < 400 (HTTP layer via Playwright) // 2. No uncaught `pageerror` events (JS crashes) // 3. No failed main-frame network requests (DNS, 5xx on document, etc.) // 4. Optional: document.title matches a regex // 5. Saves a screenshot (PNG) when --screenshot is provided // // Output: a single JSON line on stdout with the result; human logs go to stderr. import { chromium } from "playwright"; const args = process.argv.slice(2); if (args.length === 0) { console.error("Usage: verify-online.mjs [--screenshot ] [--timeout ] [--expect-title ] [--no-headless]"); process.exit(2); } const url = args[0]; const opts = {}; for (let i = 1; i < args.length; i++) { const a = args[i]; if (a === "--screenshot") opts.screenshot = args[++i]; else if (a === "--timeout") opts.timeout = parseInt(args[++i], 10); else if (a === "--expect-title") opts.expectTitle = args[++i]; else if (a === "--no-headless") opts.headless = false; } const timeout = opts.timeout || 30000; const headless = opts.headless !== false; const result = { url, ok: false, httpStatus: null, title: null, pageErrors: [], requestFailures: [], consoleErrors: [], screenshot: null, elapsedMs: null, }; const startedAt = Date.now(); let browser; try { browser = await chromium.launch({ headless }); const context = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 }, }); const page = await context.newPage(); page.on("pageerror", (err) => { result.pageErrors.push(err.message); }); page.on("requestfailed", (req) => { const u = req.url(); // Ignore third-party analytics/CDN noise; surface only main-frame document/sub-resource // failures relevant to the app itself. const failure = req.failure(); result.requestFailures.push({ url: u, method: req.method(), errorText: failure ? failure.errorText : "unknown", resourceType: req.resourceType(), }); }); page.on("console", (msg) => { if (msg.type() === "error") { const txt = msg.text(); // Suppress noisy third-party errors that don't affect operability. if (!/favicon|googletagmanager|google-analytics|doubleclick|sentry/i.test(txt)) { result.consoleErrors.push(txt); } } }); const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout }); if (!response) { throw new Error("Navigation returned no response (blocked or timeout)."); } result.httpStatus = response.status(); result.title = await page.title(); if (opts.expectTitle) { const re = new RegExp(opts.expectTitle); if (!re.test(result.title || "")) { throw new Error(`Title "${result.title}" does not match /${opts.expectTitle}/`); } } if (result.httpStatus >= 400) { throw new Error(`HTTP ${result.httpStatus} from ${url}`); } if (result.pageErrors.length > 0) { throw new Error(`Page threw ${result.pageErrors.length} uncaught error(s): ${result.pageErrors.slice(0, 3).join(" | ")}`); } // Give SPA a moment to settle, then capture screenshot. await page.waitForTimeout(1500); if (opts.screenshot) { try { await page.screenshot({ path: opts.screenshot, fullPage: false }); result.screenshot = opts.screenshot; } catch (e) { // Non-fatal: screenshot is best-effort. console.error(`[warn] screenshot failed: ${e.message}`); } } result.ok = true; } catch (err) { result.error = err.message; result.ok = false; } finally { if (browser) await browser.close().catch(() => {}); result.elapsedMs = Date.now() - startedAt; console.log(JSON.stringify(result)); } process.exit(result.ok ? 0 : 1);