Files
site-builder/craft/scripts/e2e-sitesmith-mock-server.mjs
T
shadowdaoandClaude Opus 5.5 9f9be5f41a Sitesmith modal: Clear chat explains a refusal and is disabled while a job runs
Reproduced in the e2e mock (now stateful: history + clear_history with the
server's 409 while a job is live): the normal two-step clear works (first
click arms the confirm by design), but a confirm while a job is live -- e.g.
the self-review follow-up that starts right after a send -- was refused by the
server and the modal showed the raw code 'job_in_progress', so the chat looked
like it would not clear on the first try. Now the server's sentence is shown,
and the button is disabled (with a tooltip) while a send, resume or self-review
is running. e2e scenarios 9, 9b (failed before), 9c (Firefox + Chromium).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:16:59 -08:00

390 lines
21 KiB
JavaScript

/**
* 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}
clearCalls: [], // clear_history bodies
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 });
// Thread history as the server keeps it: the user turn at start, the answer when the job finishes.
const history = [];
const msg = (role, content, type = null) => ({ role, content, response_type: type, created_at: new Date().toISOString() });
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', 'clearCalls']) s[k].length = 0;
history.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: history.slice(), 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;
history.push(msg('user', String(body.message ?? '')));
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;
if (!job.logged) { job.logged = true; history.push(msg('assistant', JSON.stringify(job.result.response ?? job.result), job.result.response?.type ?? 'error')); }
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 'review_render_upload':
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: action === 'review_render_upload' ? 'review_render' : 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 'clear_history': {
s.clearCalls.push(body);
// the real endpoint refuses while a job is live (it would orphan the answer)
for (const j of jobs.values()) if (j.status === 'running') return json(res, 409, { ok: false, error: 'job_in_progress', message: 'Sitesmith is still working on your previous request for this site. Please wait for it to finish.' });
const n = history.length; history.length = 0;
return json(res, 200, { ok: true, cleared: n });
}
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);
}