2026-11-01 10:48:12 -08:00
/**
* 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}
2026-11-01 12:16:59 -08:00
clearCalls : [], // clear_history bodies
2026-11-01 10:48:12 -08:00
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 });
2026-11-01 12:16:59 -08:00
// 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 () });
2026-11-01 10:48:12 -08:00
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 ; },
2026-11-01 20:40:09 -08:00
/** Queue a function (start body) => result, for a job whose answer depends on what the editor sent. */
setNextResultFn ( fn ) { results . push ( fn ); },
2026-11-01 10:48:12 -08:00
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 () {
2026-11-01 12:16:59 -08:00
for ( const k of [ 'requests' , 'starts' , 'cancels' , 'uploads' , 'imageDeletes' , 'refCalls' , 'clearCalls' ]) s [ k ]. length = 0 ;
history . length = 0 ;
2026-11-01 10:48:12 -08:00
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 ;
2026-11-01 12:16:59 -08:00
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 });
2026-11-01 10:48:12 -08:00
}
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 });
2026-11-01 20:40:09 -08:00
// a queued function computes the result from the start body (scenario 8n: the real WHP review lockdown)
const job = newJob ( typeof results [ 0 ] === 'function' ? toResult ( results . shift ()( body )) : undefined );
2026-11-01 10:48:12 -08:00
activeJobId = job . id ;
2026-11-01 12:16:59 -08:00
history . push ( msg ( 'user' , String ( body . message ?? '' )));
2026-11-01 10:48:12 -08:00
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.' } });
2026-11-01 12:16:59 -08:00
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 });
}
2026-11-01 10:48:12 -08:00
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 });
2026-11-01 11:47:04 -08:00
case 'review_render_upload' :
2026-11-01 10:48:12 -08:00
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' );
2026-11-01 11:47:04 -08:00
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 () };
2026-11-01 10:48:12 -08:00
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 );
}
2026-11-01 12:16:59 -08:00
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 });
}
2026-11-01 10:48:12 -08:00
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 );
}