check:composites-mount: a composite whose setup throws can no longer look green
08f89f55shipped Big Picture and Simple Research calling getRuntimeLocale, detectLocale and RTL_LOCALES with no import. The build was green and every structural gate passed, but setup threw a ReferenceError in every locale and both composites rendered nothing. This gate runs the part that failed, with no browser. Vite loads each capability-4/5 composite the way its bundle entry defines it (configureApp, plus ProfileLoader's provides where the entry mounts ProfileLoader), and @vue/runtime-core mounts it on a no-op renderer in en and ar. Any error from setup, a watcher's first run or a mounted hook fails it, naming the composite. Measured on75c4b54e: it fails exactly the two broken composites in both locales. On521f8b18all 20 mounts pass. Throws poisoned into a directly defined element and into a ProfileLoader profile both fail. It takes about 20 s. check:no-orphan-gates, check:script-flags and check:quoted-scripts pass.
This commit is contained in:
parent
521f8b18d6
commit
975597e276
2 changed files with 209 additions and 0 deletions
207
internal/check-composites-mount.mjs
Normal file
207
internal/check-composites-mount.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-composites-mount.mjs: every composite's setup() must run without throwing, in en and ar.
|
||||
*
|
||||
* WHY THIS EXISTS. 08f89f55 gave dashboard-plus-grid and research-map-plus-grid a text-direction
|
||||
* computed that called getRuntimeLocale(), detectLocale() and RTL_LOCALES without importing them.
|
||||
* Nothing checks free names at build time, so the build was green and the bundles on main carried
|
||||
* three undefined globals. watch(textDir, ...) evaluates the computed DURING setup, so setup threw
|
||||
* a ReferenceError in every locale and Vue rendered NOTHING for the whole composite (Big Picture
|
||||
* and Simple Research, both in the Research Gallery). Every structural gate passed. This gate runs
|
||||
* the thing that failed: the composite's own setup, and its mounted hooks.
|
||||
*
|
||||
* HOW. No browser (fleet policy: only rega launches one). Vite loads each composite .vue through
|
||||
* ssrLoadModule with the repo's alias and the same vue({ customElement: true }) plugin, and
|
||||
* @vue/runtime-core mounts it on a no-op renderer. The SSR compile gives the component an
|
||||
* ssrRender rather than a render, so the renderer draws nothing and no CHILD is set up: this
|
||||
* measures the composite's OWN setup and lifecycle, which is exactly the part a composite author
|
||||
* writes. Each composite is mounted twice, with profile-config {"lang":"en"} and {"lang":"ar"}
|
||||
* (ar is the RTL path, which is where the direction code lives).
|
||||
*
|
||||
* WHAT FAILS. Any error Vue reports while mounting: setup, a watcher's first run, a mounted hook.
|
||||
* An import that throws also fails, naming the file. A run that finds no composites FAILS
|
||||
* (vacuous guard). Composites are those whose *.meta.json type is nested-elements-mfe (capability
|
||||
* 4) or nested-mfe-spa (capability 5); the .vue is the meta's basename.
|
||||
*
|
||||
* NOT A SUBSTITUTE FOR A BROWSER. It proves setup runs; it cannot prove the children render.
|
||||
*
|
||||
* NEGATIVE-TESTED (2026-09-26): on main 75c4b54e (before the fix 57009b7e) it FAILS exactly
|
||||
* dashboard-plus-grid and research-map-plus-grid, in en and ar, with "ReferenceError: RTL_LOCALES is
|
||||
* not defined", and mounts the other 8 clean; on 521f8b18 all 20 mount clean. A throw poisoned into
|
||||
* big-picture's setup (a directly-defined element) and into regions-mfe's (a ProfileLoader profile)
|
||||
* each FAIL that composite in both locales. About 20 s.
|
||||
*/
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const LOCALES = ['en', 'ar']
|
||||
const TYPES = new Set(['nested-elements-mfe', 'nested-mfe-spa'])
|
||||
|
||||
// ── the page a composite expects to be dropped into, at its thinnest ──────────────────────────
|
||||
// Only what setup-time code touches. Anything a composite reaches for beyond this shows up as a
|
||||
// failure naming the missing global, which is the signal to add it here, not to skip the file.
|
||||
function installPage(lang) {
|
||||
const noop = () => {}
|
||||
const el = () => ({ style: {}, dataset: {}, setAttribute: noop, getAttribute: () => null, removeAttribute: noop,
|
||||
appendChild: (c) => c, removeChild: noop, addEventListener: noop, removeEventListener: noop,
|
||||
querySelector: () => null, querySelectorAll: () => [], getRootNode() { return this }, classList: { add: noop, remove: noop, toggle: noop, contains: () => false } })
|
||||
const html = el(); html.lang = lang
|
||||
const store = new Map()
|
||||
Object.assign(globalThis, {
|
||||
window: globalThis, self: globalThis,
|
||||
document: Object.assign(el(), { documentElement: html, head: el(), body: el(), createElement: el, createTextNode: el,
|
||||
currentScript: null, readyState: 'complete', cookie: '', getElementById: () => null }),
|
||||
location: new URL(`https://gate.invalid/${TREE_NAME}/x/x/index.html?lang=${lang}`),
|
||||
localStorage: { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
|
||||
sessionStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
customElements: { define: noop, get: () => undefined, whenDefined: () => new Promise(noop) },
|
||||
matchMedia: () => ({ matches: false, addEventListener: noop, removeEventListener: noop, addListener: noop, removeListener: noop }),
|
||||
addEventListener: noop, removeEventListener: noop, dispatchEvent: () => true,
|
||||
requestAnimationFrame: (f) => setTimeout(f, 0), cancelAnimationFrame: clearTimeout,
|
||||
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
|
||||
IntersectionObserver: class { observe() {} unobserve() {} disconnect() {} },
|
||||
MutationObserver: class { observe() {} disconnect() {} },
|
||||
HTMLElement: class {}, ShadowRoot: globalThis.ShadowRoot || class ShadowRoot {}, CustomEvent: globalThis.CustomEvent || class extends Event { constructor(t, o) { super(t, o); this.detail = o?.detail } },
|
||||
})
|
||||
if (!globalThis.navigator?.language) globalThis.navigator = { language: lang, languages: [lang], userAgent: 'gate' }
|
||||
}
|
||||
|
||||
function composites() {
|
||||
const out = []
|
||||
const base = join(ROOT, 'plugin-designs')
|
||||
for (const bundle of readdirSync(base)) {
|
||||
const dir = join(base, bundle)
|
||||
let files = []
|
||||
try { files = readdirSync(dir) } catch { continue }
|
||||
for (const f of files.filter((f) => f.endsWith('.meta.json'))) {
|
||||
let meta
|
||||
try { meta = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch { continue }
|
||||
if (!TYPES.has(meta.type)) continue
|
||||
const vue = join(dir, f.replace(/\.meta\.json$/, '.vue'))
|
||||
out.push({ bundle, profile: f.replace(/\.meta\.json$/, ''), vue, missing: !existsSync(vue) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const list = composites()
|
||||
if (!list.length) { console.error('COMPOSITES-MOUNT: FAIL, found no composites (vacuous run)'); process.exit(1) }
|
||||
|
||||
// ── mount each composite the way its bundle entry does ─────────────────────────────────────────
|
||||
// A composite's setup depends on what its entry's configureApp installs (pinia, the i18n instance,
|
||||
// provides) and, for a ProfileLoader bundle, on what ProfileLoader provides. Mounting the bare .vue
|
||||
// would fail a correct composite for a missing app.use and pass nothing real, so the gate RUNS the
|
||||
// entry: every defineCustomElement(...) in plugin-designs/<bundle>/index.js is swapped for a
|
||||
// recorder, and the recorded component + configureApp are what gets mounted.
|
||||
const ENTRY_RE = /plugin-designs\/[^/]+\/index\.js$/
|
||||
const entryCapture = {
|
||||
name: 'gate-entry-capture',
|
||||
enforce: 'post',
|
||||
resolveId: (id) => (id === 'virtual:gate-define' ? '\0gate-define' : null),
|
||||
load: (id) => (id === '\0gate-define'
|
||||
? 'export function __gateDefine(comp, opts) { (globalThis.__gateDefs ||= []).push({ comp, opts: opts || {} }); return class {} }'
|
||||
: null),
|
||||
transform(code, id) {
|
||||
if (!ENTRY_RE.test(id)) return null
|
||||
return 'import { __gateDefine } from "virtual:gate-define";\n' + code.replace(/\bdefineCustomElement\s*\(/g, '__gateDefine(')
|
||||
},
|
||||
}
|
||||
|
||||
const { createServer } = await import('vite')
|
||||
const { default: vuePlugin } = await import('@vitejs/plugin-vue')
|
||||
const server = await createServer({
|
||||
root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
|
||||
server: { middlewareMode: true, hmr: false, watch: null },
|
||||
plugins: [vuePlugin({ customElement: true }), entryCapture],
|
||||
resolve: { alias: { '@map': join(ROOT, 'library') } },
|
||||
define: { __DOXA_TREE_NAME__: JSON.stringify(TREE_NAME) },
|
||||
})
|
||||
|
||||
// A node the no-op renderer hands out. $el.getRootNode().host is how composites find their own
|
||||
// custom-element host (for dir, and for DOM events), so the root node answers with a shadow root.
|
||||
function makeNode() {
|
||||
const noop = () => {}
|
||||
const node = { style: {}, dataset: {}, setAttribute: noop, getAttribute: () => null, removeAttribute: noop,
|
||||
addEventListener: noop, removeEventListener: noop, dispatchEvent: () => true,
|
||||
querySelector: () => null, querySelectorAll: () => [], appendChild: (c) => c }
|
||||
const host = { ...node }
|
||||
const shadow = Object.assign(Object.create(globalThis.ShadowRoot.prototype), node, { host })
|
||||
node.getRootNode = () => shadow
|
||||
return node
|
||||
}
|
||||
|
||||
const failures = []
|
||||
let mounted = 0
|
||||
try {
|
||||
const rc = await import('@vue/runtime-core')
|
||||
const noop = () => {}
|
||||
const { createApp } = rc.createRenderer({
|
||||
insert: noop, remove: noop, createElement: makeNode, createText: makeNode, createComment: makeNode,
|
||||
setText: noop, setElementText: noop, parentNode: () => null, nextSibling: () => null, patchProp: noop,
|
||||
})
|
||||
|
||||
for (const lang of LOCALES) {
|
||||
installPage(lang)
|
||||
// Re-evaluate every module per locale: module-level state (the runtime locale, the stores) must
|
||||
// start from the page this locale describes, and a cached entry would not run its defines again.
|
||||
server.moduleGraph.invalidateAll()
|
||||
const stores = await Promise.all(['uiStore', 'mapStore', 'dataStore'].map((n) => server.ssrLoadModule(`/library/stores/${n}.js`)))
|
||||
const [useUIStore, useMapStore, useDataStore] = [stores[0].useUIStore, stores[1].useMapStore, stores[2].useDataStore]
|
||||
for (const c of list) {
|
||||
const tag = `${c.bundle}/${c.profile} [${lang}]`
|
||||
if (c.missing) { failures.push(`${tag}: no ${c.profile}.vue beside its meta.json`); continue }
|
||||
let comp, defs
|
||||
try {
|
||||
comp = (await server.ssrLoadModule(c.vue)).default
|
||||
globalThis.__gateDefs = []
|
||||
await server.ssrLoadModule(`/plugin-designs/${c.bundle}/index.js`)
|
||||
defs = globalThis.__gateDefs
|
||||
} catch (e) { failures.push(`${tag}: import threw: ${e?.message || e}`); continue }
|
||||
|
||||
const own = defs.find((d) => d.comp === comp)
|
||||
const viaLoader = defs.find((d) => String(d.comp?.__file || '').endsWith('/ProfileLoader.vue'))
|
||||
const def = own || viaLoader
|
||||
if (!def) { failures.push(`${tag}: its bundle entry defines neither this component nor ProfileLoader`); continue }
|
||||
|
||||
const cfg = { profile: c.profile, lang, locale: lang }
|
||||
let root = comp
|
||||
if (!own) {
|
||||
// Under ProfileLoader the profile is a child: give it what ProfileLoader provides.
|
||||
root = { setup() {
|
||||
rc.provide('mapboxToken', rc.ref(''))
|
||||
rc.provide('dataSource', rc.ref(''))
|
||||
rc.provide('colorSet', rc.ref(null))
|
||||
rc.provide('profileConfig', rc.computed(() => cfg))
|
||||
rc.provide('instanceId', 'gate')
|
||||
rc.provide('lang', rc.ref(lang))
|
||||
rc.provide('uiStore', useUIStore())
|
||||
rc.provide('mapStore', useMapStore())
|
||||
rc.provide('dataStore', useDataStore())
|
||||
return () => rc.h(comp)
|
||||
} }
|
||||
}
|
||||
const errors = []
|
||||
const app = createApp(root, own ? { profileConfig: JSON.stringify(cfg) } : null)
|
||||
app.config.errorHandler = (e, _i, info) => errors.push(`${info}: ${e?.name || 'Error'}: ${e?.message || e}`)
|
||||
app.config.warnHandler = noop
|
||||
// plugin-vue's SSR transform records each module in useSSRContext().modules during setup
|
||||
app.provide(rc.ssrContextKey, { modules: new Set() })
|
||||
try { def.opts.configureApp?.(app) } catch (e) { errors.push(`configureApp: ${e?.message || e}`) }
|
||||
try { app.mount(makeNode()) } catch (e) { errors.push(`mount: ${e?.message || e}`) }
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
try { app.unmount() } catch (_) { /* unmount is not what is measured */ }
|
||||
if (errors.length) failures.push(`${tag}: ${errors.join(' | ')}`)
|
||||
else mounted++
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
|
||||
console.log(`COMPOSITES-MOUNT: ${list.length} composite(s) x ${LOCALES.length} locale(s): ${mounted} mounted clean`)
|
||||
for (const f of failures) console.log(` FAIL ${f}`)
|
||||
console.log(failures.length ? `COMPOSITES-MOUNT: FAIL (${failures.length})` : 'COMPOSITES-MOUNT: PASS')
|
||||
process.exit(failures.length ? 1 : 0)
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"check:composite-locale-leaks": "node plugin-designs/research-map-plus-grid/scripts/check-composite-locale-leaks.mjs",
|
||||
"check:composite-reg": "node internal/check-composite-registration.mjs",
|
||||
"check:composite-srcs": "node internal/check-composite-srcs.mjs",
|
||||
"check:composites-mount": "node internal/check-composites-mount.mjs",
|
||||
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
|
||||
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
|
||||
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
|
||||
|
|
@ -245,6 +246,7 @@
|
|||
"check:composite-embed-knobs": "a composite must pass the HOST's tk and provider down to the map it nests, proven on a genuinely foreign origin (tree served on one port, partner page on another). Our own leaves bake a token and assign window.MAP_TOKEN, so this only ever breaks where there is no leaf - the partner page the embed snippet is written for, and the case nothing else tested. Keyless maplibre asserts ZERO requests to api.mapbox.com, because 'it rendered' would pass on a build that quietly fell back with a baked key.",
|
||||
"check:clustering-modes": "every option in the research map's clustering picker must DRAW something different. Choosing 'Native' changed nothing: mapStore never declared clusteringMode, so the picker wrote to a property nothing watched, AND the Clusters checkbox force-set 'mst' regardless of the store, so even a fixed picker would have snapped back on the next tick. Asserts on what the GL style RENDERS (circles, count labels, lines), not on state - 'clusteringMode === mapbox' would pass on a build still drawing MST. Also asserts no orphan layers between modes, and that MST and Network differ, since two labels for one behaviour is the same defect as a dead option.",
|
||||
"check:composite-locale-leaks": "a composite rendered in a non-English locale must not show a string the catalogue already translates. The criterion is a LOOKUP, not a category: English catalogue holds the string as a value, this locale holds a different value for that key, the page still renders English. The previous sweep split strings into chrome/data/third-party and scored single words like 'Region' and 'Prayer' as data, so 28 leaks on Big Picture and 41 on Simple Research counted as zero. Surfaces that a lookup cannot judge - vendor map chrome, table DATA cells, facet-rail values - are suppressed by NAME with a stated reason, never by word.",
|
||||
"check:composites-mount": "every capability-4/5 composite's OWN setup() and mounted hooks run without throwing, in en and ar, headless (no browser): vite loads each composite as its bundle entry defines it (configureApp, and ProfileLoader's provides where the entry mounts ProfileLoader), and @vue/runtime-core mounts it on a no-op renderer. Exists because 08f89f55 shipped two composites calling getRuntimeLocale/detectLocale/RTL_LOCALES with no import: the build was green, setup threw a ReferenceError in every locale, and Big Picture and Simple Research rendered nothing. Proves setup runs; cannot prove the children render.",
|
||||
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
|
||||
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
|
||||
"check:dashboard-geocoder-provider": "the research dashboard leaf at ?provider=maplibre makes ZERO requests to api.mapbox.com, and its search bars still suggest (built tree; browser gate). It was kept out of gates:post while the generated leaf hard-linked the Mapbox engine; the leaf now loads only the engine it is asked for.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue