← 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
A tests/e2e-room-capture.jsA tests/fixtures/test-wall.png
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 →