Merge feat/sitesmith-ui into release/sitesmith-colors

Conflict: craft/package.json scripts (kept mobile, colour and sitesmith e2e entries).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 10:55:05 -08:00
commit ae092990ca
32 files changed
+3875 -51

No files matched your search

+7
View File
@@ -20,6 +20,7 @@
"@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5",
"html-to-image": "1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -2349,6 +2350,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+2
View File
@@ -16,6 +16,7 @@
"test:browser:mobile": "node scripts/e2e-mobile-parity.mjs",
"test:browser:colorpick": "node scripts/e2e-color-pick.mjs",
"test:browser:colors": "node scripts/e2e-color-system.mjs",
"test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs",
"test:unit:watch": "vitest"
},
"dependencies": {
@@ -31,6 +32,7 @@
"@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5",
"html-to-image": "1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -0,0 +1,16 @@
// Bundled by scripts/e2e-capture-security.mjs and run in real Firefox + Chromium.
import { captureHtml } from '../src/utils/self-review-capture';
import { CAPTURE_CSP } from '../src/utils/sanitize-capture-html';
(window as any).captureHtml = captureHtml;
/** Defence-in-depth probe: the capture frame WITHOUT the sanitiser (sandbox + CSP only). */
(window as any).rawFrame = (html: string) => new Promise<void>((resolve) => {
const f = document.createElement('iframe');
f.setAttribute('sandbox', 'allow-same-origin');
const CSP = CAPTURE_CSP;
f.setAttribute('csp', CSP);
f.style.cssText = 'position:fixed;left:-9999px;width:800px;height:600px';
f.addEventListener('load', () => setTimeout(resolve, 800), { once: true });
f.srcdoc = html.replace('<head>', `<head><meta http-equiv="Content-Security-Policy" content="${CSP}">`);
document.body.appendChild(f);
});
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* Real-browser proof (Firefox + Chromium) that the self-review capture cannot run or leak hostile page
* markup. A malicious fixture goes through the WHOLE capture path; sentinels: window.__pwned in the
* parent, any request to /beacon or evil.example, parent cookie/localStorage reachability, frame
* navigation. A second probe feeds the SAME hostile page to the frame WITHOUT the sanitiser to show the
* sandbox + CSP alone also hold. Benign pages must still capture.
* Run: node scripts/e2e-capture-security.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-security-entry.ts')],
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"' },
});
const js = out.outputFiles[0].text;
const PWN = "(function(){try{parent.__pwned=1;top.__pwned=1}catch(e){}try{fetch('/beacon?js')}catch(e){}try{top.postMessage('pwned','*')}catch(e){}try{new Image().src='/beacon?img-js'}catch(e){}})()";
const EVIL = `<!doctype html><html><head>
<meta http-equiv="refresh" content="0;url=/beacon?refresh"><base href="https://evil.example/">
<link rel="import" href="/beacon?import"><link rel="stylesheet" href="/beacon?css"><link rel="preload" href="/beacon?preload" as="script">
<style>@import url(/beacon?atimport); body{background:url(https://evil.example/bg.png);} .pad{padding:8px}</style>
<script>${PWN}</script></head>
<body onload="${PWN}" style="font:16px sans-serif">
<h1 class="pad" style="color:#123">Hostile but readable</h1>
<img src="x" onerror="${PWN}"><img src="/beacon?img">
<svg width="10" height="10" onload="${PWN}"><script>${PWN}</script><a xlink:href="javascript:${PWN}"><rect width="5" height="5"/></a></svg>
<a href="javascript:${PWN}">js link</a><form action="javascript:${PWN}"><button formaction="javascript:${PWN}">b</button></form>
<iframe srcdoc="<script>${PWN}</script>"></iframe><iframe src="/beacon?iframe"></iframe>
<object data="/beacon?object"></object><embed src="/beacon?embed">
<div style="background:url(/beacon?cssurl)">x</div><video src="/beacon?video"></video>
<input autofocus onfocus="${PWN}"><details open ontoggle="${PWN}"><summary>s</summary></details>
<style>
.b1{background:u\\72l(/beacon?b-escape)} .b2{background-image:image-set("/beacon?b-imageset" 1x)} .b3{background:url("x)/beacon?b-paren")}
.b4{background:ur/**/l(/beacon?b-comment)} :root{--u:url(/beacon?b-var)} .b5{background:var(--u)}
@font-face{font-family:bx;src:url(/beacon?b-fontface)} .b6{font-family:bx}
@font-face{font-family:by;src:url(/api/site-builder.php?action=delete_asset)} .b7{font-family:by}
.b8{background:url(assets/%2e%2e/beacon?b-pct)} .b9{background:url(assets/%252e%252e/beacon?b-pct2)} .b10{background:url(assets/..%2fbeacon?b-pct3)}
.b11{background:url(javascript:1)} .b12{background:url(blob:http://x/y)} .b13{background:url("data:text/html,<b>1</b>")} .b14{-moz-binding:url(/beacon?b-moz)}
@keyframes bk{from{background:url(/beacon?b-kf)}to{color:red}} @media (min-width:1px){.b15{background:url(/beacon?b-media)}}
</style>
<div class="b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15" style="background:u\\72l(/beacon?i-escape);background-image:image-set('/beacon?i-set' 1x)">css bypass probes</div>
<img src="assets/%2e%2e/beacon?p1"><img src="assets/%252e%252e/beacon?p2"><img src="assets/..%2fbeacon?p3"><img src="assets/../beacon?p4"><img src="//localhost/beacon?p5"><img src="data:text/html,x"><img src="blob:http://x/y"><img src="assets\\..\\beacon?p6">
<p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p>
</body></html>`;
const BENIGN = `<!doctype html><html><head><style>body{margin:0;font:20px sans-serif}.h{background:linear-gradient(135deg,#0f172a,#3b82f6);color:#fff;padding:40px}</style></head>
<body><div class="h"><h1>Benign page</h1><p>${'Hello world. '.repeat(30)}</p></div></body></html>`;
const beacons = [];
const srv = http.createServer((q, r) => {
const p = q.url.split('?')[0];
if (p === '/') { r.setHeader('content-type', 'text/html'); return r.end('<!doctype html><title>host</title><body>host</body>'); }
if (p === '/beacon') { beacons.push(q.url); r.statusCode = 204; return r.end(); }
r.statusCode = 404; r.end('nf');
}).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 : ''}`); };
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const ctx = await browser.newContext();
const page = await ctx.newPage();
const evilReq = [];
await ctx.route(/evil\.example/, (route) => { evilReq.push(route.request().url()); route.abort(); });
// Only requests that actually reach the network count (Playwright also reports CSP-blocked ones as 'request').
let messages = 0;
await page.exposeFunction('__onMsg', () => { messages++; });
await page.goto(`http://localhost:${port}/`);
await page.evaluate(() => { document.cookie = 'sess=SECRET; path=/'; localStorage.setItem('csrf', 'SECRET'); window.addEventListener('message', (e) => { if (e.data === 'pwned') window.__onMsg(); }); });
await page.addScriptTag({ content: js });
// 1) hostile page through the whole capture path
beacons.length = 0;
const res = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size } : r; }, EVIL);
await page.waitForTimeout(500);
const pwned = await page.evaluate(() => window.__pwned);
check(name, 'hostile page: parent sentinel window.__pwned untouched', pwned === undefined, `(${pwned})`);
check(name, 'hostile page: no postMessage to the editor', messages === 0, `(${messages})`);
check(name, 'hostile page: ZERO requests to the panel origin /beacon (every CSS/URL/encoding bypass class)', beacons.length === 0, JSON.stringify(beacons));
check(name, 'hostile page: no request to evil.example', evilReq.length === 0, JSON.stringify(evilReq));
check(name, 'hostile page: capture result is safe (ok, or refused) and frame removed', (res.ok || typeof res.reason === 'string') && (await page.evaluate(() => document.querySelectorAll('iframe').length)) === 0, JSON.stringify(res));
check(name, 'hostile page: sanitised content still captured (readable page)', res.ok === true && res.size > 3000, JSON.stringify(res));
// 2) defence in depth: same hostile page, NO sanitiser, only sandbox + CSP
beacons.length = 0; evilReq.length = 0; messages = 0;
await page.evaluate((h) => window.rawFrame(h), EVIL);
await page.waitForTimeout(500);
const pwned2 = await page.evaluate(() => window.__pwned);
check(name, 'unsanitised probe: sandbox+CSP alone stop script execution (__pwned)', pwned2 === undefined, `(${pwned2})`);
check(name, 'unsanitised probe: no postMessage', messages === 0);
// Same-origin passive GETs (img/css url) are allowed by CSP 'self'; that is why the sanitiser also restricts them. Script-initiated ones must never occur.
check(name, 'unsanitised probe: CSP alone (img-src data:) lets no request reach the panel origin', beacons.length === 0, JSON.stringify(beacons));
check(name, 'unsanitised probe: no evil.example requests', evilReq.length === 0, JSON.stringify(evilReq));
await page.evaluate(() => document.querySelectorAll('iframe').forEach((f) => f.remove()));
// 3) benign page still captures
const ok = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size, w: r.width, h: r.height } : r; }, BENIGN);
check(name, 'benign page captures', ok.ok === true && ok.size > 3000, JSON.stringify(ok));
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);
+370
View File
@@ -0,0 +1,370 @@
/**
* Mock editor + Sitesmith backend for real-browser tests.
*
* Serves a BUILT editor (vite dist), injects window.WHP_CONFIG into index.html, answers
* /api/site-builder.php (load/save/list_assets stubs) and implements the Sitesmith backend contract
* (web-files/api/sitesmith.php, docs/sitesmith-backend.md) at /api/sitesmith.php?action=NAME.
* No network access beyond 127.0.0.1; deterministic; every request is recorded.
*
* const srv = await createMockServer({ distDir, project });
* srv.setNextResult({ type: 'patch', ops: [], message: 'x' }); // queue a job result
* srv.starts // recorded `start` bodies
* await srv.close();
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import crypto from 'node:crypto';
const MIME = {
'.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.png': 'image/png',
'.svg': 'image/svg+xml', '.json': 'application/json', '.woff2': 'font/woff2', '.map': 'application/json',
};
// ------------------------------------------------------------------ PNG helpers (also used by the test)
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
return t;
})();
const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
/** A valid solid-colour RGB PNG. */
export function makePng(w = 32, h = 32, [r, g, b] = [99, 102, 241]) {
const row = Buffer.alloc(1 + w * 3);
for (let x = 0; x < w; x++) { row[1 + x * 3] = r; row[2 + x * 3] = g; row[3 + x * 3] = b; }
const raw = Buffer.concat(Array.from({ length: h }, () => row));
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
function sniff(buf) {
if (buf.length > 24 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return { mime: 'image/png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
let i = 2, width = null, height = null;
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) { i++; continue; }
const m = buf[i + 1];
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) { height = buf.readUInt16BE(i + 5); width = buf.readUInt16BE(i + 7); break; }
i += 2 + buf.readUInt16BE(i + 2);
}
return { mime: 'image/jpeg', width, height };
}
if (buf.length > 6 && buf.subarray(0, 3).toString() === 'GIF') return { mime: 'image/gif', width: null, height: null };
if (buf.length > 12 && buf.subarray(0, 4).toString() === 'RIFF' && buf.subarray(8, 12).toString() === 'WEBP') return { mime: 'image/webp', width: null, height: null };
return null;
}
/** Minimal multipart/form-data parser: scans the raw body for the boundary. */
function parseMultipart(buf, contentType) {
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
if (!m) return [];
const delim = Buffer.from('--' + (m[1] || m[2]));
const parts = [];
let pos = buf.indexOf(delim);
while (pos !== -1) {
const next = buf.indexOf(delim, pos + delim.length);
if (next === -1) break;
// part = [pos+delim+CRLF, next-CRLF)
const part = buf.subarray(pos + delim.length + 2, next - 2);
const sep = part.indexOf('\r\n\r\n');
if (sep !== -1) {
const head = part.subarray(0, sep).toString('utf8');
const nameM = /name="([^"]*)"/.exec(head);
const fileM = /filename="([^"]*)"/.exec(head);
parts.push({ name: nameM ? nameM[1] : '', filename: fileM ? fileM[1] : null, data: part.subarray(sep + 4) });
}
pos = next;
}
return parts;
}
const PHASES = ['queued', 'preparing', 'captioning', 'generating', 'finalizing'];
export const DEFAULT_PROJECT = (() => {
const node = (resolvedName, props, nodes = [], parent = null, isCanvas = false, extra = {}) =>
({ type: { resolvedName }, isCanvas, props, displayName: resolvedName, custom: {}, hidden: false, parent, nodes, linkedNodes: {}, ...extra });
const craft = {
ROOT: node('Container', { style: { minHeight: '400px', backgroundColor: '#ffffff' }, tag: 'div' }, ['sec1'], null, true),
sec1: node('Container', { style: { backgroundColor: '#1e3a8a', padding: '40px' }, tag: 'section', node_id: 'sec1' }, ['head1'], 'ROOT', true),
head1: node('Heading', { text: 'Original heading', level: 'h1', style: { color: '#ffffff', fontSize: '36px' }, node_id: 'head1' }, [], 'sec1'),
};
return {
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: JSON.stringify(craft) }],
design: {},
extras: {},
};
})();
export const DEFAULT_ASSETS = [
{ filename: 'hero.png', caption: 'A wide blue banner photo', alt: 'Banner', width: 64, height: 32, color: [30, 58, 138] },
{ filename: 'team.png', caption: 'The team standing outside the shop', alt: null, width: 48, height: 48, color: [16, 185, 129] },
{ filename: 'logo.png', caption: null, alt: null, width: 32, height: 32, color: [245, 158, 11] },
];
/**
* @param {{distDir: string, port?: number, project?: object, host?: string}} o
*/
export async function createMockServer(o) {
const distDir = o.distDir;
const project = o.project ?? DEFAULT_PROJECT;
const csrf = 'x';
const siteId = 1;
const s = {
requests: [], // every request: {method, path, action, body?}
starts: [], // parsed `start` bodies
cancels: [], // parsed `cancel` bodies
uploads: [], // {fields, name, size, mime, id}
imageDeletes: [], // ids
refCalls: [], // {action, body}
saves: 0,
};
// ---- scriptable state
let vision = true;
let phaseDelayMs = 300;
let slow = false; // jobs stay in `generating` until release()/cancel
let uploadFailStatus = 0; // non-zero: ref_image_upload answers this status with a JSON error
let enabled = true;
const results = []; // queued results for upcoming jobs
const jobs = new Map(); // job_id -> job
const images = new Map(); // id -> {meta, bytes, mime}
let refs = [];
let refSeq = 1;
let activeJobId = null;
const defaultResult = () => ({
ok: true,
response: {
type: 'replace', scope: 'section', message: 'Default mock result',
pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: { node_id: 'mock-default', style: { padding: '20px' } }, nodes: [{ type: { resolvedName: 'Heading' }, props: { node_id: 'mock-default-h', text: 'Default mock heading', style: { color: '#111111' } }, nodes: [] }] } }],
},
});
const toResult = (r) => (r && typeof r === 'object' && 'ok' in r ? r : { ok: true, response: r });
function newJob(result) {
const id = crypto.randomBytes(8).toString('hex');
const job = { id, created: Math.floor(Date.now() / 1000), startedAt: Date.now(), step: 0, lastAdvance: 0, status: 'running', result: toResult(result ?? results.shift() ?? defaultResult()), polls: 0 };
jobs.set(id, job);
return job;
}
function stepJob(job) {
job.polls++;
if (job.status !== 'running') return;
if (slow) { job.step = Math.min(Math.max(job.step, 1), 3); if (job.step < 3) job.step++; return; }
const now = Date.now();
if (now - job.lastAdvance < phaseDelayMs * 0.8) return;
job.lastAdvance = now;
job.step++;
if (job.step >= PHASES.length) job.status = 'done';
}
const jobView = (job) => {
const phase = job.status === 'done' ? 'done' : job.status === 'cancelled' ? 'done' : PHASES[Math.min(job.step, PHASES.length - 1)];
return { job_id: job.id, status: job.status, phase, elapsed_s: Math.floor((Date.now() - job.startedAt) / 1000), created_at: job.created };
};
const imageMeta = (im) => im.meta;
// ---- public controls
Object.assign(s, {
/** Queue the result of the next job (a bare response object, or a full {ok,...} result). */
setNextResult(resp, notes) { const r = toResult(resp); if (notes) r.notes = notes; results.push(r); },
clearResults() { results.length = 0; },
setPhaseDelayMs(ms) { phaseDelayMs = ms; },
setSlow(v) { slow = !!v; },
setVision(v) { vision = !!v; },
setUploadFail(status) { uploadFailStatus = status | 0; },
setEnabled(v) { enabled = !!v; },
/** Pretend a job is already running (as after a page reload). It advances with polls like any job. */
setActiveJob(o2 = {}) {
const job = newJob(o2.result);
if (o2.phase) job.step = Math.max(0, PHASES.indexOf(o2.phase));
if (o2.elapsed_s) job.startedAt = Date.now() - o2.elapsed_s * 1000;
activeJobId = job.id;
return job.id;
},
/** Let a `setSlow(true)` job finish on its next poll. */
release() { slow = false; for (const j of jobs.values()) if (j.status === 'running') j.step = PHASES.length - 1; },
/** Forget refs/images/jobs/recordings between scenarios (keeps knobs at their defaults). */
reset() {
for (const k of ['requests', 'starts', 'cancels', 'uploads', 'imageDeletes', 'refCalls']) s[k].length = 0;
results.length = 0; jobs.clear(); images.clear(); refs = []; refSeq = 1; activeJobId = null;
vision = true; phaseDelayMs = 300; slow = false; uploadFailStatus = 0; enabled = true;
},
get imageCount() { return images.size; },
get storedImages() { return [...images.values()].map((i) => ({ ...i.meta, mime: i.mime })); },
requestsTo(pathPrefix) { return s.requests.filter((r) => r.path.startsWith(pathPrefix)); },
});
// ---- handlers
const json = (res, code, body) => { res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
const readBody = (req) => new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c))); });
async function sitesmith(req, res, u, action) {
const isPost = req.method === 'POST';
const raw = isPost ? await readBody(req) : Buffer.alloc(0);
const ct = req.headers['content-type'] || '';
const rec = { method: req.method, path: u.pathname, action, query: Object.fromEntries(u.searchParams) };
s.requests.push(rec);
if (isPost && req.headers['x-csrf-token'] !== csrf) return json(res, 403, { ok: false, error: 'csrf', message: 'Your session has expired. Refresh the page.' });
let body = {};
if (isPost && !ct.startsWith('multipart/')) { try { body = JSON.parse(raw.toString('utf8') || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad_json', message: 'Bad request.' }); } }
rec.body = body;
switch (action) {
case 'entitlement':
return json(res, 200, { ok: true, summary: { enabled, status: enabled ? 'OK_MONTHLY' : 'DISABLED', monthly_used: 0, monthly_cap: 50, bonus_credits: 0, resets_on: '2026-12-01' }, profile_supported: true, vision });
case 'capabilities':
return json(res, 200, {
ok: true,
vision: { available: vision, source: vision ? 'primary' : 'none', message: vision ? '' : 'Image features are not available on this server, so Sitesmith cannot look at pictures yet. Describing what you want in words works as usual.' },
features: { mockups: vision, reference_screenshots: vision, captions: vision, self_review: vision },
limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 },
});
case 'history': {
const aj = activeJobId ? jobs.get(activeJobId) : null;
const live = aj && aj.status === 'running' ? jobView(aj) : null;
return json(res, 200, { ok: true, messages: [], active_job: live ? { job_id: live.job_id, status: live.status, phase: live.phase, elapsed_s: live.elapsed_s, created_at: live.created_at } : null });
}
case 'job_active': {
const aj = activeJobId ? jobs.get(activeJobId) : null;
return json(res, 200, { ok: true, job: aj && aj.status === 'running' ? jobView(aj) : null });
}
case 'start': {
s.starts.push(body);
for (const j of jobs.values()) if (j.status === 'running') return json(res, 409, { ok: false, error: 'job_in_progress', status: 'JOB_IN_PROGRESS', message: 'Sitesmith is already working on a request for this site.', job_id: j.id });
const job = newJob();
activeJobId = job.id;
return json(res, 200, { ok: true, job_id: job.id, status: 'running', phase: 'queued', created_at: job.created });
}
case 'poll': {
const job = jobs.get(u.searchParams.get('job_id') || '');
if (!job) return json(res, 404, { ok: false, error: 'job_not_found', message: 'That job could not be found.' });
stepJob(job);
const v = jobView(job);
if (job.status === 'cancelled') return json(res, 200, { ...v, ok: true, result: { ok: false, status: 'CANCELLED', message: 'The request was cancelled.' } });
if (job.status === 'done') { if (activeJobId === job.id) activeJobId = null; return json(res, 200, { ...v, ok: true, result: job.result }); }
return json(res, 200, { ...v, ok: true, retry_after_ms: phaseDelayMs });
}
case 'cancel': {
s.cancels.push(body);
const job = jobs.get(body.job_id);
if (!job) return json(res, 404, { ok: false, error: 'job_not_found', message: 'That job could not be found.' });
if (job.status === 'running') job.status = 'cancelled';
if (activeJobId === job.id) activeJobId = null;
return json(res, 200, { ok: true, status: job.status });
}
case 'refs_list':
return json(res, 200, { ok: true, references: refs, images: [...images.values()].map(imageMeta) });
case 'refs_add': {
s.refCalls.push({ action, body });
const now = new Date().toISOString();
const r = { id: refSeq++, url_label: String(body.url_label ?? ''), what_i_like: String(body.what_i_like ?? ''), screenshot_ids: body.screenshot_ids ?? [], created_at: now, updated_at: now };
refs.push(r);
return json(res, 200, { ok: true, reference: r });
}
case 'refs_update': {
s.refCalls.push({ action, body });
const r = refs.find((x) => x.id === body.id);
if (!r) return json(res, 404, { ok: false, error: 'not_found', message: 'Reference not found.' });
for (const k of ['url_label', 'what_i_like', 'screenshot_ids']) if (k in body) r[k] = body[k];
r.updated_at = new Date().toISOString();
return json(res, 200, { ok: true, reference: r });
}
case 'refs_delete':
s.refCalls.push({ action, body });
refs = refs.filter((x) => x.id !== body.id);
return json(res, 200, { ok: true });
case 'ref_image_upload': {
const parts = parseMultipart(raw, ct);
const fields = Object.fromEntries(parts.filter((p) => p.filename === null).map((p) => [p.name, p.data.toString('utf8')]));
const file = parts.find((p) => p.name === 'file' && p.filename !== null);
s.uploads.push({ fields, name: file?.filename ?? null, size: file?.data.length ?? 0, head: file ? file.data.subarray(0, 4).toString('hex') : '' });
if (uploadFailStatus) return json(res, uploadFailStatus, { ok: false, error: 'server_error', message: 'The server had a problem saving that picture. Try again in a minute.' });
if (!file) return json(res, 400, { ok: false, error: 'no_file', message: 'No file was uploaded.' });
const info = sniff(file.data);
if (!info) return json(res, 415, { ok: false, error: 'unsupported_type', message: 'That file is not a picture Sitesmith can use. Use a JPEG, PNG, WebP or GIF.' });
const id = crypto.randomBytes(16).toString('hex');
const meta = { id, kind: fields.kind === 'screenshot' ? 'screenshot' : 'mockup', name: file.filename || 'image', width: info.width, height: info.height, size: file.data.length, created_at: new Date().toISOString() };
images.set(id, { meta, bytes: file.data, mime: info.mime });
s.uploads[s.uploads.length - 1].id = id;
s.uploads[s.uploads.length - 1].mime = info.mime;
s.uploads[s.uploads.length - 1].kind = meta.kind;
return json(res, 200, { ok: true, image: meta });
}
case 'ref_image_delete':
s.imageDeletes.push(body.id);
images.delete(body.id);
return json(res, 200, { ok: true });
case 'ref_image': {
const im = images.get(u.searchParams.get('id') || '');
if (!im) { res.statusCode = 404; return res.end('nf'); }
res.statusCode = 200; res.setHeader('content-type', im.mime); res.setHeader('x-content-type-options', 'nosniff');
return res.end(im.bytes);
}
case 'assets':
return json(res, 200, { ok: true, assets: DEFAULT_ASSETS.map((a) => ({ filename: a.filename, relUrl: `assets/${a.filename}`, url: `/mock-assets/${a.filename}`, width: a.width, height: a.height, caption: a.caption, alt: a.alt })) });
default:
return json(res, 400, { ok: false, error: 'unknown_action', message: 'Unknown action.' });
}
}
const server = http.createServer(async (req, res) => {
try {
const u = new URL(req.url, 'http://x');
const action = u.searchParams.get('action') || '';
if (u.pathname === '/api/sitesmith.php') return await sitesmith(req, res, u, action);
s.requests.push({ method: req.method, path: u.pathname, action });
if (u.pathname === '/api/site-builder.php') {
if (req.method === 'POST') await readBody(req);
if (action === 'load') return json(res, 200, { success: true, rev: 'r1', limits: {}, project });
if (action === 'save') { s.saves++; return json(res, 200, { success: true, rev: 'r' + (s.saves + 1) }); }
if (action === 'list_assets') return json(res, 200, { success: true, assets: [] });
return json(res, 200, { success: true });
}
if (u.pathname.startsWith('/mock-assets/')) {
const a = DEFAULT_ASSETS.find((x) => x.filename === path.basename(u.pathname));
if (a) { res.setHeader('content-type', 'image/png'); return res.end(makePng(a.width, a.height, a.color)); }
}
const p = u.pathname === '/' ? '/index.html' : u.pathname;
if (p === '/index.html') {
const cfg = { user: 't', apiUrl: '/api/site-builder.php', csrfToken: csrf, siteId, siteDomain: 'olsdigital.com', siteName: 'OLS', backUrl: '/', isRoot: false };
const h = fs.readFileSync(path.join(distDir, 'index.html'), 'utf8').replace('</head>', () => `<script>window.WHP_CONFIG=${JSON.stringify(cfg)};</script></head>`);
res.setHeader('content-type', 'text/html'); return res.end(h);
}
const f = path.join(distDir, path.normalize(p));
if (f.startsWith(distDir) && fs.existsSync(f) && fs.statSync(f).isFile()) {
res.setHeader('content-type', MIME[path.extname(f)] || 'application/octet-stream');
return res.end(fs.readFileSync(f));
}
res.statusCode = 404; res.end('nf');
} catch (e) {
res.statusCode = 500; res.end(String(e));
}
});
await new Promise((resolve) => server.listen(o.port ?? 0, o.host ?? '127.0.0.1', resolve));
s.port = server.address().port;
s.url = `http://127.0.0.1:${s.port}`;
s.close = () => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });
return s;
}
// Standalone: node scripts/e2e-sitesmith-mock-server.mjs <distDir> [port]
if (import.meta.url === `file://${process.argv[1]}`) {
const srv = await createMockServer({ distDir: path.resolve(process.argv[2] || 'dist'), port: Number(process.argv[3] || 0) });
console.log('mock sitesmith server up', srv.url);
}
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env node
/**
* Real-browser (Firefox AND Chromium, headless) end-to-end test of the Sitesmith modal.
*
* Builds the editor to a scratch dir, serves it from a mock server that implements the Sitesmith
* backend contract (scripts/e2e-sitesmith-mock-server.mjs), and drives the REAL modal: job
* start/poll/cancel/resume, mockup + reference upload, "Sites I like", asset awareness, contrast
* repair, vision-unavailable mode and the one-round self-review (real html-to-image capture).
* No real host or AI provider is contacted: every non-127.0.0.1 request is answered locally.
*
* npm run test:browser:sitesmith
*
* Env:
* CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries
* SITESMITH_E2E_DIST=<dir> where to build (default: a fresh temp dir)
* SITESMITH_E2E_SKIP_BUILD=1 reuse an existing build in SITESMITH_E2E_DIST
* SITESMITH_SHOTS=<dir> Firefox screenshots go here (default: <tmp>/sitesmith-shots)
* SITESMITH_ONLY=chromium|firefox run one browser
* SITESMITH_SCENARIO=3,5b run only these scenario ids
*/
import { chromium, firefox } from '@playwright/test';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createMockServer, makePng } from './e2e-sitesmith-mock-server.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const distDir = path.resolve(process.env.SITESMITH_E2E_DIST || fs.mkdtempSync(path.join(os.tmpdir(), 'sitesmith-e2e-')));
const shotsDir = path.resolve(process.env.SITESMITH_SHOTS || path.join(os.tmpdir(), 'sitesmith-shots'));
fs.mkdirSync(shotsDir, { recursive: true });
if (!(process.env.SITESMITH_E2E_SKIP_BUILD && fs.existsSync(path.join(distDir, 'index.html')))) {
console.log(`building editor -> ${distDir}`);
const b = spawnSync('npx', ['vite', 'build', '--outDir', distDir, '--emptyOutDir'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
if (b.status !== 0) { console.error(b.stdout, b.stderr); process.exit(2); }
}
const srv = await createMockServer({ distDir });
// ------------------------------------------------------------------ result builders
const el = (name, props, nodes = []) => ({ type: { resolvedName: name }, props, nodes });
const pageResult = (headingText, { id = 'new-h', color = '#ffffff', bg = '#0f172a', size = '36px', weight = '700' } = {}) => ({
type: 'replace', scope: 'page', message: 'Built it.',
pages: [{ name: 'Home', tree: el('Container', { node_id: 'new-root', style: { minHeight: '300px', backgroundColor: '#ffffff' } }, [
el('Container', { node_id: id + '-sec', tag: 'section', style: { backgroundColor: bg, padding: '40px' } }, [
el('Heading', { node_id: id, text: headingText, level: 'h1', style: { color, fontSize: size, fontWeight: weight } }),
]),
]) }],
});
// ------------------------------------------------------------------ harness
let failures = 0;
const log = (b, s, ok, label, detail) => {
if (!ok) failures++;
console.log(`[${b}] ${ok ? 'PASS' : 'FAIL'} ${s}: ${label}${!ok && detail !== undefined ? ` -- ${detail}` : ''}`);
};
const INIT = `(() => {
window.__phases = []; window.__bad = [];
const BAD = /NetworkError|Failed to fetch/;
const scan = () => {
const st = document.querySelector('[data-testid=ss-job-status]');
if (st) {
const ph = st.getAttribute('data-phase') + '|' + (st.querySelector('[data-testid=ss-phase-label]')?.textContent || '');
if (window.__phases[window.__phases.length - 1] !== ph) window.__phases.push(ph);
}
for (const d of document.querySelectorAll('[role=dialog]')) {
const t = d.textContent || '';
const m = BAD.exec(t);
if (m && window.__bad.length < 5) window.__bad.push(t.slice(Math.max(0, m.index - 40), m.index + 60));
}
};
new MutationObserver(scan).observe(document, { subtree: true, childList: true, characterData: true, attributes: true });
try { if (!window.__keepSelfReview) localStorage.setItem('sitesmith.selfReview', 'off'); } catch (e) {}
})();`;
async function newPage(browser, name, { selfReview = false } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const external = [];
await ctx.route(/^https?:\/\/(?!127\.0\.0\.1)/, (route) => {
const u = route.request().url();
external.push(u);
if (/\.css(\?|$)|fonts\.googleapis/.test(u)) return route.fulfill({ status: 200, contentType: 'text/css', headers: { 'access-control-allow-origin': '*' }, body: '/* offline */' });
return route.abort();
});
await ctx.addInitScript(selfReview ? `window.__keepSelfReview = true;${INIT}` : INIT);
const page = await ctx.newPage();
const errors = [];
const seenUrls = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('request', (r) => seenUrls.push(r.url()));
page.__errors = errors; page.__urls = seenUrls; page.__external = external; page.__ctx = ctx;
await page.goto(srv.url + '/');
await page.waitForSelector('.canvas-device-frame [data-craft-container]', { timeout: 20000 });
await page.waitForTimeout(500);
return page;
}
async function openModal(page, { waitInput = true } = {}) {
await page.click('button.sitesmith-btn');
await page.waitForSelector('[role=dialog]', { timeout: 10000 });
if (!waitInput) return;
await page.waitForSelector('textarea.sitesmith-textarea:not([disabled])', { timeout: 10000 });
}
const sendMsg = async (page, text) => { await page.fill('textarea.sitesmith-textarea', text); await page.click('[role=dialog] button:has-text("→")'); };
const canvasText = (page) => page.evaluate(() => document.querySelector('.canvas-device-frame')?.innerText ?? '');
const waitCanvas = (page, text, timeout = 20000) =>
page.waitForFunction((t) => (document.querySelector('.canvas-device-frame')?.innerText ?? '').includes(t), text, { timeout });
const expandSection = async (page, id) => {
const btn = page.locator(`[data-testid=ss-section-${id}] > button`);
if ((await btn.getAttribute('aria-expanded')) !== 'true') await btn.click();
};
const modalShot = async (page, name, file) => { if (name === 'firefox') await page.screenshot({ path: path.join(shotsDir, file) }); };
const contrastIn = (page, text) => page.evaluate((t) => {
const lum = ([r, g, b]) => { const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
const rgba = (s) => { const m = s.match(/[\d.]+/g).map(Number); return { c: m.slice(0, 3), a: m.length > 3 ? m[3] : 1 }; };
const h = [...document.querySelectorAll('.canvas-device-frame h1,.canvas-device-frame h2')].find((e) => e.textContent.includes(t));
if (!h) return null;
const fg = rgba(getComputedStyle(h).color);
let bg = null;
for (let n = h; n; n = n.parentElement) { const b = rgba(getComputedStyle(n).backgroundColor); if (b.a > 0.99) { bg = b.c; break; } }
bg = bg || [255, 255, 255];
const [a, b] = [lum(fg.c), lum(bg)];
return { color: getComputedStyle(h).color, bg: `rgb(${bg.join(', ')})`, ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) };
}, text);
// ------------------------------------------------------------------ scenarios
const scenarios = [];
const scenario = (id, title, fn, opts) => scenarios.push({ id, title, fn, opts });
scenario('1', 'start -> poll progress -> result applied', async ({ page, name, ok }) => {
srv.setNextResult(pageResult('Brand new heading'));
await openModal(page);
await modalShot(page, name, '01-idle.png');
await sendMsg(page, 'Build me a home page');
await page.waitForSelector('[data-testid=ss-job-status][data-phase=generating]', { timeout: 15000 });
await modalShot(page, name, '02-progress.png');
await waitCanvas(page, 'Brand new heading');
ok('result applied to the canvas', true);
const phases = await page.evaluate(() => window.__phases);
const names = phases.map((p) => p.split('|')[0]);
ok('successive phases seen', ['queued', 'preparing', 'captioning', 'generating', 'finalizing'].filter((p) => names.includes(p)).length >= 4, JSON.stringify(phases));
ok('phase labels are shown as text', phases.some((p) => /Designing your page/.test(p)) && phases.some((p) => /Preparing/.test(p)), JSON.stringify(phases));
const body = srv.starts[0] ?? {};
ok('start body has site_id', body.site_id === 1, JSON.stringify(body.site_id));
ok('start body has design.primaryColor', typeof body.design?.primaryColor === 'string', JSON.stringify(body.design));
ok('exactly one start (self-review off)', srv.starts.length === 1, String(srv.starts.length));
});
scenario('2', 'cancel a slow job', async ({ page, name, ok }) => {
srv.setSlow(true);
srv.setNextResult(pageResult('Should never appear'));
await openModal(page);
const before = await canvasText(page);
await sendMsg(page, 'Something slow');
await page.waitForSelector('[data-testid=ss-cancel]', { timeout: 10000 });
await page.waitForSelector('[data-testid=ss-job-status][data-phase=generating]', { timeout: 10000 });
ok('input replaced by status (no textarea while running)', (await page.locator('textarea.sitesmith-textarea').count()) === 0);
await modalShot(page, name, '09-running-disabled.png');
await page.click('[data-testid=ss-cancel]');
await page.waitForSelector('[data-testid=ss-note]', { timeout: 15000 });
ok('mock recorded the cancel', srv.cancels.length === 1 && typeof srv.cancels[0].job_id === 'string', JSON.stringify(srv.cancels));
const note = await page.textContent('[data-testid=ss-note]');
ok('UI shows a Cancelled note', /Cancelled/.test(note ?? ''), note);
ok('canvas unchanged', (await canvasText(page)) === before && !(await canvasText(page)).includes('Should never appear'));
});
scenario('3', 'reload with an active job resumes it', async ({ page, ok }) => {
srv.setActiveJob({ phase: 'generating', elapsed_s: 4, result: { ok: true, response: pageResult('Resumed heading') } });
await page.reload();
await page.waitForSelector('.canvas-device-frame [data-craft-container]', { timeout: 20000 });
await openModal(page, { waitInput: false }); // a resumed job replaces the input box
await page.waitForSelector('[data-testid=ss-job-status]', { timeout: 15000 });
const txt = await page.textContent('[data-testid=ss-job-status]');
ok('status shows the picked-up text', /Picked up a request that was already running/.test(txt ?? ''), txt);
await waitCanvas(page, 'Resumed heading');
ok('result applied after completion', true);
ok('no new start was made', srv.starts.length === 0, String(srv.starts.length));
});
scenario('4', 'mockup upload + client-side rejections', async ({ page, name, ok }) => {
srv.setNextResult(pageResult('Mockup based heading'));
await openModal(page);
await expandSection(page, 'mockups');
const input = page.locator('input[data-testid=ss-mockup-input]');
await input.setInputFiles({ name: 'mock.png', mimeType: 'image/png', buffer: makePng(64, 48) });
await page.waitForSelector('[data-testid=ss-mockup-item] img', { timeout: 10000 });
ok('one upload reached the server', srv.uploads.length === 1 && !!srv.uploads[0].id, JSON.stringify(srv.uploads));
await page.waitForFunction(() => { const i = document.querySelector('[data-testid=ss-mockup-item] img'); return i && i.complete && i.naturalWidth > 0; }, null, { timeout: 10000 });
ok('thumbnail rendered from the server', true);
await modalShot(page, name, '03-mockup-attached.png');
const alertText = () => page.locator('[data-testid=ss-mockups] [role=alert]').first().textContent();
await input.setInputFiles({ name: 'x.png', mimeType: 'image/png', buffer: Buffer.from('this is plain text, not a picture at all') });
await page.waitForSelector('[data-testid=ss-mockups] [role=alert]', { timeout: 5000 });
const a1 = await alertText();
ok('non-image rejected client-side with a message', !!a1 && a1.length > 5, a1);
await page.waitForTimeout(400);
ok('non-image never reached the server', srv.uploads.length === 1, String(srv.uploads.length));
await input.setInputFiles({ name: 'big.png', mimeType: 'image/png', buffer: Buffer.concat([makePng(8, 8), Buffer.alloc(9 * 1024 * 1024)]) });
await page.waitForFunction((prev) => { const a = document.querySelector('[data-testid=ss-mockups] [role=alert]'); return a && a.textContent !== prev; }, a1, { timeout: 8000 }).catch(() => {});
const a2 = await alertText();
ok('oversize file rejected client-side with a message', !!a2 && /limit|8\.0 MB|too (large|big)/i.test(a2), a2);
await page.waitForTimeout(400);
ok('oversize never reached the server', srv.uploads.length === 1, String(srv.uploads.length));
await sendMsg(page, 'Build toward my mockup');
await waitCanvas(page, 'Mockup based heading');
ok('start body mockup_ids has the uploaded id', Array.isArray(srv.starts[0]?.mockup_ids) && srv.starts[0].mockup_ids.includes(srv.uploads[0].id), JSON.stringify(srv.starts[0]?.mockup_ids));
});
scenario('5', 'Sites I like CRUD, selection, never fetched', async ({ page, name, ok }) => {
const label = `${srv.url}/should-never-be-fetched`;
srv.setNextResult(pageResult('Reference based heading'));
await openModal(page);
await expandSection(page, 'sites');
await page.click('[data-testid=ss-sites-i-like] button:has-text("Add a site I like")');
await page.fill('#ss-ref-url', label);
await page.fill('#ss-ref-likes', 'Big photo header');
await page.click('[data-testid=ss-ref-form] button:has-text("Add site")');
await page.waitForSelector('[data-testid=ss-ref-item]', { timeout: 8000 });
ok('added entry listed', (await page.locator('[data-testid=ss-ref-item]').count()) === 1);
// edit
await page.click('[data-testid=ss-ref-item] button:has-text("Edit")');
await page.fill('#ss-ref-likes', 'Simple menu and white space');
await page.click('[data-testid=ss-ref-form] button:has-text("Save changes")');
await page.waitForFunction(() => document.querySelector('[data-testid=ss-ref-item]')?.textContent.includes('Simple menu'), null, { timeout: 8000 });
ok('edit saved (refs_update sent, text changed)', srv.refCalls.some((c) => c.action === 'refs_update'));
// a second entry, then delete it with confirmation
await page.click('[data-testid=ss-sites-i-like] button:has-text("Add a site I like")');
await page.fill('#ss-ref-url', 'second-example.test');
await page.fill('#ss-ref-likes', 'Nice footer');
await page.click('[data-testid=ss-ref-form] button:has-text("Add site")');
await page.waitForFunction(() => document.querySelectorAll('[data-testid=ss-ref-item]').length === 2, null, { timeout: 8000 });
const second = page.locator('[data-testid=ss-ref-item]', { hasText: 'second-example.test' });
await second.locator('button:has-text("Delete")').click();
ok('delete asks for confirmation first', (await page.locator('button:has-text("Confirm delete")').count()) === 1 && !srv.refCalls.some((c) => c.action === 'refs_delete'));
await page.click('button:has-text("Confirm delete")');
await page.waitForFunction(() => document.querySelectorAll('[data-testid=ss-ref-item]').length === 1, null, { timeout: 8000 });
ok('delete removed the entry (refs_delete sent)', srv.refCalls.some((c) => c.action === 'refs_delete'));
// select + copy + send
await page.locator('[data-testid=ss-ref-item] input[type=checkbox]').check();
const copy = page.locator('[data-testid=ss-sites-copy]');
const copyTxt = (await copy.textContent()) ?? '';
ok('"does not open, visit or fetch it" copy visible', (await copy.isVisible()) && /does not open, visit or fetch it/.test(copyTxt), copyTxt);
await modalShot(page, name, '04-sites-i-like.png');
await sendMsg(page, 'Use the sites I like');
await waitCanvas(page, 'Reference based heading');
const refId = srv.refCalls.find((c) => c.action === 'refs_add');
ok('start body reference_ids has the selected id', Array.isArray(srv.starts[0]?.reference_ids) && srv.starts[0].reference_ids.length === 1, JSON.stringify(srv.starts[0]?.reference_ids));
ok('the label was stored as given', !!refId && refId.body.url_label.includes('should-never-be-fetched'), JSON.stringify(refId?.body));
ok('mock never received a request for the label', !srv.requests.some((r) => r.path.includes('should-never-be-fetched')));
ok('the browser never requested the label', !page.__urls.some((u) => u.includes('should-never-be-fetched')));
});
scenario('5b', 'asset awareness', async ({ page, name, ok }) => {
srv.setNextResult(pageResult('Asset aware heading'));
await openModal(page);
await expandSection(page, 'assets');
await page.waitForSelector('[data-testid=ss-asset-item]', { timeout: 8000 });
ok('three assets listed', (await page.locator('[data-testid=ss-asset-item]').count()) === 3);
const txt = (await page.textContent('[data-testid=ss-assets]')) ?? '';
ok('captions shown', txt.includes('A wide blue banner photo') && txt.includes('No description yet'), txt.slice(0, 200));
await page.waitForFunction(() => [...document.querySelectorAll('[data-testid=ss-asset-item] img')].every((i) => i.complete && i.naturalWidth > 0), null, { timeout: 8000 });
ok('thumbnails loaded from same-origin URLs', true);
await page.locator('[data-testid=ss-asset-item]').nth(2).locator('input[type=checkbox]').uncheck();
await modalShot(page, name, '05-asset-awareness.png');
await sendMsg(page, 'Use my pictures');
await waitCanvas(page, 'Asset aware heading');
const b = srv.starts[0] ?? {};
ok('use_assets stays true and the excluded asset is left out', b.use_assets === true && Array.isArray(b.asset_filenames) && b.asset_filenames.length === 2 && !b.asset_filenames.includes('logo.png'), JSON.stringify([b.use_assets, b.asset_filenames]));
});
scenario('6', 'contrast repair', async ({ page, name, ok }) => {
srv.setNextResult(pageResult('Contrast heading', { id: 'c-h', color: '#334155', bg: '#1e3a8a', size: '16px', weight: '400' }));
await openModal(page);
await sendMsg(page, 'Make a blue banner');
await waitCanvas(page, 'Contrast heading');
const c = await contrastIn(page, 'Contrast heading');
ok('heading found in canvas', !!c);
ok('heading colour was changed from #334155', !!c && c.color !== 'rgb(51, 65, 85)', c?.color);
ok('heading now passes 4.5:1 on its background', !!c && c.ratio >= 4.5, JSON.stringify(c));
const adj = page.locator('[data-testid=ss-adjust]');
await adj.waitFor({ timeout: 5000 });
const t = (await adj.textContent()) ?? '';
ok('adjustment summary visible and about readability', (await adj.isVisible()) && /text colou?rs? .*(adjusted|readab)|readab/i.test(t), t.slice(0, 200));
await modalShot(page, name, '06-contrast-summary.png');
});
scenario('7', 'vision unavailable', async ({ page, name, ok }) => {
srv.setVision(false);
srv.setNextResult(pageResult('Text only heading'));
await openModal(page);
await expandSection(page, 'mockups');
await page.waitForSelector('[data-testid=ss-vision-note]', { timeout: 8000 });
ok('vision note shown', await page.locator('[data-testid=ss-vision-note]').isVisible());
ok('file input disabled', await page.locator('input[data-testid=ss-mockup-input]').evaluate((e) => e.disabled));
ok('self-review toggle disabled', await page.locator('[data-testid=ss-selfreview-toggle] input').evaluate((e) => e.disabled && !e.checked));
await modalShot(page, name, '07-vision-unavailable.png');
await sendMsg(page, 'Just words please');
await waitCanvas(page, 'Text only heading');
ok('text-only send works end to end', srv.starts.length === 1 && !srv.starts[0].mockup_ids, JSON.stringify(srv.starts[0]?.mockup_ids));
});
scenario('8', 'self-review round trip (one round)', async ({ page, name, ok }) => {
srv.setNextResult(pageResult('First pass heading', { id: 'fp-h' }));
srv.setNextResult({ type: 'patch', message: 'Fixed spacing.', ops: [{ op: 'update_props', node_id: 'fp-h', props: { text: 'Reviewed heading' } }] });
await openModal(page);
await sendMsg(page, 'Build and then check it');
await page.waitForSelector('[data-testid=ss-review-note]', { timeout: 45000 });
const note = (await page.textContent('[data-testid=ss-review-note]')) ?? '';
ok('review note says it made fixes', /made fixes/.test(note), note);
await waitCanvas(page, 'Reviewed heading');
ok('patch from the second job applied', true);
ok('two start jobs', srv.starts.length === 2, String(srv.starts.length));
const up = srv.uploads[0];
ok('render uploaded as a JPEG mockup named self-review-render.jpg, > 2000 bytes',
!!up && up.mime === 'image/jpeg' && up.kind === 'mockup' && up.name === 'self-review-render.jpg' && up.size > 2000, JSON.stringify(up));
const s2 = srv.starts[1] ?? {};
ok('second job message starts "Review the attached screenshot"', typeof s2.message === 'string' && s2.message.startsWith('Review the attached screenshot'), String(s2.message).slice(0, 60));
ok('second job carries the upload id in mockup_ids', Array.isArray(s2.mockup_ids) && s2.mockup_ids.includes(up?.id), JSON.stringify(s2.mockup_ids));
ok('second job has use_assets false', s2.use_assets === false, String(s2.use_assets));
ok('render image deleted afterwards', srv.imageDeletes.includes(up?.id), JSON.stringify(srv.imageDeletes));
await page.waitForTimeout(2500);
ok('no third job', srv.starts.length === 2, String(srv.starts.length));
await modalShot(page, name, '08-self-review-done.png');
}, { selfReview: true });
scenario('8b', 'self-review skipped when the upload fails', async ({ page, ok }) => {
srv.setUploadFail(500);
srv.setNextResult(pageResult('Kept first pass', { id: 'kp-h' }));
await openModal(page);
await sendMsg(page, 'Build it');
await page.waitForSelector('[data-testid=ss-review-note]', { timeout: 45000 });
const note = (await page.textContent('[data-testid=ss-review-note]')) ?? '';
ok('non-blocking "Self-review skipped" note', /Self-review skipped/.test(note), note);
ok('first job content still applied', (await canvasText(page)).includes('Kept first pass'));
ok('no second job', srv.starts.length === 1, String(srv.starts.length));
ok('no blocking error shown', (await page.locator('[data-testid=ss-error]').count()) === 0);
}, { selfReview: true });
// ------------------------------------------------------------------ run
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
for (const sc of scenarios) {
if (process.env.SITESMITH_SCENARIO && !process.env.SITESMITH_SCENARIO.split(',').includes(sc.id)) continue;
srv.reset();
const label = `${sc.id} ${sc.title}`;
let page;
try {
page = await newPage(browser, name, sc.opts);
await sc.fn({ page, name, ok: (l, c, d) => log(name, label, !!c, l, d) });
await page.waitForTimeout(200);
const bad = await page.evaluate(() => window.__bad);
log(name, label, page.__errors.length === 0, 'no uncaught page errors', page.__errors.join(' | '));
log(name, label, bad.length === 0, 'no NetworkError / Failed to fetch text in the modal', JSON.stringify(bad));
const ext = page.__external.filter((u) => !/\.css(\?|$)|fonts\.(googleapis|gstatic)/.test(u));
if (ext.length) console.log(`[${name}] note ${label}: ${ext.length} external request(s) aborted locally`);
} catch (e) {
log(name, label, false, 'scenario completed', String(e?.message ?? e).split('\n').slice(0, 3).join(' '));
if (page && name === 'firefox') await page.screenshot({ path: path.join(shotsDir, `fail-${sc.id}.png`) }).catch(() => {});
} finally {
await page?.__ctx.close().catch(() => {});
}
}
await browser.close();
}
const only = process.env.SITESMITH_ONLY;
if (only !== 'firefox') await run('chromium', chromium, process.env.CHROMIUM_PATH);
if (only !== 'chromium') await run('firefox', firefox, process.env.FIREFOX_PATH);
await srv.close();
console.log(failures ? `\n${failures} FAILURE(S)` : '\nALL PASS');
process.exit(failures ? 1 : 0);
+39
View File
@@ -401,3 +401,42 @@ describe('useSitesmith capabilities and wrappers', () => {
expect(await api.uploadRefImage(file, 'mockup')).toMatchObject({ ok: false, message: expect.stringMatching(/413/) });
});
});
describe('request payload: design, assets and attachments', () => {
test('start carries design, mockup ids, reference ids, use_assets and asset_filenames exactly', async () => {
const bodies: any[] = [];
override = (url, init) => {
if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 200, json: async () => ({ ok: true, job_id: 'j9', status: 'running', phase: 'queued' }) }; }
if (url.includes('action=poll')) return { status: 200, json: async () => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response: { type: 'ask', question: 'q' } } }) };
return undefined;
};
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
try {
let p!: Promise<any>;
await act(async () => {
p = api.send('hi', 'canvas', undefined, {
design: { primaryColor: '#123456', brandColors: [{ name: 'Teal', hex: '#0d9488' }] },
mockupIds: ['a'.repeat(32)], referenceIds: [3, 4], useAssets: true, assetFilenames: ['x.png'],
});
await vi.advanceTimersByTimeAsync(1200);
await p;
});
} finally { vi.useRealTimers(); }
expect(bodies).toHaveLength(1);
expect(bodies[0]).toMatchObject({
site_id: 7, message: 'hi', canvas_summary: 'canvas',
design: { primaryColor: '#123456', brandColors: [{ name: 'Teal', hex: '#0d9488' }] },
mockup_ids: ['a'.repeat(32)], reference_ids: [3, 4], use_assets: true, asset_filenames: ['x.png'],
});
});
test('an empty design is not sent', async () => {
const bodies: any[] = [];
override = (url, init) => {
if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 400, json: async () => ({ ok: false, error: 'unknown_action' }) }; }
return undefined;
};
await act(async () => { await api.send('hi', 'c', undefined, { design: {} }); });
expect(sends[0].design).toBeUndefined();
});
});
+11 -1
View File
@@ -61,6 +61,10 @@ export interface SitesmithSendOpts {
mockupIds?: string[];
referenceIds?: number[];
useAssets?: boolean;
/** Current site design (see utils/sitesmith-design.ts); read by the backend prompt builder. */
design?: Record<string, unknown>;
/** Uploaded assets Sitesmith may see (relative filenames). Omitted = all. Needs backend support; see report. */
assetFilenames?: string[];
onProgress?: (phase: string) => void;
signal?: AbortSignal;
}
@@ -163,6 +167,7 @@ export function useSitesmith(siteId: number) {
const [error, setError] = useState<string | null>(null);
const [activeJob, setActiveJobState] = useState<SitesmithActiveJob | null>(null);
const [capabilities, setCapabilities] = useState<SitesmithCapabilities | null>(null);
const [capabilitiesFailed, setCapabilitiesFailed] = useState(false);
useEffect(() => {
alive.current = true;
@@ -185,6 +190,9 @@ export function useSitesmith(siteId: number) {
setCapabilities({
vision: p.body.vision, features: p.body.features, limits: p.body.limits,
} as SitesmithCapabilities);
} else {
// Older backend without `capabilities`: image features are unavailable, text generation is unaffected.
setCapabilitiesFailed(true);
}
}, [whpConfig]);
@@ -332,6 +340,7 @@ export function useSitesmith(siteId: number) {
if (target) body.target = target;
if (profile) body.profile = profile;
if (opts?.intent) body.intent = opts.intent;
if (opts?.design && Object.keys(opts.design).length) body.design = opts.design;
let result: SendResult;
let usedStart = false;
@@ -340,6 +349,7 @@ export function useSitesmith(siteId: number) {
if (opts?.mockupIds?.length) startBody.mockup_ids = opts.mockupIds;
if (opts?.referenceIds?.length) startBody.reference_ids = opts.referenceIds;
if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets;
if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames;
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, {
method: 'POST',
credentials: 'include',
@@ -477,7 +487,7 @@ export function useSitesmith(siteId: number) {
return {
summary, messages, loading, error, send, refreshEntitlement, clearHistory,
activeJob, resumeJob, cancel, capabilities,
activeJob, resumeJob, cancel, capabilities, capabilitiesFailed,
listRefs, addReference, updateReference, deleteReference,
uploadRefImage, deleteRefImage, refImageUrl, listAssets,
};
@@ -0,0 +1,53 @@
import React from 'react';
import type { SitesmithAsset } from '../../hooks/useSitesmith';
import { note, thumb, errBox } from './Section';
interface Props {
assets: SitesmithAsset[];
loaded: boolean;
error?: string | null;
/** Filenames Sitesmith may see. */
included: Set<string>;
onToggle: (filename: string) => void;
onAll: (on: boolean) => void;
/** Public-safe display URL for a thumbnail. */
thumbUrl: (a: SitesmithAsset) => string;
visionAvailable: boolean;
disabled?: boolean;
}
/** Which uploaded images Sitesmith will see, with the captions it has, and a per-image toggle. */
export const AssetAwareness: React.FC<Props> = ({ assets, loaded, error, included, onToggle, onAll, thumbUrl, visionAvailable, disabled }) => (
<div data-testid="ss-assets">
<p style={note}>
Sitesmith can use the images you uploaded to this site and place them on the page. Untick any you do not want it to use.
{visionAvailable
? ' It looks at each picture (a small copy) and writes a short description the first time.'
: ' Image viewing is not available on this server, so Sitesmith works from file names and sizes only.'}
</p>
{error && <div style={errBox} role="alert">{error}</div>}
{!loaded && !error && <p style={note}>Loading your images…</p>}
{loaded && !error && assets.length === 0 && <p style={note}>You have not uploaded any images yet. Add some in the Assets tab and Sitesmith will be able to use them.</p>}
{assets.length > 0 && (
<>
<div style={{ marginBottom: 6, fontSize: 12 }}>
<button type="button" disabled={disabled} onClick={() => onAll(true)} style={{ background: 'none', border: 'none', color: '#a78bfa', cursor: 'pointer', padding: 0, marginRight: 12 }}>Include all</button>
<button type="button" disabled={disabled} onClick={() => onAll(false)} style={{ background: 'none', border: 'none', color: '#a78bfa', cursor: 'pointer', padding: 0 }}>Include none</button>
<span style={{ color: '#71717a', marginLeft: 12 }}>{included.size} of {assets.length} included</span>
</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 220, overflowY: 'auto' }}>
{assets.map((a) => (
<li key={a.filename} data-testid="ss-asset-item" style={{ display: 'flex', gap: 8, alignItems: 'center', opacity: included.has(a.filename) ? 1 : 0.5 }}>
<input type="checkbox" checked={included.has(a.filename)} disabled={disabled} onChange={() => onToggle(a.filename)} aria-label={`Let Sitesmith use ${a.filename}`} />
<img src={thumbUrl(a)} alt="" loading="lazy" style={{ ...thumb, width: 40, height: 40 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#e4e4e7', overflowWrap: 'anywhere' }}>{a.filename}{a.width && a.height ? <span style={{ color: '#71717a' }}> · {a.width}×{a.height}</span> : null}</div>
<div style={{ color: '#a1a1aa', fontSize: 12 }}>{a.caption || a.alt || 'No description yet'}</div>
</div>
</li>
))}
</ul>
</>
)}
</div>
);
@@ -0,0 +1,113 @@
import React, { useRef, useState } from 'react';
import type { SitesmithRefImage } from '../../hooks/useSitesmith';
import { formatBytes } from '../../utils/sitesmith-attachments';
import { btn, btnDanger, errBox, note, thumb, warnBox } from './Section';
interface Props {
/** Mockup images already stored for this site (from refs_list). */
images: SitesmithRefImage[];
selected: string[];
onSelectedChange: (ids: string[]) => void;
/** Total image budget for one send (mockups + reference screenshots). */
maxAttach: number;
/** Images already counted by selected "Sites I like" entries. */
referenceImageCount: number;
imageUrl: (id: string) => string;
onUpload: (file: File) => Promise<{ ok: true; image?: SitesmithRefImage } | { ok: false; message: string }>;
onRemove: (id: string) => Promise<{ ok: true } | { ok: false; message: string }>;
/** Image features off: why (vision unavailable). Text-only generation is unaffected. */
unavailableReason?: string | null;
disabled?: boolean;
/** Called with the message when a file is rejected or an upload fails. */
}
/** Mockup picker: drag and drop or file picker, thumbnails, remove. Image names are rendered as text nodes only. */
export const AttachmentTray: React.FC<Props> = ({
images, selected, onSelectedChange, maxAttach, referenceImageCount, imageUrl, onUpload, onRemove, unavailableReason, disabled,
}) => {
const input = useRef<HTMLInputElement>(null);
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const mockups = images.filter((i) => i.kind === 'mockup');
const off = !!unavailableReason || !!disabled;
const used = selected.length + referenceImageCount;
const addFiles = async (files: FileList | File[] | null) => {
if (!files || off) return;
setErr(null);
setBusy(true);
try {
for (const f of Array.from(files)) {
if (used + 1 > maxAttach) { setErr(`You can attach up to ${maxAttach} images to one request.`); break; }
const r = await onUpload(f);
if (!r.ok) { setErr(r.message); break; }
if (r.image) onSelectedChange([...selected, r.image.id]);
}
} finally { setBusy(false); }
};
const toggle = (id: string) => {
if (selected.includes(id)) onSelectedChange(selected.filter((x) => x !== id));
else if (used < maxAttach) onSelectedChange([...selected, id]);
else setErr(`You can attach up to ${maxAttach} images to one request.`);
};
const remove = async (id: string) => {
setErr(null);
const r = await onRemove(id);
if (!r.ok) { setErr(r.message); return; }
onSelectedChange(selected.filter((x) => x !== id));
};
return (
<div data-testid="ss-mockups">
<p style={note}>
Attach a picture of the page you want (a sketch, a screenshot, a design export). Sitesmith builds toward it.
The image is kept privately for Sitesmith; it is never published or added to your assets.
</p>
{unavailableReason && <div style={warnBox} role="note" data-testid="ss-vision-note">{unavailableReason}</div>}
<div
data-testid="ss-dropzone"
onDragOver={(e) => { if (off) return; e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); void addFiles(e.dataTransfer?.files ?? null); }}
style={{
border: `1px dashed ${drag ? '#a78bfa' : '#3f3f46'}`, borderRadius: 8, padding: 12, textAlign: 'center',
background: drag ? 'rgba(124,58,237,0.12)' : 'transparent', opacity: off ? 0.5 : 1,
}}
>
<button type="button" style={btn} disabled={off || busy} onClick={() => input.current?.click()}>
{busy ? 'Uploading…' : 'Choose an image'}
</button>
<span style={{ color: '#a1a1aa', fontSize: 12, marginLeft: 8 }}>or drop it here (JPEG, PNG, WebP or GIF, up to 8 MB)</span>
<input
ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/gif" hidden multiple
data-testid="ss-mockup-input" disabled={off}
onChange={(e) => { void addFiles(e.target.files); e.target.value = ''; }}
/>
</div>
{err && <div role="alert" style={errBox}>{err}</div>}
{mockups.length > 0 && (
<ul style={{ listStyle: 'none', margin: '10px 0 0', padding: 0, display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{mockups.map((im) => {
const on = selected.includes(im.id);
return (
<li key={im.id} style={{ width: 96 }} data-testid="ss-mockup-item">
<label style={{ display: 'block', cursor: 'pointer' }}>
<img src={imageUrl(im.id)} alt={`Mockup ${im.name}`} style={{ ...thumb, width: 96, height: 72, outline: on ? '2px solid #a78bfa' : 'none' }} />
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#d4d4d8', marginTop: 3 }}>
<input type="checkbox" checked={on} disabled={off} onChange={() => toggle(im.id)} aria-label={`Use ${im.name} for the next request`} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{im.name}</span>
</span>
</label>
<div style={{ fontSize: 10, color: '#71717a' }}>{im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}</div>
<button type="button" style={{ ...btnDanger, marginTop: 3, padding: '2px 6px', fontSize: 11 }} disabled={off} onClick={() => void remove(im.id)} aria-label={`Remove ${im.name}`}>Remove</button>
</li>
);
})}
</ul>
)}
</div>
);
};
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
import { btn } from './Section';
export const PHASE_LABEL: Record<string, string> = {
queued: 'Waiting to start',
preparing: 'Preparing your request',
captioning: 'Looking at your images',
generating: 'Designing your page',
retrying: 'Trying again to get a valid result',
finalizing: 'Finishing up',
done: 'Done',
review: 'Reviewing the finished page',
};
const ORDER = ['queued', 'preparing', 'captioning', 'generating', 'finalizing'];
interface Props {
phase: string;
/** Seconds since the job began, if the server knows (a resumed job); else counted from mount. */
startedAgoS?: number;
onCancel?: () => void;
cancelling?: boolean;
resumed?: boolean;
}
/** Live status line for a running Sitesmith job: phase, elapsed time, steps, and a Cancel button. */
export const JobStatus: React.FC<Props> = ({ phase, startedAgoS = 0, onCancel, cancelling, resumed }) => {
const [t0] = useState(() => Date.now() - startedAgoS * 1000);
const [elapsed, setElapsed] = useState(startedAgoS);
useEffect(() => {
const id = window.setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 1000);
return () => window.clearInterval(id);
}, [t0]);
const label = cancelling ? 'Cancelling…' : (PHASE_LABEL[phase] ?? 'Working');
const idx = ORDER.indexOf(phase);
return (
<div role="status" aria-live="polite" data-testid="ss-job-status" data-phase={phase} style={{ padding: '10px 4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 14 }}>
<i className="fa fa-spinner fa-spin" aria-hidden style={{ color: '#8b5cf6' }} />
<span style={{ color: '#e4e4e7', fontWeight: 500 }} data-testid="ss-phase-label">{label}…</span>
<span style={{ color: '#71717a', fontSize: 12 }}>{elapsed}s</span>
<span style={{ flex: 1 }} />
{onCancel && <button type="button" style={btn} disabled={cancelling} onClick={onCancel} data-testid="ss-cancel">Cancel</button>}
</div>
{idx >= 0 && (
<div aria-hidden style={{ display: 'flex', gap: 4, marginTop: 8 }}>
{ORDER.map((p, i) => <span key={p} style={{ flex: 1, height: 3, borderRadius: 2, background: i <= idx ? '#8b5cf6' : '#27272a' }} />)}
</div>
)}
<div style={{ fontSize: 12, color: '#a1a1aa', marginTop: 6 }}>
{resumed ? 'Picked up a request that was already running. ' : ''}
You can close this window: Sitesmith keeps working and the result will be waiting when you come back.
</div>
</div>
);
};
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
const sectionStyle: React.CSSProperties = { border: '1px solid #27272a', borderRadius: 8, marginBottom: 8, background: '#13131c' };
const headStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, width: '100%', background: 'transparent', border: 'none', color: '#e4e4e7', padding: '8px 12px', cursor: 'pointer', fontSize: 13, fontWeight: 600, textAlign: 'left' };
const bodyStyle: React.CSSProperties = { padding: '4px 12px 12px', fontSize: 13, color: '#d4d4d8' };
/** Collapsible block used by the Sitesmith context area. */
export const Section: React.FC<{
id: string; title: string; icon?: string; badge?: string; open: boolean; onToggle: () => void; children: React.ReactNode;
}> = ({ id, title, icon, badge, open, onToggle, children }) => (
<div style={sectionStyle} data-testid={`ss-section-${id}`}>
<button type="button" style={headStyle} aria-expanded={open} aria-controls={`ss-sec-${id}`} onClick={onToggle}>
{icon && <i className={`fa ${icon}`} aria-hidden style={{ color: '#a78bfa', width: 16 }} />}
<span>{title}</span>
{badge && <span style={{ fontSize: 11, color: '#a1a1aa', fontWeight: 400 }}>{badge}</span>}
<span style={{ flex: 1 }} />
<i className={`fa fa-chevron-${open ? 'up' : 'down'}`} aria-hidden style={{ color: '#71717a', fontSize: 11 }} />
</button>
{open && <div id={`ss-sec-${id}`} style={bodyStyle}>{children}</div>}
</div>
);
export const note: React.CSSProperties = { fontSize: 12, color: '#a1a1aa', lineHeight: 1.5, margin: '4px 0 8px' };
export const warnBox: React.CSSProperties = { background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.4)', color: '#fde68a', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' };
export const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' };
export const btn: React.CSSProperties = { background: 'transparent', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' };
export const btnPrimary: React.CSSProperties = { background: '#7c3aed', color: '#fff', border: '1px solid #7c3aed', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' };
export const btnDanger: React.CSSProperties = { background: 'transparent', color: '#fca5a5', border: '1px solid #7f1d1d', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' };
export const inputStyle: React.CSSProperties = { width: '100%', boxSizing: 'border-box', background: '#0f0f17', color: '#e5e7eb', border: '1px solid #3f3f46', borderRadius: 6, padding: '6px 8px', fontSize: 13 };
export const thumb: React.CSSProperties = { width: 64, height: 64, objectFit: 'cover', borderRadius: 6, border: '1px solid #3f3f46', background: '#0f0f17', display: 'block' };
@@ -0,0 +1,166 @@
import React, { useRef, useState } from 'react';
import type { SitesmithReference, SitesmithRefImage } from '../../hooks/useSitesmith';
import { btn, btnDanger, btnPrimary, errBox, inputStyle, note, thumb, warnBox } from './Section';
import type { Outcome } from './useSitesmithRefs';
interface Props {
references: SitesmithReference[];
images: SitesmithRefImage[];
selected: number[];
onSelectedChange: (ids: number[]) => void;
imageUrl: (id: string) => string;
loadError?: string | null;
addRef: (url: string, likes: string, shots: string[]) => Promise<Outcome>;
updateRef: (id: number, url: string, likes: string, shots: string[]) => Promise<Outcome>;
deleteRef: (id: number) => Promise<Outcome>;
uploadShot: (f: File) => Promise<Outcome & { image?: SitesmithRefImage }>;
removeImage: (id: string) => Promise<Outcome>;
maxRefs: number;
/** Image features unavailable (no vision): text-only entries still work. */
imagesUnavailableReason?: string | null;
disabled?: boolean;
}
const MAX_SHOTS = 3;
interface FormState { id: number | null; url: string; likes: string; shots: string[]; added: string[] }
const EMPTY: FormState = { id: null, url: '', likes: '', shots: [], added: [] };
/** "Sites I like": a per-site list of label + notes + optional screenshots. The address is a label and is never fetched. */
export const SitesILikePanel: React.FC<Props> = ({
references, images, selected, onSelectedChange, imageUrl, loadError, addRef, updateRef, deleteRef, uploadShot, removeImage,
maxRefs, imagesUnavailableReason, disabled,
}) => {
const [form, setForm] = useState<FormState | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const imgById = new Map(images.map((i) => [i.id, i]));
const startAdd = () => { setErr(null); setForm({ ...EMPTY }); };
const startEdit = (r: SitesmithReference) => { setErr(null); setForm({ id: r.id, url: r.url_label, likes: r.what_i_like, shots: [...r.screenshot_ids], added: [] }); };
const cancel = async () => {
// Screenshots uploaded for a form that was never saved would be orphans: drop them.
if (form) for (const id of form.added) await removeImage(id);
setForm(null); setErr(null);
};
const save = async () => {
if (!form) return;
setBusy(true); setErr(null);
try {
const r = form.id === null
? await addRef(form.url, form.likes, form.shots)
: await updateRef(form.id, form.url, form.likes, form.shots);
if (!r.ok) { setErr(r.message); return; }
setForm(null);
} finally { setBusy(false); }
};
const addShot = async (files: FileList | null) => {
if (!files || !form) return;
setErr(null); setBusy(true);
let count = form.shots.length;
try {
for (const f of Array.from(files)) {
if (count >= MAX_SHOTS) { setErr(`Up to ${MAX_SHOTS} screenshots per site.`); break; }
const r = await uploadShot(f);
if (!r.ok) { setErr(r.message); break; }
const id = r.image!.id;
count++;
setForm((s) => (s ? { ...s, shots: [...s.shots, id], added: [...s.added, id] } : s));
}
} finally { setBusy(false); }
};
const dropShot = async (id: string) => {
if (!form) return;
// Removing from a saved entry detaches it; an image added in this form is deleted outright.
if (form.added.includes(id)) await removeImage(id);
setForm((s) => (s ? { ...s, shots: s.shots.filter((x) => x !== id), added: s.added.filter((x) => x !== id) } : s));
};
const del = async (id: number) => {
setErr(null);
const r = await deleteRef(id);
setConfirmDel(null);
if (!r.ok) { setErr(r.message); return; }
onSelectedChange(selected.filter((x) => x !== id));
};
const toggle = (id: number) => onSelectedChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
return (
<div data-testid="ss-sites-i-like">
<p style={note} data-testid="ss-sites-copy">
List websites whose look you admire and say what you like about each. The address is only a label for you:
<strong> Sitesmith does not open, visit or fetch it</strong>. It works from your description and any screenshots you attach yourself.
</p>
{loadError && <div style={errBox} role="alert">{loadError}</div>}
{err && !form && <div style={errBox} role="alert">{err}</div>}
{references.length === 0 && !form && <p style={note}>No sites yet.</p>}
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{references.map((r) => (
<li key={r.id} data-testid="ss-ref-item" style={{ border: '1px solid #27272a', borderRadius: 6, padding: 8 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<input type="checkbox" checked={selected.includes(r.id)} disabled={disabled} onChange={() => toggle(r.id)} aria-label={`Use ${r.url_label} for the next request`} style={{ marginTop: 3 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: '#e4e4e7', overflowWrap: 'anywhere' }}>{r.url_label}</div>
<div style={{ color: '#a1a1aa', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{r.what_i_like}</div>
{r.screenshot_ids.length > 0 && (
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
{r.screenshot_ids.map((sid) => (
<img key={sid} src={imageUrl(sid)} alt={`Screenshot ${imgById.get(sid)?.name ?? ''}`} style={{ ...thumb, width: 48, height: 48 }} />
))}
</div>
)}
</div>
<button type="button" style={btn} disabled={disabled} onClick={() => startEdit(r)} aria-label={`Edit ${r.url_label}`}>Edit</button>
{confirmDel === r.id
? <button type="button" style={btnDanger} onClick={() => void del(r.id)} autoFocus>Confirm delete</button>
: <button type="button" style={btnDanger} disabled={disabled} onClick={() => setConfirmDel(r.id)} aria-label={`Delete ${r.url_label}`}>Delete</button>}
</div>
</li>
))}
</ul>
{form ? (
<div style={{ marginTop: 10, border: '1px solid #3f3f46', borderRadius: 6, padding: 10 }} data-testid="ss-ref-form">
<label style={{ display: 'block', fontSize: 12, color: '#a1a1aa', marginBottom: 2 }} htmlFor="ss-ref-url">Site address (label only, not opened)</label>
<input id="ss-ref-url" style={inputStyle} value={form.url} maxLength={300} placeholder="example.com" onChange={(e) => setForm({ ...form, url: e.target.value })} />
<label style={{ display: 'block', fontSize: 12, color: '#a1a1aa', margin: '8px 0 2px' }} htmlFor="ss-ref-likes">What I like about it</label>
<textarea id="ss-ref-likes" style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }} value={form.likes} maxLength={1000} placeholder="Big photo header, lots of white space, simple menu" onChange={(e) => setForm({ ...form, likes: e.target.value })} />
<div style={{ fontSize: 12, color: '#a1a1aa', margin: '8px 0 4px' }}>Screenshots (optional, up to {MAX_SHOTS})</div>
{imagesUnavailableReason
? <div style={warnBox} role="note">{imagesUnavailableReason}</div>
: (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{form.shots.map((sid) => (
<span key={sid} style={{ position: 'relative' }}>
<img src={imageUrl(sid)} alt="Screenshot" style={{ ...thumb, width: 56, height: 56 }} />
<button type="button" onClick={() => void dropShot(sid)} aria-label="Remove screenshot" style={{ ...btnDanger, position: 'absolute', top: -6, right: -6, padding: '0 5px', fontSize: 11 }}>×</button>
</span>
))}
{form.shots.length < MAX_SHOTS && (
<button type="button" style={btn} disabled={busy} onClick={() => fileRef.current?.click()}>Add screenshot</button>
)}
<input ref={fileRef} type="file" hidden multiple accept="image/jpeg,image/png,image/webp,image/gif" data-testid="ss-shot-input" onChange={(e) => { void addShot(e.target.files); e.target.value = ''; }} />
</div>
)}
{err && <div style={errBox} role="alert">{err}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button type="button" style={btnPrimary} disabled={busy} onClick={() => void save()}>{form.id === null ? 'Add site' : 'Save changes'}</button>
<button type="button" style={btn} disabled={busy} onClick={() => void cancel()}>Cancel</button>
</div>
</div>
) : (
<button type="button" style={{ ...btn, marginTop: 10 }} disabled={disabled || references.length >= maxRefs} onClick={startAdd}>
Add a site I like{references.length >= maxRefs ? ` (limit ${maxRefs} reached)` : ''}
</button>
)}
</div>
);
};
@@ -0,0 +1,267 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
import { SaveGuardProvider } from '../../state/SaveGuardContext';
import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext';
import { NoticeProvider } from '../../state/NoticeContext';
import { SitesmithProvider } from '../../state/SitesmithContext';
import { SiteDesignProvider } from '../../state/SiteDesignContext';
import { SitesmithModal } from './SitesmithModal';
import { __resetProfileWizardForTests, ProfileWizardHost } from './ProfileWizard';
import { __resetCommandsForTests } from '../../editor/commands';
vi.mock('../../utils/self-review-capture', async (orig) => {
const real = await orig<typeof import('../../utils/self-review-capture')>();
return { ...real, captureHtml: vi.fn() };
});
import { captureHtml } from '../../utils/self-review-capture';
const CFG = { user: 'u', apiUrl: '/api/site-builder.php', csrfToken: 't', siteId: 7, siteDomain: 'x.com', siteName: 'x', backUrl: '/', isRoot: false };
const PROFILE = { business_name: 'Acme', business_type: 'Plumber', services: ['Drains'], area: 'Austin', tone: 'bold' };
const node = (type: string, props: any, extra: any = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, custom: {}, hidden: false, nodes: [], linkedNodes: {}, ...extra,
});
const INITIAL = JSON.stringify({
ROOT: node('Container', { style: {}, tag: 'div' }, { nodes: ['sec'] }),
sec: node('Container', { style: { backgroundColor: '#1e3a8a' }, tag: 'section' }, { parent: 'ROOT', nodes: ['h1'] }),
h1: node('Heading', { text: 'Original heading', level: 'h2' }, { parent: 'sec' }),
});
const PNG_OK = () => new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])], 'm.png', { type: 'image/png' });
let h: EditorHarness;
let extras: ReturnType<typeof useProjectExtras>;
const Spy: React.FC = () => { extras = useProjectExtras(); return null; };
const flush = (ms = 0) => h.act(async () => { await new Promise((r) => setTimeout(r, ms)); });
interface Net {
starts: any[]; cancels: any[]; deleted: string[]; uploads: number;
polls: any[]; caps: any; history: any; entitlement: any; assets: any[]; refs: any[]; startFail: boolean; uploadFail: boolean;
}
let net: Net;
const json = (v: unknown, status = 200) => ({ status, json: async () => v });
function installFetch() {
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
if (net.startFail && url.includes('action=start')) throw new TypeError('NetworkError when attempting to fetch resource.');
if (url.includes('action=start')) { net.starts.push(JSON.parse(init.body)); return json({ ok: true, job_id: 'j' + net.starts.length, status: 'running', phase: 'queued' }); }
if (url.includes('action=poll')) { const p = net.polls.length > 1 ? net.polls.shift() : net.polls[0]; if (p.status && p.status !== 'running') net.history = { ok: true, messages: [], active_job: null }; return json(p); }
if (url.includes('action=cancel')) { net.cancels.push(JSON.parse(init.body)); net.polls = [{ ok: true, status: 'cancelled', phase: 'done', result: { ok: false, status: 'CANCELLED', message: 'Cancelled' } }]; return json({ ok: true, status: 'cancelled' }); }
if (url.includes('action=entitlement')) return json(net.entitlement);
if (url.includes('action=capabilities')) return json(net.caps);
if (url.includes('action=history')) return json(net.history);
if (url.includes('action=refs_list')) return json({ ok: true, references: net.refs, images: [] });
if (url.includes('action=assets')) return json({ ok: true, assets: net.assets });
if (url.includes('action=ref_image_upload')) { net.uploads++; return net.uploadFail ? json({ ok: false, error: 'x', message: 'Upload failed' }, 500) : json({ ok: true, image: { id: 'f'.repeat(32), kind: 'mockup', name: 'self-review-render.jpg', width: 1280, height: 900, size: 5000 } }); }
if (url.includes('action=ref_image_delete')) { net.deleted.push(JSON.parse(init.body).id); return json({ ok: true }); }
return json({ ok: true, messages: [] });
}));
}
const VISION_ON = { ok: true, vision: { available: true, source: 'primary', message: '' }, features: { mockups: true, reference_screenshots: true, captions: true, self_review: true }, limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 } };
const VISION_OFF = { ...VISION_ON, vision: { available: false, source: 'none', message: 'This server has no vision-capable model.' }, features: { mockups: false, reference_screenshots: false, captions: false, self_review: false } };
const done = (response: any, extra: any = {}) => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response, ...extra } });
const replaceHeading = (text: string, color?: string) => ({ type: 'patch', message: 'ok', ops: [{ op: 'replace_node', node_id: 'sec', tree: { type: { resolvedName: 'Container' }, props: { tag: 'section', style: { backgroundColor: '#1e3a8a' } }, nodes: [{ type: { resolvedName: 'Heading' }, props: { text, level: 'h2', style: color ? { color } : {} }, nodes: [] }] } }] });
async function mount(opts: { profile?: boolean } = {}) {
h = renderEditorHarness({
initialState: INITIAL,
wrap: (tree) => (
<EditorConfigProvider config={CFG as any}>
<SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider><SitesmithProvider>{tree}</SitesmithProvider></SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider>
</EditorConfigProvider>
),
});
h.mountChild(<><Spy /><ProfileWizardHost /><SitesmithModal onClose={() => {}} target={null} /></>);
if (opts.profile !== false) h.act(() => extras.set('sitesmithProfile', PROFILE));
await flush();
await flush();
}
const ta = () => document.querySelector('textarea.sitesmith-textarea') as HTMLTextAreaElement | null;
const type = (el: HTMLTextAreaElement, v: string) => h.act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
});
const sendText = async (t: string) => { await type(ta()!, t); await h.act(async () => { (ta()!.parentElement!.querySelector('button') as HTMLButtonElement).click(); }); };
const q = (s: string) => document.querySelector(s) as HTMLElement | null;
const secBtn = (id: string) => q(`[data-testid=ss-section-${id}] button`) as HTMLButtonElement;
beforeEach(() => {
__resetProfileWizardForTests(); __resetCommandsForTests();
try { window.localStorage.removeItem('sitesmith.selfReview'); } catch { /* */ }
net = {
starts: [], cancels: [], deleted: [], uploads: 0,
polls: [done(replaceHeading('Fresh copy'))],
caps: VISION_ON, history: { ok: true, messages: [], active_job: null },
entitlement: { ok: true, profile_supported: true, summary: { enabled: true, status: 'OK_MONTHLY', monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } },
assets: [
{ filename: 'a.jpg', relUrl: 'assets/a.jpg', url: '/api/a', width: 800, height: 600, caption: 'A red door', alt: null },
{ filename: 'b.png', relUrl: 'assets/b.png', url: '/api/b', width: 100, height: 100, caption: null, alt: null },
],
refs: [{ id: 5, url_label: 'nice.example', what_i_like: 'Clean menu', screenshot_ids: [] }],
startFail: false, uploadFail: false,
};
vi.mocked(captureHtml).mockReset();
installFetch();
});
afterEach(() => { h?.unmount(); vi.unstubAllGlobals(); });
describe('Sitesmith modal: job states', () => {
test('shows live phases, then applies the result; the request carries the design and asset choices', async () => {
net.polls = [
{ ok: true, status: 'running', phase: 'captioning', retry_after_ms: 400 },
{ ok: true, status: 'running', phase: 'generating', retry_after_ms: 400 },
done(replaceHeading('Fresh copy', '#334155')),
];
await mount();
await sendText('Make it nicer');
expect(q('[data-testid=ss-job-status]')).not.toBeNull();
expect(ta()).toBeNull(); // input is replaced while a job runs
await flush(1100);
expect(q('[data-testid=ss-phase-label]')!.textContent).toContain('Looking at your images');
await flush(400);
expect(q('[data-testid=ss-phase-label]')!.textContent).toContain('Designing your page');
await flush(600);
expect(q('[data-testid=ss-job-status]')).toBeNull();
expect(h.container.textContent).toContain('Fresh copy');
expect(net.starts).toHaveLength(1);
expect(net.starts[0]).toMatchObject({ site_id: 7, message: 'Make it nicer', design: { primaryColor: '#3b82f6', bodyFont: 'Inter, sans-serif' } });
expect(net.starts[0].asset_filenames).toBeUndefined(); // all assets included
// contrast repair: #334155 on #1e3a8a is repaired and reported
expect(q('[data-testid=ss-adjust]')!.textContent).toMatch(/text colour adjusted for readability/);
}, 20000);
test('cancel: asks the server, shows a note, leaves the canvas alone', async () => {
net.polls = [{ ok: true, status: 'running', phase: 'generating', retry_after_ms: 50 }];
await mount();
await sendText('x');
await flush(50);
await h.act(async () => { q('[data-testid=ss-cancel]')!.click(); });
await flush(1300);
expect(net.cancels).toEqual([{ job_id: 'j1' }]);
expect(q('[data-testid=ss-note]')!.textContent).toMatch(/Cancelled/);
expect(h.container.textContent).toContain('Original heading');
expect(ta()).not.toBeNull();
}, 20000);
test('a job already running when the modal opens is resumed and its result applied', async () => {
net.history = { ok: true, messages: [], active_job: { job_id: 'jx', status: 'running', phase: 'generating', elapsed_s: 30, created_at: 1 } };
net.polls = [{ ok: true, status: 'running', phase: 'generating', retry_after_ms: 50 }, done(replaceHeading('Resumed result'))];
await mount();
expect(q('[data-testid=ss-job-status]')).not.toBeNull();
expect(q('[data-testid=ss-job-status]')!.textContent).toContain('Picked up a request that was already running');
expect(net.starts).toHaveLength(0);
await flush(1400);
expect(h.container.textContent).toContain('Resumed result');
expect(q('[data-testid=ss-job-status]')).toBeNull();
}, 20000);
test('a network failure gives a specific message, never the raw browser text', async () => {
net.startFail = true;
await mount();
await sendText('x');
await flush(50);
const t = q('[data-testid=ss-error]')!.textContent!;
expect(t).toMatch(/Could not reach the server/);
expect(document.body.textContent).not.toMatch(/NetworkError|Failed to fetch/);
});
test('when Sitesmith is not available the box is off and says why', async () => {
net.entitlement = { ok: true, summary: { enabled: true, status: 'CAP_REACHED', monthly_used: 10, monthly_cap: 10, bonus_credits: 0 } };
await mount();
expect(ta()!.disabled).toBe(true);
expect(q('[data-testid=ss-disabled-note]')!.textContent).toMatch(/not available on your plan/);
});
});
describe('Sitesmith modal: attachments, sites, assets, vision', () => {
test('vision unavailable: explained in the mockup section, review toggle off, text generation works', async () => {
net.caps = VISION_OFF;
await mount();
await h.act(async () => { secBtn('mockups').click(); });
expect(q('[data-testid=ss-vision-note]')!.textContent).toContain('no vision-capable model');
expect((q('[data-testid=ss-mockup-input]') as HTMLInputElement).disabled).toBe(true);
expect((q('[data-testid=ss-selfreview-toggle] input') as HTMLInputElement).disabled).toBe(true);
await sendText('Hello there');
await flush(1300);
expect(net.starts[0].mockup_ids).toBeUndefined();
expect(h.container.textContent).toContain('Fresh copy');
expect(vi.mocked(captureHtml)).not.toHaveBeenCalled();
}, 20000);
test('selected "Sites I like" and excluded assets ride with the request', async () => {
await mount();
await h.act(async () => { secBtn('sites').click(); });
await h.act(async () => { (q('[data-testid=ss-ref-item] input[type=checkbox]') as HTMLInputElement).click(); });
await h.act(async () => { secBtn('assets').click(); });
await h.act(async () => { (document.querySelectorAll('[data-testid=ss-asset-item] input')[1] as HTMLInputElement).click(); });
await sendText('Use my stuff');
await flush(50);
expect(net.starts[0].reference_ids).toEqual([5]);
expect(net.starts[0].asset_filenames).toEqual(['a.jpg']);
expect(net.starts[0].use_assets).toBe(true);
expect(q('[data-testid=ss-sites-copy]')!.textContent).toMatch(/does not open, visit or fetch it/);
expect(document.querySelector('[data-testid=ss-assets]')!.textContent).toContain('A red door');
}, 20000);
test('all assets unticked sends use_assets false', async () => {
await mount();
await h.act(async () => { secBtn('assets').click(); });
await h.act(async () => { [...document.querySelectorAll('[data-testid=ss-assets] button')].find((b) => b.textContent === 'Include none')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
await sendText('x');
await flush(50);
expect(net.starts[0].use_assets).toBe(false);
}, 20000);
});
describe('Sitesmith modal: self-review', () => {
const okCapture = () => vi.mocked(captureHtml).mockResolvedValue({ ok: true, blob: new Blob([new Uint8Array(3000)], { type: 'image/jpeg' }), width: 1280, height: 900, cropped: false });
test('round trip: first result applied, ONE review job started with the render, fixes applied, render deleted, no third job', async () => {
okCapture();
net.polls = [done(replaceHeading('Built page'))];
await mount();
await sendText('Build my home page');
// Between the two jobs the poll answers the review job.
await flush(1250);
net.polls = [done(replaceHeading('Reviewed fix'))];
await flush(2600);
expect(vi.mocked(captureHtml)).toHaveBeenCalledTimes(1);
expect(net.uploads).toBe(1);
expect(net.starts).toHaveLength(2);
expect(net.starts[1].message).toMatch(/^Review the attached screenshot/);
expect(net.starts[1].mockup_ids).toEqual(['f'.repeat(32)]);
expect(net.starts[1].use_assets).toBe(false);
expect(net.deleted).toEqual(['f'.repeat(32)]);
expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/looked at the finished page/);
}, 30000);
test('capture failure: non-blocking note, first result stays applied, no second job', async () => {
vi.mocked(captureHtml).mockResolvedValue({ ok: false, reason: 'capture timed out' });
await mount();
await sendText('x');
await flush(1500);
expect(h.container.textContent).toContain('Fresh copy');
expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/Self-review skipped: capture timed out/);
expect(net.starts).toHaveLength(1);
expect(net.uploads).toBe(0);
}, 20000);
test('toggle off: no capture at all, and the choice is remembered', async () => {
await mount();
await h.act(async () => { (q('[data-testid=ss-selfreview-toggle] input') as HTMLInputElement).click(); });
expect(window.localStorage.getItem('sitesmith.selfReview')).toBe('off');
await sendText('x');
await flush(1500);
expect(vi.mocked(captureHtml)).not.toHaveBeenCalled();
expect(net.starts).toHaveLength(1);
}, 20000);
test('upload failure of the render: skipped note, page untouched by the review', async () => {
okCapture(); net.uploadFail = true;
await mount();
await sendText('x');
await flush(1700);
expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/Self-review skipped/);
expect(net.starts).toHaveLength(1);
}, 20000);
});
@@ -51,6 +51,8 @@ async function mount(withTarget: boolean, profile: unknown = PROFILE, initialSta
if (profile) h.act(() => extras.set('sitesmithProfile', profile));
await flush();
}
/** start returns at once; the first poll is 1 s later (real timers). */
const flushPoll = () => h.act(async () => { await new Promise((r) => setTimeout(r, 1300)); });
const regenBtn = () => [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Regenerate this section')) as HTMLButtonElement | undefined;
beforeEach(() => {
@@ -70,7 +72,11 @@ beforeEach(() => {
};
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
const json = (v: unknown) => ({ json: async () => v });
if (url.includes('action=send')) { sendBodies.push(JSON.parse(init.body)); return json(sendReply); }
if (url.includes('action=start')) { sendBodies.push(JSON.parse(init.body)); return json({ ok: true, job_id: 'j1', status: 'running', phase: 'queued' }); }
if (url.includes('action=poll')) return json({ ok: true, job_id: 'j1', status: 'done', phase: 'done', result: sendReply });
if (url.includes('action=capabilities')) return json({ ok: true, vision: { available: false, source: 'none', message: 'No vision' }, features: { mockups: false, reference_screenshots: false, captions: false, self_review: false }, limits: {} });
if (url.includes('action=refs_list')) return json({ ok: true, references: [], images: [] });
if (url.includes('action=assets')) return json({ ok: true, assets: [] });
if (url.includes('action=entitlement')) return json({ ok: true, summary: { enabled: true, status: entitlementStatus, monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } });
return json({ ok: true, messages: [] });
}));
@@ -82,7 +88,7 @@ describe('SitesmithModal regenerate', () => {
await mount(true);
expect(h.container.textContent).toContain('Original heading');
h.act(() => { regenBtn()!.click(); });
await flush();
await flushPoll();
expect(sendBodies).toHaveLength(1);
expect(sendBodies[0].intent).toBe('regenerate_section');
@@ -108,7 +114,7 @@ describe('SitesmithModal regenerate', () => {
sendReply = { ok: true, response: { type: 'patch', message: 'x', ops: [] } };
await mount(true);
h.act(() => { regenBtn()!.click(); });
await flush();
await flushPoll();
expect(h.container.textContent).toContain('Original heading');
expect(document.querySelector('[role="alert"]')?.textContent).toMatch(/did not return/i);
expect(onClose).not.toHaveBeenCalled();
+296 -39
View File
@@ -1,5 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { usePages } from '../../state/PageContext';
import { useNotices } from '../../state/NoticeContext';
import { useSitesmithModal } from '../../state/SitesmithContext';
import {
@@ -9,14 +11,26 @@ import {
import { applyRegeneratedSection, isProfileComplete, profileSummary } from '../../utils/sitesmith-profile';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSitesmith } from '../../hooks/useSitesmith';
import { useApplyAiResponse } from '../../utils/apply-ai-response';
import { useApplyAiResponse, repairForCanvas } from '../../utils/apply-ai-response';
import { summarizeAdjustments, type ContrastAdjustment } from '../../utils/contrast-repair';
import { buildDesignPayload } from '../../utils/sitesmith-design';
import { composeReviewHtml } from '../../utils/compose-review-html';
import { captureHtml, resolveAssetUrls } from '../../utils/self-review-capture';
import { runSelfReview } from './selfReview';
import { useSitesmithRefs } from './useSitesmithRefs';
import { Section, warnBox, errBox as errBoxStyle, note as noteStyle } from './Section';
import { AttachmentTray } from './AttachmentTray';
import { SitesILikePanel } from './SitesILikePanel';
import { AssetAwareness } from './AssetAwareness';
import { JobStatus } from './JobStatus';
import type { SitesmithAsset } from '../../hooks/useSitesmith';
import type { SendResult } from '../../types/sitesmith';
import { summarizeCanvas } from '../../utils/canvas-summary';
import { SitesmithTarget } from '../../state/SitesmithContext';
import { UpgradeBanner } from './UpgradeBanner';
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith';
import { Modal } from '../../ui/Modal';
@@ -34,10 +48,35 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const { notify } = useNotices();
const { open: reopen } = useSitesmithModal();
const profile = useSitesmithProfile();
const { summary, messages, send, loading, clearHistory, error: entitlementError } = useSitesmith(siteId);
const ss = useSitesmith(siteId);
const { summary, messages, send, loading, clearHistory, error: entitlementError, activeJob, resumeJob, cancel, capabilities, capabilitiesFailed } = ss;
const apply = useApplyAiResponse();
const { design } = useSiteDesign();
const pageCtx = usePages();
const [busy, setBusy] = useState(false);
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
const [phase, setPhase] = useState('queued');
const [cancelling, setCancelling] = useState(false);
const [resumed, setResumed] = useState(false);
const [reviewing, setReviewing] = useState(false);
const [notes, setNotes] = useState<string[]>([]);
const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null);
const [reviewNote, setReviewNote] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean } | null>(null);
const [open, setOpen] = useState<Record<string, boolean>>({});
const [selMockups, setSelMockups] = useState<string[]>([]);
const [selRefs, setSelRefs] = useState<number[]>([]);
const [assets, setAssets] = useState<SitesmithAsset[]>([]);
const [assetsLoaded, setAssetsLoaded] = useState(false);
const [assetsError, setAssetsError] = useState<string | null>(null);
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [selfReviewOn, setSelfReviewOn] = useState<boolean>(() => {
try { return window.localStorage.getItem('sitesmith.selfReview') !== 'off'; } catch { return true; }
});
const setSelfReview = (v: boolean) => {
setSelfReviewOn(v);
try { window.localStorage.setItem('sitesmith.selfReview', v ? 'on' : 'off'); } catch { /* storage unavailable */ }
};
const [error, setError] = useState<string | null>(null);
const [confirmClearChat, setConfirmClearChat] = useState(false);
const clearChatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -58,7 +97,8 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
&& (query.node('ROOT').get().data.nodes ?? []).length === 0,
);
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
const canChat = !!summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
const running = busy || !!activeJob;
const wizardFired = useRef(false);
useEffect(() => {
@@ -73,40 +113,181 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' };
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid #27272a', gap: 8 };
const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' };
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a' };
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a', maxHeight: '48vh', overflowY: 'auto' };
const closeBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: 'none', fontSize: 18, cursor: 'pointer' };
const clearBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer', marginRight: 8 };
const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '8px 12px', borderRadius: 6, marginBottom: 10, fontSize: 13 };
const handleSend = async (text: string) => {
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send(text, canvas, targetPayload);
if (!result.ok) { setError(result.message || 'Failed'); return; }
if (result.response.type === 'replace' && result.response.scope === 'site') {
setPendingReplace(result.response);
return;
}
const applied = await apply(result.response, target?.nodeId);
if (!applied.ok) setError(applied.message || 'Apply failed');
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBusy(false); }
};
const targetPayload = target
? { node_id: target.nodeId, display_name: target.displayName, tree_json: target.treeJson }
: undefined;
// ---- what the request carries
const visionOk = !!capabilities?.vision?.available;
const features = capabilities?.features;
const limits = capabilities?.limits;
const capsUnknown = !capabilities && !capabilitiesFailed;
const visionReason: string | null = capsUnknown
? 'Checking whether image features are available…'
: visionOk ? null
: (capabilities?.vision?.message
|| 'Image features are not available on this server, so Sitesmith cannot look at pictures yet. Describing what you want in words works as usual.');
const imagesOn = visionOk && features?.mockups !== false;
const refsApi = useSitesmithRefs(ss, !!cfg.whpConfig && !!canChat, limits?.max_upload_bytes);
const refsOn = !!cfg.whpConfig && !!canChat;
const selectedRefObjs = refsApi.references.filter((r) => selRefs.includes(r.id));
const refImageCount = visionOk ? selectedRefObjs.reduce((n, r) => n + r.screenshot_ids.length, 0) : 0;
const allAssetNames = assets.map((a) => a.filename);
const includedAssets = new Set(allAssetNames.filter((f) => !excluded.has(f)));
const useAssetsFlag = !(assetsLoaded && assets.length > 0 && includedAssets.size === 0);
const assetFilter = assetsLoaded && excluded.size > 0 && includedAssets.size > 0 ? [...includedAssets] : undefined;
const assetsRequested = useRef(false);
const loadAssets = async () => {
if (assetsRequested.current) return;
assetsRequested.current = true;
const r = await ss.listAssets();
if (r.ok) { setAssets(r.assets ?? []); setAssetsError(null); } else setAssetsError(r.message);
setAssetsLoaded(true);
};
useEffect(() => { if (cfg.whpConfig && canChat && !assetsLoaded) void loadAssets(); }); // eslint-disable-line react-hooks/exhaustive-deps
/** A browser-level failure text ("NetworkError when attempting to fetch resource") never reaches the user. */
const friendly = (m: string | undefined | null): string => {
const t = (m ?? '').trim();
if (!t) return 'Something went wrong. Please try again.';
if (/NetworkError|Failed to fetch|Load failed|network request failed|AbortError/i.test(t)) return 'Could not reach the server. Check your connection and try again.';
return t;
};
const sendOpts = () => ({
design: buildDesignPayload(design),
mockupIds: imagesOn ? selMockups : [],
referenceIds: selRefs,
useAssets: useAssetsFlag,
assetFilenames: assetFilter,
});
// ---- self-review (one round, after a normal send has been applied)
const assetUrlMap = () => {
const urls: Record<string, string> = {};
for (const a of assets) {
try {
const u = new URL(a.url, window.location.href);
if (u.origin === window.location.origin) urls[a.relUrl || `assets/${a.filename}`] = u.pathname + u.search;
} catch { /* skip */ }
}
return urls;
};
const doSelfReview = async () => {
setReviewing(true); setPhase('review'); setReviewNote(null);
const urls = assetUrlMap();
try {
const out = await runSelfReview({
composeHtml: () => composeReviewHtml({
serialized: query.serialize(),
headerState: pageCtx.headerPage.craftState,
footerState: pageCtx.footerPage.craftState,
title: pageCtx.pages.find((p) => p.id === pageCtx.activePageId)?.name ?? cfg.whpConfig?.siteName ?? 'Page',
design,
}),
assetUrls: urls,
allowUrls: Object.values(urls),
resolveAssetUrls,
capture: (html, o) => captureHtml(html, o),
upload: async (file) => {
const r = await ss.uploadRefImage(file, 'mockup');
return r.ok ? { ok: true, id: r.image.id } : { ok: false, message: r.message };
},
review: (msg, imageId) => send(msg, summarizeCanvas(query.getSerializedNodes()), undefined, {
design: buildDesignPayload(design), mockupIds: [imageId], useAssets: false,
onProgress: setPhase, signal: abortRef.current?.signal,
}),
applyResult: async (res) => {
if (!res.ok) return { ok: false, message: res.message };
const a = await apply(res.response);
return { ok: a.ok, message: a.message, summary: a.adjustments?.length ? summarizeAdjustments(a.adjustments, a.notChecked) : undefined };
},
removeImage: (id) => ss.deleteRefImage(id),
});
setReviewNote(out.note);
} catch {
setReviewNote('Self-review skipped: something went wrong. Your page was built as normal.');
} finally { setReviewing(false); }
};
// ---- one pipeline for send, resume and confirm
const applyResponse = async (resp: SitesmithResponse, review: boolean) => {
const applied = await apply(resp, target?.nodeId);
if (!applied.ok) { setError(friendly(applied.message || 'Apply failed')); return; }
if (applied.adjustments?.length || applied.notChecked) setAdjust({ list: applied.adjustments ?? [], notChecked: applied.notChecked ?? 0 });
if (review && selfReviewOn && visionOk && features?.self_review !== false && resp.type !== 'ask' && !target && !pageCtx.isEditingGlobal) {
await doSelfReview();
}
};
const handleResult = async (res: SendResult, review: boolean) => {
if (!res.ok) {
const st = String((res as { status?: string }).status ?? '');
if (st === 'ABORTED') return;
if (st === 'CANCELLED') { setNotes(['Cancelled. Nothing was changed on your page.']); return; }
setError(friendly(res.message));
return;
}
setNotes(res.notes ?? []);
if (res.response.type === 'replace' && res.response.scope === 'site') {
setPendingReplace({ resp: res.response, review });
return;
}
await applyResponse(res.response, review);
};
const runJob = async (start: (o: { onProgress: (p: string) => void; signal: AbortSignal }) => Promise<SendResult>, review: boolean) => {
setBusy(true); setError(null); setNotes([]); setAdjust(null); setReviewNote(null); setPhase('queued'); setCancelling(false);
const ac = new AbortController();
abortRef.current = ac;
try {
const res = await start({ onProgress: setPhase, signal: ac.signal });
await handleResult(res, review);
} catch (e: any) { setError(friendly(String(e?.message ?? e))); }
finally { setBusy(false); setCancelling(false); abortRef.current = null; }
};
const handleSend = async (text: string) => {
const canvas = summarizeCanvas(query.getSerializedNodes());
const opts = sendOpts();
await runJob((o) => send(text, canvas, targetPayload, { ...opts, ...o }), true);
setSelMockups([]); // an attached picture is for that request; sites I like stay selected
};
const handleCancel = async () => {
setCancelling(true);
const r = await cancel();
if (!r.ok) { setCancelling(false); setError(friendly(r.message)); }
// The poll in flight returns CANCELLED; nothing else to do here.
};
// Resume a job that was running when the modal (or page) was last closed.
const resumedRef = useRef(false);
useEffect(() => {
if (!activeJob) { resumedRef.current = false; return; }
if (resumedRef.current || busy || !canChat) return;
resumedRef.current = true;
setResumed(true);
setPhase(activeJob.phase || 'generating');
void runJob((o) => resumeJob(activeJob.job_id, o), true);
}); // eslint-disable-line react-hooks/exhaustive-deps
/** One click: rewrite the targeted section for this business, as a single undo step. */
const regenerate = async () => {
if (!target || busy) return;
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section' });
if (!result.ok) { setError(result.message || 'Failed'); return; }
const r = applyRegeneratedSection(actions, query, result.response, target.nodeId);
const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter });
if (!result.ok) { setError(friendly(result.message)); return; }
const r = applyRegeneratedSection(actions, query, repairForCanvas(result.response, design, query).response, target.nodeId);
if (!r.ok) { setError(r.message || 'Apply failed'); return; }
notify({
kind: 'success',
@@ -116,11 +297,11 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
timeoutMs: 8000,
});
onClose();
} catch (e: any) { setError(String(e?.message ?? e)); }
} catch (e: any) { setError(friendly(String(e?.message ?? e))); }
finally { setBusy(false); }
};
const regenerateClick = () => {
const regenerateClick = () => {
if (isProfileComplete(profile)) void regenerate();
else openProfileWizard({ onDone: () => void regenerate() });
};
@@ -133,9 +314,10 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const confirmReplace = async () => {
if (!pendingReplace) return;
const r = await apply(pendingReplace);
const { resp, review } = pendingReplace;
setPendingReplace(null);
if (!r.ok) setError(r.message || 'Apply failed');
setBusy(true);
try { await applyResponse(resp, review); } finally { setBusy(false); }
};
const cancelClearChat = () => {
@@ -233,26 +415,101 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
<i className="fa fa-refresh" aria-hidden /> Regenerate this section
</button>
)}
{error && <div role="alert" style={errBox}>{error}</div>}
{error && <div role="alert" style={errBox} data-testid="ss-error">{error}</div>}
{notes.map((n, i) => <div key={i} role="note" style={{ ...warnBox, marginBottom: 8 }} data-testid="ss-note">{n}</div>)}
{adjust && adjust.list.length > 0 && (
<details style={{ ...noteStyle, border: '1px solid #27272a', borderRadius: 6, padding: '6px 10px', marginBottom: 8 }} data-testid="ss-adjust">
<summary style={{ cursor: 'pointer', color: '#d4d4d8' }}>{summarizeAdjustments(adjust.list, adjust.notChecked)}</summary>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{adjust.list.map((a, i) => (
<li key={i}>
{a.kind === 'text-colour'
? `${a.node}${a.text ? ` "${a.text}"` : ''}: text ${a.from} became ${a.to} (contrast ${a.ratioBefore}:1 to ${a.ratioAfter}:1)`
: `${a.node}: ${a.to} (was ${a.from})`}
</li>
))}
</ul>
</details>
)}
{reviewNote && <div role="note" style={{ ...noteStyle, marginBottom: 8 }} data-testid="ss-review-note">{reviewNote}</div>}
{loading
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
: <MessageList messages={messages} />}
</div>
<div style={footer}>
{busy ? (
<WorkingIndicator />
{canChat && cfg.whpConfig && (
<div style={{ marginBottom: 8 }} data-testid="ss-context">
<Section id="mockups" title="Mockup or reference picture" icon="fa-picture-o" badge={selMockups.length ? `${selMockups.length} attached` : undefined} open={!!open.mockups} onToggle={() => setOpen((o) => ({ ...o, mockups: !o.mockups }))}>
<AttachmentTray
images={refsApi.images}
selected={selMockups}
onSelectedChange={setSelMockups}
maxAttach={limits?.max_attach_images ?? 4}
referenceImageCount={refImageCount}
imageUrl={ss.refImageUrl}
onUpload={async (f) => { const r = await refsApi.uploadImage(f, 'mockup'); return r; }}
onRemove={refsApi.removeImage}
unavailableReason={imagesOn ? null : (visionReason ?? 'Image features are not available.')}
disabled={running}
/>
</Section>
<Section id="sites" title="Sites I like" icon="fa-heart-o" badge={selRefs.length ? `${selRefs.length} selected` : refsApi.references.length ? `${refsApi.references.length} saved` : undefined} open={!!open.sites} onToggle={() => setOpen((o) => ({ ...o, sites: !o.sites }))}>
<SitesILikePanel
references={refsApi.references}
images={refsApi.images}
selected={selRefs}
onSelectedChange={setSelRefs}
imageUrl={ss.refImageUrl}
loadError={refsApi.loadError}
addRef={refsApi.addRef}
updateRef={(id, u, l, sh) => refsApi.updateRef(id, u, l, sh)}
deleteRef={refsApi.deleteRef}
uploadShot={(f) => refsApi.uploadImage(f, 'screenshot')}
removeImage={refsApi.removeImage}
maxRefs={limits?.max_site_refs ?? 20}
imagesUnavailableReason={imagesOn ? null : (visionReason ?? 'Image features are not available.')}
disabled={running}
/>
</Section>
<Section id="assets" title="Your uploaded images" icon="fa-image" badge={assetsLoaded && assets.length ? `${includedAssets.size} of ${assets.length} used` : undefined} open={!!open.assets} onToggle={() => setOpen((o) => ({ ...o, assets: !o.assets }))}>
<AssetAwareness
assets={assets}
loaded={assetsLoaded}
error={assetsError}
included={includedAssets}
onToggle={(f) => setExcluded((x) => { const n = new Set(x); if (n.has(f)) n.delete(f); else n.add(f); return n; })}
onAll={(on) => setExcluded(on ? new Set() : new Set(allAssetNames))}
thumbUrl={(a) => a.url}
visionAvailable={visionOk}
disabled={running}
/>
</Section>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: '#d4d4d8', padding: '2px 4px' }} data-testid="ss-selfreview-toggle">
<input type="checkbox" checked={selfReviewOn && visionOk} disabled={!visionOk || running} onChange={(e) => setSelfReview(e.target.checked)} style={{ marginTop: 3 }} />
<span>
Have Sitesmith look at the finished page and fix problems (one review, makes one more request)
{!visionOk && <span style={{ display: 'block', color: '#a1a1aa', fontSize: 12 }}>Needs image features, which are not available here.</span>}
</span>
</label>
</div>
)}
{running ? (
<JobStatus phase={phase} startedAgoS={resumed ? activeJob?.elapsed_s : 0} resumed={resumed} cancelling={cancelling} onCancel={reviewing ? undefined : handleCancel} />
) : (
<ChatInput
disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
initialValue={prefill ?? undefined}
/>
<>
{!canChat && summary && <div style={{ ...warnBox, marginBottom: 8 }} data-testid="ss-disabled-note">Sitesmith is not available on your plan right now, so the message box is turned off. See the note above for what to do.</div>}
<ChatInput
disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
initialValue={prefill ?? undefined}
/>
</>
)}
</div>
<ScopeConfirmDialog
open={!!pendingReplace}
pendingMessage={pendingReplace && 'message' in pendingReplace ? (pendingReplace as any).message : undefined}
pendingMessage={pendingReplace && 'message' in pendingReplace.resp ? (pendingReplace.resp as any).message : undefined}
onConfirm={confirmReplace}
onCancel={() => setPendingReplace(null)}
/>
@@ -0,0 +1,214 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React, { useState } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { AttachmentTray } from './AttachmentTray';
import { SitesILikePanel } from './SitesILikePanel';
import { AssetAwareness } from './AssetAwareness';
import { JobStatus } from './JobStatus';
let root: Root; let box: HTMLDivElement;
beforeEach(() => { box = document.createElement('div'); document.body.appendChild(box); root = createRoot(box); });
afterEach(() => { act(() => root.unmount()); box.remove(); });
const render = (el: React.ReactElement) => act(async () => { root.render(el); });
const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const png = (name = 'm.png') => new File([PNG], name, { type: 'image/png' });
const setFiles = (input: HTMLInputElement, files: File[]) => {
Object.defineProperty(input, 'files', { value: files, configurable: true });
input.dispatchEvent(new Event('change', { bubbles: true }));
};
const img = (id: string, kind: 'mockup' | 'screenshot' = 'mockup') => ({ id, kind, name: `${id}.png`, width: 100, height: 80, size: 2048 });
describe('AttachmentTray', () => {
const Harness: React.FC<{ onUpload?: any; reason?: string | null; initial?: string[]; max?: number }> = ({ onUpload, reason, initial = [], max = 4 }) => {
const [images, setImages] = useState([img('a'), img('b')]);
const [sel, setSel] = useState<string[]>(initial);
return (
<AttachmentTray images={images} selected={sel} onSelectedChange={setSel} maxAttach={max} referenceImageCount={0} imageUrl={(id) => `/img/${id}`}
onUpload={onUpload ?? (async (f: File) => { const i = img('new'); setImages((x) => [...x, i]); return { ok: true as const, image: i }; })}
onRemove={async (id: string) => { setImages((x) => x.filter((i) => i.id !== id)); return { ok: true as const }; }}
unavailableReason={reason} />
);
};
test('picker upload adds a thumbnail and selects it', async () => {
await render(<Harness />);
setFiles(box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement, [png()]);
await flush();
const items = box.querySelectorAll('[data-testid=ss-mockup-item]');
expect(items).toHaveLength(3);
expect(items[2].querySelector('img')!.getAttribute('src')).toBe('/img/new');
expect((items[2].querySelector('input[type=checkbox]') as HTMLInputElement).checked).toBe(true);
});
test('drop uploads too; thumbnails and names are text/attributes only', async () => {
const up = vi.fn(async () => ({ ok: true as const, image: img('d') }));
await render(<Harness onUpload={up} />);
const zone = box.querySelector('[data-testid=ss-dropzone]')!;
const ev = new Event('drop', { bubbles: true, cancelable: true }) as any;
ev.dataTransfer = { files: [png('drop.png')] };
await act(async () => { zone.dispatchEvent(ev); });
await flush();
expect(up).toHaveBeenCalledTimes(1);
});
test('a rejected upload shows its message in an alert and adds nothing', async () => {
await render(<Harness onUpload={async () => ({ ok: false as const, message: 'That file is not a JPEG, PNG, WebP or GIF image.' })} />);
setFiles(box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement, [png()]);
await flush();
expect(box.querySelector('[role=alert]')!.textContent).toContain('not a JPEG');
expect(box.querySelectorAll('[data-testid=ss-mockup-item]')).toHaveLength(2);
});
test('remove deletes and deselects', async () => {
await render(<Harness initial={['a']} />);
const btn = box.querySelector('button[aria-label="Remove a.png"]') as HTMLButtonElement;
await act(async () => { btn.click(); });
await flush();
expect(box.querySelectorAll('[data-testid=ss-mockup-item]')).toHaveLength(1);
});
test('limit: cannot select more than the image budget', async () => {
await render(<Harness max={1} initial={['a']} />);
const second = box.querySelectorAll('[data-testid=ss-mockup-item] input[type=checkbox]')[1] as HTMLInputElement;
await act(async () => { second.click(); });
expect(box.querySelector('[role=alert]')!.textContent).toContain('up to 1 image');
expect(second.checked).toBe(false);
});
test('vision unavailable: clear note, picker and checkboxes disabled', async () => {
await render(<Harness reason="Image features are not available on this server." />);
expect(box.querySelector('[data-testid=ss-vision-note]')!.textContent).toContain('not available on this server');
expect((box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement).disabled).toBe(true);
expect([...box.querySelectorAll('input[type=checkbox]')].every((c) => (c as HTMLInputElement).disabled)).toBe(true);
});
test('a hostile image name is shown as text, never parsed as markup', async () => {
const evil = '<img src=x onerror=alert(1)>.png';
await render(<AttachmentTray images={[{ ...img('z'), name: evil }]} selected={[]} onSelectedChange={() => {}} maxAttach={4} referenceImageCount={0} imageUrl={() => '/i'} onUpload={async () => ({ ok: false as const, message: '' })} onRemove={async () => ({ ok: true as const })} />);
expect(box.querySelectorAll('img').length).toBe(1);
expect(box.textContent).toContain(evil);
});
});
describe('SitesILikePanel', () => {
const baseRef = { id: 1, url_label: 'example.com', what_i_like: 'Big photo header', screenshot_ids: [] as string[] };
const Harness: React.FC<{ api: any; reason?: string | null }> = ({ api, reason }) => {
const [refs, setRefs] = useState([baseRef]);
const [sel, setSel] = useState<number[]>([]);
return (
<SitesILikePanel references={refs} images={[img('s1', 'screenshot')]} selected={sel} onSelectedChange={setSel} imageUrl={(i) => `/img/${i}`} maxRefs={20}
addRef={async (u, l, s) => { const r = await api.add(u, l, s); if (r.ok) setRefs((x) => [...x, { id: 2, url_label: u, what_i_like: l, screenshot_ids: s }]); return r; }}
updateRef={async (id, u, l, s) => { const r = await api.update(id, u, l, s); if (r.ok) setRefs((x) => x.map((e) => (e.id === id ? { ...e, url_label: u, what_i_like: l, screenshot_ids: s } : e))); return r; }}
deleteRef={async (id) => { const r = await api.del(id); if (r.ok) setRefs((x) => x.filter((e) => e.id !== id)); return r; }}
uploadShot={async () => ({ ok: true as const, image: img('s2', 'screenshot') })} removeImage={async () => ({ ok: true as const })}
imagesUnavailableReason={reason} />
);
};
const ok = { ok: true as const };
const type = (el: HTMLInputElement | HTMLTextAreaElement, v: string) => act(async () => {
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
});
const btn = (text: string) => [...box.querySelectorAll('button')].find((b) => b.textContent === text) as HTMLButtonElement;
test('copy says the address is only a label and is not fetched', async () => {
await render(<Harness api={{}} />);
const t = box.querySelector('[data-testid=ss-sites-copy]')!.textContent!;
expect(t).toMatch(/only a label/);
expect(t).toMatch(/does not open, visit or fetch it/);
});
test('lists, selects for the next request', async () => {
await render(<Harness api={{}} />);
expect(box.querySelectorAll('[data-testid=ss-ref-item]')).toHaveLength(1);
const cb = box.querySelector('[data-testid=ss-ref-item] input[type=checkbox]') as HTMLInputElement;
await act(async () => { cb.click(); });
expect(cb.checked).toBe(true);
});
test('add: calls the API with label, notes and screenshots; the entry appears', async () => {
const add = vi.fn(async () => ok);
await render(<Harness api={{ add }} />);
await act(async () => { btn('Add a site I like').click(); });
await type(box.querySelector('#ss-ref-url') as HTMLInputElement, 'https://nice.example');
await type(box.querySelector('#ss-ref-likes') as HTMLTextAreaElement, 'Clean menu');
setFiles(box.querySelector('[data-testid=ss-shot-input]') as HTMLInputElement, [png()]);
await flush();
await act(async () => { btn('Add site').click(); });
await flush();
expect(add).toHaveBeenCalledWith('https://nice.example', 'Clean menu', ['s2']);
expect(box.querySelectorAll('[data-testid=ss-ref-item]')).toHaveLength(2);
});
test('add with missing notes or bad address shows the validation message from the caller', async () => {
const add = vi.fn(async () => ({ ok: false as const, message: 'Say what you like about this site so Sitesmith knows what to take from it.' }));
await render(<Harness api={{ add }} />);
await act(async () => { btn('Add a site I like').click(); });
await act(async () => { btn('Add site').click(); });
expect(box.querySelector('[role=alert]')!.textContent).toContain('Say what you like');
});
test('edit saves changes; delete needs a confirm click', async () => {
const update = vi.fn(async () => ok); const del = vi.fn(async () => ok);
await render(<Harness api={{ update, del }} />);
await act(async () => { btn('Edit').click(); });
await type(box.querySelector('#ss-ref-likes') as HTMLTextAreaElement, 'New notes');
await act(async () => { btn('Save changes').click(); });
await flush();
expect(update).toHaveBeenCalledWith(1, 'example.com', 'New notes', []);
expect(box.textContent).toContain('New notes');
await act(async () => { btn('Delete').click(); });
expect(del).not.toHaveBeenCalled();
await act(async () => { btn('Confirm delete').click(); });
await flush();
expect(del).toHaveBeenCalledWith(1);
expect(box.querySelectorAll('[data-testid=ss-ref-item]')).toHaveLength(0);
});
test('no vision: screenshots are replaced by a note; text entries still work', async () => {
await render(<Harness api={{}} reason="Image features are not available here." />);
await act(async () => { btn('Add a site I like').click(); });
expect(box.querySelector('[data-testid=ss-shot-input]')).toBeNull();
expect(box.textContent).toContain('Image features are not available here.');
expect(box.querySelector('#ss-ref-likes')).not.toBeNull();
});
test('user text is rendered as text nodes', async () => {
const evil = { id: 9, url_label: '<img src=x onerror=alert(1)>', what_i_like: '<b>bold</b>', screenshot_ids: [] };
await render(<SitesILikePanel references={[evil]} images={[]} selected={[]} onSelectedChange={() => {}} imageUrl={() => ''} maxRefs={20} addRef={async () => ok} updateRef={async () => ok} deleteRef={async () => ok} uploadShot={async () => ({ ok: false as const, message: '' })} removeImage={async () => ok} />);
expect(box.querySelectorAll('img, b').length).toBe(0);
expect(box.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('AssetAwareness', () => {
const assets = [
{ filename: 'a.jpg', relUrl: 'assets/a.jpg', url: '/p/a', width: 800, height: 600, caption: 'A red door', alt: null },
{ filename: 'b.png', relUrl: 'assets/b.png', url: '/p/b', width: null, height: null, caption: null, alt: null },
];
test('shows captions and a no-description fallback, and toggles', async () => {
const onToggle = vi.fn(); const onAll = vi.fn();
await render(<AssetAwareness assets={assets} loaded included={new Set(['a.jpg'])} onToggle={onToggle} onAll={onAll} thumbUrl={(a) => a.url} visionAvailable />);
const items = box.querySelectorAll('[data-testid=ss-asset-item]');
expect(items[0].textContent).toContain('A red door');
expect(items[0].textContent).toContain('800×600');
expect(items[1].textContent).toContain('No description yet');
expect(box.textContent).toContain('1 of 2 included');
await act(async () => { (items[1].querySelector('input') as HTMLInputElement).click(); });
expect(onToggle).toHaveBeenCalledWith('b.png');
await act(async () => { [...box.querySelectorAll('button')].find((b) => b.textContent === 'Include none')!.click(); });
expect(onAll).toHaveBeenCalledWith(false);
});
test('empty, error and no-vision states are explained', async () => {
await render(<AssetAwareness assets={[]} loaded included={new Set()} onToggle={() => {}} onAll={() => {}} thumbUrl={() => ''} visionAvailable={false} />);
expect(box.textContent).toContain('have not uploaded any images');
expect(box.textContent).toContain('works from file names and sizes only');
await render(<AssetAwareness assets={[]} loaded error="Could not reach the server." included={new Set()} onToggle={() => {}} onAll={() => {}} thumbUrl={() => ''} visionAvailable />);
expect(box.querySelector('[role=alert]')!.textContent).toContain('Could not reach');
});
});
describe('JobStatus', () => {
test('shows the phase in plain words, cancel works, and says closing is safe', async () => {
const cancel = vi.fn();
await render(<JobStatus phase="captioning" onCancel={cancel} />);
expect(box.querySelector('[data-testid=ss-phase-label]')!.textContent).toContain('Looking at your images');
expect(box.textContent).toContain('You can close this window');
await act(async () => { (box.querySelector('[data-testid=ss-cancel]') as HTMLButtonElement).click(); });
expect(cancel).toHaveBeenCalled();
await render(<JobStatus phase="generating" cancelling onCancel={cancel} />);
expect(box.textContent).toContain('Cancelling');
expect((box.querySelector('[data-testid=ss-cancel]') as HTMLButtonElement).disabled).toBe(true);
});
});
@@ -0,0 +1,69 @@
import { describe, test, expect, vi } from 'vitest';
import { runSelfReview, REVIEW_MESSAGE, type SelfReviewDeps } from './selfReview';
const blob = () => new Blob([new Uint8Array(3000)], { type: 'image/jpeg' });
const deps = (o: Partial<SelfReviewDeps> = {}): SelfReviewDeps => ({
composeHtml: () => '<html><body>x</body></html>',
assetUrls: {}, allowUrls: [],
resolveAssetUrls: (h) => h,
capture: vi.fn(async () => ({ ok: true as const, blob: blob(), width: 1280, height: 900, cropped: false })),
upload: vi.fn(async () => ({ ok: true as const, id: 'a'.repeat(32) })),
review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message: 'm', ops: [{ op: 'delete_node' as const, node_id: 'n' }] } })),
applyResult: vi.fn(async () => ({ ok: true })),
removeImage: vi.fn(async () => ({})),
...o,
});
describe('runSelfReview', () => {
test('round trip: capture -> upload -> ONE review call -> apply -> render image deleted', async () => {
const d = deps();
const out = await runSelfReview(d);
expect(out.status).toBe('applied');
expect(d.capture).toHaveBeenCalledTimes(1);
expect(d.upload).toHaveBeenCalledTimes(1);
expect(d.review).toHaveBeenCalledTimes(1);
expect(d.review).toHaveBeenCalledWith(REVIEW_MESSAGE, 'a'.repeat(32));
expect(d.applyResult).toHaveBeenCalledTimes(1);
expect(d.removeImage).toHaveBeenCalledWith('a'.repeat(32));
const file = (d.upload as any).mock.calls[0][0] as File;
expect(file.type).toBe('image/jpeg');
});
test('capture failure skips with a note and calls nothing else', async () => {
const d = deps({ capture: vi.fn(async () => ({ ok: false as const, reason: 'capture timed out' })) });
const out = await runSelfReview(d);
expect(out).toMatchObject({ status: 'skipped' });
expect(out.note).toContain('capture timed out');
expect(d.upload).not.toHaveBeenCalled();
expect(d.review).not.toHaveBeenCalled();
expect(d.removeImage).not.toHaveBeenCalled();
});
test('upload failure, review failure and apply failure all skip non-blockingly; the render is still cleaned up', async () => {
expect((await runSelfReview(deps({ upload: vi.fn(async () => ({ ok: false as const, message: 'too big' })) }))).status).toBe('skipped');
const d2 = deps({ review: vi.fn(async () => ({ ok: false as const, status: 'RATE_LIMITED' as const, message: 'Slow down' })) });
const o2 = await runSelfReview(d2);
expect(o2).toMatchObject({ status: 'skipped' });
expect(o2.note).toContain('Slow down');
expect(d2.removeImage).toHaveBeenCalled();
const d3 = deps({ applyResult: vi.fn(async () => ({ ok: false, message: 'cannot apply' })) });
expect((await runSelfReview(d3)).status).toBe('skipped');
expect(d3.removeImage).toHaveBeenCalled();
});
test('a thrown error anywhere is a skip, never an exception', async () => {
const out = await runSelfReview(deps({ capture: vi.fn(async () => { throw new Error('boom'); }) }));
expect(out).toMatchObject({ status: 'skipped' });
expect(await runSelfReview(deps({ composeHtml: () => { throw new Error('x'); } }))).toMatchObject({ status: 'skipped' });
});
test('"looks good" results (empty patch / ask) apply nothing', async () => {
const d = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message: 'fine', ops: [] } })) });
expect((await runSelfReview(d)).status).toBe('looks-good');
expect(d.applyResult).not.toHaveBeenCalled();
const d2 = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'ask' as const, question: 'q' } })) });
expect((await runSelfReview(d2)).status).toBe('looks-good');
});
test('assets are resolved before capture', async () => {
const resolve = vi.fn((h: string) => h.replace('assets/a.png', '/api/a'));
const d = deps({ composeHtml: () => '<img src="assets/a.png">', resolveAssetUrls: resolve, assetUrls: { 'assets/a.png': '/api/a' } });
await runSelfReview(d);
expect((d.capture as any).mock.calls[0][0]).toContain('/api/a');
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { SendResult } from '../../types/sitesmith';
import type { CaptureResult } from '../../utils/self-review-capture';
/** Instruction for the one follow-up job. The render rides in the mockup slot, so say plainly what it is. */
export const REVIEW_MESSAGE =
'Review the attached screenshot of my page as it is built now (it is a render of the current page, not a mockup to copy). ' +
'Fix only real problems with a small patch: hard-to-read text, cramped or uneven spacing, broken or misaligned layout, badly cropped images, missing icons. ' +
'If it already looks right, reply with a patch that has no ops and say so. Do not redesign the page or change the wording.';
export interface SelfReviewDeps {
/** Published-style HTML of the current page (untrusted; the capture sanitises it). */
composeHtml: () => string;
/** `assets/<file>` -> same-origin URL, for images in the capture. */
assetUrls: Record<string, string>;
allowUrls: string[];
capture: (html: string, o: { allowUrls: string[]; timeoutMs: number }) => Promise<CaptureResult>;
upload: (file: File) => Promise<{ ok: true; id: string } | { ok: false; message: string }>;
/** Start the follow-up job and wait for it. */
review: (message: string, imageId: string) => Promise<SendResult>;
/** Apply the review result (contrast repair included). */
applyResult: (res: SendResult) => Promise<{ ok: boolean; message?: string; summary?: string }>;
removeImage: (id: string) => Promise<unknown>;
resolveAssetUrls: (html: string, urls: Record<string, string>) => string;
}
export type SelfReviewOutcome =
| { status: 'applied'; note: string }
| { status: 'looks-good'; note: string }
| { status: 'skipped'; note: string };
const skip = (why: string): SelfReviewOutcome => ({ status: 'skipped', note: `Self-review skipped: ${why}. Your page was built as normal.` });
/** One review round, never more. Never throws; any failure is a non-blocking note. */
export async function runSelfReview(d: SelfReviewDeps): Promise<SelfReviewOutcome> {
let imageId: string | null = null;
try {
let html: string;
try { html = d.resolveAssetUrls(d.composeHtml(), d.assetUrls); } catch { return skip('the page could not be rendered'); }
const cap = await d.capture(html, { allowUrls: d.allowUrls, timeoutMs: 20000 });
if (!cap.ok) return skip(cap.reason);
const file = new File([cap.blob], 'self-review-render.jpg', { type: 'image/jpeg' });
const up = await d.upload(file);
if (!up.ok) return skip(up.message || 'the screenshot could not be uploaded');
imageId = up.id;
const res = await d.review(REVIEW_MESSAGE, imageId);
if (!res.ok) return skip(res.message || 'the review request failed');
if (res.response.type === 'ask') return { status: 'looks-good', note: 'Self-review: Sitesmith had no changes to make.' };
if (res.response.type === 'patch' && res.response.ops.length === 0) return { status: 'looks-good', note: 'Self-review: Sitesmith looked at the finished page and found nothing to fix.' };
const applied = await d.applyResult(res);
if (!applied.ok) return skip(applied.message || 'the fixes could not be applied');
const extra = applied.summary ? ` ${applied.summary}.` : '';
return { status: 'applied', note: `Self-review: Sitesmith looked at the finished page and made fixes.${extra} Use Undo to go back.` };
} catch (e) {
return skip(e instanceof Error && e.message ? e.message.slice(0, 100) : 'something went wrong');
} finally {
// The render is private scratch, not a keepsake: it would otherwise eat the per-site image quota.
if (imageId) { try { await d.removeImage(imageId); } catch { /* best effort */ } }
}
}
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { SitesmithReference, SitesmithRefImage, SitesmithApiResult } from '../../hooks/useSitesmith';
import { validateAttachment, normalizeUrlLabel, DEFAULT_MAX_UPLOAD_BYTES } from '../../utils/sitesmith-attachments';
/** The slice of useSitesmith() this state needs (keeps it easy to fake in tests). */
export interface RefsApi {
listRefs: () => Promise<SitesmithApiResult<{ references: SitesmithReference[]; images: SitesmithRefImage[] }>>;
addReference: (r: { url_label: string; what_i_like: string; screenshot_ids?: string[] }) => Promise<SitesmithApiResult<{ reference: SitesmithReference }>>;
updateReference: (id: number, p: { url_label?: string; what_i_like?: string; screenshot_ids?: string[] }) => Promise<SitesmithApiResult<{ reference: SitesmithReference }>>;
deleteReference: (id: number) => Promise<SitesmithApiResult<object>>;
uploadRefImage: (f: File | Blob, kind: 'mockup' | 'screenshot') => Promise<SitesmithApiResult<{ image: SitesmithRefImage }>>;
deleteRefImage: (id: string) => Promise<SitesmithApiResult<object>>;
}
export type Outcome = { ok: true } | { ok: false; message: string };
/** Mockups, reference screenshots and "Sites I like" for one site. Never throws. */
export function useSitesmithRefs(api: RefsApi, enabled: boolean, maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES) {
const [references, setReferences] = useState<SitesmithReference[]>([]);
const [images, setImages] = useState<SitesmithRefImage[]>([]);
const [loaded, setLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const apiRef = useRef(api);
apiRef.current = api;
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const load = useCallback(async () => {
const r = await apiRef.current.listRefs();
if (!alive.current) return;
if (r.ok) { setReferences(r.references ?? []); setImages(r.images ?? []); setLoadError(null); }
else setLoadError(r.message);
setLoaded(true);
}, []);
useEffect(() => { if (enabled && !loaded) void load(); }, [enabled, loaded, load]);
const uploadImage = useCallback(async (file: File, kind: 'mockup' | 'screenshot'): Promise<Outcome & { image?: SitesmithRefImage }> => {
const bad = await validateAttachment(file, maxUploadBytes);
if (bad) return { ok: false, message: bad };
const r = await apiRef.current.uploadRefImage(file, kind);
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setImages((xs) => [...xs, r.image]);
return { ok: true, image: r.image };
}, [maxUploadBytes]);
const removeImage = useCallback(async (id: string): Promise<Outcome> => {
const r = await apiRef.current.deleteRefImage(id);
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) {
setImages((xs) => xs.filter((i) => i.id !== id));
setReferences((rs) => rs.map((x) => ({ ...x, screenshot_ids: x.screenshot_ids.filter((s) => s !== id) })));
}
return { ok: true };
}, []);
const addRef = useCallback(async (urlLabel: string, whatILike: string, screenshotIds: string[] = []): Promise<Outcome & { reference?: SitesmithReference }> => {
const u = normalizeUrlLabel(urlLabel);
if (!u.ok) return u;
if (!whatILike.trim()) return { ok: false, message: 'Say what you like about this site so Sitesmith knows what to take from it.' };
const r = await apiRef.current.addReference({ url_label: u.value, what_i_like: whatILike.trim(), screenshot_ids: screenshotIds });
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setReferences((rs) => [...rs, r.reference]);
return { ok: true, reference: r.reference };
}, []);
const updateRef = useCallback(async (id: number, urlLabel: string, whatILike: string, screenshotIds?: string[]): Promise<Outcome> => {
const u = normalizeUrlLabel(urlLabel);
if (!u.ok) return u;
if (!whatILike.trim()) return { ok: false, message: 'Say what you like about this site so Sitesmith knows what to take from it.' };
const r = await apiRef.current.updateReference(id, { url_label: u.value, what_i_like: whatILike.trim(), ...(screenshotIds ? { screenshot_ids: screenshotIds } : {}) });
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setReferences((rs) => rs.map((x) => (x.id === id ? r.reference : x)));
return { ok: true };
}, []);
const deleteRef = useCallback(async (id: number): Promise<Outcome> => {
const r = await apiRef.current.deleteReference(id);
if (!r.ok) return { ok: false, message: r.message };
if (alive.current) setReferences((rs) => rs.filter((x) => x.id !== id));
return { ok: true };
}, []);
return { references, images, loaded, loadError, reload: load, uploadImage, removeImage, addRef, updateRef, deleteRef };
}
+3 -2
View File
@@ -4,7 +4,8 @@ export type SitesmithStatus =
| 'OK_BONUS' | 'OK_MONTHLY'
| 'DISABLED' | 'CAP_REACHED'
| 'USER_KILLSWITCH' | 'SERVER_KILLSWITCH'
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID';
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID'
| 'BAD_ATTACHMENT' | 'INTERRUPTED' | 'CANCELLED' | 'JOB_IN_PROGRESS';
export interface SitesmithSummary {
enabled: boolean;
@@ -44,6 +45,6 @@ export type SitesmithResponse =
| { type: 'patch'; ops: SitesmithPatchOp[]; message: string; }
| { type: 'ask'; question: string; options?: string[]; };
export interface SendResultOk { ok: true; response: SitesmithResponse; }
export interface SendResultOk { ok: true; response: SitesmithResponse; /** Non-blocking server messages (e.g. images skipped). */ notes?: string[]; }
export interface SendResultErr { ok: false; status: SitesmithStatus | 'BLOCKED'; message: string; }
export type SendResult = SendResultOk | SendResultErr;
+50 -6
View File
@@ -3,6 +3,8 @@ import { insertTargetOutsideInstance, isInsideInstance } from './global-sections
import { usePages } from '../state/PageContext';
import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
import { sanitizeAiTree, buildNodeTree } from './craft-tree';
import { useSiteDesign, type SiteDesign } from '../state/SiteDesignContext';
import { repairResponseContrast, type ContrastAdjustment, type PatchContext } from './contrast-repair';
// Re-exported for existing callers/tests that import sanitizeAiTree/
// buildNodeTree from this module — the implementation lives in craft-tree.ts
@@ -32,6 +34,37 @@ export function findNodeIdByAiNodeId(query: any, aiNodeId: string): string | nul
return null;
}
/**
* Existing-canvas context for `update_props` ops: the node's props and the nearest ancestor's flat
* background, so a text colour the response sets can be judged against what is really behind it.
*/
export function canvasNodeContext(query: any): PatchContext['nodeContext'] {
return (aiNodeId: string) => {
try {
const id = findNodeIdByAiNodeId(query, aiNodeId);
if (!id) return undefined;
const props = { ...(query.node(id).get().data.props ?? {}) } as Record<string, any>;
let ancestorBg: string | undefined;
let cur: string | null | undefined = query.node(id).get().data.parent;
while (cur) {
const p = query.node(cur).get().data.props ?? {};
const bg = p.style?.backgroundColor ?? p.bgColor;
if (typeof bg === 'string' && bg.trim() && !/gradient|url\(/i.test(bg)) { ancestorBg = bg; break; }
if ((p.bgType && p.bgType !== 'color' && p.bgType !== 'solid') || p.bgImage) break;
cur = query.node(cur).get().data.parent;
}
return { props, ancestorBg };
} catch { return undefined; }
};
}
export interface ApplyOutcome { ok: boolean; message?: string; adjustments?: ContrastAdjustment[]; notChecked?: number }
/** Repair text/background contrast in a response against the current design (pure; no model call). */
export function repairForCanvas(resp: SitesmithResponse, design: SiteDesign, query: any) {
return repairResponseContrast(resp, design, { nodeContext: canvasNodeContext(query) });
}
/** Exported for unit tests */
export const __test = { findNodeIdByAiNodeId, buildNodeTree, applyPatch };
@@ -43,6 +76,7 @@ export const __test = { findNodeIdByAiNodeId, buildNodeTree, applyPatch };
export function useApplyAiResponse() {
const { actions, query } = useEditor();
const pages = usePages();
const { design } = useSiteDesign();
/**
* @param targetNodeId If set and the AI returned a section-scoped replace
@@ -53,10 +87,20 @@ export function useApplyAiResponse() {
return async function apply(
resp: SitesmithResponse,
targetNodeId?: string,
): Promise<{ ok: boolean; message?: string }> {
opts: { repairContrast?: boolean } = {},
): Promise<ApplyOutcome> {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
// Contrast auto-repair (design "Settled calls" 8): fix failing text colours before they reach the canvas.
let adjustments: ContrastAdjustment[] = [];
let notChecked = 0;
if (opts.repairContrast !== false) {
const rep = repairForCanvas(resp, design, query);
resp = rep.response; adjustments = rep.adjustments; notChecked = rep.notChecked;
}
const done = (r: { ok: boolean; message?: string }): ApplyOutcome => (r.ok ? { ...r, adjustments, notChecked } : r);
if (resp.type === 'replace') {
if (resp.scope === 'site') {
if (!pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })))) {
@@ -69,7 +113,7 @@ export function useApplyAiResponse() {
}
if (resp.header) pages.setHeader(resp.header.tree);
if (resp.footer) pages.setFooter(resp.footer.tree);
return { ok: true };
return done({ ok: true });
}
if (resp.scope === 'page') {
@@ -79,7 +123,7 @@ export function useApplyAiResponse() {
message: 'You are editing a global section. Click Done, then ask again to replace the page.',
};
}
return { ok: true };
return done({ ok: true });
}
if (resp.scope === 'section') {
@@ -95,7 +139,7 @@ export function useApplyAiResponse() {
const index = siblings.indexOf(targetNodeId);
actions.delete(targetNodeId);
actions.addNodeTree(nodeTree, parent, index);
return { ok: true };
return done({ ok: true });
} catch (e) {
console.warn('sitesmith: targeted section replace failed, falling back to append', e);
}
@@ -109,12 +153,12 @@ export function useApplyAiResponse() {
console.warn('sitesmith: failed to add section tree', e);
}
}
return { ok: true };
return done({ ok: true });
}
}
if (resp.type === 'patch') {
return applyPatch(actions, query, resp.ops);
return done(applyPatch(actions, query, resp.ops));
}
return { ok: false, message: 'Unknown response type' };
+28
View File
@@ -0,0 +1,28 @@
import type { SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml, exportComposedPreviewHtml } from './html-export';
export interface ComposeInput {
/** `query.serialize()` of the page on the canvas. */
serialized: string;
headerState: string | null;
footerState: string | null;
title: string;
design: SiteDesign;
}
/**
* Published-style document for the page (header + body + footer, token CSS, fonts), built exactly like
* the editor's Preview but WITHOUT the site's custom head code (customer scripts must never reach the
* capture). The result is still untrusted and goes through sanitizeCaptureHtml before it is rendered.
*/
export function composeReviewHtml(i: ComposeInput): string {
const header = i.headerState ? exportBodyHtml(i.headerState, 'header').html : '';
const body = exportBodyHtml(i.serialized).html;
const footer = i.footerState ? exportBodyHtml(i.footerState, 'footer').html : '';
return exportComposedPreviewHtml(i.serialized, header + body + footer, {
title: i.title || 'Page',
includeFonts: true,
design: i.design,
favicon: '',
});
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, test, expect } from 'vitest';
import { repairResponseContrast, nearestPassing, summarizeAdjustments } from './contrast-repair';
import { contrastRatio } from './publish-checks';
import { parseColor } from './color';
import { resolveTokenValue } from './tokens';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
const D = { ...DEFAULT_SITE_DESIGN, primaryColor: '#1e3a8a', textColor: '#111827', backgroundColor: '#ffffff', mutedTextColor: '#6b7280' };
const n = (name: string, props: Record<string, unknown>, nodes: SerializedTreeNode[] = []): SerializedTreeNode =>
({ type: { resolvedName: name }, props, nodes });
const replace = (tree: SerializedTreeNode): SitesmithResponse => ({ type: 'replace', scope: 'section', message: 'm', pages: [{ name: 'p', tree }] });
const firstTree = (r: SitesmithResponse) => (r as any).pages[0].tree as SerializedTreeNode;
const ratio = (fg: string, bg: string) => contrastRatio(parseColor(resolveTokenValue(fg, D))!, parseColor(resolveTokenValue(bg, D))!);
describe('repairResponseContrast', () => {
test('repairs a failing heading on a flat dark section to a passing colour and reports it', () => {
const resp = replace(n('Container', { style: { backgroundColor: '#1e3a8a' } }, [
n('Heading', { text: 'Hello', style: { color: '#334155' } }),
]));
const out = repairResponseContrast(resp, D);
const h = firstTree(out.response).nodes![0];
const to = (h.props.style as any).color as string;
expect(to).not.toBe('#334155');
expect(ratio(to, '#1e3a8a')).toBeGreaterThanOrEqual(4.5);
expect(out.adjustments).toHaveLength(1);
expect(out.adjustments[0]).toMatchObject({ kind: 'text-colour', node: 'Heading', text: 'Hello', from: '#334155', to });
expect(out.adjustments[0].ratioBefore!).toBeLessThan(4.5);
expect(out.adjustments[0].ratioAfter!).toBeGreaterThanOrEqual(4.5);
});
test('does not mutate the input response', () => {
const resp = replace(n('Container', { style: { backgroundColor: '#1e3a8a' } }, [n('Heading', { text: 'x', style: { color: '#334155' } })]));
const snap = JSON.stringify(resp);
repairResponseContrast(resp, D);
expect(JSON.stringify(resp)).toBe(snap);
});
test('passing pairs are left alone', () => {
const resp = replace(n('Container', { style: { backgroundColor: '#0f172a' } }, [n('TextBlock', { text: 'ok', style: { color: '#ffffff' } })]));
const out = repairResponseContrast(resp, D);
expect(out.adjustments).toEqual([]);
expect(firstTree(out.response).nodes![0].props.style).toEqual({ color: '#ffffff' });
});
test('resolves var(--wsb-*) tokens on both sides from the current design', () => {
// muted (#6b7280) on primary (#1e3a8a) fails
const resp = replace(n('Container', { style: { backgroundColor: 'var(--wsb-primary)' } }, [n('TextBlock', { text: 't', style: { color: 'var(--wsb-muted)' } })]));
const out = repairResponseContrast(resp, D);
expect(out.adjustments).toHaveLength(1);
const to = (firstTree(out.response).nodes![0].props.style as any).color as string;
expect(ratio(to, 'var(--wsb-primary)')).toBeGreaterThanOrEqual(4.5);
// prefers a site token over raw black/white when one passes and is nearer
expect(to.startsWith('var(--wsb-') || to === '#ffffff' || to === '#000000').toBe(true);
});
test('the same token pair passes under a different design (design is really used)', () => {
const resp = replace(n('Container', { style: { backgroundColor: 'var(--wsb-primary)' } }, [n('TextBlock', { text: 't', style: { color: 'var(--wsb-muted)' } })]));
const light = { ...D, primaryColor: '#ffffff' };
expect(repairResponseContrast(resp, light).adjustments).toHaveLength(0);
});
test('uses 3:1 for large text', () => {
// #888 on white = 3.54:1: fails body (4.5) but passes large (3)
const small = replace(n('Container', {}, [n('Heading', { text: 'a', style: { color: '#888888', fontSize: '16px' } })]));
const large = replace(n('Container', {}, [n('Heading', { text: 'a', style: { color: '#888888', fontSize: '32px' } })]));
expect(repairResponseContrast(small, D).adjustments).toHaveLength(1);
expect(repairResponseContrast(large, D).adjustments).toHaveLength(0);
});
test('gradient and video backgrounds are not checked (and counted)', () => {
const resp = replace(n('Container', { style: { background: 'x', backgroundColor: 'linear-gradient(135deg,#000,#fff)' } }, [n('Heading', { text: 'g', style: { color: '#777777' } })]));
const out = repairResponseContrast(resp, D);
expect(out.adjustments).toEqual([]);
expect(out.notChecked).toBe(1);
const hero = replace(n('HeroSimple', { bgType: 'gradient', bgGradientFrom: '#000', bgGradientTo: '#fff', textColor: '#777777' }));
const o2 = repairResponseContrast(hero, D);
expect(o2.adjustments).toEqual([]);
expect(o2.notChecked).toBe(1);
});
test('text over an image without an overlay gets one; text colour is not touched', () => {
const resp = replace(n('HeroSimple', { bgType: 'image', bgImage: 'assets/a.jpg', textColor: '#ffffff', overlayOpacity: 0 }));
const out = repairResponseContrast(resp, D);
const p = firstTree(out.response).props as any;
expect(p.overlayOpacity).toBeGreaterThanOrEqual(40);
expect(p.overlayColor).toBe('#000000');
expect(p.textColor).toBe('#ffffff');
expect(out.adjustments).toEqual([expect.objectContaining({ kind: 'overlay', node: 'HeroSimple' })]);
});
test('dark text over an image gets a LIGHT overlay; an existing strong overlay is kept', () => {
const weak = replace(n('HeroSimple', { bgType: 'image', bgImage: 'assets/a.jpg', textColor: '#111111', overlayOpacity: 10 }));
expect((firstTree(repairResponseContrast(weak, D).response).props as any).overlayColor).toBe('#ffffff');
const strong = replace(n('HeroSimple', { bgType: 'image', bgImage: 'assets/a.jpg', textColor: '#ffffff', overlayOpacity: 60 }));
expect(repairResponseContrast(strong, D).adjustments).toEqual([]);
});
test('BackgroundSection overlay uses its 0..1 scale and covers child text', () => {
const resp = replace(n('BackgroundSection', { bgImage: 'assets/a.jpg', overlayOpacity: 0.1 }, [n('Heading', { text: 'On photo', style: { color: '#ffffff' } })]));
const out = repairResponseContrast(resp, D);
const p = firstTree(out.response).props as any;
expect(p.overlayOpacity).toBe(0.55);
expect(p.overlayColor).toBe('#000000');
expect(out.adjustments.filter((a) => a.kind === 'overlay')).toHaveLength(1);
});
test('named section pairs: nav links and hero button are repaired', () => {
const resp = replace(n('Container', {}, [
n('Navbar', { backgroundColor: '#0f172a', textColor: '#1e293b', ctaColor: '#3b82f6', ctaTextColor: '#ffffff' }),
]));
const out = repairResponseContrast(resp, D);
const nav = firstTree(out.response).nodes![0].props as any;
expect(ratio(nav.textColor, '#0f172a')).toBeGreaterThanOrEqual(4.5);
expect(out.adjustments.map((a) => a.node)).toContain('Navbar');
});
test('root text is judged against the page background token', () => {
const resp = replace(n('Container', {}, [n('TextBlock', { text: 'pale', style: { color: '#cccccc' } })]));
expect(repairResponseContrast(resp, D).adjustments).toHaveLength(1);
const dark = { ...D, backgroundColor: '#000000' };
expect(repairResponseContrast(resp, dark).adjustments).toHaveLength(0);
});
test('brand swatches are candidates when present', () => {
const d = { ...D, swatches: [{ label: 'Brand', hex: '#fde68a', brand: true }] } as any;
const pick = nearestPassing(parseColor('#fcd34d')!, parseColor('#0f172a')!, 4.5, d)!;
expect(pick.value).toBe('#fde68a');
});
test('update_props patch is repaired using the existing node context', () => {
const resp: SitesmithResponse = { type: 'patch', message: 'm', ops: [{ op: 'update_props', node_id: 'x', props: { style: { color: '#334155' } } }] };
const out = repairResponseContrast(resp, D, { nodeContext: () => ({ props: { style: { backgroundColor: '#1e3a8a' } } }) });
const op = (out.response as any).ops[0];
expect(ratio(op.props.style.color, '#1e3a8a')).toBeGreaterThanOrEqual(4.5);
expect(out.adjustments).toHaveLength(1);
// without context nothing can be judged
expect(repairResponseContrast(resp, D).adjustments).toEqual([]);
});
test('replace_node / insert trees in patches, header and footer are covered', () => {
const bad = () => n('Container', { style: { backgroundColor: '#1e3a8a' } }, [n('Heading', { text: 'h', style: { color: '#334155' } })]);
const p: SitesmithResponse = { type: 'patch', message: 'm', ops: [{ op: 'insert_after', node_id: 'a', tree: bad() }] };
expect(repairResponseContrast(p, D).adjustments).toHaveLength(1);
const r: SitesmithResponse = { type: 'replace', scope: 'site', message: 'm', pages: [{ name: 'p', tree: n('Container', {}) }], header: { tree: bad() }, footer: { tree: bad() } };
expect(repairResponseContrast(r, D).adjustments).toHaveLength(2);
});
test('ask responses and malformed trees pass through untouched', () => {
const ask: SitesmithResponse = { type: 'ask', question: 'q' };
expect(repairResponseContrast(ask, D).response).toEqual(ask);
const weird = { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'p', tree: null }] } as any;
expect(() => repairResponseContrast(weird, D)).not.toThrow();
});
test('summary text', () => {
expect(summarizeAdjustments([])).toBe('');
expect(summarizeAdjustments([{ kind: 'text-colour', node: 'H', from: 'a', to: 'b' }, { kind: 'overlay', node: 'Hero', from: '0%', to: 'x' }], 2))
.toBe('1 text colour adjusted for readability; 1 image overlay added so text stays readable (2 gradient/image areas not checked)');
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* Contrast auto-repair for Sitesmith responses (design "Settled calls" 8).
*
* Runs on the model's response BEFORE it is applied: every text/background pair the
* response sets is checked with the editor's own `contrastRatio` (publish-checks.ts,
* the same checker as the colour-system work) and a failing text colour is replaced
* by the nearest passing site token or black/white. No model call. Pure: the input
* response is never mutated.
*
* What is checked: a node's own text colour against its own flat background, or
* against the nearest ancestor's flat background (the page background at the root).
* What is NOT checked (reported as such): gradients, background images and videos.
* Text over an image needs an overlay: a missing/weak overlay is strengthened.
*/
import type { SiteDesign } from '../state/SiteDesignContext';
import type { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
import { contrastRatio } from './publish-checks';
import { parseColor, type RGB } from './color';
import { resolveTokenValue, tokenRef, type TokenKey } from './tokens';
export interface ContrastAdjustment {
kind: 'text-colour' | 'overlay';
/** Component display label, e.g. "Heading". */
node: string;
/** Short quote of the affected text (<= 40 chars), plain text only. */
text?: string;
from: string;
to: string;
ratioBefore?: number;
ratioAfter?: number;
}
export interface ContrastRepairResult {
response: SitesmithResponse;
adjustments: ContrastAdjustment[];
/** Pairs found but not computable (gradients / images / videos). */
notChecked: number;
}
/** Existing canvas context for `update_props` ops (the node is not in the response). */
export interface PatchContext {
nodeContext?: (aiNodeId: string) => { props: Record<string, any>; ancestorBg?: string } | undefined;
}
type Bg =
| { kind: 'flat'; rgb: RGB }
| { kind: 'image'; node: SerializedTreeNode }
| { kind: 'unknown' };
const AA = 4.5;
const AA_LARGE = 3;
const MIN_OVERLAY = 40; // percent
/** Per-component named colour props (components that do not use style.color/backgroundColor). */
interface NamedPair { fg: string; bg?: string; label: string }
const NAMED_PAIRS: Record<string, NamedPair[]> = {
HeroSimple: [
{ fg: 'textColor', bg: 'bgColor', label: 'hero text' },
{ fg: 'buttonTextColor', bg: 'buttonBgColor', label: 'hero button' },
],
CallToAction: [{ fg: 'textColor', label: 'call-to-action text' }],
Navbar: [
{ fg: 'textColor', bg: 'backgroundColor', label: 'nav links' },
{ fg: 'ctaTextColor', bg: 'ctaColor', label: 'nav button' },
],
};
const OVERLAY_SCALE: Record<string, number> = { HeroSimple: 100, CallToAction: 100, BackgroundSection: 1 };
const plain = (s: unknown) => (typeof s === 'string' ? s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() : '');
const quote = (s: string) => (s.length > 40 ? s.slice(0, 39) + '…' : s);
function pxSize(v: unknown): number | null {
if (typeof v === 'number') return v;
if (typeof v !== 'string') return null;
const m = /^([\d.]+)(px|rem|em)$/.exec(v.trim());
if (!m) return null;
return m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;
}
function isLargeText(style: Record<string, any> | undefined): boolean {
const size = pxSize(style?.fontSize);
if (size === null) return false;
const w = parseInt(String(style?.fontWeight ?? ''), 10);
return size >= 24 || (size >= 18.66 && (w >= 700 || style?.fontWeight === 'bold'));
}
const toRgb = (v: unknown, design: SiteDesign): RGB | null =>
typeof v === 'string' && v.trim() ? parseColor(resolveTokenValue(v.trim(), design)) : null;
const dist = (a: RGB, b: RGB) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
const toHex = (c: RGB) => '#' + c.map((x) => x.toString(16).padStart(2, '0')).join('');
interface Candidate { value: string; rgb: RGB }
function candidates(design: SiteDesign): Candidate[] {
const out: Candidate[] = [];
const keys: TokenKey[] = ['text', 'bg', 'primary', 'secondary', 'accent', 'muted', 'link'];
for (const k of keys) {
const ref = tokenRef(k);
const rgb = parseColor(resolveTokenValue(ref, design));
if (rgb) out.push({ value: ref, rgb });
}
const sw = (design as unknown as { swatches?: Array<{ hex?: string }> }).swatches;
if (Array.isArray(sw)) {
for (const s of sw.slice(0, 24)) {
const rgb = s && typeof s.hex === 'string' ? parseColor(s.hex) : null;
if (rgb) out.push({ value: toHex(rgb), rgb });
}
}
out.push({ value: '#000000', rgb: [0, 0, 0] }, { value: '#ffffff', rgb: [255, 255, 255] });
return out;
}
/** Nearest (in RGB) candidate that passes `need` against `bg`; black/white always exist so one passes at 3:1 and, for any bg, at least one passes 4.5:1. */
export function nearestPassing(fg: RGB, bg: RGB, need: number, design: SiteDesign): Candidate | null {
let best: Candidate | null = null;
let bestD = Infinity;
for (const c of candidates(design)) {
if (contrastRatio(c.rgb, bg) < need) continue;
const d = dist(c.rgb, fg);
if (d < bestD) { best = c; bestD = d; }
}
return best;
}
function ownBg(props: Record<string, any>, design: SiteDesign, namedBg?: string): Bg | 'none' {
if (props.bgType && props.bgType !== 'color' && props.bgType !== 'solid') {
return props.bgType === 'image' ? { kind: 'image', node: undefined as unknown as SerializedTreeNode } : { kind: 'unknown' };
}
if (props.bgImage) return { kind: 'image', node: undefined as unknown as SerializedTreeNode };
const raw = (namedBg ? props[namedBg] : undefined) ?? props.style?.backgroundColor ?? props.bgColor;
if (typeof raw !== 'string' || !raw.trim()) return 'none';
if (/gradient|url\(/i.test(raw)) return { kind: 'unknown' };
const rgb = toRgb(raw, design);
return rgb ? { kind: 'flat', rgb } : 'none';
}
function overlayPercent(node: SerializedTreeNode): number {
const p = node.props as Record<string, any>;
const scale = OVERLAY_SCALE[node.type?.resolvedName] ?? 100;
const o = Number(p.overlayOpacity ?? (scale === 1 ? 0.4 : 0));
if (!Number.isFinite(o)) return 0;
return scale === 1 ? o * 100 : o;
}
function resolveBg(chain: SerializedTreeNode[], design: SiteDesign, rootBg: Bg): Bg {
for (let i = chain.length - 1; i >= 0; i--) {
const node = chain[i];
const b = ownBg(node.props as Record<string, any>, design);
if (b === 'none') continue;
if (b.kind === 'image') return { kind: 'image', node };
return b;
}
return rootBg;
}
class Repairer {
adjustments: ContrastAdjustment[] = [];
notChecked = 0;
private overlaid = new Set<SerializedTreeNode>();
private rootBg: Bg;
constructor(private design: SiteDesign, rootBgOverride?: string) {
const raw = rootBgOverride ?? design.backgroundColor;
const rgb = toRgb(raw, design) ?? toRgb(design.backgroundColor, design);
this.rootBg = rgb ? { kind: 'flat', rgb } : { kind: 'unknown' };
}
tree(root: SerializedTreeNode, ancestors: SerializedTreeNode[] = []): void {
const node = root;
if (!node || typeof node !== 'object' || !node.props || typeof node.props !== 'object') return;
const name = node.type?.resolvedName ?? 'Component';
const props = node.props as Record<string, any>;
const chain = ancestors;
// 1) Named pairs for section components, bg is own (or the inherited one).
for (const pair of NAMED_PAIRS[name] ?? []) this.checkPair(node, name, chain, props, pair.fg, pair.bg, undefined);
// 2) style.color text vs own style.backgroundColor / ancestors.
if (typeof props.style?.color === 'string' && props.style.color.trim()) {
this.checkPair(node, name, chain, props, 'style.color', undefined, props.style);
}
// 3) Sections with an image background: text on top needs an overlay.
if (props.bgType === 'image' || props.bgImage) this.checkOverlay(node, name, props);
for (const c of node.nodes ?? []) this.tree(c, [...chain, node]);
}
private bgFor(node: SerializedTreeNode, chain: SerializedTreeNode[], props: Record<string, any>, namedBg: string | undefined): Bg {
const own = ownBg(props, this.design, namedBg);
if (own !== 'none') {
if (own.kind === 'image') return { kind: 'image', node };
return own;
}
return resolveBg(chain, this.design, this.rootBg);
}
private checkPair(
node: SerializedTreeNode, name: string, chain: SerializedTreeNode[], props: Record<string, any>,
fgKey: string, bgKey: string | undefined, style: Record<string, any> | undefined,
): void {
const raw = fgKey === 'style.color' ? props.style?.color : props[fgKey];
const fg = toRgb(raw, this.design);
if (!fg) return;
const bg = this.bgFor(node, chain, props, bgKey);
if (bg.kind === 'unknown') { this.notChecked++; return; }
if (bg.kind === 'image') {
// Image behind the text: ensure an overlay on the node that owns the image.
if (bg.node) this.checkOverlay(bg.node, bg.node.type?.resolvedName ?? 'Section', bg.node.props as Record<string, any>, fg);
return;
}
const need = isLargeText(style ?? props.style) ? AA_LARGE : AA;
const before = contrastRatio(fg, bg.rgb);
if (before >= need) return;
const pick = nearestPassing(fg, bg.rgb, need, this.design);
if (!pick) return;
const target = fgKey === 'style.color' ? props.style : props;
const key = fgKey === 'style.color' ? 'color' : fgKey;
const from = String(target[key]);
target[key] = pick.value;
this.adjustments.push({
kind: 'text-colour', node: name,
text: quote(plain(props.text ?? props.html ?? props.heading ?? props.label ?? '')) || undefined,
from, to: pick.value,
ratioBefore: Math.round(before * 100) / 100,
ratioAfter: Math.round(contrastRatio(pick.rgb, bg.rgb) * 100) / 100,
});
}
private checkOverlay(node: SerializedTreeNode, name: string, props: Record<string, any>, fgHint?: RGB): void {
if (this.overlaid.has(node)) return;
if (!(name in OVERLAY_SCALE)) { this.notChecked++; return; } // an image we cannot add an overlay to
const fg = fgHint ?? toRgb(props.textColor, this.design);
if (overlayPercent(node) >= MIN_OVERLAY) { this.notChecked++; return; }
if (!fg && name !== 'HeroSimple' && name !== 'CallToAction') { this.notChecked++; return; }
const scale = OVERLAY_SCALE[name];
const light = fg ? (0.299 * fg[0] + 0.587 * fg[1] + 0.114 * fg[2]) / 255 > 0.5 : true;
const from = `${Math.round(overlayPercent(node))}%`;
props.overlayColor = light ? '#000000' : '#ffffff';
props.overlayOpacity = scale === 1 ? 0.55 : 55;
this.overlaid.add(node);
this.adjustments.push({ kind: 'overlay', node: name, from, to: `${props.overlayColor === '#000000' ? 'dark' : 'light'} overlay 55%` });
}
patchOp(op: SitesmithPatchOp, ctx?: PatchContext): void {
if (op.op === 'update_props') {
const known = ctx?.nodeContext?.(op.node_id);
if (!known || !op.props || typeof op.props !== 'object') return;
// Evaluate the merged props, then write the repaired colour into the patch only.
const merged: Record<string, any> = { ...known.props, ...op.props };
merged.style = { ...(known.props.style ?? {}), ...((op.props as any).style ?? {}) };
const probe: SerializedTreeNode = { type: { resolvedName: 'Component' }, props: merged };
const inner = new Repairer(this.design, known.ancestorBg);
inner.tree(probe);
for (const a of inner.adjustments) {
if (a.kind !== 'text-colour') continue;
const patch = op.props as Record<string, any>;
if (merged.style?.color !== undefined && (patch.style?.color !== undefined || patch.style)) {
patch.style = { ...(patch.style ?? {}), color: merged.style.color };
} else {
for (const k of Object.keys(merged)) if (k !== 'style' && merged[k] === a.to && patch[k] !== undefined) patch[k] = a.to;
}
this.adjustments.push(a);
}
this.notChecked += inner.notChecked;
return;
}
if ('tree' in op) this.tree(op.tree);
}
}
export function repairResponseContrast(
response: SitesmithResponse, design: SiteDesign, ctx?: PatchContext,
): ContrastRepairResult {
let copy: SitesmithResponse;
try { copy = JSON.parse(JSON.stringify(response)); } catch { return { response, adjustments: [], notChecked: 0 }; }
const r = new Repairer(design);
try {
if (copy.type === 'replace') {
for (const p of copy.pages ?? []) r.tree(p.tree);
if (copy.header) r.tree(copy.header.tree);
if (copy.footer) r.tree(copy.footer.tree);
} else if (copy.type === 'patch') {
for (const op of copy.ops ?? []) r.patchOp(op, ctx);
}
} catch {
// Repair is best-effort: never block applying the model's answer.
return { response, adjustments: [], notChecked: 0 };
}
return { response: copy, adjustments: r.adjustments, notChecked: r.notChecked };
}
/** One-line summary for the "what was adjusted" note. */
export function summarizeAdjustments(a: ContrastAdjustment[], notChecked = 0): string {
const text = a.filter((x) => x.kind === 'text-colour').length;
const ov = a.filter((x) => x.kind === 'overlay').length;
const parts: string[] = [];
if (text) parts.push(`${text} text colour${text === 1 ? '' : 's'} adjusted for readability`);
if (ov) parts.push(`${ov} image overlay${ov === 1 ? '' : 's'} added so text stays readable`);
if (!parts.length) return '';
const tail = notChecked ? ` (${notChecked} gradient/image area${notChecked === 1 ? '' : 's'} not checked)` : '';
return parts.join('; ') + tail;
}
@@ -70,6 +70,11 @@ describe('no same-origin path for composed-page HTML remains in the source', ()
// on a srcdoc frame it would hand customer markup the panel origin.
const offenders = files.filter((f) => {
const src = readFileSync(f, 'utf8');
// The one sanctioned exception: the Sitesmith self-review capture frame. It is script-less (the file must
// never mention allow-scripts), fed only sanitizeCaptureHtml output, CSP-locked, and allow-same-origin is
// there only so html-to-image can read the frame's layout. See sanitize-capture-html.test.ts and
// scripts/e2e-capture-security.mjs for the hostile-page proof.
if (f.endsWith('self-review-capture.ts')) return /allow-scripts/.test(src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')) || !/sanitizeCaptureHtml\(/.test(src);
return /srcdoc/i.test(src) && /allow-same-origin['"\s]/.test(src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''));
});
expect(offenders.map((f) => f.slice(root.length + 1))).toEqual([]);
@@ -0,0 +1,199 @@
import { describe, test, expect } from 'vitest';
import { sanitizeCaptureHtml, CAPTURE_CSP, BLOCKED_IMAGE } from './sanitize-capture-html';
const san = (h: string) => sanitizeCaptureHtml(h);
const dom = (h: string) => new DOMParser().parseFromString(h, 'text/html');
describe('sanitizeCaptureHtml', () => {
const evil = `<!doctype html><html><head>
<meta http-equiv="refresh" content="0;url=https://evil.example/"><base href="https://evil.example/">
<link rel="import" href="https://evil.example/x.html"><link rel="preload" href="https://evil.example/a" as="script">
<link rel="stylesheet" href="https://evil.example/x.css">
<style>@import url(https://evil.example/i.css); body{background:url(https://evil.example/t.png)} .ok{color:red}</style>
<script>window.__pwned=1</script></head><body onload="window.__pwned=1">
<img src=x onerror="window.__pwned=1"><img src="assets/a.png" onclick="x()">
<svg onload="window.__pwned=1"><script>1</script><foreignObject><div onclick="x()">f</div></foreignObject><a xlink:href="javascript:1"><rect/></a></svg>
<a href="javascript:window.__pwned=1">j</a><a href=" JaVa&#x09;script:1">j2</a><a href="data:text/html,<script>1</script>">d</a><a href="https://ok.example/">fine</a>
<iframe srcdoc="<script>1</script>"></iframe><iframe src="https://evil.example/"></iframe>
<object data="https://evil.example/o"></object><embed src="https://evil.example/e">
<form action="javascript:1"><button formaction="javascript:1">b</button></form>
<div style="background:url(javascript:1);width:1px">s</div><div style="background-image:url('https://evil.example/p.png')">s2</div><div style="width:10px;color:blue">keep</div>
<video src="https://evil.example/v.mp4"></video><noscript><img src=x onerror=1></noscript>
<img srcset="https://evil.example/a.png 1x, assets/b.png 2x" src="assets/b.png">
<p class="ok" data-animation="fade-in">Hello <b>world</b></p>
</body></html>`;
test('removes every script-capable or network-reaching construct', () => {
const out = san(evil);
expect(out.ok).toBe(true);
const d = dom((out as any).html);
for (const sel of ['script', 'iframe', 'object', 'embed', 'base', 'video', 'noscript', 'foreignObject', 'foreignobject', 'link[rel=import]', 'link[rel=preload]', 'link[href*="evil"]', 'meta[http-equiv=refresh]']) {
expect(d.querySelectorAll(sel).length, sel).toBe(0);
}
const all = Array.from(d.querySelectorAll('*'));
for (const el of all) for (const a of Array.from(el.attributes)) {
expect(/^on/i.test(a.name), `${el.tagName}.${a.name}`).toBe(false);
expect(/^\s*(javascript|vbscript|data:text)/i.test(a.value.replace(/[\s\u0000-\u001f]/g, '')), `${el.tagName}.${a.name}=${a.value}`).toBe(false);
expect(a.name === 'srcdoc' || a.name === 'formaction' || a.name === 'action').toBe(false);
}
expect((out as any).html).not.toMatch(/evil\.example/);
expect((out as any).html).not.toMatch(/@import/i);
});
test('keeps benign content, same-origin images, class/style/data-* and https links', () => {
const out = san(evil) as any;
const d = dom(out.html);
expect(d.querySelector('p.ok')?.getAttribute('data-animation')).toBe('fade-in');
expect(d.querySelector('p.ok b')?.textContent).toBe('world');
expect(d.querySelector('img[src="assets/a.png"]')).not.toBeNull();
expect(d.querySelector('a[href="https://ok.example/"]')).not.toBeNull();
expect(Array.from(d.querySelectorAll('div')).some((x) => x.getAttribute('style') === 'width:10px;color:blue')).toBe(true);
expect(d.querySelector('style')?.textContent).toContain('.ok{color:red}');
});
test('same-origin URLs other than assets/<file> and the allow-list are refused (no GET to arbitrary panel paths)', () => {
const h = '<html><body><img src="/api/site-builder.php?action=delete_asset&n=1"><img src="/api/site-builder.php?action=asset&f=a.png"><img src="x.png"><div style="background:url(/beacon?c)">a</div></body></html>';
const open = dom((san(h, ) as any).html);
expect(Array.from(open.querySelectorAll('img')).map((i) => i.getAttribute('src'))).toEqual([BLOCKED_IMAGE, BLOCKED_IMAGE, BLOCKED_IMAGE]);
expect((san(h) as any).html).not.toContain('/beacon');
const allowed = dom((sanitizeCaptureHtml(h, { allowUrls: ['/api/site-builder.php?action=asset&f=a.png'] }) as any).html);
expect(Array.from(allowed.querySelectorAll('img')).map((i) => i.getAttribute('src'))).toEqual([BLOCKED_IMAGE, '/api/site-builder.php?action=asset&f=a.png', BLOCKED_IMAGE]);
});
test('keeps font stylesheets from the two allowed hosts only', () => {
const h = '<html><head><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css"><link rel="stylesheet" href="https://evil.example/a.css"></head><body>x</body></html>';
const d = dom((san(h) as any).html);
expect(d.querySelectorAll('link[rel=stylesheet]').length).toBe(2);
});
test('inlines a restrictive CSP meta and exposes no other meta refresh', () => {
const d = dom((san('<html><head></head><body>x</body></html>') as any).html);
const csp = d.querySelector('meta[http-equiv="Content-Security-Policy"]')!;
expect(csp.getAttribute('content')).toBe(CAPTURE_CSP);
expect(CAPTURE_CSP).toContain("script-src 'none'");
expect(CAPTURE_CSP).toContain("default-src 'none'");
expect(CAPTURE_CSP).not.toMatch(/unsafe-eval|connect-src \*/);
});
test('un-hides animated elements (no script will reveal them)', () => {
const d = dom((san('<html><body><p data-animation="fade-in">a</p></body></html>') as any).html);
expect(d.querySelector('style[data-capture]')?.textContent).toContain('[data-animation]');
});
test('is a fixed point: a second pass changes nothing (mutation-XSS guard)', () => {
const a = san(evil) as any;
const b = san(a.html) as any;
expect(b.html).toBe(a.html);
});
test('mXSS-style payloads do not survive', () => {
for (const p of [
'<svg></p><style><a id="</style><img src=1 onerror=alert(1)>">',
'<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>',
'<noscript><p title="</noscript><img src=x onerror=alert(1)>">',
'<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>',
]) {
const r = san(`<html><body>${p}</body></html>`);
if (!r.ok) continue; // refusing is also safe
expect(r.html).not.toMatch(/onerror/i);
}
});
test('empty or non-string input is refused, not thrown', () => {
expect(san('')).toMatchObject({ ok: false });
expect(san(undefined as any)).toMatchObject({ ok: false });
});
});
describe('CSS is judged from the parsed model, not by regex (bypass classes)', () => {
const css = (c: string, allow: string[] = []) => {
const r = sanitizeCaptureHtml(`<html><head><style>${c}</style></head><body><p>x</p></body></html>`, { allowUrls: allow }) as any;
expect(r.ok).toBe(true);
return dom(r.html).querySelector('style:not([data-capture])')?.textContent ?? '';
};
const inline = (v: string) => {
const r = sanitizeCaptureHtml(`<html><body><div id="d" style="${v.replace(/"/g, '&quot;')}">x</div></body></html>`) as any;
return dom(r.html).getElementById('d')?.getAttribute('style') ?? null;
};
const noFetch = (out: string) => {
expect(out).not.toMatch(/beacon|evil|\/api\//);
expect(out).not.toMatch(/image-set|@import|expression|-moz-binding|behavior/i);
};
test.each([
['escape-encoded url(', 'p{background:u\\72l(/beacon?e1)}'],
['escape inside the target', 'p{background:url("\\2f beacon?e2")}'],
['image-set with string', 'p{background-image:image-set("/beacon?e3" 1x)}'],
['-webkit-image-set', 'p{background-image:-webkit-image-set(url(/beacon?e4) 1x)}'],
['image() / cross-fade()', 'p{background-image:cross-fade(url(/beacon?e5),url(/beacon?e5b),50%)}'],
['quoted ) in the url', 'p{background:url("x)/beacon?e6")}'],
['comment splitting the token', 'p{background:ur/**/l(/beacon?e7)}'],
['comment inside url()', 'p{background:url(/*x*/ /beacon?e8)}'],
['custom-property indirection', ':root{--u:url(/beacon?e9)} p{background:var(--u)}'],
['@import', '@import url(/beacon?e10); p{color:red}'],
['escaped @import', '@im\\70ort url(/beacon?e11); p{color:red}'],
['@namespace', '@namespace url(/beacon?e12); p{color:red}'],
['expression()', 'p{width:expression(alert(1))}'],
['-moz-binding / behavior', 'p{-moz-binding:url(/beacon?e13);behavior:url(/beacon?e14)}'],
['@font-face src to a same-origin API endpoint', '@font-face{font-family:x;src:url(/api/site-builder.php?action=delete_asset)} p{font-family:x}'],
['@font-face src, other host', '@font-face{font-family:x;src:url(https://evil.example/f.woff2)}'],
['percent-encoded traversal', 'p{background:url(assets/%2e%2e/api/x)}'],
['double-encoded traversal', 'p{background:url(assets/%252e%252e/api/x)}'],
['mixed-case encoded', 'p{background:url(assets/%2E%2E%2Fapi/x)}'],
['dot segments', 'p{background:url(assets/../api/x)}'],
['protocol-relative', 'p{background:url(//evil.example/x)}'],
['javascript: url', 'p{background:url(javascript:alert(1))}'],
['blob: / data:text', 'p{background:url(blob:http://x/y)} q{background:url("data:text/html,<script>1</script>")}'],
['keyframes with url', '@keyframes k{from{background:url(/beacon?e15)}to{color:red}}'],
['@media wrapping', '@media (min-width:1px){p{background:url(/beacon?e16)}}'],
])('%s is dropped', (_n, c) => {
noFetch(css(c));
});
test('benign CSS survives, re-serialised from the parsed model', () => {
const out = css('@media (max-width:600px){.a{color:red}} .b{background:linear-gradient(135deg,#0f172a,#334155);padding:8px} @keyframes k{from{opacity:0}to{opacity:1}} .c{background:url(assets/p.png)}');
expect(out).toContain('@media');
expect(out).toContain('linear-gradient');
expect(out).toContain('@keyframes k');
expect(out).toMatch(/url\(["']?assets\/p\.png["']?\)/);
});
test('exact allow-listed URLs pass in CSS and attributes; near misses do not', () => {
const a = '/api/site-builder.php?action=asset&f=a.png';
expect(css(`p{background:url(${a})}`, [a])).toContain(a);
expect(css(`p{background:url(${a}&x=1)}`, [a])).not.toContain('action=asset');
});
test('inline style: injection and fetchers are dropped, plain declarations kept', () => {
expect(inline('color:red;padding:4px')).toContain('color:red');
expect(inline('background:url(/beacon?i1)') ?? '').not.toMatch(/beacon|image-set|url\(/i);
expect(inline('color:red}b{background:url(/beacon?i2)') ?? '').not.toMatch(/beacon/);
expect(inline('background:image-set("/beacon?i3" 1x)') ?? '').not.toMatch(/beacon|image-set|url\(/i);
expect(inline('background:u\\72l(/beacon?i4)') ?? '').not.toMatch(/beacon|image-set|url\(/i);
expect(inline('background-image:url(assets/p.png);color:red')).toContain('color:red');
});
test.each([
['assets/%2e%2e/api/x'], ['assets/%2E%2E/x'], ['assets/%252e%252e/x'], ['assets/..%2fx'], ['assets/a%2Fb'],
['assets\\..\\x'], ['assets/../x'], ['assets/./x'], ['//evil.example/x'], ['/beacon?x'], ['x.png'], ['blob:http://x/y'],
['javascript:alert(1)'], ['data:text/html;base64,PHNjcmlwdD4='], ['data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='], ['assets/a\u0000b'],
])('img src %j is replaced by the placeholder', (src) => {
const r = sanitizeCaptureHtml(`<html><body><img id="i" src="${src.replace(/"/g, '&quot;')}"></body></html>`) as any;
expect(dom(r.html).getElementById('i')!.getAttribute('src')).toBe(BLOCKED_IMAGE);
});
test('plain assets/ images are kept', () => {
const r = sanitizeCaptureHtml('<html><body><img id="i" src="assets/photo-1.webp"><img id="j" src="assets/sub/dir/p.png"></body></html>') as any;
expect(dom(r.html).getElementById('i')!.getAttribute('src')).toBe('assets/photo-1.webp');
expect(dom(r.html).getElementById('j')!.getAttribute('src')).toBe('assets/sub/dir/p.png');
});
test('SVG presentation attributes may only reference in-document ids', () => {
const r = sanitizeCaptureHtml('<html><body><svg><rect id="a" fill="url(#g)" filter="url(/beacon?f)" mask="url(https://evil.example/m)"/></svg></body></html>') as any;
const rect = dom(r.html).getElementById('a')!;
expect(rect.getAttribute('fill')).toBe('url(#g)');
expect(rect.hasAttribute('filter')).toBe(false);
expect(rect.hasAttribute('mask')).toBe(false);
});
});
+339
View File
@@ -0,0 +1,339 @@
/**
* Sanitiser for the self-review capture. The page HTML contains model output and user text, so it is
* treated as hostile: it is parsed with DOMParser (an inert document: nothing runs, nothing loads),
* stripped to a small allow-list, re-serialised, and only then handed to a script-less iframe whose
* own CSP (meta) forbids scripts and every network load except fonts/stylesheets from named hosts.
*
* CSS is NEVER filtered with regular expressions. Every stylesheet and style attribute is parsed by the
* browser's own CSSOM (inert), each rule and declaration is judged from the PARSED, normalised value
* (so escapes, comments, quoting and image-set() tricks are already resolved), and the output is
* re-serialised from that model. Anything not understood is dropped, not repaired.
*
* URLs (attributes and CSS alike) go through one strict gate, `checkUrl`: data: images, `assets/<file>`
* (no percent signs, no dot segments), the caller's exact allow-list, and font files from named hosts.
* Nothing else is ever fetched by the editor on the page's behalf.
*
* Guarantees (sanitize-capture-html.test.ts + the real-browser proof scripts/e2e-capture-security.mjs):
* no script, frame, object, embed, base, refresh, import, event handler, javascript:/vbscript:/data:text
* URL, @import, escape-encoded or image-set() url, or non-allow-listed url() survives; the output is a
* fixed point of the sanitiser (mutation-XSS guard).
*/
export const CAPTURE_CSP = [
"default-src 'none'",
"script-src 'none'",
'img-src data:',
"style-src 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com",
'font-src data: https://fonts.gstatic.com https://cdnjs.cloudflare.com',
"base-uri 'none'",
"form-action 'none'",
"frame-src 'none'",
"object-src 'none'",
].join('; ');
const REMOVE_TAGS = new Set([
'script', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'base', 'noscript', 'template', 'math',
'video', 'audio', 'source', 'track', 'portal', 'foreignobject', 'animate', 'set', 'animatetransform', 'animatemotion',
'meta',
]);
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'srcset', 'poster', 'data', 'background', 'ping', 'cite', 'longdesc', 'manifest', 'codebase', 'usemap', 'lowsrc', 'dynsrc']);
const DROP_ATTRS = new Set(['srcdoc', 'http-equiv', 'is', 'formmethod', 'target', 'ping']);
export const FONT_SHEET_HOSTS = /^https:\/\/(fonts\.googleapis\.com\/|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/)/i;
export const FONT_FILE_HOSTS = /^https:\/\/(fonts\.gstatic\.com\/|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/)/i;
/** 1x1 grey PNG: stands in for an image the capture may not fetch, keeping layout. */
export const BLOCKED_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8d+/efwAIgQMeVxsvbAAAAABJRU5ErkJggg==';
export interface SanitizeOptions {
/** Exact URLs (root-relative path + query, as the editor knows them) that may be fetched, e.g. the site's asset URLs. */
allowUrls?: string[];
}
export interface Policy { allow: Set<string> }
export function makePolicy(allowUrls: string[] = []): Policy {
return { allow: new Set(allowUrls.filter((u) => typeof u === 'string' && /^\/[^\s"'<>\\]*$/.test(u) && !u.startsWith('//'))) };
}
const CTRL = /[\u0000-\u001f\u007f-\u009f\\]/;
const DATA_IMAGE = /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/;
const DATA_FONT = /^data:(font\/(woff2?|ttf|otf)|application\/(font-woff2?|x-font-(woff|ttf|otf)|vnd\.ms-fontobject|octet-stream));base64,[A-Za-z0-9+/=]+$/;
const ASSET_PATH = /^assets\/[A-Za-z0-9._~+-]+(?:\/[A-Za-z0-9._~+-]+)*$/;
/**
* The single URL gate. Returns the canonical form to emit, or null when the URL may not be used.
* `font` widens it to font data URLs and the named font hosts.
*/
export function checkUrl(raw: string, pol: Policy, font = false): string | null {
if (typeof raw !== 'string') return null;
const t = raw.trim();
if (!t || t.length > 4000) return null;
if (pol.allow.has(t)) return t; // exact, caller-supplied (server-provided asset URLs)
if (CTRL.test(t) || t.startsWith('//') || t.includes('%')) return null;
if (DATA_IMAGE.test(t)) return t;
if (font && DATA_FONT.test(t)) return t;
if (ASSET_PATH.test(t) && !t.split('/').some((s) => /^\.+$/.test(s))) return t;
if (font) {
let u: URL;
try { u = new URL(t); } catch { return null; }
if (u.protocol === 'https:' && !u.username && !u.password && FONT_FILE_HOSTS.test(u.href) && !u.href.includes('%')) return u.href;
}
return null;
}
// ---------------------------------------------------------------- CSS (CSSOM, never regex)
const RULE = { STYLE: 1, MEDIA: 4, FONT_FACE: 5, KEYFRAMES: 7, KEYFRAME: 8, SUPPORTS: 12 } as const;
const BAD_PROP = /^(behavior|-moz-binding|-ms-behavior)$/i;
// Functions that can fetch: anything but plain url() is dropped outright.
const FETCH_FN = /(?:^|[^a-z0-9_-])(image-set|-webkit-image-set|src|cross-fade|-webkit-cross-fade|element|-moz-element|paint|image)\s*\(/i;
const FETCH_ANY = /(?:^|[^a-z0-9_-])(url|image-set|-webkit-image-set|src|cross-fade|-webkit-cross-fade|element|-moz-element|paint|image)\s*\(/i;
const SAFE_KEY = /^[A-Za-z0-9%., -]+$/;
const SAFE_IDENT = /^[A-Za-z0-9_-]+$/;
function baseDir(): string {
try { return new URL('./', typeof document !== 'undefined' ? document.baseURI : 'http://localhost/').href; } catch { return ''; }
}
/** Browsers serialise url() targets resolved against the document: map back to what was written. */
function unresolve(u: string): string[] {
const out = [u];
const dir = baseDir();
if (dir && u.startsWith(dir)) out.push(u.slice(dir.length));
try {
const abs = new URL(u);
if (typeof document !== 'undefined' && abs.origin === new URL(document.baseURI).origin) out.push(abs.pathname + abs.search);
} catch { /* relative: already in `out` */ }
return out;
}
/** Judge one parsed declaration value: every fetching function must be plain url() with an allowed target. */
function valueOk(value: string, pol: Policy, font: boolean): boolean {
if (/[\\]/.test(value)) return false;
if (/expression\s*\(/i.test(value) || /javascript:/i.test(value)) return false;
if (!FETCH_ANY.test(value)) return true;
if (FETCH_FN.test(value)) return false;
let rest = value;
const re = /url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^"'()\s\\]+))\s*\)/gi;
let m: RegExpExecArray | null;
let n = 0;
while ((m = re.exec(value))) {
n++;
const target = m[1] ?? m[2] ?? m[3] ?? '';
if (!unresolve(target).some((c) => checkUrl(c, pol, font))) return false;
}
rest = value.replace(re, '');
if (/url\s*\(/i.test(rest)) return false; // an url( we could not parse as a plain quoted/bare token
return n > 0;
}
/** Canonical form of every url() in an accepted value, so the emitted CSS carries the checked targets only. */
function canonicalize(value: string, pol: Policy, font: boolean): string {
return value.replace(/url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^"'()\s\\]+))\s*\)/gi, (_m, a, b, c) => {
const target = a ?? b ?? c ?? '';
for (const cand of unresolve(target)) {
const ok = checkUrl(cand, pol, font);
if (ok) return `url("${ok.replace(/"/g, '')}")`;
}
return 'none';
});
}
function declarations(style: CSSStyleDeclaration, pol: Policy, font = false): string {
const out: string[] = [];
for (let i = 0; i < style.length; i++) {
const prop = style[i];
if (!prop || /[^A-Za-z0-9_-]/.test(prop) || BAD_PROP.test(prop)) continue;
const value = style.getPropertyValue(prop);
if (!value || !valueOk(value, pol, font || prop === 'src')) continue;
const pri = style.getPropertyPriority(prop) ? ' !important' : '';
out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`);
}
return out.join(';');
}
function parseSheet(css: string): CSSRuleList | null {
try {
if (typeof CSSStyleSheet !== 'undefined') {
const s = new CSSStyleSheet();
s.replaceSync(css);
return s.cssRules;
}
} catch { /* fall through to the inert document */ }
try {
const d = document.implementation.createHTMLDocument('');
const st = d.createElement('style');
st.textContent = css;
d.head.appendChild(st);
return st.sheet ? st.sheet.cssRules : null;
} catch { return null; }
}
function rulesToCss(rules: CSSRuleList, pol: Policy): string {
const out: string[] = [];
for (const r of Array.from(rules)) {
switch (r.type) {
case RULE.STYLE: {
const sr = r as CSSStyleRule;
if (/\\/.test(sr.selectorText) || /[<>]/.test(sr.selectorText.replace(/[>+~]/g, ''))) break;
const d = declarations(sr.style, pol);
if (d) out.push(`${sr.selectorText}{${d}}`);
break;
}
case RULE.FONT_FACE: {
const d = declarations((r as CSSFontFaceRule).style, pol, true);
if (/(^|;)src:/.test(d)) out.push(`@font-face{${d}}`);
break;
}
case RULE.MEDIA: {
const mr = r as CSSMediaRule;
const cond = mr.media.mediaText;
if (/[\\{}<]/.test(cond)) break;
const inner = rulesToCss(mr.cssRules, pol);
if (inner) out.push(`@media ${cond}{${inner}}`);
break;
}
case RULE.SUPPORTS: {
const sr = r as CSSRule & { conditionText?: string; cssRules: CSSRuleList };
const cond = sr.conditionText ?? '';
if (!cond || /[\\{}<]/.test(cond)) break;
const inner = rulesToCss(sr.cssRules, pol);
if (inner) out.push(`@supports ${cond}{${inner}}`);
break;
}
case RULE.KEYFRAMES: {
const kr = r as CSSKeyframesRule;
if (!SAFE_IDENT.test(kr.name)) break;
const frames: string[] = [];
for (const f of Array.from(kr.cssRules) as CSSKeyframeRule[]) {
if (f.type !== RULE.KEYFRAME || !SAFE_KEY.test(f.keyText)) continue;
const d = declarations(f.style, pol);
if (d) frames.push(`${f.keyText}{${d}}`);
}
if (frames.length) out.push(`@keyframes ${kr.name}{${frames.join('')}}`);
break;
}
default:
// @import, @namespace, @page, @property, @layer, @container, unknown at-rules: dropped.
break;
}
}
return out.join('\n');
}
/** Sanitise a stylesheet's text through the CSSOM. Empty string when nothing safe is left. */
export function cleanCss(css: string, pol: Policy = { allow: new Set() }): string {
const rules = parseSheet(css);
return rules ? rulesToCss(rules, pol) : '';
}
/** Sanitise a style="" attribute value; '' when it is not a plain declaration list. */
export function cleanInlineStyle(value: string, pol: Policy = { allow: new Set() }): string {
const rules = parseSheet(`a{${value}}`);
if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) return '';
const sr = rules[0] as CSSStyleRule;
if (sr.selectorText.trim() !== 'a') return '';
return declarations(sr.style, pol);
}
// ---------------------------------------------------------------- HTML
export type SanitizeResult = { ok: true; html: string; csp: string } | { ok: false; reason: string };
function walk(root: Element, pol: Policy): void {
const all = Array.from(root.querySelectorAll('*'));
for (const el of all) {
const tag = el.localName.toLowerCase();
if (REMOVE_TAGS.has(tag)) { el.remove(); continue; }
if (tag === 'link') {
const rel = (el.getAttribute('rel') || '').toLowerCase();
const href = (el.getAttribute('href') || '').trim();
if (!(rel === 'stylesheet' && FONT_SHEET_HOSTS.test(href) && !CTRL.test(href) && !href.includes('%2'))) { el.remove(); continue; }
for (const a of Array.from(el.attributes)) if (!['rel', 'href', 'crossorigin', 'referrerpolicy'].includes(a.name)) el.removeAttribute(a.name);
continue;
}
if (tag === 'style') {
if (el.hasAttribute('data-capture')) { el.remove(); continue; } // ours is re-added last
const t = el.textContent || '';
if (t.includes('<')) { el.remove(); continue; } // markup-in-CSS is only ever an mXSS probe
el.textContent = cleanCss(t, pol);
}
if (tag === 'use') {
const h = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
if (h && !h.trim().startsWith('#')) { el.remove(); continue; }
}
for (const a of Array.from(el.attributes)) {
const name = a.name.toLowerCase();
if (name.startsWith('on') || DROP_ATTRS.has(name)) { el.removeAttribute(a.name); continue; }
if (name === 'style') {
const s = cleanInlineStyle(a.value, pol);
if (s) el.setAttribute(a.name, s); else el.removeAttribute(a.name);
continue;
}
if (URL_ATTRS.has(name)) {
if (name === 'srcset') { el.removeAttribute(a.name); continue; } // one src is enough for a capture
if (name === 'href' && (tag === 'a' || tag === 'area')) { if (!safeLink(a.value)) el.removeAttribute(a.name); continue; }
if (name === 'href' && el.namespaceURI === 'http://www.w3.org/2000/svg') { if (!a.value.trim().startsWith('#')) el.removeAttribute(a.name); continue; }
const ok = checkUrl(a.value, pol);
if (ok) { if (ok !== a.value) el.setAttribute(a.name, ok); }
else if (name === 'src' && tag === 'img') { el.setAttribute('src', BLOCKED_IMAGE); el.removeAttribute('srcset'); }
else el.removeAttribute(a.name);
continue;
}
// Presentation attributes (fill, filter, mask...) may carry url(): only in-document #refs.
if (/url\s*\(/i.test(a.value) || /[\\]/.test(a.value) && /url/i.test(a.value)) {
const only = Array.from(a.value.matchAll(/url\(\s*["']?([^"')]*)["']?\s*\)/gi)).every((m) => m[1].trim().startsWith('#'));
if (!only || /\\/.test(a.value)) el.removeAttribute(a.name);
}
}
}
}
function safeLink(raw: string): boolean {
const v = raw.replace(/[\s\u0000-\u001f\u007f-\u009f]/g, '').toLowerCase();
if (/^(javascript|vbscript|data|blob|file):/.test(v)) return false;
if (/^[a-z][a-z0-9+.-]*:/.test(v)) return /^(https?|mailto|tel):/.test(v);
return true;
}
function core(html: string, pol: Policy): string | null {
let doc: Document;
try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch { return null; }
const root = doc.documentElement;
if (!root) return null;
walk(root, pol);
return root.outerHTML;
}
/** Our own head additions, applied last to already-clean markup (trusted constants). */
function addCaptureHead(clean: string): string | null {
let doc: Document;
try { doc = new DOMParser().parseFromString(clean, 'text/html'); } catch { return null; }
const root = doc.documentElement;
let head = doc.head;
if (!head) { head = doc.createElement('head'); root.insertBefore(head, root.firstChild); }
const csp = doc.createElement('meta');
csp.setAttribute('http-equiv', 'Content-Security-Policy');
csp.setAttribute('content', CAPTURE_CSP);
head.insertBefore(csp, head.firstChild);
const un = doc.createElement('style');
un.setAttribute('data-capture', '');
// No script will ever add .animated, so reveal animated blocks and stop transitions.
un.textContent = '[data-animation]{opacity:1!important;animation:none!important}*{transition:none!important}';
head.appendChild(un);
return '<!DOCTYPE html>' + root.outerHTML;
}
/** Strip a model/user-derived page to something safe to render in the script-less capture frame. */
export function sanitizeCaptureHtml(html: string, opts: SanitizeOptions = {}): SanitizeResult {
const pol = makePolicy(opts.allowUrls);
if (typeof html !== 'string' || !html.trim()) return { ok: false, reason: 'no page to capture' };
const first = core(html, pol);
if (first === null) return { ok: false, reason: 'page could not be parsed' };
// Mutation-XSS guard: what we output must re-parse to itself under the same sanitiser.
const second = core(first, pol);
if (second === null || second !== first) return { ok: false, reason: 'page markup was not stable under sanitising' };
const out = addCaptureHead(first);
return out === null ? { ok: false, reason: 'page could not be parsed' } : { ok: true, html: out, csp: CAPTURE_CSP };
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Client-side page capture for the Sitesmith self-review loop.
*
* Renders published-style HTML into a hidden iframe and rasterises it to a JPEG with
* html-to-image (MIT, zero dependencies). The HTML is hostile input (model output, user
* text, customer head code): it is first run through sanitizeCaptureHtml (inert DOMParser
* walk: no script/frame/object/handlers/javascript: URLs/@import/foreign url(); fixed-point
* checked) and the frame itself has a sandbox WITHOUT allow-scripts plus a CSP that forbids
* scripts and all network access except fonts/stylesheets from two hosts. `allow-same-origin`
* is kept only so the parent can read the (already sanitised, script-less) frame document.
*
* Never throws: every failure is a `{ ok: false, reason }` so the caller can
* skip the review with a non-blocking note.
*/
import { toJpeg } from 'html-to-image';
import { sanitizeCaptureHtml, checkUrl, makePolicy, BLOCKED_IMAGE, FONT_FILE_HOSTS, type Policy } from './sanitize-capture-html';
export const FONT_AWESOME_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css';
export interface CaptureOptions {
/** Render width in CSS px (default 1280). */
width?: number;
/** The page is cropped to this height (default 3000 px). */
maxHeight?: number;
/** Whole-capture time limit (default 20 s). */
timeoutMs?: number;
/** JPEG quality 0..1 (default 0.8). */
quality?: number;
/** Reject captures bigger than this many bytes (default 3 MB). */
maxBytes?: number;
/** Add the Font Awesome stylesheet when the HTML has none (Preview head may lack it). Default true. */
injectFontAwesome?: boolean;
/** Same-origin URLs (root-relative) the page may load, e.g. the site's asset proxy URLs. */
allowUrls?: string[];
/** Extra settle time after load for fonts/images (default 600 ms). */
settleMs?: number;
}
export type CaptureResult =
| { ok: true; blob: Blob; width: number; height: number; cropped: boolean }
| { ok: false; reason: string };
/** Make `html` safe and complete for capture: FA link if missing, scripts and head code are not executed anyway (sandbox). */
export function prepareCaptureHtml(html: string, injectFontAwesome = true): string {
// A cross-origin stylesheet without CORS is unreadable (cssRules throws), so its @font-face
// rules could never be embedded in the capture. Google Fonts and cdnjs both answer CORS.
let out = html.replace(/<link\b[^>]*>/gi, (tag) => (
/rel=["']?stylesheet/i.test(tag) && /href=["']?https:\/\/(fonts\.googleapis\.com|cdnjs\.cloudflare\.com)\//i.test(tag) && !/crossorigin/i.test(tag)
? tag.replace(/\s*\/?>$/, ' crossorigin="anonymous">')
: tag
));
if (injectFontAwesome && !/font-awesome|fontawesome/i.test(out)) {
const link = `<link rel="stylesheet" href="${FONT_AWESOME_CSS}" crossorigin="anonymous" referrerpolicy="no-referrer">`;
out = /<\/head>/i.test(out) ? out.replace(/<\/head>/i, () => link + '</head>') : link + out;
}
return out;
}
/** Replace relative `assets/<file>` references with fetchable URLs (the editor page is not at the site root). */
export function resolveAssetUrls(html: string, urls: Record<string, string>): string {
let out = html;
for (const [rel, url] of Object.entries(urls)) {
if (!/^assets\/[A-Za-z0-9._%~+-]{1,200}$/.test(rel)) continue;
// Same-origin only, written as a root-relative path (the sanitiser refuses absolute URLs).
let local = url;
try {
const u = new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/');
if (typeof location !== 'undefined' && u.origin !== location.origin) continue;
local = u.pathname + u.search;
} catch { continue; }
if (!/^\/[^\s"'<>]*$/.test(local)) continue;
out = out.split(rel).join(local);
}
return out;
}
const MAX_FONT_FACES = 24;
const MAX_FONT_BYTES = 2.5 * 1024 * 1024;
const MAX_RESOURCE_BYTES = 4 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 8000;
const blobToDataUrl = (b: Blob) => new Promise<string>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(new Error('read failed'));
r.readAsDataURL(b);
});
/**
* The ONLY network path the capture uses on a page's behalf. A URL must be one the sanitiser would have
* allowed (exact allow-list entry, assets/ path, or a font file on a named host); no credentials unless it
* is an exact allow-listed (server-provided, same-origin) URL; no cache; no redirects; size and time caps.
*/
export async function fetchBounded(url: string, pol: Policy, maxBytes = MAX_RESOURCE_BYTES): Promise<Blob | null> {
const isFont = FONT_FILE_HOSTS.test(url);
if (!isFont && !checkUrl(url, pol)) return null;
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
credentials: !isFont && pol.allow.has(url) ? 'same-origin' : 'omit',
cache: 'no-store', redirect: 'error', signal: ctl.signal,
});
if (!res.ok) return null;
const len = Number(res.headers.get('content-length') || 0);
if (len > maxBytes) return null;
const blob = await res.blob();
return blob.size > maxBytes ? null : blob;
} catch { return null; } finally { clearTimeout(timer); }
}
/** True when a @font-face unicode-range could cover basic Latin (or has no range at all). */
function coversLatin(range: string): boolean {
if (!range.trim()) return true;
return range.split(',').some((part) => {
const m = /^U\+([0-9A-F?]+)(?:-([0-9A-F]+))?$/i.exec(part.trim());
if (!m) return false;
const lo = parseInt(m[1].replace(/\?/g, '0'), 16);
const hi = m[2] ? parseInt(m[2], 16) : m[1].includes('?') ? parseInt(m[1].replace(/\?/g, 'F'), 16) : lo;
return lo <= 0x7e && hi >= 0x41;
});
}
/**
* Our own web-font embedding (html-to-image's built-in version silently produced fallback fonts for
* Google Fonts and Font Awesome in the measurement). Reads the frame's stylesheets (only sanitiser-allowed
* <link>s and our already-sanitised <style>s exist there), and inlines each @font-face file as a data URL
* through fetchBounded, which refuses any URL that is not a named font host or an allow-listed asset.
*/
export async function buildFontEmbedCss(doc: Document, pol: Policy = makePolicy()): Promise<{ css: string; faces: number; failed: number }> {
const jobs: Promise<string | null>[] = [];
let failed = 0;
let total = 0;
for (const sheet of Array.from(doc.styleSheets)) {
let rules: CSSRuleList;
try { rules = sheet.cssRules; } catch { failed++; continue; }
for (const rule of Array.from(rules)) {
if (rule.type !== 5) continue; // CSSFontFaceRule
const ff = rule as CSSFontFaceRule;
if (!coversLatin(ff.style.getPropertyValue('unicode-range'))) continue;
if (jobs.length >= MAX_FONT_FACES) break;
const base = sheet.href || doc.baseURI;
const src = ff.style.getPropertyValue('src');
const urls = Array.from(src.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)).map((m) => m[1]);
const family = ff.style.getPropertyValue('font-family');
const weight = ff.style.getPropertyValue('font-weight') || 'normal';
const style = ff.style.getPropertyValue('font-style') || 'normal';
if (/[;{}\\<]/.test(family + weight + style)) continue;
jobs.push((async () => {
for (const u of urls) {
if (u.startsWith('data:')) return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${u})}`;
let abs: string;
try { abs = new URL(u, base).href; } catch { continue; }
const blob = await fetchBounded(abs, pol, MAX_FONT_BYTES);
if (!blob) continue;
total += blob.size;
if (total > MAX_FONT_BYTES) return null;
try { return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${await blobToDataUrl(blob)})}`; } catch { /* next */ }
}
failed++;
return null;
})());
}
}
const parts = (await Promise.all(jobs)).filter((x): x is string => !!x);
return { css: parts.join('\n'), faces: parts.length, failed };
}
const IMAGE_TYPE = /^image\/(png|jpeg|gif|webp)$/;
const FONT_TYPE = /^(font\/|application\/(font|x-font|octet-stream))/;
/**
* Replace every remaining (sanitiser-approved) URL in the clean page with a data: URL, fetched once each
* through fetchBounded, so the frame itself performs no image/font loads (its CSP allows data: only) and
* html-to-image has nothing left to fetch. A URL that cannot be fetched, or whose bytes are not an image
* (img src, CSS images) or a font (@font-face) becomes the grey placeholder / `none`.
*/
export async function inlineResources(cleanHtml: string, pol: Policy): Promise<string> {
const doc = new DOMParser().parseFromString(cleanHtml, 'text/html');
const blobs = new Map<string, Promise<Blob | null>>();
const fetchOnce = (u: string) => {
if (!blobs.has(u)) blobs.set(u, fetchBounded(u, pol));
return blobs.get(u)!;
};
const toData = async (u: string, font: boolean): Promise<string | null> => {
const blob = await fetchOnce(u);
if (!blob) return null;
const type = blob.type.toLowerCase().split(';')[0];
if (!(font ? FONT_TYPE.test(type) : IMAGE_TYPE.test(type))) return null;
try { return await blobToDataUrl(blob); } catch { return null; }
};
const rewriteCss = async (css: string, font: boolean): Promise<string> => {
const urls = Array.from(new Set(Array.from(css.matchAll(/url\("([^"]*)"\)/g)).map((m) => m[1]).filter((u) => !u.startsWith('data:'))));
let out = css;
for (const u of urls) {
const d = await toData(u, font);
out = out.split(`url("${u}")`).join(d ? `url("${d}")` : 'none');
}
return out;
};
for (const img of Array.from(doc.querySelectorAll('img[src]'))) {
const u = img.getAttribute('src') || '';
if (u.startsWith('data:')) continue;
img.setAttribute('src', (await toData(u, false)) ?? BLOCKED_IMAGE);
}
for (const st of Array.from(doc.querySelectorAll('style:not([data-capture])'))) {
// @font-face blocks hold fonts; everything else holds images.
const parts = (st.textContent || '').split(/(@font-face\{[^}]*\})/);
const out: string[] = [];
for (let i = 0; i < parts.length; i++) out.push(await rewriteCss(parts[i], i % 2 === 1));
st.textContent = out.join('');
}
for (const el of Array.from(doc.querySelectorAll('[style]'))) el.setAttribute('style', await rewriteCss(el.getAttribute('style') || '', false));
return '<!DOCTYPE html>' + doc.documentElement.outerHTML;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function captureHtml(html: string, opts: CaptureOptions = {}): Promise<CaptureResult> {
const width = opts.width ?? 1280;
const maxHeight = opts.maxHeight ?? 3000;
const timeoutMs = opts.timeoutMs ?? 20000;
const maxBytes = opts.maxBytes ?? 3 * 1024 * 1024;
if (typeof document === 'undefined' || !document.body) return { ok: false, reason: 'no document' };
const frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-same-origin');
frame.setAttribute('aria-hidden', 'true');
frame.tabIndex = -1;
frame.style.cssText = `position:fixed;left:-99999px;top:0;width:${width}px;height:900px;border:0;visibility:hidden;pointer-events:none`;
let timer: ReturnType<typeof setTimeout> | undefined;
const pol = makePolicy(opts.allowUrls);
const work = (async (): Promise<CaptureResult> => {
await new Promise<void>((resolve, reject) => {
frame.addEventListener('load', () => resolve(), { once: true });
frame.addEventListener('error', () => reject(new Error('frame failed to load')), { once: true });
const clean = sanitizeCaptureHtml(prepareCaptureHtml(html, opts.injectFontAwesome !== false), { allowUrls: opts.allowUrls });
if (!clean.ok) { reject(new Error(`unsafe page not captured (${clean.reason})`)); return; }
inlineResources(clean.html, pol).then((inlined) => {
frame.srcdoc = inlined;
frame.setAttribute('csp', clean.csp);
document.body.appendChild(frame);
}, reject);
});
const doc = frame.contentDocument;
if (!doc || !doc.body) return { ok: false, reason: 'page not readable' };
// Fonts + images: wait for what the page asked for, then a short settle.
try { await (doc as Document & { fonts?: { ready: Promise<unknown> } }).fonts?.ready; } catch { /* ignore */ }
await Promise.all(Array.from(doc.images).map((img) => (img.complete ? null : new Promise<void>((r) => {
img.addEventListener('load', () => r(), { once: true });
img.addEventListener('error', () => r(), { once: true });
}))));
await sleep(opts.settleMs ?? 600);
const full = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight, 1);
const height = Math.min(full, maxHeight);
frame.style.height = `${height}px`;
await sleep(50);
const fonts = await buildFontEmbedCss(doc, pol);
const dataUrl = await toJpeg(doc.body, {
quality: opts.quality ?? 0.8,
width, height, canvasWidth: width, canvasHeight: height, pixelRatio: 1,
backgroundColor: '#ffffff',
cacheBust: false,
fetchRequestInit: { credentials: 'omit', cache: 'no-store', redirect: 'error' },
fontEmbedCSS: fonts.css,
style: { margin: '0' },
});
const blob = await (await fetch(dataUrl)).blob();
if (blob.size < 2000) return { ok: false, reason: 'capture came out empty' };
if (blob.size > maxBytes) return { ok: false, reason: 'capture too large' };
return { ok: true, blob, width, height, cropped: full > maxHeight };
})();
const timeout = new Promise<CaptureResult>((resolve) => {
timer = setTimeout(() => resolve({ ok: false, reason: 'capture timed out' }), timeoutMs);
});
try {
return await Promise.race([work, timeout]);
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message.slice(0, 120) : (typeof Event !== 'undefined' && e instanceof Event ? `an image or resource failed to render (${e.type})` : 'capture failed') };
} finally {
if (timer) clearTimeout(timer);
frame.remove();
work.catch(() => undefined);
}
}
+45
View File
@@ -0,0 +1,45 @@
/** Client-side validation for mockup / reference-screenshot attachments (the server re-checks from the bytes). */
export const ATTACH_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
export const DEFAULT_MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
export type SniffedType = (typeof ATTACH_TYPES)[number] | null;
/** Decide the type from the file's first bytes, never from its name or declared MIME. */
export async function sniffImageType(file: Blob): Promise<SniffedType> {
let b: Uint8Array;
try { b = new Uint8Array(await file.slice(0, 12).arrayBuffer()); } catch { return null; }
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif';
if (b.length >= 12 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') return 'image/webp';
return null;
}
export function formatBytes(n: number): string {
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
return `${n} B`;
}
/** null when the file is acceptable; otherwise a plain-language reason. */
export async function validateAttachment(file: File, maxBytes = DEFAULT_MAX_UPLOAD_BYTES): Promise<string | null> {
const label = file.name ? `"${file.name.slice(0, 60)}"` : 'That file';
if (file.size === 0) return `${label} is empty.`;
if (file.size > maxBytes) return `${label} is ${formatBytes(file.size)}, which is over the ${formatBytes(maxBytes)} limit. Use a smaller image.`;
const t = await sniffImageType(file);
if (!t) return `${label} is not a JPEG, PNG, WebP or GIF image (SVG, PDF and other formats are not accepted).`;
return null;
}
/** Only http(s) URLs (or a bare domain) are accepted as a "site I like" label; it is never fetched. */
export function normalizeUrlLabel(raw: string): { ok: true; value: string } | { ok: false; message: string } {
const v = raw.trim();
if (!v) return { ok: false, message: 'Enter the site address, for example example.com.' };
if (v.length > 300) return { ok: false, message: 'That address is too long (300 characters at most).' };
if (/[\u0000-\u001f<>"]/.test(v)) return { ok: false, message: 'The address contains characters that are not allowed.' };
if (/^[a-z][a-z0-9+.-]*:/i.test(v) && !/^https?:\/\//i.test(v)) {
return { ok: false, message: 'Only http:// or https:// addresses are accepted.' };
}
return { ok: true, value: v };
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, test, expect } from 'vitest';
import { buildDesignPayload } from './sitesmith-design';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import { normalizeUrlLabel, validateAttachment, sniffImageType, formatBytes } from './sitesmith-attachments';
describe('buildDesignPayload', () => {
test('sends the keys the backend prompt builder reads, with the live values', () => {
const p = buildDesignPayload({ ...DEFAULT_SITE_DESIGN, primaryColor: '#112233', headingFont: "'Playfair Display', serif", borderRadius: '12px' });
expect(p).toMatchObject({
primaryColor: '#112233', secondaryColor: '#8b5cf6', accentColor: '#10b981', backgroundColor: '#ffffff', textColor: '#1f2937',
mutedTextColor: '#6b7280', borderColor: '#e5e7eb', headingFont: 'Playfair Display, serif', bodyFont: 'Inter, sans-serif', borderRadius: '12px', buttonRadius: '8px',
});
expect(Object.keys(p)).not.toContain('headCode');
expect(Object.keys(p)).not.toContain('favicon');
expect(p.brandColors).toBeUndefined();
});
test('brand swatches become brandColors (max 6, valid hex only, optional)', () => {
const swatches = [
{ label: 'Ink', hex: '#0F172A', brand: true }, { label: 'Not brand', hex: '#ffffff', brand: false },
{ label: 'Bad', hex: 'red', brand: true }, { label: 'A<b>', hex: '#abc', brand: true },
...Array.from({ length: 8 }, (_, i) => ({ label: `S${i}`, hex: '#00000' + i, brand: true })),
];
const p = buildDesignPayload({ ...DEFAULT_SITE_DESIGN, swatches } as any);
const b = p.brandColors as Array<{ name?: string; hex: string }>;
expect(b).toHaveLength(6);
expect(b[0]).toEqual({ name: 'Ink', hex: '#0f172a' });
expect(b[1].name).toBe('Ab');
expect(b.every((x) => /^#[0-9a-f]{3,8}$/.test(x.hex))).toBe(true);
});
});
describe('attachments validation', () => {
const file = (bytes: number[], name = 'a.png', size?: number) => {
const f = new File([new Uint8Array(bytes)], name);
if (size !== undefined) Object.defineProperty(f, 'size', { value: size });
return f;
};
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0];
test('type is decided from the bytes, not the name', async () => {
expect(await sniffImageType(file(PNG, 'x.txt'))).toBe('image/png');
expect(await sniffImageType(file([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]))).toBe('image/jpeg');
expect(await sniffImageType(file([...'GIF89a'].map((c) => c.charCodeAt(0)).concat([0, 0])))).toBe('image/gif');
expect(await sniffImageType(file([...'RIFF'].map((c) => c.charCodeAt(0)).concat([0, 0, 0, 0], [...'WEBP'].map((c) => c.charCodeAt(0)))))).toBe('image/webp');
expect(await sniffImageType(file([...'<svg xmlns="x">'].map((c) => c.charCodeAt(0)), 'a.png'))).toBeNull();
});
test('rejects wrong type, empty, and oversize with plain messages', async () => {
expect(await validateAttachment(file([60, 115, 118, 103, 62, 0, 0, 0], 'logo.png'))).toMatch(/not a JPEG, PNG, WebP or GIF/);
expect(await validateAttachment(file([], 'e.png'))).toMatch(/empty/);
expect(await validateAttachment(file(PNG, 'big.png', 9 * 1024 * 1024))).toMatch(/9\.0 MB, which is over the 8\.0 MB limit/);
expect(await validateAttachment(file(PNG, 'ok.png'))).toBeNull();
expect(formatBytes(2048)).toBe('2 KB');
});
test('URL labels: kept as text, scheme-checked, never resolved', () => {
expect(normalizeUrlLabel(' example.com ')).toEqual({ ok: true, value: 'example.com' });
expect(normalizeUrlLabel('https://a.example/x?y=1')).toMatchObject({ ok: true });
for (const bad of ['', 'javascript:alert(1)', 'file:///etc/passwd', 'ftp://x', 'a<b>.com', 'x'.repeat(301)]) {
expect(normalizeUrlLabel(bad).ok, bad).toBe(false);
}
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* The site design block sent with every Sitesmith request, in the shape the backend prompt builder
* (sitesmith_design_block in libs/sitesmith-prompts.php) reads: SiteDesign-named keys plus
* `brandColors: [{name?, hex}]` (up to 6, from the colour-system swatches flagged `brand`).
* The backend re-validates every value; this only keeps obviously unusable values out.
*/
import type { SiteDesign } from '../state/SiteDesignContext';
export interface DesignSwatch { label?: string; hex?: string; brand?: boolean }
const KEYS = [
'primaryColor', 'secondaryColor', 'accentColor', 'backgroundColor', 'textColor', 'mutedTextColor',
'borderColor', 'headingFont', 'bodyFont', 'borderRadius', 'buttonRadius',
] as const;
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
export function buildDesignPayload(design: SiteDesign & { swatches?: DesignSwatch[] }): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const k of KEYS) {
const v = (design as unknown as Record<string, unknown>)[k];
if (typeof v !== 'string' || !v.trim()) continue;
// Font stacks: the backend accepts letters, digits, space, comma, dash and underscore only.
out[k] = k.endsWith('Font') ? v.replace(/["']/g, '').trim() : v.trim();
}
const sw = (design as { swatches?: unknown }).swatches;
if (Array.isArray(sw)) {
const brand = sw
.filter((s): s is DesignSwatch => !!s && typeof s === 'object' && (s as DesignSwatch).brand === true
&& typeof (s as DesignSwatch).hex === 'string' && HEX.test((s as DesignSwatch).hex!.trim()))
.slice(0, 6)
.map((s) => {
const name = typeof s.label === 'string' ? s.label.replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 40) : '';
return name ? { name, hex: s.hex!.trim().toLowerCase() } : { hex: s.hex!.trim().toLowerCase() };
});
if (brand.length) out.brandColors = brand;
}
return out;
}