Files

195 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);