364 lines
20 KiB
JavaScript
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)
|