diff --git a/runner.js b/runner.js index 0296f2a..d49598e 100644 --- a/runner.js +++ b/runner.js @@ -1,19 +1,46 @@ -import { spawn } from 'child_process'; +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)); -// Path to sitespeed.io — works both locally and in Docker -const SITESPEED_BIN = process.env.SITESPEED_BIN || - join(__dirname, '..', 'sitespeed.io', 'bin', 'sitespeed.js'); +// 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); - const args = [ - SITESPEED_BIN, + let bin, scriptArg; + try { + ({ cmd: bin, scriptArg } = resolveBin()); + } catch (err) { + return reject(err); + } + + const sitespeedArgs = [ job.url, '--browser', job.browser, '-n', String(job.runs), @@ -24,46 +51,53 @@ export function runTest(job, onLine) { '--coach', ]; - if (job.mobile) { - args.push('--mobile'); - } + if (job.mobile) sitespeedArgs.push('--mobile'); - // Chrome needs these flags when running inside Docker + // Chrome flags required in Docker if (process.env.IN_DOCKER) { - args.push('--browsertime.chrome.args', 'no-sandbox'); - args.push('--browsertime.chrome.args', 'disable-dev-shm-usage'); - // Disable GPU compositing — not needed in Xvfb and can cause crashes - args.push('--browsertime.chrome.args', 'disable-gpu'); + sitespeedArgs.push('--browsertime.chrome.args', 'no-sandbox'); + sitespeedArgs.push('--browsertime.chrome.args', 'disable-dev-shm-usage'); + sitespeedArgs.push('--browsertime.chrome.args', 'disable-gpu'); } - onLine(`[runner] Starting: node ${args.slice(0, 3).join(' ')} ...`); - onLine(`[runner] Output folder: ${outputFolder}`); + // Build the final argv for spawn + const spawnArgs = scriptArg + ? [scriptArg, ...sitespeedArgs] // node /path/to/sitespeed.js + : sitespeedArgs; // sitespeed.io - const child = spawn('node', args, { - cwd: __dirname, - env: { ...process.env, DISPLAY: process.env.DISPLAY || ':99' }, - }); + 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) onLine(line); + 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) onLine(`[stderr] ${line}`); + for (const line of lines) { allLines.push('[stderr] ' + line); onLine('[stderr] ' + line); } }); child.on('close', (code) => { if (code === 0) { resolve(outputFolder); } else { - reject(new Error(`sitespeed.io exited with code ${code}`)); + // 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: ${err.message}`)); + reject(new Error(`Failed to spawn sitespeed.io (${bin}): ${err.message}`)); }); }); } diff --git a/views/running.pug b/views/running.pug index e89c3f2..c68045c 100644 --- a/views/running.pug +++ b/views/running.pug @@ -65,9 +65,14 @@ block content badge.className = 'text-xs px-3 py-1 rounded-full bg-red-100 text-red-800 font-semibold'; try { const d = JSON.parse(e.data); - const el = document.createElement('div'); - el.className = 'text-red-400 log-line'; - el.textContent = '[ERROR] ' + d.message; - logBox.appendChild(el); + // Render each line of the error message separately so multi-line errors are readable + const lines = d.message.split('\n'); + for (const line of lines) { + const el = document.createElement('div'); + el.className = 'text-red-400 log-line'; + el.textContent = line; + logBox.appendChild(el); + } + logBox.scrollTop = logBox.scrollHeight; } catch {} });