Files
site-builder/craft/scripts/e2e-capture-fidelity.mjs
T
shadowdaoandClaude Opus 5.5 28119f0666 sitesmith capture: keep a var() border shorthand that a border-top overrides
`border:1px solid var(--x); border-top:4px solid ...` (the AI's accent-top card) lost its left/right/
bottom sides in the self-review capture in Chromium AND Firefox: once a later declaration overrides
one longhand, the CSSOM can no longer serialise the var() shorthand (it and its remaining longhands
read back as ""), so the 4c1c8db recovery had nothing to emit. Recover such shorthands from the
declaration block's source text (inline value; innermost blocks of a sheet, matched to rules by
cssText), re-parse each ALONE through the CSSOM and judge it with the unchanged value/URL gate.
e2e-capture-fidelity gains the card (var/var, var/literal, stylesheet) sampled on all four sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:19:07 -08:00

101 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Real-browser proof (Chromium + Firefox) that the self-review capture keeps what the page shows:
* backgrounds and borders written as CSS SHORTHAND containing var() (e.g. `background: var(--wsb-secondary)`,
* `border: 1px solid var(--wsb-border)`). The CSSOM exposes such a declaration only as pending-substitution
* longhands whose values read back as "", so a sanitiser that re-serialises longhands drops them and the
* hero came out white in the screenshot the model reviewed (eval 2026-11-01). Goes through the WHOLE capture
* path (sanitiser -> inlining -> sandboxed frame -> html-to-image), decodes the JPEG and samples pixels.
* Run: node scripts/e2e-capture-fidelity.mjs (CHROMIUM_PATH / FIREFOX_PATH override the binaries)
*/
import { build } from 'esbuild';
import { chromium, firefox } from '@playwright/test';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const out = await build({
entryPoints: [path.join(root, 'scripts/e2e-capture-fidelity-entry.ts')],
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"' },
});
const js = out.outputFiles[0].text;
// Token CSS the way the exporter emits it (:root custom properties), bands written the four ways that occur.
const PAGE = `<!doctype html><html><head><style>
:root{--wsb-primary:#1f3a5f;--wsb-secondary:#101a2b;--wsb-border:#d4372c;--wsb-accent:#d9a24a;--pad:40px}
body{margin:0;background:#ffffff}
.sheet-bg{background:var(--wsb-secondary);height:100px}
.sheet-border{border:12px solid var(--wsb-border);height:76px;background-color:#ffffff}
.sheet-card{border:6px solid var(--wsb-border);border-top:14px solid var(--wsb-accent);height:60px;background-color:#ffffff}
</style></head><body>
<div style="background: var(--wsb-secondary); height:100px"></div>
<div style="background-color: var(--wsb-primary); height:100px"></div>
<div style="border:12px solid var(--wsb-border); height:76px; background-color:#ffffff"></div>
<div class="sheet-bg"></div>
<div class="sheet-border"></div>
<div style="background: linear-gradient(var(--wsb-accent), var(--wsb-accent)); height:100px"></div>
<div style="padding: var(--pad) 0 0 0; background-color:#ffffff"><div style="height:60px;background-color:#00aa00"></div></div>
<div style="padding:0;background-color:#ffffff;border:6px solid var(--wsb-border);border-top:14px solid var(--wsb-accent);border-radius:0;height:60px"></div>
<div style="background-color:#ffffff;border:6px solid var(--wsb-border);border-top:14px solid #00aa00;height:60px"></div>
<div class="sheet-card"></div>
</body></html>`;
// [label, x, y, expected rgb]
const POINTS = [
['inline `background: var(--wsb-secondary)` band', 640, 50, [0x10, 0x1a, 0x2b]],
['inline `background-color: var(--wsb-primary)` band (control)', 640, 150, [0x1f, 0x3a, 0x5f]],
['inline `border: 12px solid var(--wsb-border)` left edge', 5, 240, [0xd4, 0x37, 0x2c]],
['inline `border` box interior stays white', 640, 250, [0xff, 0xff, 0xff]],
['stylesheet `.sheet-bg{background: var(--wsb-secondary)}`', 640, 350, [0x10, 0x1a, 0x2b]],
['stylesheet `.sheet-border{border: ... var(--wsb-border)}` top edge', 640, 405, [0xd4, 0x37, 0x2c]],
['inline `background: linear-gradient(var(--wsb-accent), ...)`', 640, 550, [0xd9, 0xa2, 0x4a]],
['inline `padding: var(--pad) 0 0 0` keeps the 40px gap (white above the green box)', 640, 620, [0xff, 0xff, 0xff]],
['... and the green box starts below the padding', 640, 670, [0x00, 0xaa, 0x00]],
// A card written `border: ... var(); border-top: <override>` (the AI's "accent top" card, eval 2026-11-01):
// the CSSOM cannot serialise the overridden var() shorthand, so the left/right/bottom sides were lost.
...[['inline `border:var()` + `border-top:var()` card', 700, [0xd9, 0xa2, 0x4a]],
['inline `border:var()` + literal `border-top:#00aa00` card', 780, [0x00, 0xaa, 0x00]],
['stylesheet `.sheet-card{border:var(); border-top:var()}`', 860, [0xd9, 0xa2, 0x4a]]].flatMap(([label, y0, top]) => [
[`${label}: top border`, 640, y0 + 7, top],
[`${label}: left border`, 2, y0 + 50, [0xd4, 0x37, 0x2c]],
[`${label}: right border`, 1277, y0 + 50, [0xd4, 0x37, 0x2c]],
[`${label}: bottom border`, 640, y0 + 77, [0xd4, 0x37, 0x2c]],
[`${label}: interior stays white`, 640, y0 + 45, [0xff, 0xff, 0xff]],
]),
];
const srv = http.createServer((q, r) => { r.setHeader('content-type', 'text/html'); r.end('<!doctype html><title>host</title><body>host</body>'); }).listen(0);
const port = srv.address().port;
let failed = 0;
const check = (name, label, ok, extra = '') => { if (!ok) failed++; console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); };
const near = (a, b, tol = 24) => a.every((v, i) => Math.abs(v - b[i]) <= tol);
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const page = await browser.newPage();
await page.goto(`http://localhost:${port}/`);
await page.addScriptTag({ content: js });
const clean = await page.evaluate((h) => window.sanitizeCaptureHtml(h), PAGE);
check(name, 'sanitiser keeps `background: var(--wsb-secondary)` as written', clean.ok && /background:\s*var\(--wsb-secondary\)/.test(clean.html));
check(name, 'sanitiser keeps `border: 12px solid var(--wsb-border)` as written', clean.ok && /border:\s*12px solid var\(--wsb-border\)/.test(clean.html));
// The recovered shorthand goes through the same URL gate: a var() fallback url() to a non-allowed target is dropped.
const hostile = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '<div style="background: var(--missing, url(/beacon?x)) center; border: 1px solid var(--b)">x</div><style>.a{background:var(--q, url(https://evil.example/a.png)) no-repeat}</style>');
check(name, 'recovered var() shorthand with a disallowed url() fallback is dropped (inline + sheet)', hostile.ok && !/beacon|evil\.example/.test(hostile.html) && /border:\s*1px solid var\(--b\)/.test(hostile.html), hostile.ok ? hostile.html.slice(0, 300) : hostile.reason);
check(name, 'sanitiser keeps an overridden `border: ... var()` before its `border-top` override (inline + sheet)', clean.ok
&& /border:\s*6px solid var\(--wsb-border\);\s*border-top:\s*14px solid var\(--wsb-accent\)/.test(clean.html)
&& /border:\s*6px solid var\(--wsb-border\);\s*border-top(-width)?:\s*14px/.test(clean.html)
&& /\.sheet-card\s*\{[^}]*border:\s*6px solid var\(--wsb-border\);\s*border-top:/.test(clean.html));
// Recovered-from-source shorthands pass the same URL gate.
const hostile2 = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '<div style="background: var(--m, url(/beacon2?x)) center; background-color: red; border: 1px solid var(--b); border-top: 2px solid red">x</div><style>.b{background:var(--q, url(https://evil.example/b.png)) no-repeat;background-position:0 0}</style>');
check(name, 'overridden var() shorthand with a disallowed url() fallback is still dropped (inline + sheet)', hostile2.ok && !/beacon2|evil\.example/.test(hostile2.html) && /border:\s*1px solid var\(--b\)/.test(hostile2.html), hostile2.ok ? '' : hostile2.reason);
const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, POINTS.map((p) => [p[1], p[2]])]);
check(name, 'capture succeeded', res.ok === true, res.ok ? '' : res.reason);
if (res.ok) POINTS.forEach(([label, , , want], i) => check(name, label, near(res.samples[i], want), `got rgb(${res.samples[i].join(',')}) want rgb(${want.join(',')})`));
await browser.close();
}
await run('chromium', chromium, process.env.CHROMIUM_PATH);
await run('firefox', firefox, process.env.FIREFOX_PATH);
srv.close();
process.exit(failed ? 1 : 0);