diff --git a/craft/scripts/e2e-sitesmith-mock-server.mjs b/craft/scripts/e2e-sitesmith-mock-server.mjs
new file mode 100644
index 0000000..248cb1a
--- /dev/null
+++ b/craft/scripts/e2e-sitesmith-mock-server.mjs
@@ -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('', () => ``);
+ 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 [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);
+}