/** * 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); }