About thirty browser gates could pass while describing another worktree's build.
This machine runs several checkouts and a default port belongs to whichever
server started first — measured 2026-09-24, :4173 was another worktree's, and an
hour of probing reported confidently on code nobody here had touched. check:sri
cannot see it: a stale tree and its stale leaf agree perfectly.
Twenty gates now call requireThisTree() right after they resolve their target and
exit 75 on a mismatch — EX_TEMPFAIL, the code this repo already uses for "not
measured", which the wave runner reports as ⊘ rather than ✗. Exiting 1 would turn
a mis-pointed probe into a red product gate, which is the failure this whole
family of checks exists to prevent.
★ NARROWER THAN THE PROBLEM STATEMENT, ON PURPOSE. Three classes do NOT get it,
because there it would be wrong rather than missing:
self-serving gates start their own server on an OS-assigned port
file:// gates read this checkout directly — no server to be wrong about
remote targets verify-map-paint defaults to the LIVE site, and "does
production match what we built" is a deploy question asked
deliberately. MEASURED mid-rollout: it reported NOT
MEASURED against the live host, which was the guard being
wrong, not the gate. It now enforces on localhost only.
TWO THINGS THE ROLLOUT TAUGHT, both caught by running rather than reading:
· node --check passes a file that will die on ReferenceError. Adding a ROOT
constant to sixteen gates that do not import resolve/dirname/fileURLToPath
parsed perfectly and crashed on first run — the same shape that broke
check-locale-reachable twice. The imports are added under aliased names so
they cannot collide with a gate's own.
· Five gates take their URL from argv with no default and are left for a
per-file pass rather than patched blind.
CONTROLS: against another worktree's server a guarded gate prints NOT MEASURED
naming the mismatch and exits 75; against this checkout's server it runs and
reports normally; against the live site it proceeds.
164 lines
9.2 KiB
JavaScript
164 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-fixed-anchoring.mjs — a `position:fixed` popover must stay anchored to its trigger IN EVERY
|
|
* HOST, not just the one it was developed in.
|
|
*
|
|
* THE DEFECT THIS CATCHES. FilterDropdown positions its menu with position:fixed and viewport
|
|
* coordinates. That is correct only while nothing establishes a containing block for fixed
|
|
* descendants. VerticalTabsLayout — the cap-5 shell every plugin is hosted in as a tab — sets
|
|
* `contain: layout paint` on .vtl-content, inside `.vtl-main { left: 256px }`. So the identical
|
|
* component read:
|
|
* STANDALONE research-dashboard ....... dLeft=0 dTop=4 (six of six)
|
|
* INSIDE plugin-studio dashboard tab .. dLeft=256 dTop=111 (every one)
|
|
* The Driver reported the menu appearing "in a random spot further down to the right" for days. It
|
|
* was never random. 256 is the rail width, 111 the header height.
|
|
*
|
|
* ★ WHY NO EXISTING GATE SAW IT, AND WHY THAT IS THE INTERESTING PART. A probe DID exist and it DID
|
|
* pass — six triggers, four viewport widths, every one accurate. It measured the standalone
|
|
* composite. Widening it across MORE viewport widths would never have found this, because the
|
|
* variable that mattered was not the width but the HOST. A measurement can be precise, repeatable,
|
|
* and about the wrong thing. So this gate's contract is deliberately "the same component in two
|
|
* different hosts", and the comparison between them — not either number alone — is the evidence.
|
|
*
|
|
* WHAT IT ASSERTS. For each (host, trigger) pair: opening the menu puts it within 8px horizontally
|
|
* of its trigger's left or right edge, and within [-8, 40]px vertically of the trigger's bottom.
|
|
* When a pair fails it names the capturing ancestor from a live computed-style walk, so the report
|
|
* says `div.vtl-content [contain:layout paint]` rather than "misplaced".
|
|
*
|
|
* PRECONDITIONS: a built tree and `bun run serve` on 4173. No Mapbox token needed — this measures
|
|
* DOM geometry, and the dropdowns render with or without a basemap. (This VPS has no WebGL, so any
|
|
* gate that needed paint would be unrunnable here anyway.)
|
|
*
|
|
* NEGATIVE-TESTED: reverting the fixedOrigin() subtraction in FilterDropdown.vue makes the
|
|
* plugin-studio host fail with dLeft=256, while standalone continues to pass — i.e. the gate fails
|
|
* exactly where the old probe passed, which is the property that makes it worth having.
|
|
*/
|
|
import { chromium } from 'playwright'
|
|
import { requireThisTree } from './_serves-this-tree.mjs'
|
|
import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path'
|
|
import { fileURLToPath as _fileURLToPath } from 'node:url'
|
|
|
|
const BASE = process.env.BASE || 'http://127.0.0.1:4173/doxa-maps'
|
|
const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..')
|
|
|
|
// ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port
|
|
// belongs to whichever server started first — measured 2026-09-24 on :4173, where browser gates
|
|
// reported confidently about a build nobody here had touched. 75 (EX_TEMPFAIL), not 1: the wrong
|
|
// tree is a precondition, not a product defect, and the wave runner reports 75 as ⊘ rather than ✗.
|
|
// A file:// target passes straight through — there is no server there to be wrong about.
|
|
await requireThisTree(BASE, ROOT, 'FIXED-ANCHORING')
|
|
const TK = process.env.MAPBOX_TOKEN ? `?tk=${process.env.MAPBOX_TOKEN}` : ''
|
|
|
|
const DEEP = `(sel)=>{const out=[],seen=new Set();const w=(r)=>{if(!r||seen.has(r))return;seen.add(r);try{out.push(...r.querySelectorAll(sel))}catch(e){};for(const el of r.querySelectorAll('*'))if(el.shadowRoot)w(el.shadowRoot)};w(document);return out}`
|
|
|
|
const MEASURE = (i) => `(()=>{
|
|
const t=(${DEEP})('.filter-dropdown__trigger')[${i}];
|
|
if(!t) return null;
|
|
const ms=(${DEEP})('.filter-dropdown__menu');
|
|
const m=ms[ms.length-1];
|
|
const tr=t.getBoundingClientRect();
|
|
const name=(t.textContent||'').trim().replace(/\\s+/g,' ').slice(0,14);
|
|
if(!m) return {name,open:false};
|
|
const mr=m.getBoundingClientRect();
|
|
const cap=[];let el=t;
|
|
while(el){ if(el.nodeType===1){ const cs=getComputedStyle(el); const why=[];
|
|
if(cs.transform&&cs.transform!=='none')why.push('transform');
|
|
if(cs.filter&&cs.filter!=='none')why.push('filter');
|
|
if(cs.perspective&&cs.perspective!=='none')why.push('perspective');
|
|
if(cs.backdropFilter&&cs.backdropFilter!=='none')why.push('backdrop-filter');
|
|
if(/paint|layout|strict|content/.test(cs.contain||''))why.push('contain:'+cs.contain);
|
|
if(/transform|filter|perspective/.test(cs.willChange||''))why.push('will-change:'+cs.willChange);
|
|
if(why.length)cap.push((el.tagName||'').toLowerCase()+(typeof el.className==='string'&&el.className?'.'+el.className.trim().split(/\\s+/)[0]:'')+' ['+why.join(',')+']'); }
|
|
el=el.parentElement||(el.parentNode&&el.parentNode.host)||null; }
|
|
return {name,open:true,
|
|
dLeftEdge:Math.round(mr.left-tr.left),
|
|
dRightEdge:Math.round(mr.right-tr.right),
|
|
dTop:Math.round(mr.top-tr.bottom),cap};
|
|
})()`
|
|
|
|
async function hostRun(browser, label, url, activate) {
|
|
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } })
|
|
const rows = []
|
|
try {
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 })
|
|
await page.waitForTimeout(6000)
|
|
if (activate) {
|
|
// Heavy tabs are lazy-mounted on activation, so nothing exists until a tab is clicked. A flat
|
|
// query before this point returns 0 triggers and reads as "no dropdowns here" — which is how
|
|
// a host can silently drop out of coverage while the gate still reports success.
|
|
const idx = await page.evaluate(`(${DEEP})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)').findIndex(t=>${activate}.test(t.textContent||''))`)
|
|
if (idx < 0) return { label, error: 'no tab matched ' + activate }
|
|
await page.evaluate(`(${DEEP})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[${idx}].click()`)
|
|
await page.waitForTimeout(9000)
|
|
}
|
|
const n = await page.evaluate(`(${DEEP})('.filter-dropdown__trigger').length`)
|
|
if (!n) return { label, error: 'no .filter-dropdown__trigger in the composed tree' }
|
|
for (let i = 0; i < n; i++) {
|
|
await page.evaluate(`(${DEEP})('.filter-dropdown__trigger')[${i}].click()`)
|
|
await page.waitForTimeout(320)
|
|
const r = await page.evaluate(MEASURE(i))
|
|
if (r) rows.push(r)
|
|
await page.evaluate(`(${DEEP})('.filter-dropdown__trigger')[${i}].click()`)
|
|
await page.waitForTimeout(110)
|
|
}
|
|
} catch (e) {
|
|
return { label, error: e.message.slice(0, 140) }
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
return { label, rows }
|
|
}
|
|
|
|
const browser = await chromium.launch()
|
|
// ★ index.html IS EXPLICIT. These were bare-directory URLs. The old local server resolved a
|
|
// directory to its index; the live static layer returns 404, so this gate was only ever green
|
|
// because the local server was more forgiving than production. Under serve-strict.mjs it reported
|
|
// UNMEASURED for BOTH hosts the first time it ran — correctly refusing to claim a pass it could not
|
|
// measure, which is the difference between this gate and one that would have found 0 dropdowns and
|
|
// called that 0 problems.
|
|
const hosts = [
|
|
await hostRun(browser, 'STANDALONE research-dashboard', `${BASE}/research-dashboard/research-dashboard/index.html${TK}`, null),
|
|
await hostRun(browser, 'INSIDE cap-5 plugin-studio', `${BASE}/plugin-studio/plugin-studio/index.html${TK}`, '/dashboard-plus-grid/i'),
|
|
]
|
|
await browser.close()
|
|
|
|
let failed = 0
|
|
let measured = 0
|
|
console.log('FIXED-ANCHORING: measuring the same dropdown in two different hosts\n')
|
|
for (const h of hosts) {
|
|
console.log(`--- ${h.label} ---`)
|
|
if (h.error) {
|
|
console.log(' UNMEASURED: ' + h.error)
|
|
failed++ // an unmeasured host is a failure, not a pass; silence is how coverage rots
|
|
continue
|
|
}
|
|
for (const r of h.rows) {
|
|
if (!r.open) { console.log(` FAIL [${r.name}] menu did not open`); failed++; continue }
|
|
measured++
|
|
// Anchored means sharing an edge: left-aligned OR right-aligned (the standard popover flip).
|
|
const edgeOk = Math.abs(r.dLeftEdge) <= 8 || Math.abs(r.dRightEdge) <= 8
|
|
const vertOk = r.dTop >= -8 && r.dTop <= 40
|
|
if (edgeOk && vertOk) {
|
|
console.log(` ok [${r.name}] dLeft=${r.dLeftEdge} dTop=${r.dTop}`)
|
|
} else {
|
|
failed++
|
|
console.log(` FAIL [${r.name}] dLeft=${r.dLeftEdge} dRight=${r.dRightEdge} dTop=${r.dTop}`)
|
|
if (r.cap.length) {
|
|
console.log(' a containing block for fixed descendants exists on this chain:')
|
|
r.cap.forEach((c) => console.log(' - ' + c))
|
|
console.log(' The menu is position:fixed, so its coordinates resolve against that')
|
|
console.log(' ancestor, not the viewport. Measure the real origin instead of assuming')
|
|
console.log(' the viewport (see fixedOrigin() in FilterDropdown.vue).')
|
|
}
|
|
}
|
|
}
|
|
console.log('')
|
|
}
|
|
|
|
if (failed) {
|
|
console.error(`FIXED-ANCHORING: FAIL — ${failed} problem(s) across ${hosts.length} host(s).`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`FIXED-ANCHORING: PASS — ${measured} trigger(s) anchored across ${hosts.length} hosts.`)
|
|
console.log(' The two-host comparison IS the assertion. A single-host pass proves nothing about a')
|
|
console.log(' popover, because the defect lives in the HOST chain, not in the component.')
|