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>
390 lines
21 KiB
JavaScript
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);
|
|
}
|