diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 231bc2d5..8d1d3e55 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -259,6 +259,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-issue-1236-map-mobile-e2e.js b/test-issue-1236-map-mobile-e2e.js new file mode 100644 index 00000000..05b2efe9 --- /dev/null +++ b/test-issue-1236-map-mobile-e2e.js @@ -0,0 +1,123 @@ +/** + * E2E (#1236): Map page mobile layout. + * + * At 375x800 viewport the /#/map page must: + * - Collapsed map controls: leaflet map canvas width must equal the viewport + * width (within 1px tolerance). No silent gutter on the right. + * - Expanded map controls: the panel must either fit within viewport OR + * have a sticky element at the top of the scroll container AND + * `overflow-y: auto` so the scroll affordance is real. + * + * Desktop guard (≥768px): map controls panel layout must remain absolute + * (position: absolute), not stretched full width. + * + * Run: BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-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(' \u2713 ' + name); } + catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function run() { + const launchOpts = { args: ['--no-sandbox'] }; + if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH; + const browser = await chromium.launch(launchOpts); + + // === Mobile: 375x800 === + const ctx = await browser.newContext({ viewport: { width: 375, height: 800 } }); + const page = await ctx.newPage(); + + await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 }); + await page.waitForSelector('#leaflet-map', { timeout: 10000 }); + await page.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 }); + await page.waitForTimeout(500); + + await step('mobile collapsed: leaflet-map width fills viewport (no right gutter)', async () => { + // Ensure controls panel is collapsed (default on mobile per map.js) + const data = await page.evaluate(() => { + const panel = document.getElementById('mapControls'); + const btn = document.getElementById('mapControlsToggle'); + if (panel && !panel.classList.contains('collapsed')) btn && btn.click(); + const lm = document.getElementById('leaflet-map'); + return { + mapW: lm ? Math.round(lm.getBoundingClientRect().width) : null, + mapLeft: lm ? Math.round(lm.getBoundingClientRect().left) : null, + vw: window.innerWidth, + }; + }); + assert(data.mapW !== null, 'leaflet-map not found'); + // Map must start at left edge and span to right edge (allow 1px rounding). + assert(data.mapLeft <= 1, + 'leaflet-map must start at viewport left, got left=' + data.mapLeft); + assert(data.mapW >= data.vw - 1, + 'leaflet-map width must equal viewport (' + data.vw + 'px), got ' + data.mapW + 'px'); + }); + + await step('mobile expanded: panel has sticky header AND overflow-y auto', async () => { + const data = await page.evaluate(() => { + const panel = document.getElementById('mapControls'); + const btn = document.getElementById('mapControlsToggle'); + if (panel.classList.contains('collapsed')) btn && btn.click(); + const cs = getComputedStyle(panel); + const h3 = panel.querySelector('h3'); + const hcs = h3 ? getComputedStyle(h3) : null; + return { + overflowY: cs.overflowY, + h3Position: hcs ? hcs.position : null, + scrollGutter: cs.scrollbarGutter || '', + scrollH: panel.scrollHeight, + clientH: panel.clientHeight, + isScrollable: panel.scrollHeight > panel.clientHeight + 1, + }; + }); + // Require explicit sticky header + scrollable overflow regardless of + // whether content currently overflows (so future content additions are + // covered too). + assert(data.h3Position === 'sticky', + 'panel h3 must be position:sticky (scroll affordance), got ' + data.h3Position); + assert(data.overflowY === 'auto' || data.overflowY === 'scroll', + 'panel overflow-y must be auto/scroll, got ' + data.overflowY); + }); + + await ctx.close(); + + // === Desktop: 1280x800 — guard against regression === + const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const p2 = await ctx2.newPage(); + await p2.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 }); + await p2.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 }); + await p2.waitForTimeout(300); + + await step('desktop (1280px): map controls panel position is absolute', async () => { + const data = await p2.evaluate(() => { + const panel = document.getElementById('mapControls'); + const cs = getComputedStyle(panel); + const rect = panel.getBoundingClientRect(); + return { + position: cs.position, + width: Math.round(rect.width), + vw: window.innerWidth, + }; + }); + assert(data.position === 'absolute', + 'desktop panel must be position:absolute, got ' + data.position); + // Should be modest width (not full viewport) + assert(data.width < data.vw * 0.5, + 'desktop panel must be <50% viewport width, got ' + data.width + '/' + data.vw); + }); + + await browser.close(); + + console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' + + (failed ? ', ' + failed + ' failed' : '')); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch(err => { console.error('Fatal:', err); process.exit(1); });