← back to NationalPaperHangers

tests/e2e-buyer-wizard.js

157 lines

// e2e click-test: buyer booking wizard on /installer/:slug/book.
//
// Walks through the 5-step intake wizard end-to-end:
//   1. Scope    (rooms / sq ft / ceiling / surface state / access)
//   2. Wallpaper (Yes/No → reveals brand/SKU on Yes)
//   3. Who      (homeowner / designer / architect / contractor / property_mgr / other)
//   4. Where    (address)
//   5. Slot & details — fills name/email/phone but does NOT submit
//      (avoids creating a real booking row in the DB)
//
// Verifies: each step is visible only when active, Back+Next nav works,
// step-pill state advances, conditional brand reveal in step 2 works.
//
// Run: node tests/e2e-buyer-wizard.js
// Env: BASE=http://localhost:9765 (default), SLUG=<paid-tier studio slug>

const { chromium } = require('playwright-core');

const BASE = process.env.BASE || 'http://localhost:9765';
const SLUG = process.env.SLUG || 'rivera-installs-miami';  // Pro tier in seed

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'); process.exit(2); }
  console.log(`[e2e-buyer] 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 {
    // ─── Setup · /book reachable + paid-tier studio with calendar enabled ───
    console.log(`\n[setup] GET /installer/${SLUG}/book`);
    await page.goto(`${BASE}/installer/${SLUG}/book`, { waitUntil: 'domcontentloaded' });
    const title = await page.title();
    assert(/Book/i.test(title), `page title contains "Book" (got "${title}")`);

    // Verify wizard rendered (calendar enabled)
    const wizard = await page.$('[data-intake-wizard]');
    assert(!!wizard, 'wizard form rendered (calendar enabled for this studio)');
    if (!wizard) {
      console.log('\n  Studio has no calendar — pick a paid-tier slug via SLUG=...');
      await browser.close();
      process.exit(2);
    }

    const stepCount = await page.$$eval('.intake-step', els => els.length);
    assert(stepCount === 5, `5 step fieldsets present (got ${stepCount})`);
    const pillCount = await page.$$eval('[data-step-pill]', els => els.length);
    assert(pillCount === 5, `5 step pills present (got ${pillCount})`);

    // ─── Step 1 · Scope ────────────────────────────────────────────
    console.log('\n[step 1] Scope');
    const step1Active = await page.$eval('[data-step="1"]', el => el.classList.contains('is-active'));
    assert(step1Active, 'step 1 is active on load');

    await page.fill('input[name="surfaces"]', 'Dining room walls + ceiling');
    await page.fill('input[name="square_feet"]', '320');
    await page.fill('input[name="ceiling_height_ft"]', '10');
    await page.selectOption('select[name="surface_state"]', 'painted_drywall');
    await page.selectOption('select[name="access_constraints"]', 'ground_floor');
    await page.click('[data-step-next="2"]');
    await page.waitForFunction(() => document.querySelector('[data-step="2"]').classList.contains('is-active'));
    assert(true, 'advanced to step 2');

    // ─── Step 2 · Wallpaper ────────────────────────────────────────
    console.log('\n[step 2] Wallpaper');
    // "Yes — already chosen" reveals brand fields
    await page.locator('label').filter({ has: page.locator('input[name="product_sourced"][value="true"]') }).click();
    await page.waitForFunction(() => {
      const el = document.querySelector('[data-show-if]');
      return el && getComputedStyle(el).display !== 'none';
    }, { timeout: 3000 }).catch(() => {});
    const brandFieldsVisible = await page.$eval('[data-show-if]', el => getComputedStyle(el).display !== 'none');
    assert(brandFieldsVisible, 'brand fields revealed when product_sourced=Yes');

    await page.selectOption('select[name="material"]', 'silk');
    await page.fill('input[name="brand"]', 'de Gournay');
    await page.fill('input[name="brand_sku"]', 'Earlham 7821');
    await page.click('[data-step-next="3"]');
    await page.waitForFunction(() => document.querySelector('[data-step="3"]').classList.contains('is-active'));
    assert(true, 'advanced to step 3');

    // ─── Step 3 · Who ─────────────────────────────────────────────
    console.log('\n[step 3] Who is ordering');
    await page.locator('label').filter({ has: page.locator('input[name="customer_role"][value="designer"]') }).click();
    const roleChecked = await page.$eval('input[name="customer_role"][value="designer"]', el => el.checked);
    assert(roleChecked, '"designer" role selected');
    await page.click('[data-step-next="4"]');
    await page.waitForFunction(() => document.querySelector('[data-step="4"]').classList.contains('is-active'));
    assert(true, 'advanced to step 4');

    // ─── Step 4 · Where ───────────────────────────────────────────
    console.log('\n[step 4] Where');
    await page.fill('input[name="address_line1"]', '1234 Sunset Blvd');
    await page.fill('input[name="city"]', 'Los Angeles');
    await page.fill('input[name="state"]', 'CA');
    await page.fill('input[name="zip"]', '90028');
    await page.click('[data-step-next="5"]');
    await page.waitForFunction(() => document.querySelector('[data-step="5"]').classList.contains('is-active'));
    assert(true, 'advanced to step 5');

    // ─── Step 5 · Slot & details ─────────────────────────────────
    console.log('\n[step 5] Slot & details (filling but NOT submitting)');
    await page.fill('input[name="customer_name"]', 'Test Buyer (e2e)');
    await page.fill('input[name="customer_email"]', 'e2e+buyer@example.com');
    await page.fill('input[name="customer_phone"]', '+1 555 555 0100');
    const nameVal = await page.$eval('input[name="customer_name"]', el => el.value);
    assert(nameVal === 'Test Buyer (e2e)', 'name field accepts input');

    // ─── Step pills reflect progression ──────────────────────────
    console.log('\n[step-pill state]');
    const pillState = await page.$$eval('[data-step-pill]', els => els.map(e => ({
      n: e.getAttribute('data-step-pill'),
      done: e.classList.contains('is-done'),
      active: e.classList.contains('is-active')
    })));
    assert(pillState[4].active, 'pill 5 marked active');
    assert(pillState[0].done && pillState[1].done && pillState[2].done && pillState[3].done, 'pills 1-4 marked done');

    // ─── Back navigation works ───────────────────────────────────
    console.log('\n[nav] Back from step 5 → step 4');
    await page.click('[data-step-prev="4"]');
    await page.waitForFunction(() => document.querySelector('[data-step="4"]').classList.contains('is-active'));
    const addrVal = await page.$eval('input[name="address_line1"]', el => el.value);
    assert(addrVal === '1234 Sunset Blvd', 'address persists when nav-ing back');

    // ─── Step 2 conditional reveal toggles correctly ─────────────
    console.log('\n[regression] product_sourced toggle');
    await page.click('[data-step-prev="3"]');  // back to step 3
    await page.click('[data-step-prev="2"]');  // back to step 2
    await page.locator('label').filter({ has: page.locator('input[name="product_sourced"][value="false"]') }).click();
    const brandFieldsHidden = await page.$eval('[data-show-if]', el => getComputedStyle(el).display === 'none');
    assert(brandFieldsHidden, 'brand fields hide when product_sourced=No');

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