From f69947df261d05f49161821956055bfb51a06463 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 19:05:59 -0400 Subject: [PATCH] Add HTML artifact screenshot provider --- php-transformer/tools/visual-parity/README.md | 23 +++ .../visual-parity/bin/screenshot-provider.mjs | 187 ++++++++++++++++++ .../tools/visual-parity/package-lock.json | 1 + .../tools/visual-parity/package.json | 3 +- .../tools/visual-parity/tests/smoke.mjs | 16 ++ 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100755 php-transformer/tools/visual-parity/bin/screenshot-provider.mjs diff --git a/php-transformer/tools/visual-parity/README.md b/php-transformer/tools/visual-parity/README.md index 09673f46..b0757c5e 100644 --- a/php-transformer/tools/visual-parity/README.md +++ b/php-transformer/tools/visual-parity/README.md @@ -63,6 +63,29 @@ node bin/dom-box-provider.mjs --node-id-attr=data-node-id --node-name-attr=data- The node id attribute drives element enumeration, selector generation, and id reads. Node name attributes are tried in order; `aria-label` is always appended as a final generic fallback. The defaults stay backward-compatible with the figma-transformer's `data-figma-*` output, so existing figma callers work with no changes; non-figma consumers override the attributes to match their own emitter. +## Screenshot provider + +`bin/screenshot-provider.mjs` captures PNG screenshots for static HTML pages served from an artifact-origin base URL and writes a JSON manifest to stdout. + +```sh +HOMEBOY_SCREENSHOT_BASE_URL=https://example.test \ +HOMEBOY_SCREENSHOT_PAGE_PATHS_JSON='["/index.html"]' \ +HOMEBOY_SCREENSHOT_OUTPUT_DIR=tmp/screenshots \ +node bin/screenshot-provider.mjs --viewport=1440x900 +``` + +Equivalent flag-only form: + +```sh +node bin/screenshot-provider.mjs \ + --base-url=https://example.test \ + --page-path=/index.html \ + --output-dir=tmp/screenshots \ + --viewport=1440x900 +``` + +Expected PNG paths are deterministic from page paths, for example `tmp/screenshots/index.html.png`. The stdout manifest uses schema `blocks-engine.visual-parity.screenshots.v1` and records each `page_path`, `page_url`, PNG `path`, `filename`, and `exists` flag. + ## Output The JSON report includes normalized config, source and target probe snapshots, and a deterministic comparison summary. Default probes cover button, link, nav/menu, and card-like candidates. Each match records text, href, bounding box, and computed styles for display, color, background color, border, border radius, padding, font size, and font weight. diff --git a/php-transformer/tools/visual-parity/bin/screenshot-provider.mjs b/php-transformer/tools/visual-parity/bin/screenshot-provider.mjs new file mode 100755 index 00000000..667d5a5f --- /dev/null +++ b/php-transformer/tools/visual-parity/bin/screenshot-provider.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +import { mkdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const DEFAULT_VIEWPORT = { width: 1440, height: 900, device_scale_factor: 1 }; +const SCREENSHOT_SCHEMA = 'blocks-engine.visual-parity.screenshots.v1'; +const PLAYWRIGHT_SETUP_HELP = 'Install screenshot dependencies with: npm ci --prefix php-transformer/tools/visual-parity && npm --prefix php-transformer/tools/visual-parity run install:browsers'; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); + +async function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + printHelp(); + return; + } + + const cli = parseCliArgs(process.argv.slice(2)); + const baseUrl = requiredSetting(cli, 'base-url', 'HOMEBOY_SCREENSHOT_BASE_URL').replace(/\/+$/, ''); + const pagePaths = resolvePagePaths(cli); + const outputDir = requiredSetting(cli, 'output-dir', 'HOMEBOY_SCREENSHOT_OUTPUT_DIR'); + const viewport = parseViewport(cli.viewport ?? process.env.HOMEBOY_SCREENSHOT_VIEWPORT ?? `${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`); + const fullPage = parseBoolean(cli['full-page'] ?? process.env.HOMEBOY_SCREENSHOT_FULL_PAGE ?? 'true'); + const waitUntil = cli['wait-until'] ?? process.env.HOMEBOY_SCREENSHOT_WAIT_UNTIL ?? 'load'; + const { chromium } = await loadPlaywright(); + + await mkdir(outputDir, { recursive: true }); + + let browser; + try { + browser = await chromium.launch(); + } catch (error) { + throw withPlaywrightSetupHelp(error); + } + + try { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: viewport.device_scale_factor, + }); + const screenshots = []; + + for (const pagePath of pagePaths) { + const page = await context.newPage(); + const pageUrl = `${baseUrl}${String(pagePath).startsWith('/') ? pagePath : `/${pagePath}`}`; + const filename = `${safeScreenshotName(pagePath)}.png`; + const outputPath = path.join(outputDir, filename); + await page.goto(pageUrl, { waitUntil }); + await page.screenshot({ path: outputPath, fullPage }); + await page.close(); + + screenshots.push({ + page_path: pagePath, + page_url: pageUrl, + path: outputPath, + filename, + exists: await fileExists(outputPath), + }); + } + + process.stdout.write(`${JSON.stringify({ + schema: SCREENSHOT_SCHEMA, + base_url: baseUrl, + output_dir: outputDir, + viewport, + full_page: fullPage, + wait_until: waitUntil, + screenshots, + }, null, 2)}\n`); + } finally { + await browser.close(); + } +} + +async function loadPlaywright() { + try { + return await import('playwright'); + } catch (error) { + if (isMissingPlaywrightModule(error)) { + throw new Error(`Playwright is required for screenshot capture but is not installed. ${PLAYWRIGHT_SETUP_HELP}`); + } + throw error; + } +} + +function isMissingPlaywrightModule(error) { + return error?.code === 'ERR_MODULE_NOT_FOUND' && String(error?.message ?? '').includes("'playwright'"); +} + +function withPlaywrightSetupHelp(error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('Executable doesn\'t exist') || message.includes('playwright install')) { + return new Error(`${message}\n${PLAYWRIGHT_SETUP_HELP}`); + } + return error; +} + +function parseCliArgs(argv) { + const parsed = { 'page-path': [] }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith('--')) { + continue; + } + + const [rawKey, inlineValue] = arg.slice(2).split('=', 2); + const next = argv[index + 1]; + const value = inlineValue ?? (next !== undefined && !next.startsWith('--') ? next : 'true'); + if (inlineValue === undefined && value !== 'true') { + index += 1; + } + + if (rawKey === 'page-path') { + parsed['page-path'].push(value); + continue; + } + + parsed[rawKey] = value; + } + return parsed; +} + +function requiredSetting(cli, flag, envVar) { + const value = cli[flag] ?? process.env[envVar]; + if (!value) { + throw new Error(`Missing required setting: --${flag} / ${envVar}`); + } + return String(value); +} + +function resolvePagePaths(cli) { + if (cli['page-path'].length > 0) { + return cli['page-path']; + } + const raw = process.env.HOMEBOY_SCREENSHOT_PAGE_PATHS_JSON; + if (!raw) { + throw new Error('Missing required setting: --page-path / HOMEBOY_SCREENSHOT_PAGE_PATHS_JSON'); + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`HOMEBOY_SCREENSHOT_PAGE_PATHS_JSON must be valid JSON: ${error.message}`); + } + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string' || item.trim() === '')) { + throw new Error('HOMEBOY_SCREENSHOT_PAGE_PATHS_JSON must be a JSON array of paths.'); + } + return parsed; +} + +function parseViewport(value) { + const [width, height] = String(value).toLowerCase().split('x').map((part) => Number(part)); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error(`Invalid viewport: ${value}`); + } + return { width, height, device_scale_factor: 1 }; +} + +function parseBoolean(value) { + if (['1', 'true', 'yes'].includes(String(value).toLowerCase())) { + return true; + } + if (['0', 'false', 'no'].includes(String(value).toLowerCase())) { + return false; + } + throw new Error(`Invalid boolean value: ${value}`); +} + +function safeScreenshotName(pagePath) { + const normalized = String(pagePath).replace(/^\/+/, '').replace(/\/+$/, '') || 'index'; + return normalized.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'index'; +} + +async function fileExists(filePath) { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +} + +function printHelp() { + process.stdout.write(`Capture PNG screenshots for static artifact-origin HTML pages.\n\nEnvironment:\n HOMEBOY_SCREENSHOT_BASE_URL Static artifact origin base URL.\n HOMEBOY_SCREENSHOT_PAGE_PATHS_JSON JSON array of page paths to capture.\n HOMEBOY_SCREENSHOT_OUTPUT_DIR Directory where PNG screenshots are written.\n HOMEBOY_SCREENSHOT_VIEWPORT Optional viewport, default ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}.\n HOMEBOY_SCREENSHOT_FULL_PAGE Optional boolean, default true.\n HOMEBOY_SCREENSHOT_WAIT_UNTIL Optional Playwright waitUntil value, default load.\n\nFlags override matching environment values:\n --base-url= Static artifact origin base URL.\n --page-path= Page path to capture; repeat for multiple pages.\n --output-dir= Directory where PNG screenshots are written.\n --viewport=x Browser viewport.\n --full-page= Capture the full scrollable page.\n --wait-until= Playwright navigation waitUntil value.\n\nOutput:\n JSON screenshot manifest on stdout. PNG files are written to --output-dir.\n`); +} diff --git a/php-transformer/tools/visual-parity/package-lock.json b/php-transformer/tools/visual-parity/package-lock.json index 525fe2b7..8178bf23 100644 --- a/php-transformer/tools/visual-parity/package-lock.json +++ b/php-transformer/tools/visual-parity/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "bin": { "blocks-engine-dom-box-provider": "bin/dom-box-provider.mjs", + "blocks-engine-screenshot-provider": "bin/screenshot-provider.mjs", "blocks-engine-visual-parity": "bin/visual-parity.mjs" }, "devDependencies": { diff --git a/php-transformer/tools/visual-parity/package.json b/php-transformer/tools/visual-parity/package.json index b1a5b5ef..790b6229 100644 --- a/php-transformer/tools/visual-parity/package.json +++ b/php-transformer/tools/visual-parity/package.json @@ -6,7 +6,8 @@ "type": "module", "bin": { "blocks-engine-visual-parity": "bin/visual-parity.mjs", - "blocks-engine-dom-box-provider": "bin/dom-box-provider.mjs" + "blocks-engine-dom-box-provider": "bin/dom-box-provider.mjs", + "blocks-engine-screenshot-provider": "bin/screenshot-provider.mjs" }, "scripts": { "install:browsers": "playwright install chromium", diff --git a/php-transformer/tools/visual-parity/tests/smoke.mjs b/php-transformer/tools/visual-parity/tests/smoke.mjs index 73a13e2c..7ca42eb0 100644 --- a/php-transformer/tools/visual-parity/tests/smoke.mjs +++ b/php-transformer/tools/visual-parity/tests/smoke.mjs @@ -10,6 +10,7 @@ const outputDir = path.join(root, 'tmp'); const output = path.join(outputDir, 'visual-parity-smoke.json'); const domFixture = path.join(outputDir, 'dom-box.html'); const domFixtureGeneric = path.join(outputDir, 'dom-box-generic.html'); +const screenshotDir = path.join(outputDir, 'screenshots'); const missingPlaywrightDir = path.join(tmpdir(), `blocks-engine-dom-provider-missing-playwright-${process.pid}`); const missingPlaywrightProvider = path.join(missingPlaywrightDir, 'dom-box-provider.mjs'); @@ -130,6 +131,20 @@ try { assert(genericFlagReport.entrypoints[0].elements[0].node_id === 'n-1', 'DOM provider reads node id from configured generic attribute (flag)'); assert(genericFlagReport.entrypoints[0].elements[0].node_name === 'Generic Hero', 'DOM provider reads node name from configured generic attribute (flag)'); assert(genericFlagReport.entrypoints[0].elements[0].selector === 'main[data-node-id="n-1"]', 'DOM provider keys selector off configured generic attribute (flag)'); + + const screenshotReport = await runJson(process.execPath, [ + path.join(root, 'bin/screenshot-provider.mjs'), + '--base-url', `http://127.0.0.1:${address.port}`, + '--page-path', '/dom-box.html', + '--output-dir', screenshotDir, + '--viewport', '390x844', + ], root, process.env); + assert(screenshotReport.schema === 'blocks-engine.visual-parity.screenshots.v1', 'screenshot provider emits stable schema'); + assert(screenshotReport.screenshots.length === 1, 'screenshot provider captures one screenshot'); + assert(screenshotReport.screenshots[0].filename === 'dom-box.html.png', 'screenshot provider derives deterministic filename'); + assert(screenshotReport.screenshots[0].exists === true, 'screenshot provider records written PNG'); + const screenshotBytes = await readFile(screenshotReport.screenshots[0].path); + assert(screenshotBytes[0] === 0x89 && screenshotBytes[1] === 0x50 && screenshotBytes[2] === 0x4e && screenshotBytes[3] === 0x47, 'screenshot provider writes a PNG file'); } finally { await new Promise((resolve) => server.close(resolve)); } @@ -147,6 +162,7 @@ assert(missingPlaywright.stderr.includes('install:browsers'), 'DOM provider sugg await rm(output, { force: true }); await rm(domFixture, { force: true }); await rm(domFixtureGeneric, { force: true }); +await rm(screenshotDir, { recursive: true, force: true }); await rm(missingPlaywrightDir, { recursive: true, force: true }); console.log('Visual parity smoke test passed.');