← back to NationalPaperHangers
ops: launchd-driven e2e prod-monitor (every 6h @ :17), local-only tests skip cleanly when BASE is remote, sort test uses localeCompare to match page impl. Emails Steve via George on failure
2ff46f6b9a705222df2e4006c7afb6342379b1b3 · 2026-05-06 20:44:17 -0700 · Steve
Files touched
A scripts/run-e2e-prod-monitor.shM tests/e2e-claim-flow.jsM tests/e2e-find-sort-density.jsM tests/e2e-template-chooser.js
Diff
commit 2ff46f6b9a705222df2e4006c7afb6342379b1b3
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 20:44:17 2026 -0700
ops: launchd-driven e2e prod-monitor (every 6h @ :17), local-only tests skip cleanly when BASE is remote, sort test uses localeCompare to match page impl. Emails Steve via George on failure
---
scripts/run-e2e-prod-monitor.sh | 73 +++++++++++++++++++++++++++++++++++++++++
tests/e2e-claim-flow.js | 6 ++++
tests/e2e-find-sort-density.js | 25 +++++++++-----
tests/e2e-template-chooser.js | 6 ++++
4 files changed, 102 insertions(+), 8 deletions(-)
diff --git a/scripts/run-e2e-prod-monitor.sh b/scripts/run-e2e-prod-monitor.sh
new file mode 100755
index 0000000..2f0153b
--- /dev/null
+++ b/scripts/run-e2e-prod-monitor.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+#
+# Runs the NPH e2e suite against live prod on a schedule.
+# If any test fails, emails Steve via George with the tail of the output.
+# Runs cleanly when prod is healthy; goes loud when something breaks.
+#
+# Triggered by ~/Library/LaunchAgents/com.steve.nph-e2e-prod.plist (every 6h).
+
+set -u
+PROJECT="$HOME/Projects/NationalPaperHangers"
+LOG_DIR="$HOME/Library/Logs"
+LOG="$LOG_DIR/nph-e2e-prod.log"
+mkdir -p "$LOG_DIR"
+
+cd "$PROJECT" || exit 1
+
+TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+echo "" >> "$LOG"
+echo "═══ run @ $TS ═══" >> "$LOG"
+
+# Path needs Homebrew node etc.
+export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
+
+# Run the suite, capture both streams
+OUT=$(BASE=https://nationalpaperhangers.com npm run test:e2e 2>&1)
+EXIT=$?
+echo "$OUT" >> "$LOG"
+echo "exit=$EXIT" >> "$LOG"
+
+# Parse summary line
+SUMMARY=$(echo "$OUT" | grep -E '^\s+TOTAL' | tail -1 | sed 's/^[[:space:]]*//')
+
+if [ "$EXIT" -ne 0 ]; then
+ # Build the JSON payload via Python with stdin (no shell-quoting hell).
+ PAYLOAD=$(mktemp)
+ TS_LOCAL="$TS" SUMMARY_TXT="$SUMMARY" LOG_PATH="$LOG" HOST="$(hostname -s)" RUN_OUT="$OUT" \
+ python3 - <<'PY' >"$PAYLOAD" 2>>"$LOG" || true
+import json, os, html
+out = os.environ.get('RUN_OUT','')
+ts = os.environ.get('TS_LOCAL','')
+summary = os.environ.get('SUMMARY_TXT','(no summary)')
+log_path = os.environ.get('LOG_PATH','')
+host = os.environ.get('HOST','')
+tail_html = html.escape('\n'.join(out.splitlines()[-50:]))
+body = (
+ f"<p style='font-family:system-ui;color:#0a0a0a'><b>NPH e2e prod-monitor failed</b></p>"
+ f"<p style='font-size:13px;color:#666'>Run @ {html.escape(ts)} UTC · summary: <code>{html.escape(summary)}</code></p>"
+ f"<pre style='background:#0a0a0a;color:#f3f1ea;padding:14px 18px;font-size:11px;line-height:1.5;overflow-x:auto;border-radius:8px'>{tail_html}</pre>"
+ f"<p style='font-size:11px;color:#888'>Full log: {html.escape(log_path)} · machine: {html.escape(host)}</p>"
+)
+print(json.dumps({
+ 'to': 'steve@designerwallcoverings.com',
+ 'subject': 'NPH e2e prod-monitor — FAIL',
+ 'body': body,
+ 'account': 'steve-office'
+}))
+PY
+
+ # Send via George (Basic Auth from MEMORY.md: admin:DWSecure2024!)
+ curl -s -X POST -H 'content-type: application/json' \
+ -u 'admin:DWSecure2024!' \
+ --data-binary "@$PAYLOAD" \
+ http://localhost:9850/api/send \
+ >>"$LOG" 2>&1
+ rm -f "$PAYLOAD"
+fi
+
+# Trim log to last 5000 lines (keep ~few weeks of runs)
+if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 5000 ]; then
+ tail -5000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
+fi
+
+exit 0 # always 0 — failures are reported via email, launchd doesn't need to "fail"
diff --git a/tests/e2e-claim-flow.js b/tests/e2e-claim-flow.js
index 51b28d2..36f612d 100644
--- a/tests/e2e-claim-flow.js
+++ b/tests/e2e-claim-flow.js
@@ -22,6 +22,12 @@ const { chromium } = require('playwright-core');
const { Client } = require('pg');
const BASE = process.env.BASE || 'http://localhost:9765';
+// Test reads/writes the local PG to verify token state + clean up. Skip when
+// BASE is remote — we can't (and shouldn't) write to prod DB from here.
+if (!/localhost|127\.0\.0\.1/.test(BASE)) {
+ console.log(`[e2e-claim-flow] SKIP — BASE=${BASE} is remote; this test mutates DB and is local-only`);
+ process.exit(78);
+}
const TARGET_SLUG = process.env.TARGET_SLUG || 'demo-unclaimed-marquee-finishes';
const PG_CONFIG = {
host: process.env.PGHOST || '/tmp',
diff --git a/tests/e2e-find-sort-density.js b/tests/e2e-find-sort-density.js
index 4851aa1..281126b 100644
--- a/tests/e2e-find-sort-density.js
+++ b/tests/e2e-find-sort-density.js
@@ -36,19 +36,28 @@ function findChromium() {
} else {
// Capture default first-card name
const firstName0 = await page.$eval('.installer-card:first-child', el => el.dataset.name);
- // Switch to A→Z and verify reorder
+ // Switch to A→Z — wait for the DOM mutation to actually finish (522
+ // cards on prod take much longer to sort than ~10 locally).
await page.selectOption('#sort-select', 'az');
- await page.waitForTimeout(150);
- const firstNameAZ = await page.$eval('.installer-card:first-child', el => el.dataset.name);
+ await page.waitForFunction(prevFirst => {
+ const f = document.querySelector('.installer-card:first-child');
+ return f && f.dataset.name && f.dataset.name !== prevFirst;
+ }, firstName0, { timeout: 5000 }).catch(() => {});
const allNames = await page.$$eval('.installer-card', els => els.map(e => e.dataset.name || ''));
- const sortedAsc = [...allNames].sort();
- assert(JSON.stringify(allNames) === JSON.stringify(sortedAsc), 'A→Z sort orders cards alphabetically');
+ // Match the page's sort impl, which uses localeCompare (handles punctuation
+ // like `.` vs `&` differently from default codepoint sort).
+ const sortedAsc = [...allNames].sort((a, b) => a.localeCompare(b));
+ assert(JSON.stringify(allNames) === JSON.stringify(sortedAsc), `A→Z sort orders cards alphabetically (${allNames.length} cards)`);
// Switch to Z→A
+ const firstAZ = allNames[0];
await page.selectOption('#sort-select', 'za');
- await page.waitForTimeout(150);
+ await page.waitForFunction(prevFirst => {
+ const f = document.querySelector('.installer-card:first-child');
+ return f && f.dataset.name && f.dataset.name !== prevFirst;
+ }, firstAZ, { timeout: 5000 }).catch(() => {});
const allNamesZA = await page.$$eval('.installer-card', els => els.map(e => e.dataset.name || ''));
- const sortedDesc = [...allNamesZA].sort().reverse();
- assert(JSON.stringify(allNamesZA) === JSON.stringify(sortedDesc), 'Z→A sort orders cards reverse-alphabetically');
+ const sortedDesc = [...allNamesZA].sort((a, b) => b.localeCompare(a));
+ assert(JSON.stringify(allNamesZA) === JSON.stringify(sortedDesc), `Z→A sort orders cards reverse-alphabetically (${allNamesZA.length} cards)`);
}
// Density slider — set min to 400, verify CSS var updates
diff --git a/tests/e2e-template-chooser.js b/tests/e2e-template-chooser.js
index 6495228..5136abe 100644
--- a/tests/e2e-template-chooser.js
+++ b/tests/e2e-template-chooser.js
@@ -17,6 +17,12 @@ const { chromium } = require('playwright-core');
const path = require('path');
const BASE = process.env.BASE || 'http://localhost:9765';
+// Test writes a real upload file and checks it on local disk — only meaningful
+// when BASE is local. Skip cleanly against remote prod.
+if (!/localhost|127\.0\.0\.1/.test(BASE)) {
+ console.log(`[e2e-template-chooser] SKIP — BASE=${BASE} is remote; this test verifies on local disk`);
+ process.exit(78);
+}
const EMAIL = 'demo+bond@example.com';
const PW = 'demo1234';
const SLUG = 'atelier-bond-nyc';
← a0527b7 tests: graceful rate-limit handling — public-pages soft-pass
·
back to NationalPaperHangers
·
tests: e2e-business-invariants — 27 prod-runnable assertions d145b79 →