1. summarizeCanvas() walked only node.nodes, so everything inside Section/ColumnLayout/forms (linkedNodes) was missing and live self-reviews could only name top-level sections. It now walks nodes + linked slots (stable key order) and lists id, type and key props (text first, links, main style values as style.<key>, colour-like props) as key="value", within a documented budget (150 nodes, 6000 chars, 300 chars of props per line; detail is dropped before nodes; cuts happen at line boundaries -- a mid-line cut used to make the server refuse the whole review). HtmlBlock code is no longer summarised. applyPatch no longer counts an op with nothing left to set as a change; ops that did nothing are counted per reason. The self-review note and console say why ops were dropped (server and editor reasons, counts only). Real-browser test 8n builds a nested page, sends the real summary through the real WHP review lockdown (php) and checks the nested edit takes effect while link/text edits, unknown ids and deletes are dropped. 3. Background placeholders use ?text=%E2%80%8B (measured: placehold.co then draws no glyphs; the same-colour label left a 4-colour ghost in its PNG); the capture draws no <text> for a zero-width or same-colour label. Pixel test on the case-4 hero markup in both browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
172 lines
14 KiB
JavaScript
172 lines
14 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>
|
|
<img alt="Team working" src="https://placehold.co/800x1000/1f3a5f/fbfaf7?text=Team+working" style="display:block;width:400px;aspect-ratio:4/3;object-fit:cover">
|
|
<div style="background:url(https://placehold.co/1200x400/222222/eeeeee?text=Hero) center/cover #ff0000;height:100px"></div>
|
|
<img alt="Nope" src="https://placehold.co/200x100/orange/white?text=Nope" style="display:block">
|
|
</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]],
|
|
]),
|
|
// placehold.co images are drawn LOCALLY (no request) in the element's own box (eval 2026-11-01: blank / alt text).
|
|
['placehold.co <img> (width 400, aspect-ratio 4/3, object-fit cover): bg colour', 10, 950, [0x1f, 0x3a, 0x5f]],
|
|
['... box ends at 400px wide (white beyond)', 405, 950, [0xff, 0xff, 0xff]],
|
|
['... and at 300px tall (bottom-right corner still bg)', 395, 1236, [0x1f, 0x3a, 0x5f]],
|
|
['placehold.co CSS background (center/cover, red fallback colour): bg colour, not the fallback', 5, 1245, [0x22, 0x22, 0x22]],
|
|
['off-spec placehold.co (named colours): plain neutral #cccccc box of the URL size', 100, 1390, [0xcc, 0xcc, 0xcc]],
|
|
['... that is 200px wide (white beyond)', 205, 1390, [0xff, 0xff, 0xff]],
|
|
];
|
|
// Rows scanned for the label text colour (any pixel close to it).
|
|
const SCANS = [
|
|
['placehold.co <img> shows its ?text= label in the fg colour', 1090, 20, 380, [0xfb, 0xfa, 0xf7]],
|
|
['placehold.co CSS background shows its label in the fg colour', 1290, 300, 980, [0xee, 0xee, 0xee]],
|
|
];
|
|
|
|
// Tall pages -> side-by-side strips (the geometry the server-side review prompt describes): bands of 2500 px
|
|
// (or 4000 px) each with a 100 px black bar on top, so each strip's colour AND its top edge can be checked.
|
|
const band = (h, colors) => `<!doctype html><html><head><style>body{margin:0}</style></head><body>${colors.map((c) => `<div style="height:${h}px;background:${c}"><div style="height:100px;background:#000"></div></div>`).join('')}</body></html>`;
|
|
const TALL = [
|
|
{ label: '7500 px page', html: band(2500, ['#c03030', '#30c030', '#3030c0']), strips: 3, w: 3888, h: 2500, cropped: false,
|
|
pts: [[640, 1250, [0xc0, 0x30, 0x30]], [1292, 1250, [0x80, 0x80, 0x80]], [1944, 1250, [0x30, 0xc0, 0x30]], [2596, 1250, [0x80, 0x80, 0x80]], [3248, 1250, [0x30, 0x30, 0xc0]],
|
|
[640, 50, [0, 0, 0]], [1944, 50, [0, 0, 0]], [3248, 50, [0, 0, 0]], [1944, 2490, [0x30, 0xc0, 0x30]]] },
|
|
{ label: '5000 px page', html: band(2500, ['#c03030', '#30c030']), strips: 2, w: 2584, h: 2500, cropped: false,
|
|
pts: [[640, 1250, [0xc0, 0x30, 0x30]], [1292, 1250, [0x80, 0x80, 0x80]], [1944, 1250, [0x30, 0xc0, 0x30]], [1944, 50, [0, 0, 0]]] },
|
|
// 12000 px: top 9000 only, 3 x 3000; strip 2 starts at page y 3000 (inside band 1: red), strip 3 at 6000 (green), bars at 0/4000/8000.
|
|
{ label: '12000 px page (cropped)', html: band(4000, ['#c03030', '#30c030', '#3030c0']), strips: 3, w: 3888, h: 3000, cropped: true,
|
|
pts: [[640, 50, [0, 0, 0]], [1944, 50, [0xc0, 0x30, 0x30]], [1944, 1050, [0, 0, 0]], [3248, 50, [0x30, 0xc0, 0x30]], [3248, 2050, [0, 0, 0]], [3248, 2990, [0x30, 0x30, 0xc0]]] },
|
|
{ label: '1500 px page (no strips)', html: band(1500, ['#c03030']), strips: 1, w: 1280, h: 1500, cropped: false, pts: [[640, 750, [0xc0, 0x30, 0x30]]] },
|
|
];
|
|
|
|
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 scanPts = SCANS.flatMap(([, y, x0, x1]) => Array.from({ length: Math.floor((x1 - x0) / 2) }, (_, k) => [x0 + 2 * k, y]));
|
|
const placeholdReq = [];
|
|
page.on('request', (q) => { if (/placehold\.co/.test(q.url())) placeholdReq.push(q.url()); });
|
|
const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, [...POINTS.map((p) => [p[1], p[2]]), ...scanPts]]);
|
|
check(name, 'capture succeeded', res.ok === true, res.ok ? '' : res.reason);
|
|
check(name, 'no request to placehold.co', placeholdReq.length === 0, JSON.stringify(placeholdReq));
|
|
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(',')})`));
|
|
let at = POINTS.length;
|
|
for (const [label, , x0, x1, want] of SCANS) {
|
|
const n = Math.floor((x1 - x0) / 2);
|
|
const row = res.samples.slice(at, at + n); at += n;
|
|
const hits = row.filter((px) => near(px, want, 12)).length;
|
|
check(name, label, hits >= 3, `(${hits} pixels near rgb(${want.join(',')}))`);
|
|
}
|
|
}
|
|
// Round 6 (live case 4, Firefox): a background placeholder showed a faint "1920 x 1080" ghost label behind the
|
|
// hero in the review render (the same-colour label still antialiased). The URL the editor stores for the live
|
|
// case's hero, through the real capture path: every pixel of the box must be the box colour (max channel
|
|
// deviation <= 2 for JPEG), over a dense grid that covers where the label was drawn.
|
|
{
|
|
const LIVE = 'https://placehold.co/1920x1080/101a2b/fbfaf7?text=Team+collaborating+in+a+modern+office';
|
|
const bgUrl = await page.evaluate((u) => window.cleanedBgImage(u), LIVE);
|
|
check(name, 'label-free background placeholder URL (zero-width label)', /^https:\/\/placehold\.co\/1920x1080\/101a2b\/101a2b\?text=%E2%80%8B$/.test(bgUrl), bgUrl);
|
|
// the live case-4 hero as exported (BackgroundSection: quoted url(), cover, a same-colour overlay at 0.55)
|
|
const hero = `<!doctype html><html><head><style>body{margin:0;background:#fff}</style></head><body><section style="position:relative;width:100%;min-height:200px;background-color:#ff0000;background-image:url('${bgUrl}');background-size:cover;background-position:center;padding-top:160px;padding-bottom:160px;padding-left:24px;padding-right:24px;text-align:left"><div style="position:absolute;inset:0;background-color:#101a2b;opacity:0.55"></div><div style="position:relative;height:400px"></div></section></body></html>`;
|
|
const grid = [];
|
|
for (let y = 16; y <= 704; y += 4) for (let x = 16; x <= 1264; x += 4) grid.push([x, y]);
|
|
const r = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [hero, grid]);
|
|
check(name, 'label-free background placeholder captured', r.ok === true, r.ok ? '' : r.reason);
|
|
if (r.ok) {
|
|
let worst = 0; let at = null;
|
|
r.samples.forEach((px, i) => { const d = Math.max(...px.map((v, k) => Math.abs(v - [0x10, 0x1a, 0x2b][k]))); if (d > worst) { worst = d; at = grid[i]; } });
|
|
check(name, `label-free background placeholder: all ${grid.length} pixels are the box colour (no ghost label)`, worst <= 2, `(max deviation ${worst} at ${JSON.stringify(at)})`);
|
|
}
|
|
}
|
|
for (const t of TALL) {
|
|
const r = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [t.html, t.pts.map((p) => [p[0], p[1]])]);
|
|
check(name, `${t.label}: captured`, r.ok === true, r.ok ? `(${r.bytes} bytes, ${r.ms} ms)` : r.reason);
|
|
if (!r.ok) continue;
|
|
check(name, `${t.label}: ${t.strips} strip(s), ${t.w}x${t.h}, cropped=${t.cropped}`, r.strips === t.strips && r.w === t.w && r.h === t.h && r.cropped === t.cropped,
|
|
`got ${r.strips} strip(s) ${r.w}x${r.h} cropped=${r.cropped} pageHeight=${r.pageHeight}`);
|
|
t.pts.forEach(([x, y, want], i) => check(name, `${t.label}: pixel (${x},${y})`, near(r.samples[i], want), `got rgb(${r.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);
|