diff --git a/internal/verify-shell-scroll.mjs b/internal/verify-shell-scroll.mjs index 861aa9c9a..a7ee7af2c 100644 --- a/internal/verify-shell-scroll.mjs +++ b/internal/verify-shell-scroll.mjs @@ -23,6 +23,9 @@ * (defaults to http://localhost:8899; serve ./public) */ import { chromium } from 'playwright' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { requireThisTree } from './_serves-this-tree.mjs' // Default matches every other browser gate in this repo: `bun run serve` on loopback 4173. // It was `localhost:8899` — one of the ad-hoc ports a previous session happened to have running, @@ -37,6 +40,9 @@ const BASE = process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173' // internal/serve-strict.mjs, which 404s a directory exactly as production does, and it caught these // two the first time it ran. The manifest already ships 28/28 iframeUrl values in this form. const SHELL = '/doxa-maps/plugin-studio/plugin-studio/index.html' +// Measure THIS build, or measure nothing: a default port on this machine can belong to another +// worktree, and a stale studio there would be graded as ours. Exit 75 = NOT MEASURED (see a9550858). +await requireThisTree(BASE, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'SHELL-SCROLL') const LEAF = '/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html' // Shadow DOM DOES NOT PIERCE. A single-level querySelector lies about every composite, so every @@ -110,76 +116,62 @@ const run = async () => { if (!before) { await browser.close(); fail('.pgg-root is NOT MOUNTED in the shell — different bug, not a scroll bug') } if (before.h < 400) { await browser.close(); fail('.pgg-root mounted but collapsed (h=' + before.h + ') — not a scroll bug') } - // 2. A REAL WHEEL GESTURE over the MAP AREA must move the grid up the screen. This is the - // gesture the Driver makes, over the surface he makes it on. Mapbox scrollZoom eating the - // wheel would show up here as no movement. - await wheel(page, 850, 420, WHEEL_DY) - const afterMap = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`) - const movedByMap = before.top - afterMap.top - - // 3. ...and a gesture BELOW the map must too (in case the map swallows its own area). - await wheel(page, 850, 800, WHEEL_DY) + // 2. THE DRIVER'S RULING, asserted as stated (2026-09-23, recorded in + // plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue:~1188): + // "I can't zoom in and out on the map. The scrolling is causing that to change." → + // a wheel OVER the map ZOOMS and moves the page 0 px; a wheel BELOW the map SCROLLS the page. + // ★ THIS STEP USED TO ASSERT THE OPPOSITE — that a wheel over the map must scroll the page — + // written before the ruling, when the map row filled the whole pane. Once the ruling made the + // map a bounded region with zoom always on, that assertion failed on the correct behaviour and + // kept this gate red on a product that was right (re-measured 2026-09-25: over-map 0 px, below + // the map 2502 px, grid on screen). dx-shepherd ruled the gate stale; this is the ruling. + // Negative control: SHELL_SCROLL_NEGATIVE=1 disables scrollZoom on the map before the gestures + // (the pre-ruling behaviour), and the gate must FAIL. + const MAP = `(() => { ${WALK} + const c = __walk('canvas.mapboxgl-canvas, canvas.maplibregl-canvas').find(x => x.getBoundingClientRect().height > 50) + if (!c) return null + const host = __walk('*').find(e => e.__doxaMap && e.__doxaMap.getCanvas && e.__doxaMap.getCanvas() === c) + const r = c.getBoundingClientRect() + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), bottom: Math.round(r.bottom), top: Math.round(r.top), + zoom: host ? host.__doxaMap.getZoom() : null } + })()` + const mapBox = await page.evaluate(MAP) + if (!mapBox || mapBox.zoom == null) { await browser.close(); fail('no map (canvas + its map instance) on the dashboard tab — cannot assert the zoom-vs-scroll ruling') } + if (process.env.SHELL_SCROLL_NEGATIVE === '1') { + await page.evaluate(`(() => { ${WALK} for (const e of __walk('*')) if (e.__doxaMap && e.__doxaMap.scrollZoom) e.__doxaMap.scrollZoom.disable() })()`) + console.log(' NEGATIVE CONTROL: scrollZoom disabled on the map — this run must FAIL') + } + // (a) a scroll-DOWN wheel over the map: the page must not move. + await wheel(page, mapBox.x, mapBox.y, WHEEL_DY, 3) + const afterMapDown = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`) + const movedByMap = before.top - afterMapDown.top + // (b) a zoom-IN wheel over the map: the zoom must go up (in, not out, so a map already at its + // minimum zoom cannot fake a failure). + const zBefore = (await page.evaluate(MAP)).zoom + await wheel(page, mapBox.x, mapBox.y, -WHEEL_DY, 3) + const zAfter = (await page.evaluate(MAP)).zoom + // (c) the same scroll-down wheel BELOW the map's box: this is how the page reaches the grid. + // Below the map's box, and no higher than the y=800 the original gate measured this gesture at: a + // point just under the canvas (y=636 on a 900px viewport) lands on the dashboard's own lower + // controls, which consume the wheel themselves, and read as "the page will not scroll". + const belowY = Math.min(Math.max(mapBox.bottom + 60, VIEWPORT_FOLD - 100), VIEWPORT_FOLD - 20) + const pageBefore = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`) + await wheel(page, mapBox.x, belowY, WHEEL_DY) const afterLow = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`) - const movedTotal = before.top - afterLow.top - - // 4. THE REAL QUESTION: is the grid actually ON SCREEN now? A grid that moved 50px but still - // sits below the fold is still unreachable. Require its top inside the viewport. + const movedBelow = pageBefore.top - afterLow.top const onScreen = afterLow.top < VIEWPORT_FOLD console.log('SHELL-SCROLL measurements (cap-5 shell, tab="' + tab + '"):') - console.log(' grid mounted : h=' + before.h + 'px, top=' + before.top) - console.log(' wheel over MAP area : grid moved ' + movedByMap + 'px (top ' + before.top + ' -> ' + afterMap.top + ')') - console.log(' wheel BELOW map : grid moved ' + movedTotal + 'px total (top -> ' + afterLow.top + ')') - console.log(' grid on screen : ' + onScreen) + console.log(' grid mounted : h=' + before.h + 'px, top=' + before.top) + console.log(' wheel OVER map (down) : page moved ' + movedByMap + 'px (ruling: 0)') + console.log(' wheel OVER map (zoom in) : zoom ' + zBefore.toFixed(2) + ' -> ' + zAfter.toFixed(2) + ' (ruling: zooms)') + console.log(' wheel BELOW map (y=' + belowY + ') : page moved ' + movedBelow + 'px (ruling: scrolls)') + console.log(' grid on screen : ' + onScreen) - if (movedTotal < 200) fail('grid did not move — a real wheel gesture in the SHELL scrolled ' + movedTotal + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.') - if (!onScreen) fail('grid moved ' + movedTotal + 'px but its top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.') - - // ★ ASSERT ON THE NUMBER WE ALREADY MEASURED. This gate computed `movedByMap` at step 2 — the - // gesture the Driver actually makes — and then graded only `movedTotal`, which sums in the - // step-3 gesture BELOW the map. So it reported PASS (2611px total) while wheeling OVER the map - // moved just 300px and the Driver still could not reach the grid: Mapbox scrollZoom was eating - // the wheel because syncScrollZoomToHost sampled the layout on three fixed timeouts and froze a - // stale answer. Passing on the total rewarded an escape hatch the user will never find — his - // cursor is over the map, which fills most of the band. - // - // The failure mode to remember: a gate that MEASURES the right thing and then ASSERTS on a - // different variable. Distinct from a gate that cannot fail; it fails honestly, just never on - // the condition it was built for. Audit every gate for values it computes but never checks. - // ★ THE THRESHOLD IS DERIVED, NOT GUESSED. This was `movedByMap < 200` — a number picked as a - // vague "did it move meaningfully" floor WITHOUT comparing it to the value already measured as - // broken, which was 300. So the assertion passed on the exact measurement that demonstrates the - // bug, and the gate reported PASS across three ticks while the Driver still could not scroll. - // - // The gesture requests WHEEL_TICKS * WHEEL_DY px. A healthy scrollport delivers most of that - // (capped only by the scroll range actually available). Delivering one tick's worth means the - // other nine were swallowed by a nested scrollport under the cursor. Comparing DELIVERED against - // REQUESTED is self-calibrating: change the tick count or delta and the assertion still holds, - // where a hardcoded constant silently rots. - // ★ THE BASELINE IS MEASURED, NOT COMPUTED. A first attempt derived the expected travel as - // `before.top - VIEWPORT_FOLD`, which is 911-900 = 11px — a plausible-looking quantity that is - // simply the wrong one (it asks how far until the grid reaches the fold, not how much scroll the - // PANE has). With expected=11 the assertion passed on the broken 300px, repeating the exact - // mistake it was written to fix. - // - // The honest baseline is the SAME gesture performed somewhere it is known to work: step 3 wheels - // identically lower down the page and delivers `movedTotal - movedByMap`. Two identical gestures - // on one surface should move the page comparably; if the one over the dashboard moves far less, - // something under that cursor is eating it. No constant to rot, and it self-adjusts to whatever - // scroll range the layout actually has. - const requested = WHEEL_TICKS * WHEEL_DY - const movedByLower = movedTotal - movedByMap - if (movedByLower > 100 && movedByMap < movedByLower * 0.5) { - fail('a nested scrollport ATE THE WHEEL — the SAME ' + requested + 'px gesture moved the grid ' + - movedByLower + 'px lower down the page but only ' + movedByMap + 'px over the dashboard' + - '. The grid is technically reachable but NOT by ' + - 'the gesture the Driver makes.\n' + - ' ★ Do NOT assume the map. MEASURED 2026-09-22: the culprit was .rd-grid — a virtualized table ' + - 'with scrollHeight 54834 in a ~207px window — consuming the wheel by normal scroll-chaining, ' + - 'while the Mapbox canvas sat ABOVE the cursor and was never involved. Fix was ' + - 'overscroll-behavior:contain on .rd-grid. Identify the element UNDER THE CURSOR before blaming ' + - 'the one nearby: compare each scrollport\'s scrollTop before and after the gesture.') - } + if (Math.abs(movedByMap) > 4) fail('a wheel OVER the map scrolled the page ' + movedByMap + 'px. The Driver ruled (2026-09-23) that a wheel over the map ZOOMS and must not move the page — is scrollZoom disabled on this map?') + if (!(zAfter > zBefore + 0.05)) fail('a zoom-in wheel OVER the map left the zoom at ' + zAfter.toFixed(2) + ' (was ' + zBefore.toFixed(2) + '). The Driver ruled that a wheel over the map zooms it.') + if (movedBelow < 200) fail('a wheel BELOW the map scrolled the page only ' + movedBelow + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.') + if (!onScreen) fail('the page scrolled ' + movedBelow + 'px but the grid top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.') console.log('SHELL-SCROLL: PASS — the grid is reachable by wheel in the cap-5 shell.')