import { spawn, execSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { existsSync } from 'fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Resolve sitespeed.io binary. // In Docker the global install is on PATH; outside Docker use the local clone. function resolveBin() { if (process.env.SITESPEED_BIN) return { cmd: 'node', scriptArg: process.env.SITESPEED_BIN }; // Try global install on PATH (works in Docker image) try { const globalPath = execSync('which sitespeed.io', { encoding: 'utf8' }).trim(); if (globalPath) return { cmd: globalPath, scriptArg: null }; } catch {} // Try common global npm location const candidates = [ '/usr/local/lib/node_modules/sitespeed.io/bin/sitespeed.js', '/usr/lib/node_modules/sitespeed.io/bin/sitespeed.js', join(__dirname, '..', 'sitespeed.io', 'bin', 'sitespeed.js'), ]; for (const p of candidates) { if (existsSync(p)) return { cmd: 'node', scriptArg: p }; } throw new Error('sitespeed.io binary not found. Set SITESPEED_BIN env var.'); } export function runTest(job, onLine) { return new Promise((resolve, reject) => { const outputFolder = join(__dirname, 'reports', job.id); let bin, scriptArg; try { ({ cmd: bin, scriptArg } = resolveBin()); } catch (err) { return reject(err); } const sitespeedArgs = [ job.url, '--browser', job.browser, '-n', String(job.runs), '--outputFolder', outputFolder, '--json', '--sustainable.enable', '--axe.enable', '--coach', ]; if (job.mobile) sitespeedArgs.push('--mobile'); // Chrome flags required in Docker if (process.env.IN_DOCKER) { sitespeedArgs.push('--browsertime.chrome.args', 'no-sandbox'); sitespeedArgs.push('--browsertime.chrome.args', 'disable-dev-shm-usage'); sitespeedArgs.push('--browsertime.chrome.args', 'disable-gpu'); } // Build the final argv for spawn const spawnArgs = scriptArg ? [scriptArg, ...sitespeedArgs] // node /path/to/sitespeed.js : sitespeedArgs; // sitespeed.io const env = { ...process.env, DISPLAY: process.env.DISPLAY || ':99' }; onLine(`[runner] binary: ${bin}${scriptArg ? ' ' + scriptArg : ''}`); onLine(`[runner] DISPLAY: ${env.DISPLAY}`); onLine(`[runner] outputFolder: ${outputFolder}`); const child = spawn(bin, spawnArgs, { cwd: __dirname, env }); // Collect all output lines — included in error if process fails const allLines = []; child.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(Boolean); for (const line of lines) { allLines.push(line); onLine(line); } }); child.stderr.on('data', (data) => { const lines = data.toString().split('\n').filter(Boolean); for (const line of lines) { allLines.push('[stderr] ' + line); onLine('[stderr] ' + line); } }); child.on('close', (code) => { if (code === 0) { resolve(outputFolder); } else { // Surface the last 20 lines of output so the error is visible in the UI const tail = allLines.slice(-20).join('\n'); reject(new Error(`sitespeed.io exited with code ${code}\n${tail}`)); } }); child.on('error', (err) => { reject(new Error(`Failed to spawn sitespeed.io (${bin}): ${err.message}`)); }); }); }