Standalone repo that builds embeddable JAMstack map bundles (web components), composes them into a lazy-loaded SPA via layouts/, and optionally wraps a Capacitor Android/iOS app — one build, choose your output/CDN. Diátaxis docs. History reset to a single clean commit; no PII, IPs, or internal terms.
119 lines
6.4 KiB
JavaScript
119 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* smoke.mjs — boot the built site in a real browser and assert it actually works.
|
|
*
|
|
* WHY THIS EXISTS
|
|
* Every other gate checks artefacts: that files exist, parse, and carry the right
|
|
* paths. All of them passed while the app was incapable of drawing a single map,
|
|
* because the shell had no way to obtain a Mapbox token on any host but this one.
|
|
* Nothing that inspects files can catch that. It only shows up when a browser runs
|
|
* the page.
|
|
*
|
|
* WHAT IT ASSERTS
|
|
* 1. the shell renders,
|
|
* 2. it resolves a token from the endpoint the build writes,
|
|
* 3. a bundle lazily imports and registers its custom element,
|
|
* 4. an element actually mounts,
|
|
* 5. no uncaught page errors,
|
|
* 6. no failed same-origin requests — this is what caught a 404 on every single
|
|
* page load, from a <script src> for a file that is deliberately never deployed.
|
|
*
|
|
* It checks TWO surfaces, because they resolve a token by different routes and one
|
|
* passing says nothing about the other: the shell at / fetches the endpoint itself,
|
|
* while a generated profile page walks the six-rung ladder. A generated page is also
|
|
* what a partner iframes, so it is the surface an outside site actually depends on.
|
|
*
|
|
* Requests to Mapbox and the people-group API are IGNORED: this runs offline, and
|
|
* their failure says nothing about whether the site is correctly built.
|
|
*
|
|
* Usage: bun run smoke (serves public/ on a free port, then drives it)
|
|
*/
|
|
import { chromium } from 'playwright'
|
|
import { createServer } from 'node:http'
|
|
import { readFile, stat } from 'node:fs/promises'
|
|
import { join, extname, resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'public')
|
|
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' }
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
let p = join(ROOT, decodeURIComponent(req.url.split('?')[0]))
|
|
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}`
|
|
|
|
const EXTERNAL = ['api.mapbox.com', 'events.mapbox.com', 'pray.doxa.life', 'esm.sh']
|
|
const browser = await chromium.launch()
|
|
const page = await browser.newPage()
|
|
const pageErrors = [], failed = []
|
|
page.on('pageerror', e => pageErrors.push(e.message.split('\n')[0]))
|
|
page.on('requestfailed', r => {
|
|
if (!EXTERNAL.some(h => r.url().includes(h))) failed.push(`${r.failure()?.errorText} ${r.url().replace(BASE, '')}`)
|
|
})
|
|
|
|
const fail = []
|
|
try {
|
|
await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 60000 })
|
|
const title = await page.title()
|
|
if (!title.trim()) fail.push('shell rendered no <title>')
|
|
|
|
const token = await page.evaluate(async () => {
|
|
try { const r = await fetch('./api/maps/token.json'); return r.ok ? ((await r.json()).token || '') : `HTTP ${r.status}` }
|
|
catch (e) { return 'ERR ' + e.message }
|
|
})
|
|
if (!String(token).startsWith('pk.') && !String(token).startsWith('tk.'))
|
|
fail.push(`shell could not resolve a token from ./api/maps/token.json (got: ${token}) — every basemap would be blank`)
|
|
|
|
const registered = await page.evaluate(() =>
|
|
[...document.querySelectorAll('[profile-config]')].map(e => e.tagName.toLowerCase())
|
|
.filter(t => !!customElements.get(t)))
|
|
if (!registered.length) fail.push('no bundle lazily imported and registered its custom element')
|
|
|
|
const mounted = await page.evaluate(() => document.querySelectorAll('[profile-config]').length)
|
|
if (!mounted) fail.push('no element mounted with a profile-config')
|
|
|
|
if (pageErrors.length) fail.push(`uncaught page errors: ${pageErrors.slice(0, 3).join(' | ')}`)
|
|
if (failed.length) fail.push(`failed same-origin requests: ${failed.slice(0, 3).join(' | ')}`)
|
|
|
|
console.log(`[smoke] shell title=${JSON.stringify(title)} token=${String(token).slice(0, 6)}… registered=[${registered}] mounted=${mounted}`)
|
|
|
|
// A generated profile page — the surface a partner iframes. Picked from the
|
|
// manifest rather than hardcoded, so renaming or removing a bundle cannot leave
|
|
// this silently checking a page that no longer exists.
|
|
const manifest = JSON.parse(await readFile(join(ROOT, 'doxa-maps', 'manifest.json'), 'utf8'))
|
|
const first = manifest.bundles.find(b => b.profiles?.length)
|
|
if (!first) fail.push('manifest declares no bundle with a profile — nothing to iframe')
|
|
else {
|
|
const path = `/doxa-maps/${first.bundle}/${first.profiles[0].profile}/`
|
|
const p2 = await browser.newPage()
|
|
const err2 = [], failed2 = []
|
|
p2.on('pageerror', e => err2.push(e.message.split('\n')[0]))
|
|
p2.on('requestfailed', r => { if (!EXTERNAL.some(h => r.url().includes(h))) failed2.push(`${r.failure()?.errorText} ${r.url().replace(BASE, '')}`) })
|
|
await p2.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 })
|
|
const mounted2 = await p2.evaluate(() => document.querySelectorAll('[profile-config]').length)
|
|
const defined2 = await p2.evaluate(() => [...document.querySelectorAll('[profile-config]')]
|
|
.map(e => e.tagName.toLowerCase()).filter(t => !!customElements.get(t)))
|
|
// These pages surface their own failure in #status — an empty one means no complaint.
|
|
const status = await p2.evaluate(() => document.getElementById('status')?.textContent.trim() || '')
|
|
if (!mounted2) fail.push(`${path} mounted no element`)
|
|
if (!defined2.length) fail.push(`${path} imported no bundle that registered its element`)
|
|
if (status) fail.push(`${path} reported: ${status.slice(0, 90)}`)
|
|
if (err2.length) fail.push(`${path} uncaught errors: ${err2.slice(0, 2).join(' | ')}`)
|
|
if (failed2.length) fail.push(`${path} failed same-origin requests: ${failed2.slice(0, 2).join(' | ')}`)
|
|
console.log(`[smoke] profile ${path} defined=[${defined2}] mounted=${mounted2}`)
|
|
await p2.close()
|
|
}
|
|
} finally {
|
|
await browser.close(); server.close()
|
|
}
|
|
|
|
if (fail.length) { console.error('\n[smoke] FAILED:'); for (const f of fail) console.error(' ✗ ' + f); process.exit(1) }
|
|
console.log('[smoke] PASS — the built site boots, resolves a token, and mounts a map')
|