ontoref-outreach/scripts/decks/gates/served.mjs
2026-07-17 01:00:32 +01:00

364 lines
20 KiB
JavaScript

// Gates over the PAGE AS SERVED. Verifying the file would prove nothing: the site's
// content pipeline sits between the generator and the reader.
//
// E SLIDES every slide RENDERS (a DOM-node count passes while overflow:hidden
// silently swallows 14 of 16), and stacked mode reaches them all
// F FONTS zero third-party requests, zero 4xx, the self-hosted faces load
// H NAV Space advances ONLY with the deck focused (unfocused, the page must
// still scroll); the index is derived from the H1s and jumps correctly
// I ZOOM fills the viewport as far as 16:9 allows — not "N times bigger", a
// threshold I could quietly lower until the test went green
// J SCALE the slide's CONTENT fills its box: never clipped, never overflowing.
// This is the gate whose absence hid a real bug — every other check
// measured the BOX, and the box was always right while the content
// stayed 980px inside a 328px phone. Measure the thing you claim.
// M ANCHORS every `gloss_href` the term registry points AT THIS PAGE lands on the
// slide it names — measured by LOADING the URL and reading which slide
// came up, never by checking that an id exists in the DOM.
//
// Usage: node gates/served.mjs <url>
import { chromium } from '../lib/paths.mjs'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'
import { OUTREACH } from '../lib/paths.mjs'
const URL_ = process.argv[2]
if (!URL_) { console.error('uso: node gates/served.mjs <url-de-la-pagina>'); process.exit(2) }
const W = 980, H = 552
const browser = await chromium.launch()
const out = {}
let ok = true
const fail = (k, msg) => { out[k] = 'FAIL — ' + msg; ok = false }
// ── F: the DECK must add no third-party origin, and break nothing ─────────────────────
//
// Not "the page makes zero external requests": measured, the SITE itself pulls IBM Plex
// from Google Fonts on every page. That is the site's business, pre-dating this work, and
// a gate that failed on it would be blaming the deck for someone else's request. What the
// deck owes is that it adds NONE — so diff against a control page of the same site.
{
const origins = async (url) => {
const p = await browser.newPage({ viewport: { width: 1280, height: 900 } })
const hosts = new Set(), broken = []
p.on('request', r => {
const h = new global.URL(r.url()).host
if (h && !/^(localhost|127\.0\.0\.1)/.test(h)) hosts.add(h)
})
p.on('response', r => { if (r.status() >= 400) broken.push(r.status() + ' ' + r.url()) })
await p.goto(url, { waitUntil: 'networkidle' })
await p.evaluate(() => document.fonts.ready)
await p.waitForTimeout(800)
const fonts = await p.evaluate(() => ({
nunito: document.fonts.check('400 16px "Nunito Sans"'),
victor: document.fonts.check('400 16px "Victor Mono"'),
}))
await p.close()
return { hosts, broken, fonts }
}
const control = new global.URL(URL_)
control.pathname = '/'
const base = await origins(control.href)
const deck = await origins(URL_)
// Same discipline for 4xx: a broken resource the SITE already ships is not the deck's
// failure. Measured: /public/r/glosario-en.json 404s on every English page of the site —
// the English glossary simply does not exist. Report it, loudly; do not fail the deck
// for it, and do not bury it either.
const isDeckOwned = (line) => line.includes('/images/decks/')
const deckBroken = deck.broken.filter(isDeckOwned)
const siteBroken = deck.broken.filter(l => !isDeckOwned(l))
const added = [...deck.hosts].filter(h => !base.hosts.has(h))
if (added.length) fail('F_fuentes', `el deck añade ${added.length} origen(es) externo(s): ${added.join(', ')}`)
else if (deckBroken.length) fail('F_fuentes', `${deckBroken.length} recursos del deck rotos: ${deckBroken.slice(0, 3).join(', ')}`)
else if (!deck.fonts.nunito || !deck.fonts.victor) fail('F_fuentes', 'las fuentes auto-hospedadas del deck no cargan')
else out.F_fuentes = 'PASS — el deck no añade ningún origen externo, 0 recursos propios rotos, fuentes auto-hospedadas cargadas'
if (base.hosts.size) {
out['_terceros_del_SITIO'] = `${[...base.hosts].join(', ')} — presentes en toda la web, ajenos al deck`
}
if (siteBroken.length) {
out['_ROTO_EN_EL_SITIO'] = siteBroken.join(' · ') + ' ← no es del deck, pero está roto'
}
}
// ── E: slides rendered + Ctrl+F reach per mode ────────────────────────────────────────
{
const p = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(900)
const probe = (mode) => p.evaluate((m) => {
const view = document.querySelector('.deck-view')
if (m) view.querySelector(`[data-set-mode="${m}"]`).click()
const d = document.querySelector('.deck-read')
const box = d.getBoundingClientRect()
const slides = [...d.querySelectorAll('.deck-slide')]
const laid = slides.filter(s => { const r = s.getBoundingClientRect(); return r.height > 100 && r.width > 100 })
const reach = laid.filter(s => s.getBoundingClientRect().bottom - box.top <= box.height + 1)
return { inDom: slides.length, reachable: reach.length, words: d.innerText.split(/\s+/).filter(Boolean).length }
}, mode)
const served = await probe(null)
const read = await probe('read')
const slider = await probe('slider')
if (!read.inDom) fail('E_slides', 'no hay ninguna slide en la página')
else if (read.reachable !== read.inDom) fail('E_slides', `solo ${read.reachable}/${read.inDom} alcanzables en modo lectura`)
else if (read.words < 500) fail('E_slides', `solo ${read.words} palabras — ¿sigue siendo el carrusel de imágenes?`)
else out.E_slides = `PASS — lectura: ${read.reachable}/${read.inDom} slides, ${read.words} palabras`
// Not a verdict, a fact kept in plain sight: this is the price of the slider default.
out['_alcance_de_Ctrl+F'] = `tal como se sirve: ${served.words} palabras · Leer todo: ${read.words} · Diapositivas: ${slider.words}`
out['_nota'] = 'Las slides están SIEMPRE en el DOM (indexables). El modo solo cambia lo que el LECTOR alcanza.'
await p.close()
}
// ── H: keyboard + derived index ───────────────────────────────────────────────────────
{
const p = await browser.newPage({ viewport: { width: 1280, height: 800 } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(700)
const count = () => p.evaluate(() => document.querySelector('.deck-count').textContent.trim())
const toc = await p.evaluate(() => [...document.querySelectorAll('.deck-toc-link')].map(l => l.textContent.replace(/\s+/g, ' ').trim()))
const h1s = await p.evaluate(() => [...document.querySelectorAll('.deck-slide')].map(s => s.querySelector('h1,h2,h3')?.textContent.trim() ?? ''))
const derived = toc.length === h1s.length && toc.every((t, k) => !h1s[k] || t.includes(h1s[k].slice(0, 16)))
await p.evaluate(() => { document.querySelector('[data-set-mode="slider"]').click(); document.querySelector('.deck-view').focus() })
const c0 = await count()
await p.keyboard.press(' '); await p.waitForTimeout(150)
const c1 = await count()
await p.keyboard.down('Shift'); await p.keyboard.press(' '); await p.keyboard.up('Shift'); await p.waitForTimeout(150)
const c2 = await count()
// Space with the deck NOT focused must leave the page's own scrolling alone.
await p.evaluate(() => { document.querySelector('.deck-view').blur(); document.body.tabIndex = -1; document.body.focus(); window.scrollTo(0, 0) })
const y0 = await p.evaluate(() => window.scrollY)
await p.keyboard.press(' '); await p.waitForTimeout(400)
const y1 = await p.evaluate(() => window.scrollY)
const cBlur = await count()
// The index is a <nav> now, shown/hidden from the bar — no <details> to expand.
await p.evaluate(() => {
const v = document.getElementById('deck-view')
if (v.dataset.toc !== 'on') v.querySelector('.deck-toc-toggle').click()
v.querySelector('.deck-toc-link[data-goto="4"]').click()
})
await p.waitForTimeout(250)
const jumped = await count()
const adv = c1 !== c0, back = c2 === c0
const scrolls = y1 > y0 && cBlur === c2
const jumps = jumped.startsWith('5 ')
if (!adv) fail('H_navegacion', `la barra espaciadora no avanza (${c0}${c1})`)
else if (!back) fail('H_navegacion', `Shift+Espacio no retrocede (${c2})`)
else if (!scrolls) fail('H_navegacion', `sin foco, la barra espaciadora no scrollea la página (scrollY ${y0}${y1})`)
else if (!derived) fail('H_navegacion', 'el índice no coincide con los H1 de las slides')
else if (!jumps) fail('H_navegacion', `el índice no salta a la slide correcta (${jumped})`)
else out.H_navegacion = `PASS — espacio ${c0}${c1}, sin foco scrollea, índice de ${toc.length} derivado de los H1`
await p.close()
}
// ── I + J: nothing is clipped, and zoom fills the viewport ────────────────────────────
//
// J does NOT assert the card is 980x552. Slidev's frame is 552px tall and 12 of this deck's
// 16 slides hold more — up to 672px — so a rigid 16:9 box cut three quarters of the deck, and
// Slidev cuts them too: nobody had noticed, because until now the slides were PNGs of
// themselves. The card grows to its measured content. What J asserts is the property that
// actually matters and cannot be tuned away: NO CONTENT IS LOST — horizontally or vertically.
{
const VIEWPORTS = [
{ name: 'monitor 1920', width: 1920, height: 1080 },
{ name: 'portátil 1440', width: 1440, height: 900 },
{ name: 'tablet 820', width: 820, height: 1180 },
{ name: 'móvil 390', width: 390, height: 844 },
]
const clipped = [], zoom = []
for (const v of VIEWPORTS) {
const p = await browser.newPage({ viewport: { width: v.width, height: v.height } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(2200) // deja aterrizar las fuentes: re-miden
await p.evaluate(() => document.querySelector('[data-set-mode="read"]').click())
await p.waitForTimeout(600)
// Geometry alone is not enough: getBoundingClientRect reports a rect whether or not an
// ancestor is clipping the element. Slidev's .print-slide-container is height:552 +
// overflow:hidden, and it cut the content while every rect said it fitted. So check the
// clipping ancestor too, not just the numbers.
const clipper = await p.evaluate(() => {
const c = document.querySelector('.deck-slide .print-slide-container')
return getComputedStyle(c).overflow
})
if (clipper !== 'visible') { clipped.push({ v: v.name, cut: [{ slide: 0, overV: 0, overH: 0 }],
why: `.print-slide-container recorta (overflow: ${clipper})` }) }
const cut = await p.evaluate(() => {
return [...document.querySelectorAll('.deck-slide')].map((s, i) => {
const page = s.querySelector('.slidev-page')
const top = page.getBoundingClientRect().top
const left = page.getBoundingClientRect().left
const box = s.getBoundingClientRect()
let maxB = 0, maxR = 0
page.querySelectorAll('*').forEach(c => {
const r = c.getBoundingClientRect()
if (r.bottom - top > maxB) maxB = r.bottom - top
if (r.right - left > maxR) maxR = r.right - left
})
return { slide: i + 1, overV: Math.round(maxB - box.height), overH: Math.round(maxR - box.width) }
}).filter(r => r.overV > 2 || r.overH > 2)
})
if (cut.length) clipped.push({ v: v.name, cut })
await p.evaluate(() => document.querySelector('.deck-slide[data-active] .deck-zoom').click())
await p.waitForTimeout(400)
const z = await p.evaluate(() => {
const s = document.querySelector('.deck-slide[data-active]')
const r = s.getBoundingClientRect()
return { top: r.top, bottom: r.bottom, right: r.right, left: r.left, w: r.width }
})
const fits = z.top >= -1 && z.bottom <= v.height + 1 && z.left >= -1 && z.right <= v.width + 1
zoom.push({ v: v.name, fits, top: Math.round(z.top), w: Math.round(z.w) })
await p.close()
}
if (clipped.length) {
fail('J_nada_recortado', clipped.map(c => c.why
? `${c.v}: ${c.why}`
: `${c.v}: ${c.cut.length} slide(s) recortadas (${c.cut.slice(0,3).map(x => `#${x.slide} +${Math.max(x.overV,x.overH)}px`).join(', ')})`).join('; '))
} else out.J_nada_recortado = `PASS — ninguna slide pierde contenido en ${VIEWPORTS.length} viewports`
const badZoom = zoom.filter(z => !z.fits)
if (badZoom.length) {
fail('I_zoom', badZoom.map(z => `${z.v}: se sale del viewport (top=${z.top}px)`).join('; '))
} else out.I_zoom = `PASS — el zoom cabe entero en ${zoom.length} viewports (top ${zoom[0].top}px)`
}
// ── L: the sticky bar must carry the page's REAL background ───────────────────────────
// It sits over the deck as you scroll, so a transparent bar shows the slides through it and
// a wrong-coloured one is a band that belongs to no one. Measured: `background: Canvas`
// painted rgb(18,18,18) on a rgb(17,24,39) page — a black stripe across a navy site.
{
const p = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(1300)
const m = await p.evaluate(() => {
const bar = document.querySelector('.deck-bar')
const deck = document.getElementById('deck-view')
let e = deck.parentElement, page = null
while (e && !page) {
const bg = getComputedStyle(e).backgroundColor
if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') page = bg
e = e.parentElement
}
return { bar: getComputedStyle(bar).backgroundColor, page }
})
const transparent = m.bar === 'rgba(0, 0, 0, 0)' || m.bar === 'transparent'
if (transparent) fail('L_barra_pegajosa', 'la barra es transparente: las slides se ven a través al scrollear')
else if (m.bar !== m.page) fail('L_barra_pegajosa', `la barra (${m.bar}) no lleva el fondo de la página (${m.page})`)
else out.L_barra_pegajosa = `PASS — la barra lleva el fondo real de la página (${m.page})`
await p.close()
}
// ── K: the code-copy buttons Slidev shipped must actually copy ────────────────────────
// They survive the capture — 32 of them — but their handler was Vue and did not. And the
// button arrived 0x0: UnoCSS's preflight zeroes button padding and the icon has no
// intrinsic size, so its hit area was nothing. It was in the DOM and no human could click it.
{
const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'],
viewport: { width: 1440, height: 950 } })
const p = await ctx.newPage()
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(1200)
await p.evaluate(() => { const w = document.getElementById('cookie-consent-wrapper'); if (w) w.remove() })
await p.evaluate(() => document.querySelector('[data-set-mode="read"]').click())
await p.waitForTimeout(500)
const hit = await p.evaluate(() => {
const b = document.querySelector('.slidev-code-copy')
if (!b) return null
const r = b.getBoundingClientRect()
return { w: Math.round(r.width), h: Math.round(r.height) }
})
if (!hit) fail('K_copiar_codigo', 'no hay botones de copia en el DOM')
else if (hit.w < 16 || hit.h < 16) fail('K_copiar_codigo', `el botón mide ${hit.w}x${hit.h}: nadie puede pulsarlo`)
else {
await p.bringToFront()
const wrap = p.locator('.slidev-code-wrapper').first()
await wrap.scrollIntoViewIfNeeded(); await wrap.hover(); await p.waitForTimeout(200)
await p.locator('.slidev-code-copy').first().click()
await p.waitForTimeout(500)
const clip = await p.evaluate(() => navigator.clipboard.readText())
const lines = clip ? clip.split('\n').length : 0
if (lines < 2) fail('K_copiar_codigo', `el portapapeles trae ${lines} línea(s) — ¿se perdieron los saltos?`)
else out.K_copiar_codigo = `PASS — botón ${hit.w}x${hit.h}px, ${lines} líneas copiadas con saltos reales`
}
await ctx.close()
}
// ── M: un enlace con fragmento ATERRIZA EN SU SLIDE ───────────────────────────────────
//
// El glosario del sitio enlaza `NCL` a este deck. Hasta ahora el enlace no llevaba fragmento y
// dejaba al lector en la portada: le prometías la definición y le dabas una presentación entera
// por delante. Ahora lleva `#ncl`, y esta puerta existe porque un fragmento es la clase de
// enlace que se rompe SIN QUE NADA FALLE — borras la slide, o le cambias el ancla, y la página
// sigue respondiendo 200 y sigue enseñando la portada. Exactamente igual que antes. El fallo es
// invisible desde dentro: solo se ve comparando lo que el enlace PROMETE con dónde CAE.
//
// Por eso no se comprueba que el id esté en el DOM: se CARGA la URL que el lector va a pulsar y
// se mira qué slide quedó delante. Un id presente y un salto que no ocurre son indistinguibles
// para el lector, y solo una de las dos cosas es la que le importa.
{
const spine = join(OUTREACH, '..', '.ontoref')
const reg = JSON.parse(execFileSync('nickel',
['export', '--format', 'json', '--import-path', spine, join(spine, 'ontology/registry.ncl')],
{ encoding: 'utf8' }))
const here = new global.URL(URL_)
// Sólo los enlaces que apuntan A ESTA página: la puerta corre por página servida, y un
// gloss_href a /adr/050 no es asunto de este deck.
const linked = reg.rules
.filter(r => (r.gloss_href ?? '').includes('#'))
.map(r => ({ term: r.instead, href: r.gloss_href }))
.filter(r => new global.URL(r.href, here).pathname === here.pathname)
if (!linked.length) {
out.M_anclas = 'PASS — ningún término del registro enlaza con fragmento a esta página'
} else {
const p = await browser.newPage({ viewport: { width: 1280, height: 900 } })
const bad = []
for (const l of linked) {
const frag = decodeURIComponent(new global.URL(l.href, here).hash.slice(1))
await p.goto(new global.URL(l.href, here).href, { waitUntil: 'networkidle' })
await p.evaluate(() => document.fonts.ready)
await p.waitForTimeout(400)
const r = await p.evaluate((f) => {
const el = document.getElementById(f)
const target = el && el.closest('.deck-slide')
const active = document.querySelector('.deck-slide[data-active]')
const slides = [].slice.call(document.querySelectorAll('.deck-slide'))
const title = (s) => { const h = s && s.querySelector('h1,h2,h3'); return h ? h.textContent.trim() : '(sin título)' }
return {
exists: !!el,
want: target ? slides.indexOf(target) + 1 : -1,
got: active ? slides.indexOf(active) + 1 : -1,
gotTitle: title(active),
wantTitle: title(target),
}
}, frag)
if (!r.exists) {
bad.push(`«${l.term}» → ${l.href}: no hay ninguna slide con el ancla "${frag}" — el enlace deja al lector en la portada, a 200 y sin decir nada`)
} else if (r.got !== r.want) {
bad.push(`«${l.term}» → ${l.href}: el ancla vive en la slide ${r.want} (${r.wantTitle}) y la página abrió en la ${r.got} (${r.gotTitle})`)
}
}
await p.close()
if (bad.length) fail('M_anclas', bad.join(' · '))
else out.M_anclas = `PASS — ${linked.length} enlace(s) del glosario caen en su slide: ${linked.map(l => l.term + ' → ' + l.href.split('#')[1]).join(', ')}`
}
}
await browser.close()
console.log(JSON.stringify(out, null, 2))
process.exit(ok ? 0 : 1)