From a406daae1e5f4ab16f5951451acd2b6dee89d16b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:48:12 -0800 Subject: [PATCH] Sitesmith e2e: reusable mock editor + Sitesmith backend server for real-browser tests Co-Authored-By: Claude Sonnet 5.5 --- craft/scripts/e2e-sitesmith-mock-server.mjs | 370 ++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 craft/scripts/e2e-sitesmith-mock-server.mjs 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); +}