← back to NationalPaperHangers
tests: e2e-business-invariants — 27 prod-runnable assertions for content rules that have regressed today (no /book CTAs on unclaimed × 6 templates, /find sort+density, /watch renders, referral disclaimer on /privacy /terms). Auto-detects local vs prod slug
d145b795e92e863112b73253c3d6600eac195cb0 · 2026-05-06 21:17:26 -0700 · Steve
Files touched
A tests/e2e-business-invariants.js
Diff
commit d145b795e92e863112b73253c3d6600eac195cb0
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 21:17:26 2026 -0700
tests: e2e-business-invariants — 27 prod-runnable assertions for content rules that have regressed today (no /book CTAs on unclaimed × 6 templates, /find sort+density, /watch renders, referral disclaimer on /privacy /terms). Auto-detects local vs prod slug
---
tests/e2e-business-invariants.js | 106 +++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
diff --git a/tests/e2e-business-invariants.js b/tests/e2e-business-invariants.js
new file mode 100644
index 0000000..5695767
--- /dev/null
+++ b/tests/e2e-business-invariants.js
@@ -0,0 +1,106 @@
+// 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 · privacy + terms have correct DW-disclaimer text ────
+ console.log(`\n[invariant 5] 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);
+ }
+})();
← 2ff46f6 ops: launchd-driven e2e prod-monitor (every 6h @ :17), local
·
back to NationalPaperHangers
·
og: add og:image + twitter:image to every page (default bran 442cc6c →