← back to NationalPaperHangers
tests/e2e-template-chooser.js
161 lines
// e2e click-test: /admin/template chooser, end-to-end.
//
// Flow:
// 1. Login as a paid-tier installer (atelier-bond-nyc / signature)
// 2. Navigate to /admin/template
// 3. Pick a non-default template (e.g. "studio")
// 4. Save
// 5. Verify the public profile renders with the new template_slug
//
// Drag-drop image upload is left as a TODO — it requires a file fixture and
// adds 30s to the run. Step-1-through-5 covers the critical-path bug surface.
//
// Run: node tests/e2e-template-chooser.js
// Env: BASE=http://localhost:9765 (default)
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';
const TARGET_TEMPLATE = 'studio'; // pick something different from default 'editorial'
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 binary found. Install Google Chrome or set CHROME_PATH.');
process.exit(2);
}
console.log(`[e2e] using browser: ${exe}`);
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 = (cond, msg) => { if (cond) { console.log(` ✓ ${msg}`); pass++; } else { console.log(` ✗ ${msg}`); fail++; } };
try {
// ─── STEP 1 · login ───────────────────────────────────────────
console.log('\n[step 1] login as installer');
const loginResp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
// /login has a 10/15min rate limiter. Chained suite runs trip it.
if (loginResp && loginResp.status() === 429) {
console.log(' ⊘ SKIP — login rate-limit (429) tripped from prior runs; wait ~15min and retry');
await browser.close();
process.exit(78);
}
await page.fill('input[name="email"]', EMAIL);
await page.fill('input[name="password"]', PW);
await Promise.all([
page.waitForURL(/\/admin/, { timeout: 8000 }),
page.click('form[action="/login"] button[type="submit"]'),
]);
assert(page.url().includes('/admin'), `landed on /admin (got ${page.url()})`);
// ─── STEP 2 · /admin/template ─────────────────────────────────
console.log('\n[step 2] open /admin/template');
await page.goto(`${BASE}/admin/template`, { waitUntil: 'domcontentloaded' });
const cards = await page.$$('.tpl-card');
assert(cards.length === 6, `6 template cards present (got ${cards.length})`);
const previewIframes = await page.$$('.tpl-card iframe');
assert(previewIframes.length === 6, `6 preview iframes (got ${previewIframes.length})`);
// ─── STEP 3 · pick template ───────────────────────────────────
console.log(`\n[step 3] pick template: ${TARGET_TEMPLATE}`);
await page.click(`.tpl-card[data-tpl="${TARGET_TEMPLATE}"]`);
const stamp = await page.$eval(
`.tpl-card[data-tpl="${TARGET_TEMPLATE}"] .stamp`,
el => el.textContent.trim()
);
assert(stamp.includes('Selected'), `selected card shows "Selected" stamp (got "${stamp}")`);
const hidden = await page.$eval('#tpl_slug', el => el.value);
assert(hidden === TARGET_TEMPLATE, `hidden tpl_slug input = "${TARGET_TEMPLATE}" (got "${hidden}")`);
// ─── STEP 4 · save ────────────────────────────────────────────
console.log('\n[step 4] save form');
await Promise.all([
page.waitForURL(/\/admin\/template/, { timeout: 8000 }),
page.click('#tplForm button[type="submit"]'),
]);
const flash = await page.$eval('.callout-success', el => el.textContent.trim()).catch(() => null);
assert(flash && /saved/i.test(flash), `success flash shown (got "${flash}")`);
// ─── STEP 5 · verify public profile uses new template ──────────
console.log('\n[step 5] verify public profile renders new template');
await page.goto(`${BASE}/installer/${SLUG}`, { waitUntil: 'domcontentloaded' });
const bodyClass = await page.$eval('body', el => el.className);
assert(
bodyClass.includes(`tpl-${TARGET_TEMPLATE}`),
`body has class tpl-${TARGET_TEMPLATE} (got "${bodyClass}")`
);
// ─── STEP 6 · drag-drop upload ────────────────────────────────
console.log('\n[step 6] hero image upload');
const fixturePath = process.env.UPLOAD_FIXTURE || '/tmp/nph-test-pixel.png';
if (!require('fs').existsSync(fixturePath)) {
console.log(` ⊘ skipped — fixture missing at ${fixturePath}`);
} else {
await page.goto(`${BASE}/admin/template`, { waitUntil: 'domcontentloaded' });
// The hero file input is created dynamically by JS, inserted right after #heroDrop.
// Click the dropzone so the hidden input exists, then set its files.
await page.click('#heroDrop');
// Find the file input that was just inserted (sibling of dropzone).
const fileInput = await page.$('#heroDrop ~ input[type="file"], input[type="file"]');
assert(!!fileInput, 'hidden file input exists after dropzone click');
const uploadResp = page.waitForResponse(r => r.url().endsWith('/admin/uploads') && r.request().method() === 'POST', { timeout: 8000 });
await fileInput.setInputFiles(fixturePath);
const resp = await uploadResp;
assert(resp.status() === 200, `POST /admin/uploads → 200 (got ${resp.status()})`);
const json = await resp.json();
assert(json.ok === true, 'response body { ok: true }');
assert(/^\/uploads\/\d+\/hero-[a-f0-9]+\.png$/.test(json.url), `URL shape correct (got ${json.url})`);
// Hidden hero_url field should now be set
await page.waitForFunction(() => document.getElementById('hero_url').value !== '', { timeout: 4000 });
const heroUrlVal = await page.$eval('#hero_url', el => el.value);
assert(heroUrlVal === json.url, `hero_url hidden input updated`);
// Verify file is on disk
const fs = require('fs');
const diskPath = path.join(__dirname, '..', 'public', json.url);
assert(fs.existsSync(diskPath), `file written to disk at ${diskPath.replace(__dirname + '/..', '')}`);
// Cleanup the test file
try { fs.unlinkSync(diskPath); } catch {}
}
// ─── CLEANUP · revert to editorial ────────────────────────────
console.log('\n[cleanup] reverting tpl_slug → editorial');
await page.goto(`${BASE}/admin/template`);
await page.click('.tpl-card[data-tpl="editorial"]');
// Clear the hero_url so the cleanup save doesn't keep the (now-deleted) image
await page.$eval('#hero_url', el => el.value = '');
await Promise.all([
page.waitForURL(/\/admin\/template/, { timeout: 8000 }),
page.click('#tplForm button[type="submit"]'),
]);
} 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);
}
})();