← 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);
}
})();