2026-10-08 17:35:15 +03:00
/**
2026-11-01 01:13:43 +03:00
* Boot a standalone payload, then probe its GET-only desktop API surface.
* node scripts/smoke-server.mjs [--standalone <directory>] [--runtime <Node/Electron>] [--electron]
* Defaults to .next/standalone and host Node; --electron retains its legacy meaning.
* Uses an isolated empty home/data/auth environment and a per-launch API capability.
* No real keys, provider generation, installed app UI or production data are used.
2026-10-08 17:35:15 +03:00
*/
2026-11-01 01:13:43 +03:00
import assert from "node:assert/strict" ;
2026-10-08 17:35:15 +03:00
import { spawn } from "node:child_process" ;
2026-11-01 01:13:43 +03:00
import { randomBytes } from "node:crypto" ;
2026-10-20 14:51:40 +03:00
import { probeEnv , verifyDemoRuntime } from "./smoke-demo-runtime.mjs" ;
2026-10-24 08:15:04 +03:00
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs" ;
2026-10-28 00:20:49 +02:00
import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs" ;
2026-10-11 10:12:13 +03:00
import { createRequire } from "node:module" ;
2026-10-08 17:35:15 +03:00
import * as fs from "node:fs" ;
import * as http from "node:http" ;
import * as net from "node:net" ;
import * as os from "node:os" ;
import * as path from "node:path" ;
2026-11-01 01:13:43 +03:00
import { fileURLToPath } from "node:url" ;
2026-10-08 17:35:15 +03:00
const ROOT = path . resolve ( import . meta . dirname , ".." );
const POLL_MS = 500 ;
const BOOT_TIMEOUT_MS = 30_000 ;
const PROBES = [ "/api/graph" , "/api/models" , "/api/settings" ];
2026-11-01 01:13:43 +03:00
/** Match the provider smoke's checkout default and caller-relative explicit paths. */
export function resolveSmokeOptions ( args = []) {
let standalone = path . join ( ROOT , ".next" , "standalone" );
let runtime ;
let electron = false ;
for ( let i = 0 ; i < args . length ; i ++ ) {
const option = args [ i ];
if ( option === "--electron" ) { electron = true ; continue ; }
assert . ok ( option === "--standalone" || option === "--runtime" , `Unknown option: ${ option } ` );
const value = args [ ++ i ];
assert . ok ( value && ! value . startsWith ( "--" ), ` ${ option } requires a path` );
if ( option === "--standalone" ) standalone = path . resolve ( value );
else runtime = path . resolve ( value );
}
assert . ok ( ! ( electron && runtime ), "Use either --electron or --runtime, not both" );
return {
standalone ,
server : path . join ( standalone , "server.js" ),
runtime : runtime ?? ( electron ? createRequire ( import . meta . url )( "electron" ) : process . execPath ),
};
}
export function validateSmokeOptions ({ standalone , server , runtime }) {
assert . ok ( fs . existsSync ( standalone ) && fs . statSync ( standalone ). isDirectory (), `Standalone directory not found: ${ standalone } ` );
assert . ok ( fs . existsSync ( server ) && fs . statSync ( server ). isFile (), `Standalone server not found: ${ server } — run next build + node scripts/build-server.mjs first` );
assert . ok ( fs . existsSync ( runtime ) && fs . statSync ( runtime ). isFile (), `Runtime not found: ${ runtime } ` );
fs . accessSync ( runtime , fs . constants . X_OK );
for ( const rel of [ "auth.json" , ".env" , ".env.local" , ".env.production" , ".env.production.local" , ".learnharness" , "data" ]) {
assert . ok ( ! fs . existsSync ( path . join ( standalone , rel )), `Credential/data path leaked into standalone: ${ rel } ` );
}
2026-10-08 17:35:15 +03:00
}
function freePort () {
return new Promise (( resolve , reject ) => {
const srv = net . createServer ();
srv . listen ( 0 , "127.0.0.1" , () => {
const { port } = srv . address ();
2026-11-01 01:13:43 +03:00
srv . close (( error ) => error ? reject ( error ) : resolve ( port ));
2026-10-08 17:35:15 +03:00
});
srv . on ( "error" , reject );
});
}
2026-11-01 01:13:43 +03:00
/** Bounded GET; failed/refused connections return status 0 for boot polling. */
function get ( url , headers = {}) {
2026-10-08 17:35:15 +03:00
return new Promise (( resolve ) => {
2026-11-01 01:13:43 +03:00
const req = http . get ( url , { headers }, ( res ) => {
2026-10-08 17:35:15 +03:00
res . resume ();
resolve ( res . statusCode ?? 0 );
});
req . on ( "error" , () => resolve ( 0 ));
2026-11-01 01:13:43 +03:00
req . setTimeout ( 5000 , () => { req . destroy (); resolve ( 0 ); });
2026-10-08 17:35:15 +03:00
});
}
async function main () {
2026-11-01 01:13:43 +03:00
const { standalone , server , runtime } = resolveSmokeOptions ( process . argv . slice ( 2 ));
validateSmokeOptions ({ standalone , server , runtime });
console . log ( `[smoke-server] standalone: ${ standalone } ; runtime: ${ runtime } ` );
// All nested gates use the SAME payload and runtime, never staging fallbacks.
verifyDemoRuntime ( standalone , runtime );
verifyProviderRuntime ( standalone , runtime );
verifyCompressionRuntime ( standalone , runtime );
2026-10-08 17:35:15 +03:00
const port = await freePort ();
const url = `http://127.0.0.1: ${ port } ` ;
const home = fs . mkdtempSync ( path . join ( os . tmpdir (), "lh-smoke-" ));
const dataDir = path . join ( home , "data" );
const authFile = path . join ( home , "auth.json" );
2026-11-01 01:13:43 +03:00
const secret = randomBytes ( 32 ). toString ( "hex" );
const headers = { "x-learnharness-desktop" : secret };
2026-10-08 17:35:15 +03:00
2026-11-01 01:13:43 +03:00
let tail = "" ;
const keep = ( buf ) => { tail = ( tail + buf . toString ()). slice ( - 16_000 ); };
2026-10-08 17:35:15 +03:00
const dumpTail = () => {
console . error ( "--- server log tail " + "-" . repeat ( 20 ));
2026-11-01 01:13:43 +03:00
for ( const line of tail . replaceAll ( secret , "[redacted]" ). split ( "\n" ). slice ( - 40 )) {
if ( line . trim ()) console . error ( ` ${ line } ` );
}
2026-10-08 17:35:15 +03:00
};
2026-11-01 01:13:43 +03:00
let child ;
2026-10-08 17:35:15 +03:00
let exited = false ;
2026-11-01 01:13:43 +03:00
let launchError ;
let failed ;
2026-10-08 17:35:15 +03:00
try {
2026-11-01 01:13:43 +03:00
fs . writeFileSync ( authFile , "{}\n" , { mode : 0o600 });
child = spawn ( runtime , [ server ], {
cwd : standalone ,
env : {
... probeEnv ( process . env , home ),
ELECTRON_RUN_AS_NODE : "1" ,
NODE_ENV : "production" , PORT : String ( port ), HOSTNAME : "127.0.0.1" ,
LH_DATA_DIR : dataDir , LH_AUTH_FILE : authFile ,
LH_DESKTOP_SERVER : "1" , LH_DESKTOP_API_SECRET : secret ,
},
stdio : [ "ignore" , "pipe" , "pipe" ],
});
child . stdout . on ( "data" , keep );
child . stderr . on ( "data" , keep );
child . on ( "exit" , () => { exited = true ; });
child . on ( "error" , ( error ) => { launchError = error ; });
2026-10-08 17:35:15 +03:00
let boot = 0 ;
const deadline = Date . now () + BOOT_TIMEOUT_MS ;
while ( Date . now () < deadline ) {
2026-11-01 01:13:43 +03:00
if ( launchError ) throw launchError ;
if ( exited ) throw new Error ( `server exited before answering on ${ url } (code ${ child . exitCode } , signal ${ child . signalCode } )` );
2026-10-08 17:35:15 +03:00
boot = await get ( url );
if ( boot === 200 ) break ;
await new Promise (( r ) => setTimeout ( r , POLL_MS ));
}
if ( boot !== 200 ) throw new Error ( `server never answered 200 on ${ url } (last status: ${ boot } )` );
console . log ( `[smoke-server] boot ok: GET / → 200 ( ${ url } )` );
2026-11-01 01:13:43 +03:00
assert . equal ( await get ( url + "/api/models" ), 403 , "Desktop API must deny unauthenticated requests" );
console . log ( "[smoke-server] unauthenticated GET /api/models → 403" );
2026-10-08 17:35:15 +03:00
for ( const route of PROBES ) {
2026-11-01 01:13:43 +03:00
const status = await get ( url + route , headers );
assert . equal ( status , 200 , `Authenticated GET ${ route } → ${ status } (expected 200)` );
console . log ( `[smoke-server] authenticated GET ${ route } → 200` );
2026-10-08 17:35:15 +03:00
}
} catch ( err ) {
failed = err ;
} finally {
try {
2026-11-01 01:13:43 +03:00
if ( child ? . pid && ! exited ) {
await new Promise (( resolve , reject ) => {
const force = setTimeout (() => child . kill ( "SIGKILL" ), 3000 );
const deadline = setTimeout (() => reject ( new Error ( `Server cleanup timed out (pid ${ child . pid } )` )), 5000 );
child . once ( "exit" , () => { clearTimeout ( force ); clearTimeout ( deadline ); resolve (); });
child . kill ( "SIGTERM" );
});
}
} finally {
2026-10-08 17:35:15 +03:00
fs . rmSync ( home , { recursive : true , force : true });
}
2026-11-01 01:13:43 +03:00
console . log ( "[smoke-server] owned server stopped; disposable home removed" );
2026-10-08 17:35:15 +03:00
}
2026-11-01 01:13:43 +03:00
if ( failed ) { dumpTail (); throw failed ; }
2026-10-08 17:35:15 +03:00
console . log ( "[smoke-server] smoke passed — the packaged server boots and serves." );
}
2026-11-01 01:13:43 +03:00
if ( process . argv [ 1 ] && path . resolve ( process . argv [ 1 ]) === fileURLToPath ( import . meta . url )) {
main (). catch (( err ) => {
console . error ( `[smoke-server] FAIL: ${ err . stack || err } ` );
process . exitCode = 1 ;
});
}