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