← back to NationalPaperHangers

tests/e2e-business-invariants.js

137 lines

// e2e: business invariants that have regressed at least once today.
//
// These check the *content* of pages, not just status code. Each catch
// corresponds to a real bug that shipped at some point:
//   • Schedule CTAs leaking onto unclaimed studio profiles  (fix: tick 9)
//   • Sort + density controls missing on /find              (rule: 2026-05-06)
//   • social-videos partial rendering self-claimed handles  (fix: 2026-05-06)
//   • /watch page failing to render after curated_videos     (rule: 2026-05-06)
//
// Prod-runnable. No auth, no DB writes. Skipped on remote-base only when
// the test inherently can't be checked over HTTP (none currently).
//
// Run: BASE=https://nationalpaperhangers.com node tests/e2e-business-invariants.js

const { chromium } = require('playwright-core');
const BASE = process.env.BASE || 'http://localhost:9765';
// Default differs by environment — local seed has marquee, prod has ridgeway.
const _isLocal = /localhost|127\.0\.0\.1/.test(BASE);
const UNCLAIMED_SLUG = process.env.UNCLAIMED_SLUG || (_isLocal ? 'demo-unclaimed-marquee-finishes' : 'demo-unclaimed-ridgeway-paper');
const CLAIMED_SLUG = process.env.CLAIMED_SLUG || 'atelier-bond-nyc';

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;
}

(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++; } };

  try {
    // ─── Invariant 1 · UNCLAIMED studio shows NO schedule CTAs ─────────
    // Across all 6 templates, an unclaimed studio's profile must hide the
    // /book link entirely (per Steve's "until installer signs up, just info"
    // directive). Visitors should see studio info + a Claim CTA.
    console.log(`\n[invariant 1] unclaimed studio = no schedule CTAs in any of 6 templates`);
    for (const tpl of ['editorial','trade-pro','concierge','studio','heritage','bilingue']) {
      const url = `${BASE}/installer/${UNCLAIMED_SLUG}?_preview=${tpl}`;
      const r = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 12000 });
      assert(r && r.status() === 200, `${tpl} renders 200`);
      const html = await page.content();
      const bookLinks = (html.match(/href="\/installer\/[^"]+\/book"/g) || []).length;
      assert(bookLinks === 0, `${tpl} has 0 /book CTAs (got ${bookLinks})`);
      // Conversely, must show the Claim listing prompt
      const hasClaim = /\/claim/.test(html) && /[Cc]laim/.test(html);
      assert(hasClaim, `${tpl} shows Claim CTA`);
    }

    // ─── Invariant 2 · /find has sort + density per CLAUDE.md rule ─────
    console.log(`\n[invariant 2] /find sort + density controls`);
    await page.goto(`${BASE}/find`, { waitUntil: 'domcontentloaded', timeout: 12000 });
    assert(!!(await page.$('#sort-select')), '#sort-select present');
    assert(!!(await page.$('#density-slider')), '#density-slider present');
    const sortOpts = await page.$$eval('#sort-select option', els => els.length);
    assert(sortOpts >= 5, `sort has at least 5 options (got ${sortOpts})`);

    // ─── Invariant 3 · /watch renders without errors ───────────────────
    console.log(`\n[invariant 3] /watch page renders cleanly`);
    const consoleErrors = [];
    const handler = msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); };
    page.on('console', handler);
    const watchResp = await page.goto(`${BASE}/watch`, { waitUntil: 'domcontentloaded', timeout: 12000 });
    assert(watchResp && watchResp.status() === 200, '/watch HTTP 200');
    const watchHtml = await page.content();
    assert(!/Something went wrong|We hit a snag/i.test(watchHtml), '/watch did not render error template');
    page.off('console', handler);
    const ourErrors = consoleErrors.filter(e =>
      !/google.*analytics|gtag|stripe|favicon|tagmanager/i.test(e) &&
      !/Failed to load resource:.*404/i.test(e)
    );
    assert(ourErrors.length === 0, `/watch — no first-party JS errors (${ourErrors.length})`);

    // ─── Invariant 4 · /find toggles between verified-only and full directory ─────
    console.log(`\n[invariant 4] /find verified-only default + show-all toggle`);
    await page.goto(`${BASE}/find`, { waitUntil: 'domcontentloaded' });
    const showToggle = await page.$('a:has-text("directory listings")').catch(() => null);
    assert(!!showToggle, 'show/hide directory listings toggle present');

    // ─── Invariant 5 · OG image meta on every page ─────────────────────────
    // Without og:image, link previews on iMessage / Slack / Twitter render
    // text-only and look broken. Default falls to /img/og-default.jpg.
    console.log(`\n[invariant 5] og:image meta on every page`);
    for (const path of ['/', '/find', '/watch', `/installer/${UNCLAIMED_SLUG}`]) {
      const r = await page.goto(`${BASE}${path}`, { waitUntil: 'domcontentloaded', timeout: 12000 });
      const ogUrl = await page.$eval('meta[property="og:image"]', el => el.getAttribute('content')).catch(() => null);
      assert(!!ogUrl && /^https?:\/\//.test(ogUrl), `${path} has og:image with absolute URL`);
      // Twitter image too
      const twUrl = await page.$eval('meta[name="twitter:image"]', el => el.getAttribute('content')).catch(() => null);
      assert(!!twUrl && /^https?:\/\//.test(twUrl), `${path} has twitter:image`);
    }

    // ─── Invariant 6 · sitemap.xml + robots.txt are healthy ──────────────
    // Sitemap should include all studio profiles + the static pages.
    // robots.txt should reference the sitemap and Disallow private paths.
    console.log(`\n[invariant 6] sitemap.xml + robots.txt`);
    const robotsResp = await page.goto(`${BASE}/robots.txt`, { waitUntil: 'domcontentloaded', timeout: 8000 });
    assert(robotsResp && robotsResp.status() === 200, '/robots.txt → 200');
    const robotsBody = await page.content();
    assert(/Sitemap:.*sitemap\.xml/i.test(robotsBody), 'robots.txt references sitemap');
    assert(/Disallow:\s*\/admin/i.test(robotsBody), 'robots.txt disallows /admin');

    const smapResp = await page.goto(`${BASE}/sitemap.xml`, { waitUntil: 'domcontentloaded', timeout: 12000 });
    assert(smapResp && smapResp.status() === 200, '/sitemap.xml → 200');
    const smapBody = await page.content();
    const urlCount = (smapBody.match(/<loc>/g) || []).length;
    assert(urlCount > 100, `sitemap has > 100 URLs (got ${urlCount})`);
    assert(/\/installer\//i.test(smapBody), 'sitemap includes installer profiles');

    // ─── Invariant 7 · privacy + terms have correct DW-disclaimer text ────
    console.log(`\n[invariant 7] privacy + terms include the referral-service disclaimer`);
    for (const path of ['/privacy', '/terms']) {
      await page.goto(`${BASE}${path}`, { waitUntil: 'domcontentloaded', timeout: 12000 });
      const html = await page.content();
      const hasReferralLang = /referral.*lead.coordination|directory|installer|National Paper Hangers/i.test(html);
      assert(hasReferralLang, `${path} has referral-service / directory language`);
    }

  } catch (err) {
    console.error('\nFATAL:', err.message);
    fail++;
  } finally {
    await browser.close();
    console.log(`\n[result] ${pass} pass · ${fail} fail`);
    process.exit(fail ? 1 : 0);
  }
})();