[object Object]

← back to NationalPaperHangers

e2e-room-capture: 14-assertion test for the camera quote flow

5c5c9dfae953edd04978a614b0001e65c5d30f24 · 2026-05-18 16:53:50 -0700 · SteveStudio2

Covers the capture UI (photo upload → measure stage → box-aspect width
derivation → ceiling-height recompute → wallpaper preview + serialization),
the upload endpoint type-guard, and a room_captures DB round-trip proving
the sanitiser drops an off-site photo URL. Auto-discovered by run-e2e.js;
suite now 180 pass · 0 fail. Skips cleanly (78) without Chromium/fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 5c5c9dfae953edd04978a614b0001e65c5d30f24
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 18 16:53:50 2026 -0700

    e2e-room-capture: 14-assertion test for the camera quote flow
    
    Covers the capture UI (photo upload → measure stage → box-aspect width
    derivation → ceiling-height recompute → wallpaper preview + serialization),
    the upload endpoint type-guard, and a room_captures DB round-trip proving
    the sanitiser drops an off-site photo URL. Auto-discovered by run-e2e.js;
    suite now 180 pass · 0 fail. Skips cleanly (78) without Chromium/fixture.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 tests/e2e-room-capture.js    | 163 +++++++++++++++++++++++++++++++++++++++++++
 tests/fixtures/test-wall.png | Bin 0 -> 8548 bytes
 2 files changed, 163 insertions(+)

diff --git a/tests/e2e-room-capture.js b/tests/e2e-room-capture.js
new file mode 100644
index 0000000..dccef3b
--- /dev/null
+++ b/tests/e2e-room-capture.js
@@ -0,0 +1,163 @@
+// e2e: room capture & measure quote flow (UX #6).
+//
+//   Phase 1 — capture UI: upload a wall photo, the measure stage appears,
+//             wall width is derived from the box aspect, a taller ceiling
+//             widens it, the wallpaper preview tiles at scale, and the whole
+//             thing serializes into the hidden room_captures field.
+//   Phase 2 — the public upload endpoint rejects non-image/video files.
+//   Phase 3 — a sanitised room_captures array round-trips through the
+//             bookings JSONB column, and an off-site photo URL is dropped.
+//
+// Skips cleanly (exit 78) against a remote BASE, without Chromium, or
+// without the fixture image.
+
+const path = require('path');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
+const fs = require('fs');
+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
+const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);
+const FIXTURE = path.resolve(__dirname, 'fixtures', 'test-wall.png');
+
+if (!IS_LOCAL) {
+  console.log(`[e2e-room-capture] SKIP — BASE=${BASE} is remote`);
+  process.exit(78);
+}
+
+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 { fs.accessSync(p); return p; } catch (e) {} }
+  return null;
+}
+
+let _db = null;
+function db() { if (!_db) _db = require('../lib/db'); return _db; }
+
+(async () => {
+  const exe = findChromium();
+  if (!exe) { console.log('[e2e-room-capture] SKIP — no Chromium binary'); process.exit(78); }
+  if (!fs.existsSync(FIXTURE)) { console.log('[e2e-room-capture] SKIP — fixture missing'); process.exit(78); }
+  console.log(`[e2e-room-capture] using browser: ${exe}`);
+
+  const browser = await chromium.launch({ executablePath: exe, headless: true });
+  const page = await (await browser.newContext({ viewport: { width: 1280, height: 1100 } })).newPage();
+  let pass = 0, fail = 0;
+  const ok = (c, m) => { c ? (pass++, console.log('  ✓ ' + m)) : (fail++, console.log('  ✗ ' + m)); };
+  const jsErrors = [];
+  page.on('pageerror', e => jsErrors.push(e.message));
+
+  try {
+    // ── Phase 1 — capture & measure UI ──────────────────────────────
+    console.log('\n[phase 1] capture & measure UI');
+    await page.goto(`${BASE}/installer/${SLUG}/book`, { waitUntil: 'domcontentloaded' });
+    ok(await page.locator('[data-room-capture]').count() === 1, 'room-capture section present');
+    ok(await page.locator('.rc-room').count() === 1, 'one room card auto-opened');
+
+    await page.locator('.rc-photo-input').setInputFiles(FIXTURE);
+    await page.waitForSelector('.rc-stage:not([hidden])', { timeout: 8000 });
+    ok(true, 'photo uploads → measure stage shown');
+
+    await page.waitForFunction(
+      () => { const w = document.querySelector('.rc-width'); return w && parseFloat(w.value) > 0; },
+      { timeout: 5000 }
+    );
+    const w1 = parseFloat(await page.locator('.rc-width').inputValue());
+    ok(w1 > 0, `wall width auto-derived (${w1} ft)`);
+
+    await page.locator('.rc-height').fill('14');
+    await page.locator('.rc-height').dispatchEvent('input');
+    await page.waitForTimeout(150);
+    const w2 = parseFloat(await page.locator('.rc-width').inputValue());
+    ok(w2 > w1, `taller ceiling widens derived width (${w1} → ${w2})`);
+
+    await page.locator('.rc-room-label').fill('Dining room');
+
+    await page.locator('.rc-wallpaper summary').click();
+    await page.locator('.rc-wp-input').setInputFiles(FIXTURE);
+    await page.waitForTimeout(600);
+    await page.locator('.rc-wp-width').fill('27');
+    await page.locator('.rc-wp-width').dispatchEvent('input');
+    await page.waitForTimeout(150);
+    ok(await page.locator('.rc-wp-note.is-error').count() === 1, 'wallpaper missing its repeat flags an error');
+
+    await page.locator('.rc-wp-repeat').fill('18');
+    await page.locator('.rc-wp-repeat').dispatchEvent('input');
+    await page.waitForTimeout(200);
+    const bg = await page.locator('.rc-wallpaper-layer').evaluate(el => el.style.backgroundImage);
+    ok(/url/.test(bg), 'wallpaper tiles onto the wall at scale');
+
+    const captures = JSON.parse(await page.locator('[data-rc-field]').inputValue());
+    ok(captures.length === 1 && captures[0].room === 'Dining room'
+       && captures[0].photo_url && captures[0].wall_width_ft > 0,
+       'room_captures serialized into the hidden field');
+    ok(captures[0].wallpaper && captures[0].wallpaper.width_in === 27
+       && captures[0].wallpaper.repeat_in === 18,
+       'wallpaper width + repeat serialized');
+
+    await page.locator('[data-rc-add]').click();
+    ok(await page.locator('.rc-room').count() === 2, 'add-room adds a second card');
+
+    // ── Phase 2 — upload endpoint type-guard ────────────────────────
+    console.log('\n[phase 2] upload endpoint type-guard');
+    const rejected = await page.evaluate(async () => {
+      const meta = document.querySelector('meta[name="csrf-token"]');
+      const fd = new FormData();
+      fd.append('file', new Blob(['not an image'], { type: 'text/plain' }), 'x.txt');
+      const r = await fetch('/api/booking-media', {
+        method: 'POST', headers: { 'X-CSRF-Token': meta.content }, body: fd
+      });
+      return r.json();
+    });
+    ok(rejected && rejected.ok === false, 'non-image/video upload rejected');
+
+    // ── Phase 3 — room_captures DB round-trip + sanitiser ───────────
+    console.log('\n[phase 3] room_captures DB round-trip + sanitiser');
+    const { sanitize } = require('../lib/room-captures');
+    const payload = JSON.stringify([
+      { room: 'Dining', wall_width_ft: 12, wall_height_ft: 9,
+        photo_url: '/uploads/bookings/' + 'a'.repeat(24) + '.jpg',
+        wallpaper: { image_url: '/uploads/bookings/' + 'b'.repeat(24) + '.jpg',
+                     width_in: 27, repeat_in: 18, match_type: 'straight' } },
+      { room: 'Evil', photo_url: 'https://evil.example/x.jpg', wall_width_ft: 9, wall_height_ft: 9 }
+    ]);
+    const clean = sanitize(payload);
+    const inst = await db().one('SELECT id FROM installers LIMIT 1');
+    const s = new Date(Date.now() + 7 * 864e5), e = new Date(s.getTime() + 36e5);
+    const row = await db().one(
+      `INSERT INTO bookings
+         (installer_id, customer_name, customer_email, scheduled_start, scheduled_end,
+          room_captures, status, source)
+       VALUES ($1,$2,$3,$4,$5,$6::jsonb,'pending','e2e')
+       RETURNING id, room_captures`,
+      [inst.id, 'E2E RoomCapture', 'e2e-roomcapture@example.com',
+       s.toISOString(), e.toISOString(), JSON.stringify(clean)]
+    );
+    try {
+      ok(Array.isArray(row.room_captures), 'room_captures reads back as a JSON array');
+      ok(row.room_captures.length === 1, 'off-site-URL capture dropped by sanitiser (2 → 1)');
+      ok(row.room_captures[0].wallpaper && row.room_captures[0].wallpaper.width_in === 27,
+         'wallpaper width + repeat persisted on the booking row');
+    } finally {
+      await db().query('DELETE FROM bookings WHERE id = $1', [row.id]);
+    }
+  } catch (err) {
+    console.error('\nFATAL:', err.message);
+    fail++;
+  }
+
+  if (jsErrors.length) {
+    console.log('\nUncaught JS errors on page:');
+    jsErrors.forEach(e => console.log('  ' + e));
+    fail += jsErrors.length;
+  }
+  await browser.close();
+  if (_db && _db.pool) await _db.pool.end();
+  console.log(`\n[result] ${pass} pass · ${fail} fail`);
+  process.exit(fail > 0 ? 1 : 0);
+})();
diff --git a/tests/fixtures/test-wall.png b/tests/fixtures/test-wall.png
new file mode 100644
index 0000000..6854b33
Binary files /dev/null and b/tests/fixtures/test-wall.png differ

← a1dfc05 Surface room captures in the installer brief — admin page +  ·  back to NationalPaperHangers  ·  Harden the room-capture flow for mobile + touch 51b33df →