216 lines
12 KiB
JavaScript
216 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* readme-shots.mjs — capture the README screenshot family from the BUILT tree.
|
|
*
|
|
* Serves public/ on a local port (same static server shape as smoke.mjs), then
|
|
* drives headless chromium to screenshot each capability + deploy surface.
|
|
* A live Mapbox token is fetched from https://doxa.life/api/maps/token and passed
|
|
* as ?tk= so maps fully paint (network is up). Token-free surfaces (vision-card,
|
|
* template) render regardless.
|
|
*
|
|
* Output: docs/readme-images/*.png
|
|
*/
|
|
import { chromium } from 'playwright'
|
|
import { createServer } from 'node:http'
|
|
import { readFile, stat, mkdir } from 'node:fs/promises'
|
|
import { join, extname, resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT = resolve(HERE, '..', 'public')
|
|
const OUT = resolve(HERE, '..', 'docs', 'readme-images')
|
|
await mkdir(OUT, { recursive: true })
|
|
|
|
const TYPES = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript',
|
|
'.json':'application/json', '.css':'text/css', '.webp':'image/webp', '.png':'image/png',
|
|
'.svg':'image/svg+xml', '.geojson':'application/json', '.csv':'text/csv', '.woff2':'font/woff2' }
|
|
|
|
// In-memory pages (e.g. the iframe host demo). Served without touching public/, so no agentic
|
|
// scratch lands in the studio tree and nothing is lost to the next build's clean.
|
|
const virtualPages = new Map()
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const reqPath = decodeURIComponent(req.url.split('?')[0])
|
|
if (virtualPages.has(reqPath)) {
|
|
res.writeHead(200, { 'content-type': 'text/html' })
|
|
return res.end(virtualPages.get(reqPath))
|
|
}
|
|
let p = join(ROOT, reqPath)
|
|
if ((await stat(p)).isDirectory()) p = join(p, 'index.html')
|
|
res.writeHead(200, { 'content-type': TYPES[extname(p)] || 'application/octet-stream' })
|
|
res.end(await readFile(p))
|
|
} catch { res.writeHead(404).end('not found') }
|
|
})
|
|
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
|
const BASE = `http://127.0.0.1:${server.address().port}`
|
|
|
|
// Live token so maps paint fully.
|
|
let TK = ''
|
|
try {
|
|
const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })
|
|
TK = r.ok ? ((await r.json()).token || '') : ''
|
|
} catch {}
|
|
console.log(TK ? `token: acquired (${TK.length} chars) — maps will paint` : 'token: NONE — maps token-limited')
|
|
|
|
// ★ SwiftShader — WITHOUT THESE ARGS EVERY MAP SHOT IS TAKEN WITH NO GL CONTEXT.
|
|
// This script was written under the belief "this VPS has no WebGL", which was a LAUNCH-FLAG
|
|
// property, not a machine property. A bare chromium.launch() yields no WebGL, so mapbox-gl
|
|
// never paints and the README documents blank maps. Same flags as internal/verify-map-paint.mjs.
|
|
const browser = await chromium.launch({ args: [
|
|
'--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--no-sandbox',
|
|
] })
|
|
|
|
// ★ A SHOT THAT FAILS MUST NOT SILENTLY PRODUCE A README IMAGE. Collected and asserted at the end.
|
|
const failures = []
|
|
|
|
// mapbox canvas selector to wait on (leaf pages mount a custom element with a canvas)
|
|
const CANVAS = 'canvas'
|
|
|
|
// Readiness floor. Declared BEFORE shot() because it is a default parameter value; leaving it
|
|
// below the function relied on shot() not being CALLED until later, which is true today but is a
|
|
// trap for anyone who adds a call higher up.
|
|
// A login wall renders ~67 elements; a real plugin leaf renders hundreds. Per-shot overrides via
|
|
// `minElements` (cap-1 is legitimately 34) and `countFrames` (iframe hosts) handle the outliers.
|
|
const MIN_ELEMENTS = Number(process.env.MIN_ELEMENTS || 40)
|
|
|
|
// path is relative to the tree root (public/). tk appended unless tokenFree.
|
|
async function shot(name, path, {
|
|
vp = { width: 1440, height: 900 }, wait = 6000, tokenFree = false,
|
|
waitFor = null, before = null, fullPage = false, clip = null, dsf = 2,
|
|
minElements = MIN_ELEMENTS, countFrames = false,
|
|
} = {}) {
|
|
const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: dsf })
|
|
const page = await ctx.newPage()
|
|
const url = BASE + path + (tokenFree ? '' : (path.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK))
|
|
try {
|
|
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
|
|
if (waitFor) await page.waitForSelector(waitFor, { timeout: 20000 })
|
|
if (before) await before(page)
|
|
await page.waitForTimeout(wait)
|
|
|
|
// ★ READINESS GUARD — a screenshot of a blank page is still a valid PNG, so the file
|
|
// existing proves nothing. Walk shadow roots (a composite's content is NOT in the light
|
|
// DOM; a single-level querySelectorAll lies about every cap-4/5 surface) and require a
|
|
// real element count before anything is written to docs/.
|
|
const countIn = (frame) => frame.evaluate(() => {
|
|
const deep = (root, out = []) => {
|
|
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) deep(el.shadowRoot, out) }
|
|
return out
|
|
}
|
|
return deep(document).length
|
|
})
|
|
// ★ deep() CANNOT CROSS AN IFRAME. An iframe is a separate document, not a shadow root, so a
|
|
// host page whose whole point is the embedded map counts only its own chrome (~12 elements).
|
|
// For those shots, count the frames too — otherwise the guard rejects a working page.
|
|
let ready = await countIn(page)
|
|
if (countFrames) for (const f of page.frames()) { if (f !== page.mainFrame()) ready += await countIn(f).catch(() => 0) }
|
|
if (ready < minElements) throw new Error(`rendered only ${ready} elements (<${minElements}) — surface did not load`)
|
|
|
|
const opts = { path: join(OUT, name + '.png') }
|
|
if (fullPage) opts.fullPage = true
|
|
if (clip) opts.clip = clip
|
|
await page.screenshot(opts)
|
|
console.log(' OK', name + '.png', `<- ${path} (${ready} elements)`)
|
|
} catch (e) {
|
|
console.log(' ERR', name, e.message.split('\n')[0])
|
|
failures.push(name + ': ' + e.message.split('\n')[0])
|
|
} finally { await ctx.close() }
|
|
}
|
|
|
|
// ---- CAP1: single element (token-free vision card) ----
|
|
// minElements lowered DELIBERATELY: a single vision card is legitimately the smallest surface in
|
|
// the family. MEASURED 2026-09-20: 34 elements, shadow root mounted, 1440x900, 0 console errors --
|
|
// small, not broken. (document.body.innerText is EMPTY here and that is also fine: innerText does
|
|
// not reach into shadow DOM, which is why the element COUNT is the readiness signal, not text.)
|
|
await shot('cap1-single-element', '/doxa-maps/doxa-vision-card/doxa-vision-card/index.html',
|
|
{ tokenFree: true, wait: 3500, minElements: 25 })
|
|
|
|
// ---- CAP2: prop-table -> 3 shots from ONE root.vue (prayer/engagement/adoption) ----
|
|
await shot('cap2-prayer', '/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html', { waitFor: CANVAS, wait: 8000 })
|
|
await shot('cap2-engagement', '/doxa-maps/doxa-simple-map/doxa-simple-map/engagement/index.html', { waitFor: CANVAS, wait: 8000 })
|
|
await shot('cap2-adoption', '/doxa-maps/doxa-simple-map/doxa-simple-map/adoption/index.html', { waitFor: CANVAS, wait: 8000 })
|
|
|
|
// ---- CAP3: monolithic research map ----
|
|
await shot('cap3-monolithic-research', '/doxa-maps/doxa-research-map/research-map/index.html', { waitFor: CANVAS, wait: 9000 })
|
|
|
|
// ---- CAP4: nested-element SPA (upg-100-mfe is the cat-4 composite; list is the pages) ----
|
|
await shot('cap4-upg100-mfe', '/doxa-maps/my-upg-100-list/upg-100-mfe/index.html', { wait: 8000 })
|
|
await shot('cap4-upg100-list-page1', '/doxa-maps/my-upg-100-list/upg-100-list/index.html', { wait: 7000 })
|
|
// "page 2" — scroll the list down to show a later page of groups
|
|
await shot('cap4-upg100-list-page2', '/doxa-maps/my-upg-100-list/upg-100-list/index.html', {
|
|
wait: 7000,
|
|
before: async (page) => {
|
|
try { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) } catch {}
|
|
// try clicking a "next" control if one exists
|
|
for (const sel of ['button:has-text("Next")', '[aria-label="Next"]', '.pagination-next', 'button.next']) {
|
|
try { const el = await page.$(sel); if (el) { await el.click(); break } } catch {}
|
|
}
|
|
await page.waitForTimeout(1500)
|
|
}
|
|
})
|
|
|
|
// ---- CAP5: nested SPA — the doxa-maps-spa ELEMENT, not the gallery page ----
|
|
// ★ CORRECTED 2026-09-20. This shot previously pointed at /index.html, the AUTO-GENERATED
|
|
// GALLERY PAGE. That is a static preview page, NOT the capability-5 plugin — publishing it as
|
|
// the cap-5 evidence is mistake #1 in the /plugin-designer skill's do-not-repeat list ("a page
|
|
// is not a plugin"). The real cap-5 artifact is the doxa-maps-spa custom element, declared by
|
|
// doxa-maps-spa.meta.json {"type":"nested-mfe-spa"} and served from its own leaf page.
|
|
await shot('cap5-doxa-maps-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html', { wait: 10000 })
|
|
|
|
// ---- DEPLOY1: iframe embed on a host page ----
|
|
// ★ FIXED 2026-09-20. This shot pointed at /__readme-iframe-host.html, a file the script's own
|
|
// comment said it would write and NEVER DID. The page 404'd, the old error path logged ERR and
|
|
// exited 0, so deploy1-iframe.png has been a screenshot of a 404 page since 2026-09-18.
|
|
// The host page is SERVED FROM MEMORY rather than written into public/: anything written there
|
|
// is agentic scratch in the studio tree and would be destroyed by the next build's clean.
|
|
const IFRAME_SRC = '/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html'
|
|
+ '?tk=' + encodeURIComponent(TK)
|
|
virtualPages.set('/__readme-iframe-host.html', `<!doctype html>
|
|
<html><head><meta charset="utf-8"><title>Embedding a Doxa map</title>
|
|
<style>
|
|
body { margin:0; font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
|
background:#f3f3f1; color:#3b463d; }
|
|
.wrap { max-width:900px; margin:0 auto; padding:32px 24px; }
|
|
h1 { font-size:26px; margin:0 0 6px; }
|
|
p { color:#6d766e; margin:0 0 20px; }
|
|
.frame { border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#fff; }
|
|
iframe { display:block; width:100%; height:520px; border:0; }
|
|
code { background:#fff; border:1px solid #d1d4cd; border-radius:5px; padding:2px 6px; font-size:14px; }
|
|
</style></head>
|
|
<body><div class="wrap">
|
|
<h1>Any site can embed a Doxa map</h1>
|
|
<p>One <code><iframe></code> tag — no build step, no framework, no npm install.</p>
|
|
<div class="frame"><iframe src="${IFRAME_SRC}" title="Prayer map embedded in a host page"></iframe></div>
|
|
</div></body></html>`)
|
|
// Viewport trimmed to the host page's actual content height (32px pad + heading/lede + a 520px
|
|
// frame + 32px pad). At 1440x900 the shot carried ~330px of dead background below the card.
|
|
await shot('deploy1-iframe', '/__readme-iframe-host.html',
|
|
{ vp: { width: 1000, height: 700 }, wait: 11000, countFrames: true })
|
|
|
|
// ---- DEPLOY2: mobile — the SPA at phone viewport ----
|
|
// Same correction as cap5: shoot the cap-5 ELEMENT at phone viewport, not the gallery page.
|
|
await shot('deploy2-mobile-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html',
|
|
{ vp: { width: 390, height: 844 }, wait: 10000, dsf: 3 })
|
|
|
|
// ---- DEPLOY3: research dashboard ----
|
|
// ★ NOT `waitFor: CANVAS` (2026-09-21). research-dashboard OWNS NO MAP — it is an SVG-charts
|
|
// surface (measured: 1083 elements, 10 <svg>, ZERO <canvas>). Waiting for a canvas could only ever
|
|
// time out, so this shot failed for 20s on every run and left a STALE image behind. The page was
|
|
// never broken; the EXPECTATION was wrong about what this surface is. The minElements readiness
|
|
// guard already proves the surface rendered, without asserting a technology it does not use.
|
|
await shot('deploy3-research-dashboard', '/doxa-maps/research-dashboard/research-dashboard/index.html',
|
|
{ waitFor: 'svg', wait: 10000, minElements: 200 })
|
|
|
|
await browser.close()
|
|
server.close()
|
|
|
|
// ★ EXIT NON-ZERO ON ANY FAILED SHOT. Previously this printed DONE unconditionally, so a run
|
|
// where every map came back blank still reported success and the stale PNGs stayed in docs/.
|
|
if (failures.length) {
|
|
console.error(`\nREADME-SHOTS: FAIL — ${failures.length} shot(s) did not render:`)
|
|
failures.forEach((f) => console.error(' ' + f))
|
|
console.error(' docs/readme-images/ may still hold STALE images for these surfaces.')
|
|
process.exit(1)
|
|
}
|
|
console.log('DONE — shots in docs/readme-images/')
|