forked from c-fu/arah2
195 lines
8.0 KiB
JavaScript
195 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/*
|
||
* screenshot.mjs — Playwright capture toolchain (D-14)
|
||
* ------------------------------------------------------------------
|
||
* Produces the DSGN-02 gallery captures as REAL-BROWSER screenshots
|
||
* (chromium via playwright) — the same toolchain later UI phases
|
||
* reuse for their gallery rows (this script is the committed
|
||
* evidence generator; nothing here is scratch tooling).
|
||
*
|
||
* npm run capture (root delegate → theme workspace)
|
||
*
|
||
* Outputs (theme/screenshots/ — regenerated artifacts, gitignored):
|
||
* sheet-full.png component sheet, 390px, full page
|
||
* mockup-<screen>.png ×4 the four mockup screens rendered
|
||
* via file:// (CDN Tailwind + fonts,
|
||
* network required); on capture
|
||
* failure or a blank/undersized shot
|
||
* the screen's committed screen.png
|
||
* is copied in as fallback — mockups
|
||
* are read-only reference (D-06) and
|
||
* are NEVER edited
|
||
* basemap-kl.png Rams Liberty basemap over KL
|
||
* (1280×800, waits for the proof
|
||
* page's RAMS-BASEMAP-READY console
|
||
* signal + tile settle)
|
||
*
|
||
* The static server below serves theme/ as docroot so that
|
||
* ./dist/rams.css, the self-hosted woff2 fonts, and
|
||
* basemap/liberty-rams.json resolve exactly as they do for a user
|
||
* serving the theme workspace (e.g. npm run basemap:preview).
|
||
*/
|
||
import { createServer } from 'node:http';
|
||
import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
||
import { readFile } from 'node:fs/promises';
|
||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { chromium } from 'playwright';
|
||
|
||
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
||
const THEME = resolve(SCRIPTS, '..');
|
||
const SHOTS = join(THEME, 'screenshots');
|
||
const MOCKUPS = resolve(THEME, '..', 'stitch_minimalist_route_navigator');
|
||
|
||
/* Minimum plausible non-blank PNG (the plan's own blank bar). */
|
||
const MIN_BYTES = 10240;
|
||
|
||
/* The four mockup screens (stitch directory names verbatim). */
|
||
const SCREENS = [
|
||
'active_route_guidance',
|
||
'community_incident_reporter',
|
||
'navigate_map_overview',
|
||
'telegram_live_traffic_feed',
|
||
];
|
||
|
||
const MIME = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.png': 'image/png',
|
||
'.woff2': 'font/woff2',
|
||
};
|
||
|
||
/* Tiny static server: theme/ as docroot (no deps beyond node itself). */
|
||
function serveTheme() {
|
||
const server = createServer(async (req, res) => {
|
||
try {
|
||
const url = new URL(req.url, 'http://127.0.0.1');
|
||
const target = join(THEME, decodeURIComponent(url.pathname));
|
||
/* docroot guard: the prefix must end with a separator — a bare
|
||
* startsWith(THEME) lets sibling theme* paths (e.g. a decoded
|
||
* ..%2FthemeX) escape the docroot (WR-05). */
|
||
if (target !== THEME && !target.startsWith(THEME + sep)) {
|
||
res.writeHead(403);
|
||
res.end('forbidden');
|
||
return;
|
||
}
|
||
const body = await readFile(target);
|
||
res.writeHead(200, { 'content-type': MIME[extname(target)] || 'application/octet-stream' });
|
||
res.end(body);
|
||
} catch {
|
||
res.writeHead(404);
|
||
res.end('not found');
|
||
}
|
||
});
|
||
return new Promise((resolveReady) => {
|
||
server.listen(0, '127.0.0.1', () => resolveReady({ server, port: server.address().port }));
|
||
});
|
||
}
|
||
|
||
function sizeOf(file) {
|
||
return statSync(file).size;
|
||
}
|
||
|
||
const log = [];
|
||
const failures = [];
|
||
|
||
mkdirSync(SHOTS, { recursive: true });
|
||
const { server, port } = await serveTheme();
|
||
const base = `http://127.0.0.1:${port}`;
|
||
log.push(`serving theme/ → ${base}/`);
|
||
|
||
const browser = await chromium.launch();
|
||
|
||
try {
|
||
/* 1 — component sheet (the implementation render): 390 viewport, full page. */
|
||
{
|
||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||
await page.goto(`${base}/src/sheet.html`, { waitUntil: 'networkidle', timeout: 45000 });
|
||
await page.evaluate(() => document.fonts.ready);
|
||
await page.waitForTimeout(400); // font raster settle
|
||
await page.screenshot({ path: join(SHOTS, 'sheet-full.png'), fullPage: true });
|
||
await page.close();
|
||
log.push(`captured sheet-full.png (${sizeOf(join(SHOTS, 'sheet-full.png'))}B, fullPage)`);
|
||
}
|
||
|
||
/* 2 — mockup screens via file:// (CDN Tailwind + Google fonts need network).
|
||
* Fallback: committed <screen>/screen.png copied in — never edit mockups (D-06). */
|
||
for (const screen of SCREENS) {
|
||
const target = join(SHOTS, `mockup-${screen}.png`);
|
||
const url = pathToFileURL(join(MOCKUPS, screen, 'code.html')).href;
|
||
try {
|
||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||
await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 });
|
||
await page.evaluate(() => document.fonts.ready);
|
||
await page.waitForTimeout(800); // plan's settle window
|
||
await page.screenshot({ path: target });
|
||
await page.close();
|
||
if (sizeOf(target) < MIN_BYTES) {
|
||
throw new Error(`capture undersized (${sizeOf(target)}B < ${MIN_BYTES}B) — likely blank`);
|
||
}
|
||
log.push(`captured mockup-${screen}.png (${sizeOf(target)}B, network render)`);
|
||
} catch (err) {
|
||
const committed = join(MOCKUPS, screen, 'screen.png');
|
||
if (!existsSync(committed)) {
|
||
failures.push(`mockup-${screen}.png: capture failed AND committed screen.png missing (${err.message})`);
|
||
continue;
|
||
}
|
||
copyFileSync(committed, target);
|
||
log.push(`FALLBACK mockup-${screen}.png ← committed screen.png (${err.message})`);
|
||
}
|
||
}
|
||
|
||
/* 3 — basemap proof (1280×800): wait for the page's own READY signal, then settle. */
|
||
{
|
||
const target = join(SHOTS, 'basemap-kl.png');
|
||
try {
|
||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||
const ready = new Promise((resolveReady) => {
|
||
page.on('console', (m) => {
|
||
if (m.text().startsWith('RAMS-BASEMAP-READY')) resolveReady(true);
|
||
});
|
||
});
|
||
await page.goto(`${base}/basemap/index.html`, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||
const signal = await Promise.race([
|
||
ready,
|
||
new Promise((r) => setTimeout(() => r('timeout'), 30000)),
|
||
]);
|
||
if (signal === 'timeout') {
|
||
throw new Error('RAMS-BASEMAP-READY console signal never arrived (tiles/network?)');
|
||
}
|
||
await page.waitForTimeout(2000); // tile render settle (plan: first render + 2s)
|
||
await page.screenshot({ path: target });
|
||
await page.close();
|
||
if (sizeOf(target) < MIN_BYTES) {
|
||
throw new Error(`capture undersized (${sizeOf(target)}B < ${MIN_BYTES}B)`);
|
||
}
|
||
log.push(`captured basemap-kl.png (${sizeOf(target)}B, READY-signaled)`);
|
||
} catch (err) {
|
||
failures.push(`basemap-kl.png: ${err.message}`);
|
||
}
|
||
}
|
||
} finally {
|
||
await browser.close();
|
||
server.close();
|
||
}
|
||
|
||
/* ————— report + blank gate ————— */
|
||
for (const line of log) console.log(`[capture] ${line}`);
|
||
|
||
const expected = ['sheet-full.png', 'basemap-kl.png', ...SCREENS.map((s) => `mockup-${s}.png`)];
|
||
const missing = expected.filter((f) => !existsSync(join(SHOTS, f)));
|
||
const undersized = expected.filter((f) => existsSync(join(SHOTS, f)) && sizeOf(join(SHOTS, f)) < MIN_BYTES);
|
||
|
||
if (missing.length || undersized.length || failures.length) {
|
||
for (const f of failures) console.error(`[capture] FAILED ${f}`);
|
||
for (const f of missing) console.error(`[capture] MISSING ${f}`);
|
||
for (const f of undersized) console.error(`[capture] UNDERSIZED ${f} (<${MIN_BYTES}B)`);
|
||
console.error(`CAPTURE FAILED — ${expected.length - missing.length}/${expected.length} usable captures`);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`CAPTURE OK — ${expected.length}/${expected.length} non-blank captures under theme/screenshots/`);
|
||
process.exit(0);
|