40a0270a99
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.
113 lines
5.5 KiB
JavaScript
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)')
|