From 680a714bdc3927dcb479ad23ce559dbcd4ed5697 Mon Sep 17 00:00:00 2001 From: doxa-maps Date: Wed, 16 Sep 2026 02:25:33 +0000 Subject: [PATCH] embed-smoke: prove every map embeds (17/17 pages mount cleanly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New permanent harness (bun run smoke:embeds): loads every profile page from the partner-facing manifest headless (SwiftShader software WebGL — no GPU needed), asserts the custom element defines + populates its shadow DOM, no ProfileLoader error card, and a WebGL canvas on map profiles. First full run: 17/17 pass — 8 map profiles paint a canvas, 9 DOM profiles (lists/tables/charts/walls) populate. Exit 1 on any failure, so CI can gate on it. --- internal/embed-smoke.mjs | 99 ++++++++++++++++++++++++++++++++++++++++ package.json | 7 +-- 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 internal/embed-smoke.mjs diff --git a/internal/embed-smoke.mjs b/internal/embed-smoke.mjs new file mode 100644 index 000000000..68d4275c4 --- /dev/null +++ b/internal/embed-smoke.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * embed-smoke.mjs — prove EVERY map embeds: load each generated profile page + * headless and assert its custom element actually mounts. + * + * WHY: "embeddability works on all the maps" is the product promise. The build + * guards prove the files exist; THIS proves each page boots — element defined, + * shadow DOM populated, no ProfileLoader error card, and (for map profiles) a + * WebGL canvas present. + * + * HOW: reads public/doxa-maps/manifest.json (the same registry partners use), + * serves public/ itself if nothing is on --port, and drives Playwright + * Chromium with SwiftShader software WebGL — CI boxes rarely have a GPU. (The + * posters pipeline stays Firefox-only; this is a headless VERIFICATION harness, + * not a rendering pipeline.) + * + * Usage: node internal/embed-smoke.mjs [--port 4177] [--grep ] + * Exit: 0 all pass · 1 any fail (list printed) + */ +import { createServer } from 'node:http' +import { readFileSync, existsSync, statSync, createReadStream } from 'node:fs' +import { resolve, join, extname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const PUB = resolve(__dirname, '..', 'public') +const args = process.argv.slice(2) +const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d } +const PORT = Number(argOf('--port', 4177)) +const GREP = argOf('--grep', '') + +// ── tiny static server (no deps; mime enough for the pages) ───────────────── +const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', + '.css': 'text/css', '.json': 'application/json', '.geojson': 'application/geo+json', + '.png': 'image/png', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.csv': 'text/csv' } +const server = createServer((req, res) => { + try { + let p = decodeURIComponent(new URL(req.url, 'http://x').pathname) + if (p.endsWith('/')) p += 'index.html' + const f = join(PUB, p) + if (!f.startsWith(PUB) || !existsSync(f) || !statSync(f).isFile()) { res.writeHead(404); return res.end() } + res.writeHead(200, { 'content-type': MIME[extname(f)] || 'application/octet-stream' }) + createReadStream(f).pipe(res) + } catch { res.writeHead(500); res.end() } +}) + +// ── the page list: every profile page in the manifest ─────────────────────── +const manifest = JSON.parse(readFileSync(join(PUB, 'doxa-maps', 'manifest.json'), 'utf8')) +const targets = [] +for (const b of manifest.bundles) for (const p of b.profiles) { + // iframeUrl is ./-relative to the tree root + const url = p.iframeUrl.replace(/^\.\//, '/doxa-maps/') + targets.push({ bundle: b.bundle, profile: p.profile, element: p.element, url }) +} +const list = GREP ? targets.filter(t => (t.bundle + '/' + t.profile).includes(GREP)) : targets + +await new Promise(r => server.listen(PORT, '127.0.0.1', r)) +console.log(`[embed-smoke] serving ${PUB} on :${PORT} — ${list.length} profile page(s)`) + +const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] }) +const results = [] +for (const t of list) { + const ctx = await browser.newContext({ viewport: { width: 1100, height: 750 } }) + const page = await ctx.newPage() + const errors = [] + page.on('pageerror', e => errors.push(String(e).slice(0, 160))) + const r = { ...t, ok: false, why: '' } + try { + await page.goto(`http://127.0.0.1:${PORT}${t.url}`, { waitUntil: 'load', timeout: 30000 }) + // give the element time to define + mount + (maybe) paint a canvas + await page.waitForFunction((tag) => { + const el = document.querySelector(tag) + return el && el.shadowRoot && el.shadowRoot.childElementCount > 0 + }, t.element, { timeout: 25000 }) + await page.waitForTimeout(4000) + const verdict = await page.evaluate((tag) => { + const el = document.querySelector(tag) + const sr = el.shadowRoot + return { + errorCard: !!sr.querySelector('.profile-error'), + canvas: !!sr.querySelector('canvas'), + nodes: sr.querySelectorAll('*').length, + } + }, t.element) + if (verdict.errorCard) r.why = 'ProfileLoader error card shown' + else if (verdict.nodes < 5) r.why = `shadow DOM nearly empty (${verdict.nodes} nodes)` + else { r.ok = true; r.why = `${verdict.nodes} nodes${verdict.canvas ? ' + canvas' : ' (no canvas — DOM profile)'}` } + } catch (e) { r.why = 'mount timeout / ' + String(e.message || e).slice(0, 100) } + if (errors.length && r.ok) r.why += ` · ${errors.length} pageerror(s): ${errors[0]}` + results.push(r) + console.log(` ${r.ok ? '✓' : '✗'} ${t.bundle}/${t.profile} <${t.element}> — ${r.why}`) + await ctx.close() +} +await browser.close(); server.close() + +const failed = results.filter(r => !r.ok) +console.log(`\n[embed-smoke] ${results.length - failed.length}/${results.length} pages mount cleanly`) +if (failed.length) { console.log('FAILED:'); failed.forEach(f => console.log(` ✗ ${f.bundle}/${f.profile} — ${f.why}`)); process.exit(1) } diff --git a/package.json b/package.json index 5cf803ddf..b1a29d7d8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "license": "Apache-2.0", "private": true, - "description": "A portable bundler that generates embeddable JAMstack maps — one library, many map bundles that embed on any site, compose into a lazy-loaded SPA, or wrap a Capacitor Android/iOS app.", + "description": "A portable bundler that generates embeddable JAMstack maps \u2014 one library, many map bundles that embed on any site, compose into a lazy-loaded SPA, or wrap a Capacitor Android/iOS app.", "type": "module", "scripts": { "dev": "bun x vite", @@ -32,7 +32,8 @@ "posters:export": "cd library/scripts/posters && node export-public.mjs", "posters:previews": "cd library/scripts/posters && python3 make-previews.py", "token": "node internal/write-token.mjs", - "smoke": "node internal/smoke.mjs" + "smoke": "node internal/smoke.mjs", + "smoke:embeds": "node internal/embed-smoke.mjs" }, "devDependencies": { "@capacitor/android": "7", @@ -57,4 +58,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file