diff --git a/php-transformer/tools/visual-parity/README.md b/php-transformer/tools/visual-parity/README.md index 5b5e5ede..09673f46 100644 --- a/php-transformer/tools/visual-parity/README.md +++ b/php-transformer/tools/visual-parity/README.md @@ -46,6 +46,23 @@ Then run: node bin/visual-parity.mjs --config visual-parity.config.json ``` +## DOM box provider + +`bin/dom-box-provider.mjs` captures per-node DOM boxes for Homeboy's `artifact-origin dom-boxes` flow. Node identity is keyed off a configurable attribute, so the provider is product-neutral and not tied to any single source format. + +```sh +HOMEBOY_DOM_BOX_BASE_URL=https://example.test \ +HOMEBOY_DOM_BOX_PAGE_PATHS_JSON='["/index.html"]' \ +node bin/dom-box-provider.mjs --node-id-attr=data-node-id --node-name-attr=data-node-name +``` + +| Setting | Env var | Flag | Default | +| --- | --- | --- | --- | +| Node id attribute | `HOMEBOY_DOM_BOX_NODE_ID_ATTR` | `--node-id-attr` | `data-figma-node-id` | +| Node name attributes | `HOMEBOY_DOM_BOX_NODE_NAME_ATTR` | `--node-name-attr` | `data-figma-node-name,data-figma-name` | + +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. + ## 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/dom-box-provider.mjs b/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs index c73c1ef1..4591604f 100755 --- a/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs +++ b/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node const DEFAULT_VIEWPORT = { width: 1440, height: 900, device_scale_factor: 1 }; +const DEFAULT_NODE_ID_ATTR = 'data-figma-node-id'; +const DEFAULT_NODE_NAME_ATTRS = ['data-figma-node-name', 'data-figma-name']; +const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/; const PLAYWRIGHT_SETUP_HELP = 'Install DOM capture dependencies with: npm ci --prefix php-transformer/tools/visual-parity && npm --prefix php-transformer/tools/visual-parity run install:browsers'; const COMPUTED_STYLE_PROPERTIES = [ 'font-family', @@ -57,9 +60,12 @@ async function main() { return; } + const cli = parseCliArgs(process.argv.slice(2)); const baseUrl = requiredEnv('HOMEBOY_DOM_BOX_BASE_URL').replace(/\/+$/, ''); const pagePaths = parsePagePaths(requiredEnv('HOMEBOY_DOM_BOX_PAGE_PATHS_JSON')); const textSampleLimit = parseTextSampleLimit(process.env.HOMEBOY_DOM_BOX_TEXT_SAMPLE_LIMIT ?? '160'); + const nodeIdAttr = resolveNodeIdAttr(cli); + const nodeNameAttrs = resolveNodeNameAttrs(cli); const { chromium } = await loadPlaywright(); let browser; @@ -83,7 +89,7 @@ async function main() { page_path: pagePath, page_url: page.url(), viewport: DEFAULT_VIEWPORT, - elements: await extractElements(page, pagePath, textSampleLimit), + elements: await extractElements(page, pagePath, textSampleLimit, nodeIdAttr, nodeNameAttrs), }); await page.close(); } @@ -146,12 +152,68 @@ function parseTextSampleLimit(raw) { return value; } +function parseCliArgs(argv) { + const parsed = {}; + 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); + if (inlineValue !== undefined) { + parsed[rawKey] = inlineValue; + continue; + } + + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith('--')) { + parsed[rawKey] = next; + index += 1; + } else { + parsed[rawKey] = 'true'; + } + } + return parsed; +} + +function validateAttributeName(name, source) { + const trimmed = String(name).trim(); + if (!ATTRIBUTE_NAME_PATTERN.test(trimmed)) { + throw new Error(`${source} must be a valid HTML attribute name, received: ${JSON.stringify(name)}`); + } + return trimmed; +} + +function resolveNodeIdAttr(cli) { + const raw = cli['node-id-attr'] ?? process.env.HOMEBOY_DOM_BOX_NODE_ID_ATTR ?? DEFAULT_NODE_ID_ATTR; + return validateAttributeName(raw, '--node-id-attr / HOMEBOY_DOM_BOX_NODE_ID_ATTR'); +} + +function resolveNodeNameAttrs(cli) { + const raw = cli['node-name-attr'] ?? process.env.HOMEBOY_DOM_BOX_NODE_NAME_ATTR; + const configured = raw === undefined + ? DEFAULT_NODE_NAME_ATTRS + : String(raw).split(',').map((value) => value.trim()).filter((value) => value !== ''); + + if (configured.length === 0) { + throw new Error('--node-name-attr / HOMEBOY_DOM_BOX_NODE_NAME_ATTR must list at least one attribute name.'); + } + + const names = configured.map((value) => validateAttributeName(value, '--node-name-attr / HOMEBOY_DOM_BOX_NODE_NAME_ATTR')); + // aria-label is a standard accessibility attribute, kept as a generic final fallback for node naming. + if (!names.includes('aria-label')) { + names.push('aria-label'); + } + return names; +} + function printHelp() { - process.stdout.write(`Capture DOM boxes for Homeboy artifact-origin dom-boxes.\n\nEnvironment:\n HOMEBOY_DOM_BOX_BASE_URL Static artifact origin base URL.\n HOMEBOY_DOM_BOX_PAGE_PATHS_JSON JSON array of page paths to capture.\n HOMEBOY_DOM_BOX_TEXT_SAMPLE_LIMIT Optional positive integer, default 160.\n\nOutput:\n JSON browser payload on stdout for Homeboy to shape as homeboy/static-artifact-dom-boxes/v1.\n`); + process.stdout.write(`Capture DOM boxes for Homeboy artifact-origin dom-boxes.\n\nNode identity is keyed off a configurable attribute so the tool is product-neutral.\nThe figma-transformer's data-figma-* attributes remain the backward-compatible default.\n\nEnvironment:\n HOMEBOY_DOM_BOX_BASE_URL Static artifact origin base URL.\n HOMEBOY_DOM_BOX_PAGE_PATHS_JSON JSON array of page paths to capture.\n HOMEBOY_DOM_BOX_TEXT_SAMPLE_LIMIT Optional positive integer, default 160.\n HOMEBOY_DOM_BOX_NODE_ID_ATTR Node identity attribute, default ${DEFAULT_NODE_ID_ATTR}.\n HOMEBOY_DOM_BOX_NODE_NAME_ATTR Comma-separated node name attributes, default ${DEFAULT_NODE_NAME_ATTRS.join(',')} (aria-label is always a final fallback).\n\nFlags (override the matching environment variable):\n --node-id-attr= Node identity attribute used for enumeration, selectors, and id reads.\n --node-name-attr=[,...] Node name attributes, tried in order before aria-label.\n\nOutput:\n JSON browser payload on stdout for Homeboy to shape as homeboy/static-artifact-dom-boxes/v1.\n`); } -async function extractElements(page, pagePath, textSampleLimit) { - return page.evaluate(({ pagePath: currentPagePath, limit, computedStyleProperties }) => { +async function extractElements(page, pagePath, textSampleLimit, nodeIdAttr, nodeNameAttrs) { + return page.evaluate(({ pagePath: currentPagePath, limit, computedStyleProperties, nodeIdAttr: idAttr, nodeNameAttrs: nameAttrs }) => { function normalizeText(value) { return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, limit); } @@ -162,7 +224,17 @@ async function extractElements(page, pagePath, textSampleLimit) { function selectorFor(element, nodeId) { const tag = element.tagName.toLowerCase(); - return `${tag}[data-figma-node-id="${String(nodeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`; + return `${tag}[${idAttr}="${String(nodeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`; + } + + function readNodeName(element) { + for (const attr of nameAttrs) { + const value = element.getAttribute(attr); + if (value) { + return value; + } + } + return null; } function serializeComputedStyle(element) { @@ -260,10 +332,10 @@ async function extractElements(page, pagePath, textSampleLimit) { }; } - return Array.from(document.querySelectorAll('[data-figma-node-id]')).map((element) => { + return Array.from(document.querySelectorAll(`[${idAttr}]`)).map((element) => { const rect = element.getBoundingClientRect(); - const nodeId = element.getAttribute('data-figma-node-id') || ''; - const nodeName = element.getAttribute('data-figma-node-name') || element.getAttribute('data-figma-name') || element.getAttribute('aria-label') || null; + const nodeId = element.getAttribute(idAttr) || ''; + const nodeName = readNodeName(element); const computedStyle = serializeComputedStyle(element); const textSample = normalizeText(element.textContent); const textLength = fullTextLength(element.textContent); @@ -285,5 +357,7 @@ async function extractElements(page, pagePath, textSampleLimit) { pagePath, limit: textSampleLimit, computedStyleProperties: COMPUTED_STYLE_PROPERTIES, + nodeIdAttr, + nodeNameAttrs, }); } diff --git a/php-transformer/tools/visual-parity/tests/smoke.mjs b/php-transformer/tools/visual-parity/tests/smoke.mjs index fc05df7d..73a13e2c 100644 --- a/php-transformer/tools/visual-parity/tests/smoke.mjs +++ b/php-transformer/tools/visual-parity/tests/smoke.mjs @@ -9,6 +9,7 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); 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 missingPlaywrightDir = path.join(tmpdir(), `blocks-engine-dom-provider-missing-playwright-${process.pid}`); const missingPlaywrightProvider = path.join(missingPlaywrightDir, 'dom-box-provider.mjs'); @@ -56,12 +57,20 @@ await writeFile(domFixture, `
Hello world
`); +await writeFile(domFixtureGeneric, `
Hello generic
`); const server = createServer(async (request, response) => { if (request.url === '/dom-box.html') { response.writeHead(200, { 'content-type': 'text/html' }); response.end(await readFile(domFixture)); return; } + if (request.url === '/dom-box-generic.html') { + response.writeHead(200, { 'content-type': 'text/html' }); + response.end(await readFile(domFixtureGeneric)); + return; + } response.writeHead(404, { 'content-type': 'application/json' }); response.end('{"error":"not_found"}'); }); @@ -80,6 +89,7 @@ try { assert(domReport.entrypoints.length === 1, 'DOM provider captures one entrypoint'); assert(domReport.entrypoints[0].elements[0].node_id === '12:34', 'DOM provider captures node id'); assert(domReport.entrypoints[0].elements[0].node_name === 'Hero', 'DOM provider captures node name'); + assert(domReport.entrypoints[0].elements[0].selector === 'main[data-figma-node-id="12:34"]', 'DOM provider keys selector off default figma attribute'); assert(domReport.entrypoints[0].elements[0].text_sample === 'Hello world', 'DOM provider captures text sample'); assert(domReport.entrypoints[0].elements[0].page_path === '/dom-box.html', 'DOM provider adds page path to elements'); assert(domReport.entrypoints[0].elements[0].computed_style['font-size'] === '20px', 'DOM provider captures computed font size'); @@ -95,6 +105,31 @@ try { assert(domReport.entrypoints[0].elements[0].asset_state.descendants[0].complete === true, 'DOM provider captures image complete state'); assert(domReport.entrypoints[0].elements[0].visibility.visible === true, 'DOM provider captures visible state'); assert(domReport.entrypoints[0].elements[0].visibility.clipped === true, 'DOM provider captures clipped-ish overflow state'); + + const genericEnvReport = await runJson(process.execPath, [path.join(root, 'bin/dom-box-provider.mjs')], root, { + ...process.env, + HOMEBOY_DOM_BOX_BASE_URL: `http://127.0.0.1:${address.port}`, + HOMEBOY_DOM_BOX_PAGE_PATHS_JSON: JSON.stringify(['/dom-box-generic.html']), + HOMEBOY_DOM_BOX_NODE_ID_ATTR: 'data-node-id', + HOMEBOY_DOM_BOX_NODE_NAME_ATTR: 'data-node-name', + }); + assert(genericEnvReport.entrypoints[0].elements.length === 1, 'DOM provider enumerates by configured generic attribute (env)'); + assert(genericEnvReport.entrypoints[0].elements[0].node_id === 'n-1', 'DOM provider reads node id from configured generic attribute (env)'); + assert(genericEnvReport.entrypoints[0].elements[0].node_name === 'Generic Hero', 'DOM provider reads node name from configured generic attribute (env)'); + assert(genericEnvReport.entrypoints[0].elements[0].selector === 'main[data-node-id="n-1"]', 'DOM provider keys selector off configured generic attribute (env)'); + + const genericFlagReport = await runJson(process.execPath, [ + path.join(root, 'bin/dom-box-provider.mjs'), + '--node-id-attr=data-node-id', + '--node-name-attr=data-node-name', + ], root, { + ...process.env, + HOMEBOY_DOM_BOX_BASE_URL: `http://127.0.0.1:${address.port}`, + HOMEBOY_DOM_BOX_PAGE_PATHS_JSON: JSON.stringify(['/dom-box-generic.html']), + }); + 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)'); } finally { await new Promise((resolve) => server.close(resolve)); } @@ -111,6 +146,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(missingPlaywrightDir, { recursive: true, force: true }); console.log('Visual parity smoke test passed.');