syncScrollZoomToHost decided whether Mapbox should keep scrollZoom by sampling the layout at 0, 1200 and 4000ms and then never asking again. That is a race, not a sync: the cap-5 dashboard tab lazy-mounts on activation, so at every one of those instants the scrollport ancestor had not reached its final height. The map kept scrollZoom enabled and ate the wheel. Wheeling over the map moved the grid 300px; wheeling below it moved 2611px. The cursor is over the map, so the people-groups grid under the dashboard was unreachable. The condition is layout-dependent, so observe layout instead of guessing when it settles: a ResizeObserver on the map container, the scrollport that owns the page scroll, and body, plus a resize listener. Every event that can flip the answer now re-asks it. verify-shell-scroll already measured this. It computed movedByMap for the gesture over the map, then asserted only on movedTotal, which sums in a second gesture below the map -- so it reported PASS at 2611px while the real gesture moved 300px. It now asserts on the number it was built to measure. Worth remembering as its own failure family: a gate that measures the right thing and grades a different variable, which fails honestly but never on the condition it exists for.
178 lines
10 KiB
JavaScript
178 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* verify-shell-scroll.mjs — THE GATE FOR BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID.
|
|
*
|
|
* ★ WHAT IT ASSERTS, and why each assertion exists rather than the obvious one.
|
|
*
|
|
* The obvious assertion — `scrollHeight > innerHeight` — is a FALSE PASS and was used twice to
|
|
* close this bug while the Driver could still not scroll. In the cap-5 shell the document measures
|
|
* scrollHeight 900 === clientHeight 900 while the composite element measures 3380/769: the
|
|
* overflow is real, but it belongs to an element with `overflow:visible` that no scroller owns.
|
|
* Geometry said "tall"; the user's wheel moved nothing.
|
|
*
|
|
* So this gate asserts the ONLY thing the Driver actually cares about: that a REAL WHEEL GESTURE
|
|
* over the map area moves the grid up the screen and brings it into view. It measures the grid's
|
|
* position before and after, in the SHELL, which is the surface he uses — not the standalone leaf,
|
|
* which passed all along and is why the two previous fixes looked green.
|
|
*
|
|
* ★ NEGATIVE-TESTED. Reverting the fix (restoring `.ps-pane`/`.vtl-host` to `position:absolute;
|
|
* inset:0` with no overflow, and `.vtl-content` to `contain:layout paint`) makes this exit 1 with
|
|
* "grid did not move". A gate nobody has broken is a gate nobody knows works.
|
|
*
|
|
* Usage: node internal/verify-shell-scroll.mjs [baseUrl]
|
|
* (defaults to http://localhost:8899; serve ./public)
|
|
*/
|
|
import { chromium } from 'playwright'
|
|
|
|
// 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,
|
|
// which meant the gate failed with ERR_CONNECTION_REFUSED for anyone else and looked like a broken
|
|
// gate rather than a missing server. Prefer BASE_URL (env) over a positional argument, because
|
|
// `bun run <script> <arg>` cannot forward arguments past an inline `#` comment — see
|
|
// check:script-flags. The positional form is kept as a fallback for direct `node` invocation.
|
|
const BASE = process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173'
|
|
const SHELL = '/doxa-maps/plugin-studio/plugin-studio/'
|
|
const LEAF = '/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/'
|
|
|
|
// Shadow DOM DOES NOT PIERCE. A single-level querySelector lies about every composite, so every
|
|
// lookup here is a recursive walk with a seen-Set, re-injected after each interaction (a page
|
|
// navigation or re-render drops anything we defined earlier).
|
|
const WALK = `
|
|
function __walk(sel) {
|
|
const seen = new Set(); const out = [];
|
|
const visit = (root) => {
|
|
if (!root || seen.has(root)) return; seen.add(root);
|
|
let els = []; try { els = Array.from(root.querySelectorAll('*')) } catch (e) { return }
|
|
for (const el of els) {
|
|
try { if (el.matches && el.matches(sel)) out.push(el) } catch (e) {}
|
|
if (el.shadowRoot) visit(el.shadowRoot);
|
|
}
|
|
};
|
|
visit(document); return out;
|
|
}
|
|
// ★ ANCHOR ON .pgg-root, NOT the <people-groups-grid> TAG. In the cap-4 composite the grid is
|
|
// IMPORTED as a Vue child component and compiled into one bundle — so there is no custom-element
|
|
// tag to find, and a tag-based lookup reports "not mounted" for a grid that is right there. That
|
|
// wrong answer cost a run of this very gate. .pgg-root is the grid's own root div and exists in
|
|
// both shapes (standalone element and baked-in child).
|
|
function __gridTop() {
|
|
const g = __walk('.pgg-root')[0];
|
|
if (!g) return null;
|
|
const r = g.getBoundingClientRect();
|
|
return { top: Math.round(r.top), h: Math.round(r.height) };
|
|
}
|
|
`
|
|
|
|
const fail = (m) => { console.error('SHELL-SCROLL: FAIL — ' + m); process.exit(1) }
|
|
|
|
async function wheel(page, x, y, dy, times = 10) {
|
|
await page.mouse.move(x, y)
|
|
for (let i = 0; i < times; i++) { await page.mouse.wheel(0, dy); await page.waitForTimeout(80) }
|
|
await page.waitForTimeout(500)
|
|
}
|
|
|
|
const run = async () => {
|
|
const browser = await chromium.launch({
|
|
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--use-angle=swiftshader']
|
|
})
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
|
|
|
await page.goto(BASE + SHELL, { waitUntil: 'load' })
|
|
await page.waitForTimeout(6000)
|
|
|
|
// HEAVY TABS LAZY-MOUNT ON ACTIVATION. Before this click the composite does not exist and every
|
|
// query returns 0 — which reads as "no bug" if you stop here. This is the step both previous
|
|
// investigations skipped.
|
|
const tab = await page.evaluate(`(() => { ${WALK}
|
|
for (const t of __walk('.vtl-tab')) {
|
|
if ((t.textContent || '').toLowerCase().includes('dashboard-plus-grid')) { t.click(); return t.textContent.trim() }
|
|
}
|
|
return null
|
|
})()`)
|
|
if (!tab) { await browser.close(); fail('could not find the dashboard-plus-grid tab in the shell rail') }
|
|
await page.waitForTimeout(9000)
|
|
|
|
// 1. The grid must be MOUNTED. If it is not rendered at all, "cannot scroll to it" has a
|
|
// completely different cause and this gate must say so rather than blaming the scroll.
|
|
const before = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
|
|
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, 300)
|
|
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, 300)
|
|
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 onScreen = afterLow.top < 900
|
|
|
|
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)
|
|
|
|
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.
|
|
if (movedByMap < 200) {
|
|
fail('the MAP AREA ate the wheel — wheeling over the map moved the grid only ' + movedByMap +
|
|
'px, while wheeling below it moved ' + movedTotal + 'px. The grid is technically reachable but ' +
|
|
'NOT by the gesture the Driver makes. Usual cause: Mapbox scrollZoom left ENABLED because the ' +
|
|
'scrollport-ancestor check ran before the lazy-mounted tab reached its final height.')
|
|
}
|
|
|
|
console.log('SHELL-SCROLL: PASS — the grid is reachable by wheel in the cap-5 shell.')
|
|
|
|
// ── 5. THE OTHER SIDE OF THE FIX: a MAP tab must NOT gain a scrollbar. ─────────────────────
|
|
// Making the pane scrollable is only correct if it stays a NO-OP for the ~19 fixed-viewport map
|
|
// tabs. It did not, at first: a hosted map ships min-height:100dvh (resolved against the 900px
|
|
// VIEWPORT) beside height:100% (resolved against the 769px PANE), so the floor made it 131px
|
|
// taller than its container and the new overflow:auto faithfully grew a scrollbar on it. That
|
|
// regression was invisible to every assertion above — the grid test passed while a map tab broke —
|
|
// so it is asserted here, in the same gate, rather than left to be re-found by hand.
|
|
const page2 = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
|
await page2.goto(BASE + SHELL, { waitUntil: 'load' })
|
|
await page2.waitForTimeout(6000)
|
|
const mapTab = await page2.evaluate(`(() => { ${WALK}
|
|
for (const t of __walk('.vtl-tab')) {
|
|
if ((t.textContent || '').toLowerCase().includes('doxa-research-map')) { t.click(); return t.textContent.trim() }
|
|
}
|
|
return null
|
|
})()`)
|
|
if (mapTab) {
|
|
await page2.waitForTimeout(9000)
|
|
const panes = await page2.evaluate(`(() => { ${WALK}
|
|
return __walk('.ps-pane, .vtl-host').filter(e => e.getBoundingClientRect().height > 0)
|
|
.map(e => ({ sh: e.scrollHeight, ch: e.clientHeight, over: e.scrollHeight - e.clientHeight }))
|
|
})()`)
|
|
const leaking = panes.filter(p => p.over > 4)
|
|
console.log(' map tab "' + mapTab + '" pane overflow: ' + JSON.stringify(panes))
|
|
await browser.close()
|
|
if (leaking.length) fail('a MAP tab grew a scrollbar (' + leaking[0].over + 'px overflow: ' + leaking[0].sh + '/' + leaking[0].ch + '). The pane scroll must be a NO-OP for fixed-viewport elements — check the dvh floor neutraliser.')
|
|
console.log('MAP-TAB-NO-SCROLLBAR: PASS — fixed-viewport map tabs gained no scrollbar.')
|
|
} else {
|
|
await browser.close()
|
|
console.log('MAP-TAB-NO-SCROLLBAR: SKIPPED — no doxa-research-map tab in this shell.')
|
|
}
|
|
}
|
|
run().catch(e => { console.error('SHELL-SCROLL: FAIL — ' + e.message); process.exit(1) })
|