Files
ten31-database/start9/0.4/render-smoke.mjs
T
Keysat 40a0270a99 Vendor + SRI-pin front-end libs; add render smoke gate (v0.1.0:82)
React/ReactDOM/Babel were loaded from the unpkg CDN at runtime — react@18
and react-dom@18 weren't even exact-pinned, and none had SRI. A CDN swap (or
react auto-resolving a new 18.x) could blank the whole app with no change on
our side: exactly the v78/v79 blank-screen class. It also made the self-hosted
box depend on outbound internet to render.

Vendor the three libs into frontend/assets/vendor/ (React 18.3.1, ReactDOM
18.3.1, @babel/standalone 7.29.7) and load them same-origin with sha384
integrity attributes. They now ship inside the s9pk (Dockerfile already COPYs
frontend/; server.py serves /assets/* with the path-containment check), so a
CDN can never swap prod deps again and no outbound fetch is needed at runtime.

Add start9/0.4/render-smoke.mjs: a jsdom render smoke check that (1) runs the
shipped Babel over the app's inline JSX and asserts a classic, non-module,
parseable script (the v79 ESM-import regression), and (2) mounts the app in
jsdom and asserts the login UI renders (the v78 blank-screen class). Wired into
the default `make` goal so every package build is gated on the frontend
actually rendering — closing the "verified live via curl only" gap. jsdom is a
build-time devDependency, not shipped in the image.
2026-06-16 16:10:26 -05:00

113 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
// Frontend render smoke check (added v0.1.0:82). Dev/build-time only — NOT shipped in
// the image. Catches the v78/v79 blank-screen class that curl/health checks miss:
//
// Stage 1 (deterministic, no DOM): run the *shipped* Babel over the app's inline JSX
// and assert the output is a CLASSIC (non-module) script. Babel 8's preset-react
// defaults to the automatic JSX runtime, which emits `import {jsx} from
// "react/jsx-runtime"` — illegal in this inline <script> and blanks the whole app
// (the v79 incident). This stage fails loudly if a re-vendor reintroduces that.
// Stage 2 (jsdom mount): load index.html with the vendored React/ReactDOM/Babel served
// from disk, let Babel transform + execute the app exactly as a browser would, and
// assert React actually mounts the login UI into #root (the v78 blank-screen class).
//
// Run standalone: node render-smoke.mjs (from start9/0.4/; needs the jsdom devDep)
// Wired into the default `make` goal so every package build is gated on it.
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join } from 'node:path'
import vm from 'node:vm'
import { JSDOM, ResourceLoader, VirtualConsole } from 'jsdom'
const here = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(here, '..', '..') // start9/0.4 -> repo root
const frontendDir = join(repoRoot, 'frontend')
const vendorDir = join(frontendDir, 'assets', 'vendor')
const fail = (m) => { console.error(` FAIL ${m}`); process.exitCode = 1 }
const pass = (m) => console.log(` PASS ${m}`)
const html = readFileSync(join(frontendDir, 'index.html'), 'utf8')
const babelFile = (html.match(/src="\/assets\/vendor\/(babel-standalone-[^"]+)"/) || [])[1]
const inline = (html.match(/<script type="text\/babel">([\s\S]*?)<\/script>/) || [])[1]
if (!babelFile) fail('index.html references a vendored babel-standalone file')
if (!inline) fail('index.html has an inline <script type="text/babel"> block')
// ── Stage 1: transform with the shipped Babel; require a classic, parseable script ──
if (babelFile && inline) {
const sandbox = { console }
sandbox.window = sandbox
sandbox.self = sandbox
vm.runInNewContext(readFileSync(join(vendorDir, babelFile), 'utf8'), sandbox)
const Babel = sandbox.Babel || sandbox.window.Babel
if (!Babel) {
fail('vendored Babel did not expose a global Babel')
} else {
// No data-presets on the tag -> the app relies on the classic 'react' preset.
const out = Babel.transform(inline, { presets: ['react'] }).code
pass('inline JSX transforms under the shipped Babel')
if (/^\s*import[\s{('"*]/m.test(out) || /^\s*export[\s{ ]/m.test(out)) {
fail('transformed app has a top-level ESM import/export (the v79 blank-screen bug)')
} else {
pass('transformed app is a classic (non-module) script — no ESM import/export')
}
try { new vm.Script(out); pass('transformed app parses as an executable classic script') }
catch (e) { fail(`transformed app does not parse: ${e.message}`) }
}
}
// ── Stage 2: mount in jsdom and assert the login UI rendered ──
{
class DiskLoader extends ResourceLoader {
fetch(url) {
const m = url.match(/\/assets\/vendor\/([^?#]+)$/)
if (m) { try { return Promise.resolve(readFileSync(join(vendorDir, m[1]))) } catch { return null } }
return null // ignore fonts, favicon, API, etc.
}
}
const dom = new JSDOM(html, {
runScripts: 'dangerously',
resources: new DiskLoader(),
url: 'http://localhost:8080/',
pretendToBeVisual: true,
virtualConsole: new VirtualConsole(), // swallow app console noise; we assert on the DOM
})
const { window } = dom
// Stub the browser APIs jsdom lacks that an app may touch on first render, and
// neutralize the network so the initial data fetch can't crash the mount.
const noopMql = () => ({ matches: false, media: '', onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return false } })
window.matchMedia = window.matchMedia || noopMql
window.scrollTo = window.scrollTo || (() => {})
class NoopObserver { observe() {} unobserve() {} disconnect() {} takeRecords() { return [] } }
window.ResizeObserver = window.ResizeObserver || NoopObserver
window.IntersectionObserver = window.IntersectionObserver || NoopObserver
window.fetch = () => new Promise(() => {}) // never resolves -> app stays on its initial render
const deadlineMs = 10000
const startedAt = Date.now()
await new Promise((done) => {
const tick = () => {
const root = window.document.getElementById('root')
if ((root && root.children.length > 0) || Date.now() - startedAt > deadlineMs) return done()
setTimeout(tick, 50)
}
if (window.document.readyState === 'complete') tick()
else window.addEventListener('load', tick)
})
const root = window.document.getElementById('root')
if (root && root.children.length > 0) pass('React mounted content into #root')
else fail('React did not mount any content into #root (blank screen)')
const loginEl = window.document.querySelector('.login-card, .login-title, .login-form')
const txt = window.document.body.textContent || ''
if (loginEl || /sign in|log in|password/i.test(txt)) pass('login UI rendered')
else fail('login UI did not render (no .login-* element or login text found)')
dom.window.close()
}
if (process.exitCode) console.error('\nRENDER SMOKE FAILED')
else console.log('\nALL PASS (render smoke)')