Measured in Chromium and Firefox: the CSSOM exposes `background: var(--x)` and `border: 1px solid var(--x)` only as pending-substitution longhands whose values read back as "", so the capture sanitiser (which re-serialises the parsed declarations) dropped them. The self-review screenshot then showed dark heroes, image placeholders and card borders as white, and the model "fixed" things that were fine on the real page. The sanitiser now emits the declared shorthand once, at its first longhand, through the same value/URL gate as every other declaration. Sandbox, CSP and the single URL allow-list are unchanged. - scripts/e2e-capture-fidelity.mjs: real-browser test through the whole capture path (sanitise -> inline -> sandboxed frame -> html-to-image), decodes the JPEG and samples pixels. Failed 9/12 checks per browser before, passes after. - e2e-capture-security: hostile var() shorthand / var() fallback url() probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
5.5 KiB
JavaScript
79 lines
5.5 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}
|
|
</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>
|
|
</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]],
|
|
];
|
|
|
|
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);
|
|
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);
|