← back to NationalPaperHangers

tests/e2e-public-pages.js

100 lines

// e2e: every auth-free public route renders cleanly.
// Designed to run against prod without writing anything.
//   BASE=https://nationalpaperhangers.com node tests/e2e-public-pages.js
//
// Per route, asserts:
//   • HTTP 200
//   • a route-specific content marker is present
//   • the error template ("Something went wrong") did NOT render
//   • no JS errors landed in the browser console while loading

const { chromium } = require('playwright-core');

const BASE = process.env.BASE || 'http://localhost:9765';

function findChromium() {
  const paths = [
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    '/Applications/Chromium.app/Contents/MacOS/Chromium',
    process.env.CHROME_PATH,
  ].filter(Boolean);
  for (const p of paths) { try { require('fs').accessSync(p); return p; } catch {} }
  return null;
}

// Each row: [path, marker text, optional skip-if-empty]
const routes = [
  ['/',                    'National Paper Hangers'],
  ['/find',                'verified installer'],
  ['/map',                 'map'],
  ['/watch',               'Watch'],
  ['/for-installers',      'installer'],
  ['/about',               'About'],
  ['/privacy',             'Privacy'],
  ['/terms',               'Terms'],
  ['/installer/atelier-bond-nyc',       'Atelier'],
  ['/installer/atelier-bond-nyc/book',  'consultation'],
  ['/login',               'Log in'],
  ['/signup',              'Apply to list'],
];

(async () => {
  const exe = findChromium();
  if (!exe) { console.error('FAIL: no Chromium'); process.exit(2); }
  const browser = await chromium.launch({ executablePath: exe, headless: true });
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
  const page = await ctx.newPage();
  let pass = 0, fail = 0;
  const assert = (c, m) => { if (c) { console.log(`  ✓ ${m}`); pass++; } else { console.log(`  ✗ ${m}`); fail++; } };

  for (const [routePath, marker] of routes) {
    const url = BASE + routePath;
    console.log(`\n[${routePath}]`);
    const consoleErrors = [];
    const handler = msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); };
    page.on('console', handler);

    let resp;
    try {
      resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 12000 });
    } catch (e) {
      assert(false, `navigation succeeded — ${e.message}`);
      page.off('console', handler);
      continue;
    }
    const status = resp ? resp.status() : 0;
    // /login and /signup share a 10/15min rate limiter; chained suite runs
    // hit it. Treat 429 on those routes as a soft-pass (rate-limiter working).
    if (status === 429 && /^\/(login|signup)$/.test(routePath)) {
      console.log(`  ⊘ rate-limited (429) — limiter is working as designed; soft-pass`);
      page.off('console', handler);
      pass++;
      continue;
    }
    assert(status === 200, `HTTP 200 (got ${status})`);

    const html = await page.content();
    const noErrPage = !/Something went wrong|We hit a snag/i.test(html);
    assert(noErrPage, 'no error template rendered');

    const hasMarker = new RegExp(marker, 'i').test(html);
    assert(hasMarker, `content marker "${marker}" present`);

    // Filter out CSP-noise + analytics/3rd-party errors + lazy-asset 404s.
    // "Failed to load resource: 404" is typically a missing image/SVG that
    // an editorial template references — not a JS bug.
    const ourErrors = consoleErrors.filter(e =>
      !/google.*analytics|gtag|stripe|favicon|net::ERR_BLOCKED|tagmanager/i.test(e) &&
      !/Failed to load resource:.*404/i.test(e)
    );
    assert(ourErrors.length === 0, `no first-party JS console errors (${ourErrors.length} ${ourErrors.length === 1 ? 'error' : 'errors'})`);
    if (ourErrors.length) ourErrors.slice(0, 3).forEach(e => console.log('      console:', e.slice(0, 120)));

    page.off('console', handler);
  }

  await browser.close();
  console.log(`\n[result] ${pass} pass · ${fail} fail`);
  process.exit(fail ? 1 : 0);
})();