← back to NationalPaperHangers
tests/e2e-room-capture.js
227 lines
// 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})`);
// Drag the bottom-right box corner inward — the wall re-measures.
await page.locator('.rc-photo-wrap').scrollIntoViewIfNeeded();
await page.waitForTimeout(120);
const wPreDrag = parseFloat(await page.locator('.rc-width').inputValue());
const hb = await page.locator('.rc-handle[data-corner="br"]').boundingBox();
const wb = await page.locator('.rc-photo-wrap').boundingBox();
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
await page.mouse.down();
await page.mouse.move(wb.x + wb.width * 0.45, wb.y + wb.height * 0.5, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(200);
const wPostDrag = parseFloat(await page.locator('.rc-width').inputValue());
ok(wPostDrag !== wPreDrag && wPostDrag > 0,
`dragging a box corner re-measures the wall (${wPreDrag} → ${wPostDrag})`);
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');
await page.locator('.rc-wp-match').selectOption('half_drop');
await page.waitForTimeout(400);
const bgHalf = await page.locator('.rc-wallpaper-layer').evaluate(el => el.style.backgroundImage);
ok(/^url\("data:image/.test(bgHalf), 'half-drop match composites a super-tile');
await page.locator('.rc-wp-match').selectOption('straight');
await page.waitForTimeout(150);
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');
// A square-feet figure the customer types by hand must survive a later
// room re-measure — the measured-walls total no longer clobbers it.
await page.locator('input[name="square_feet"]').fill('999');
await page.locator('input[name="square_feet"]').dispatchEvent('input');
await page.locator('.rc-room-label').fill('Dining room (updated)');
await page.locator('.rc-room-label').dispatchEvent('input');
await page.waitForTimeout(150);
ok(await page.locator('input[name="square_feet"]').inputValue() === '999',
'customer-entered square feet is not overwritten by measured total');
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 postFile = async (bytes, type, name) => page.evaluate(
async ({ bytes, type, name }) => {
const meta = document.querySelector('meta[name="csrf-token"]');
const fd = new FormData();
fd.append('file', new Blob([new Uint8Array(bytes)], { type }), name);
const r = await fetch('/api/booking-media', {
method: 'POST', headers: { 'X-CSRF-Token': meta.content }, body: fd
});
return r.json();
}, { bytes, type, name });
const textBytes = Array.from('not an image').map(c => c.charCodeAt(0));
const rejected = await postFile(textBytes, 'text/plain', 'x.txt');
ok(rejected && rejected.ok === false, 'non-image/video upload rejected');
// Content-sniff gate: text bytes mislabelled image/png clear multer's
// fileFilter but must fail the magic-byte check.
const spoofed = await postFile(textBytes, 'image/png', 'fake.png');
ok(spoofed && spoofed.ok === false,
'image/png mimetype with non-image bytes rejected by content sniff');
// ── 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]);
}
// ── Phase 4 — mobile viewport (the capture flow's primary surface) ──
console.log('\n[phase 4] mobile viewport — 390px, touch');
const mctx = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true
});
const mpage = await mctx.newPage();
try {
await mpage.goto(`${BASE}/installer/${SLUG}/book`, { waitUntil: 'domcontentloaded' });
const noOverflow = await mpage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1
);
ok(noOverflow, 'no horizontal overflow at 390px');
await mpage.locator('.rc-photo-input').setInputFiles(FIXTURE);
await mpage.waitForSelector('.rc-stage:not([hidden])', { timeout: 8000 });
const hSize = await mpage.locator('.rc-handle[data-corner="br"]')
.evaluate(el => el.getBoundingClientRect().width);
ok(hSize >= 30, `drag handles finger-sized on touch (${Math.round(hSize)}px)`);
} finally {
await mctx.close();
}
} 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);
})();