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.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
#!/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)')
|
||||
Reference in New Issue
Block a user