/** * E2E (#1059): Map controls + modal fluid/safe-max-height behavior. * * Strengthened per polish review (round 2): * - MAJOR-1: assert .modal max-height is STRICTLY > 80vh (i.e. >= 90vh); * reject 80vh by inspecting the computed pixel value. * - MAJOR-2: behavioral sticky-close test — inflate modal body past viewport, * scroll modal content to the bottom, assert close button still inside * viewport AND clickable (elementFromPoint at its center returns the close * button or its child). * - MAJOR-3: inject 100 tall paragraphs into BYOP modal body to force the * overflow scenario (otherwise the modal never grows past 90vh and the * overflow path is never exercised). * - MAJOR-4 AC1: at 768x900, inject a synthetic .leaflet-marker-icon at the * top-right of the leaflet container (where map controls live) and assert * no .map-controls element bounds overlap the marker bounds. * - MAJOR-4 AC2: at 2560x1440, assert .leaflet-container width >= 2400px * (map fills extra horizontal space on ultrawide). * - MAJOR-5: viewports list includes 1080 (matches PR body). * * Usage: BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js */ 'use strict'; const { chromium } = require('playwright'); const BASE = process.env.BASE_URL || 'http://localhost:13581'; let passed = 0, failed = 0; async function step(name, fn) { try { await fn(); passed++; console.log(' ✓ ' + name); } catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } } function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } (async () => { const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], }); const ctx = await browser.newContext(); const page = await ctx.newPage(); page.setDefaultTimeout(8000); page.on('pageerror', (e) => console.error('[pageerror]', e.message)); console.log(`\n=== #1059 map+modal fluid E2E (strengthened) against ${BASE} ===`); // --- Map page: no horizontal scroll across viewports (incl. 1080 per PR body) --- const viewports = [ { w: 1024, h: 768 }, { w: 1080, h: 800 }, // MAJOR-5: aligns with PR body claim { w: 1440, h: 900 }, { w: 1920, h: 1080 }, { w: 2560, h: 1440 }, ]; for (const v of viewports) { await step(`no horizontal scroll on /#/map at ${v.w}x${v.h}`, async () => { await page.setViewportSize({ width: v.w, height: v.h }); await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#leaflet-map', { timeout: 8000 }); await page.waitForTimeout(300); const overflow = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, })); assert(overflow.sw <= overflow.cw + 1, `horizontal scroll: scrollWidth=${overflow.sw} clientWidth=${overflow.cw}`); }); } // --- MAJOR-4 AC1: at 768x900, controls do NOT overlap marker bounds --- await step('AC1: map controls do not overlap marker at 768x900', async () => { await page.setViewportSize({ width: 768, height: 900 }); await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#leaflet-map', { timeout: 8000 }); await page.waitForSelector('.map-controls', { timeout: 8000 }); await page.waitForTimeout(400); // Inject a synthetic marker in the LEFT half of the leaflet container. // The controls panel sits in the top-right corner; if it grows // uncontrolled (e.g. fixed 220px+ at narrow viewports) or wraps into // the map area, it can overlap markers placed away from the corner. // We assert controls DO NOT bleed across the centerline into a marker // sitting at left:50%, top:80px. const result = await page.evaluate(() => { const lc = document.querySelector('.leaflet-container'); if (!lc) return { ok: false, reason: 'no .leaflet-container' }; const lr = lc.getBoundingClientRect(); const m = document.createElement('div'); m.className = 'leaflet-marker-icon test-marker-1059'; // Marker centered horizontally inside leaflet, at top:80px. const left = lr.left + (lr.width / 2) - 12; m.style.cssText = 'position:absolute;width:24px;height:24px;' + 'left:' + left + 'px;top:' + (lr.top + 80) + 'px;' + 'background:red;z-index:399;pointer-events:none;'; document.body.appendChild(m); const mb = m.getBoundingClientRect(); const ctrls = Array.from(document.querySelectorAll('.map-controls')); const overlaps = ctrls.map((el) => { const r = el.getBoundingClientRect(); const overlap = !(r.right <= mb.left || r.left >= mb.right || r.bottom <= mb.top || r.top >= mb.bottom); return { overlap, ctrl: { l: r.left, r: r.right, t: r.top, b: r.bottom, w: r.width } }; }); return { ok: true, marker: { l: mb.left, r: mb.right, t: mb.top, b: mb.bottom }, overlaps, vw: window.innerWidth }; }); assert(result.ok, result.reason || 'setup failed'); const overlapping = result.overlaps.filter((o) => o.overlap); assert(overlapping.length === 0, `map controls overlap centered marker (controls bled across viewport): ` + `marker=${JSON.stringify(result.marker)} overlapping=${JSON.stringify(overlapping)} ` + `vw=${result.vw}`); }); // --- MAJOR-4 AC2: at 2560x1440, leaflet-container fills extra space --- await step('AC2: leaflet-container width >= 2400px at 2560x1440', async () => { await page.setViewportSize({ width: 2560, height: 1440 }); await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.leaflet-container', { timeout: 8000 }); await page.waitForTimeout(300); const w = await page.evaluate(() => { const lc = document.querySelector('.leaflet-container'); return lc ? lc.getBoundingClientRect().width : 0; }); assert(w >= 2400, `leaflet-container width ${w} < 2400px (map not filling ultrawide)`); }); // --- MAJOR-1 + 2 + 3: BYOP modal — strict 90vh, inflated content, sticky close --- // Helper: open BYOP modal cleanly. Close any existing modal first since // hash-route navigation does not reload the SPA and would leave a previous // modal open. async function openByopModal(viewport) { await page.setViewportSize(viewport); // Force a real reload to clear any modal/state from the previous step. await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('[data-action="pkt-byop"]', { timeout: 8000 }); // Defensive: dismiss any pre-existing overlay. await page.evaluate(() => { document.querySelectorAll('.byop-overlay, .modal-overlay').forEach((el) => el.remove()); }); await page.click('[data-action="pkt-byop"]'); await page.waitForSelector('.byop-modal', { timeout: 5000 }); } await step('BYOP modal: max-height >= 90vh STRICT (rejects 80vh)', async () => { await openByopModal({ width: 1024, height: 800 }); const m = await page.evaluate(() => { const modal = document.querySelector('.byop-modal'); const cs = getComputedStyle(modal); return { vh: window.innerHeight, maxHeightPx: parseFloat(cs.maxHeight), rawMaxHeight: cs.maxHeight, }; }); // STRICT: max-height in pixels must be >= 90% of viewport height. // 80vh would be 0.80 * vh ≈ 640 at vh=800. 90vh ≈ 720. const eightyVh = m.vh * 0.80; const ninetyVh = m.vh * 0.90; assert(m.maxHeightPx >= ninetyVh - 1, `modal max-height ${m.maxHeightPx}px < 90vh (${ninetyVh}px). raw=${m.rawMaxHeight}`); // NEGATIVE: 80vh must NOT be acceptable. If max-height equals 80vh, fail. assert(m.maxHeightPx > eightyVh + 4, `modal max-height ${m.maxHeightPx}px is at or below 80vh (${eightyVh}px). ` + `Spec requires > 80vh. raw=${m.rawMaxHeight}`); }); await step('BYOP modal: inflated content overflows internally (90vh cap holds)', async () => { await openByopModal({ width: 1024, height: 800 }); // MAJOR-3: inject 100 tall paragraphs INSIDE the modal so content >> 90vh. await page.evaluate(() => { const modal = document.querySelector('.byop-modal'); const filler = document.createElement('div'); filler.id = 'byop-overflow-filler-1059'; let html = ''; for (let i = 0; i < 100; i++) { html += '
' + 'Filler paragraph ' + i + ' — lorem ipsum dolor sit amet, consectetur ' + 'adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore ' + 'magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
'; } filler.innerHTML = html; modal.appendChild(filler); }); await page.waitForTimeout(150); const m = await page.evaluate(() => { const modal = document.querySelector('.byop-modal'); const r = modal.getBoundingClientRect(); const cs = getComputedStyle(modal); return { vh: window.innerHeight, modalH: r.height, scrollH: modal.scrollHeight, clientH: modal.clientHeight, overflowY: cs.overflowY, }; }); // Modal box must NOT exceed 90vh even though content is huge. assert(m.modalH <= m.vh * 0.90 + 2, `modal height ${m.modalH} > 90vh of ${m.vh}=${m.vh * 0.90}`); // Content must actually overflow internally (proves overflow path is exercised). assert(m.scrollH > m.clientH + 50, `modal content did not overflow: scrollHeight=${m.scrollH} clientHeight=${m.clientH}`); // Internal scroll must be auto/scroll, not visible/hidden. assert(m.overflowY === 'auto' || m.overflowY === 'scroll', `modal overflow-y must be auto/scroll under overflow, got ${m.overflowY}`); }); await step('BYOP modal: close button reachable AFTER scrolling past it (behavioral)', async () => { await openByopModal({ width: 1024, height: 800 }); // Inflate content so modal scrolls. await page.evaluate(() => { const modal = document.querySelector('.byop-modal'); const filler = document.createElement('div'); let html = ''; for (let i = 0; i < 100; i++) { html += '' + 'Filler ' + i + ' — lorem ipsum dolor sit amet.
'; } filler.innerHTML = html; modal.appendChild(filler); }); await page.waitForTimeout(150); // Capture initial close-button position. const initialClose = await page.evaluate(() => { const c = document.querySelector('.byop-modal .byop-x'); const r = c.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, vh: window.innerHeight }; }); // Scroll modal content to the bottom. await page.evaluate(() => { const modal = document.querySelector('.byop-modal'); modal.scrollTop = modal.scrollHeight; }); await page.waitForTimeout(150); const m = await page.evaluate(() => { const modal = document.querySelector('.byop-modal'); const close = document.querySelector('.byop-modal .byop-x'); const cr = close.getBoundingClientRect(); const cx = cr.left + cr.width / 2; const cy = cr.top + cr.height / 2; const hit = document.elementFromPoint(cx, cy); const inViewport = cr.top >= 0 && cr.bottom <= window.innerHeight + 1; // hit should be the close button itself or a descendant of it, NOT // some scrolled-past content. Walk up from hit to find close. let n = hit, isCloseOrChild = false; for (let i = 0; n && i < 8; i++) { if (n === close) { isCloseOrChild = true; break; } n = n.parentElement; } return { scrollTop: modal.scrollTop, scrollMax: modal.scrollHeight - modal.clientHeight, closeTop: cr.top, closeBottom: cr.bottom, vh: window.innerHeight, inViewport, isCloseOrChild, hitTag: hit ? hit.tagName + '.' + (hit.className || '') : 'null', }; }); // Sanity: we actually scrolled. assert(m.scrollTop > 50, `modal did not scroll: scrollTop=${m.scrollTop} scrollMax=${m.scrollMax}`); // BEHAVIORAL: close button still inside viewport after scrolling content. assert(m.inViewport, `close button left viewport after scroll: top=${m.closeTop} bottom=${m.closeBottom} vh=${m.vh} ` + `(initial top=${initialClose.top}); means close is NOT sticky`); // BEHAVIORAL: close button is hit-testable (no overlay covers it). assert(m.isCloseOrChild, `elementFromPoint at close-button center returned ${m.hitTag}, not the close button`); }); await browser.close(); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); process.exit(failed === 0 ? 0 : 1); })();