Files
Web-Page-Performance-Test/lib/runner.js

357 lines
11 KiB
JavaScript

// lighthouse, chrome-launcher, uuid are ESM only/compatible, imported dynamically
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// Simple Queue Implementation to prevent concurrency crashes
class TestQueue {
constructor() {
this.queue = [];
this.running = false;
}
add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.process();
});
}
async process() {
if (this.running || this.queue.length === 0) return;
this.running = true;
const { task, resolve, reject } = this.queue.shift();
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running = false;
this.process(); // Process next item
}
}
}
const runnerQueue = new TestQueue();
/**
* Run a performance test using Lighthouse (Serialized)
* @param {string} url - The URL to test
* @param {object} options - Configuration options (mobile vs desktop, etc.)
* @returns {Promise<object>} - Test results
*/
async function runTest(url, options = {}) {
// Wrap the actual test logic in a queue task
return runnerQueue.add(async () => {
return _executeTest(url, options);
});
}
/**
* Internal function to execute the test (NOT exported directly)
*/
async function _executeTest(url, options) {
// Dynamically import dependencies (ESM)
const { default: lighthouse } = await import('lighthouse');
const chromeLauncher = await import('chrome-launcher');
const { v4: uuidv4 } = await import('uuid');
const isMobile = options.isMobile ?? false; // Default to desktop (matches frontend)
const captureFilmstrip = options.captureFilmstrip ?? false;
const reportDir = path.join(__dirname, '..', 'reports');
// Ensure reports directory exists
if (!fs.existsSync(reportDir)) {
fs.mkdirSync(reportDir, { recursive: true });
}
// Launch Chrome
console.log('Launching Chrome...');
const chromePath = process.platform === 'linux' ? '/usr/bin/chromium' : undefined;
// Use a unique user data dir to prevent profile locking issues
const userDataDir = path.join(os.tmpdir(), `lh-profile-${uuidv4()}`);
// Ensure tmp dir exists if likely to crash
if (!fs.existsSync(userDataDir)) {
fs.mkdirSync(userDataDir, { recursive: true });
}
const chrome = await chromeLauncher.launch({
chromeFlags: [
'--headless',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
`--user-data-dir=${userDataDir}` // Unique profile for this run
],
chromePath: chromePath,
port: 0 // Force random port
});
// Lighthouse Config
const port = chrome.port;
const config = {
extends: 'lighthouse:default',
settings: {
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
formFactor: isMobile ? 'mobile' : 'desktop',
screenEmulation: isMobile
? undefined // Uses default mobile emulation
: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
throttling: {
rttMs: 0,
throughputKbps: 0,
cpuSlowdownMultiplier: 1,
requestLatencyMs: 0,
downloadThroughputKbps: 0,
uploadThroughputKbps: 0
},
throttlingMethod: 'provided'
},
passes: [{
passName: 'defaultPass',
recordTrace: true, // Required for filmstrip
useThrottling: true,
pauseAfterFcpMs: 0,
pauseAfterLoadMs: 0,
networkQuietThresholdMs: 0,
cpuQuietThresholdMs: 0,
gatherers: []
}]
};
if (!captureFilmstrip) {
config.settings.skipAudits = ['screenshot-thumbnails', 'final-screenshot'];
}
const runnerOptions = {
logLevel: 'info',
output: 'html',
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
port: port,
};
const testId = uuidv4();
try {
const runnerResult = await lighthouse(url, runnerOptions, config);
// Save HTML Report
const reportHtml = runnerResult.report;
const reportPath = path.join(reportDir, `${testId}.html`);
fs.writeFileSync(reportPath, reportHtml);
// Prepare JSON Summary
const lhr = runnerResult.lhr;
if (!lhr) {
throw new Error('Lighthouse failed to generate a report (no LHR).');
}
// Safe property access
const getScore = (cat) => (lhr.categories && lhr.categories[cat] && lhr.categories[cat].score) ? lhr.categories[cat].score * 100 : 0;
const getMetric = (aud) => (lhr.audits && lhr.audits[aud] && lhr.audits[aud].numericValue) ? lhr.audits[aud].numericValue : 0;
let summary = {
id: testId,
url: lhr.finalUrl || url,
timestamp: lhr.fetchTime || new Date().toISOString(),
scores: {
performance: getScore('performance'),
accessibility: getScore('accessibility'),
bestPractices: getScore('best-practices'),
seo: getScore('seo'),
},
metrics: {
lcp: getMetric('largest-contentful-paint'),
cls: getMetric('cumulative-layout-shift'),
tbt: getMetric('total-blocking-time'),
},
userAgent: lhr.userAgent,
isMobile: isMobile,
filmstrip: captureFilmstrip ? (lhr.audits['screenshot-thumbnails']?.details?.items || []) : []
};
// Save JSON Summary
let jsonPath = path.join(reportDir, `${testId}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2));
// Parse and save HAR data for waterfall
const harParser = require('./har-parser');
const harData = harParser.parseHAR(lhr);
const harPath = path.join(reportDir, `${testId}.har.json`);
fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
// Run optimization checks
const optimizationChecker = require('./optimization-checker');
const optimizations = optimizationChecker.analyzeOptimizations(lhr);
const optPath = path.join(reportDir, `${testId}.optimizations.json`);
fs.writeFileSync(optPath, JSON.stringify(optimizations, null, 2));
// Capture High-Res Video (Run separate pass if needed or extraction)
// We already have chrome running. Let's try to capture detailed video.
// Note: Lighthouse has finished. We can use the browser instance for a quick video pass.
// But ideally we want the video of the FIRST load.
// Since we can't easily hook into Lighthouse's run, we accept that we record a "Second Load"
// OR we rely on this separate pass for visual record.
// Alternatively, if this was the only run, we use it.
// For now, let's run a dedicated video capture pass to guarantee quality.
let highResFrames = [];
try {
console.log('Starting High-Res Video Capture pass...');
const videoCapture = require('./video-capture');
// We reuse the running chrome instance
highResFrames = await videoCapture.captureVideo(url, chrome.port);
} catch (vidErr) {
console.error('High-res video capture failed, falling back to thumbnails:', vidErr);
}
await chrome.kill();
// Cleanup User Data Dir
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch (e) {
console.error('Failed to cleanup temp profile:', e);
}
// Use High-Res frames if available, otherwise fallback to Lighthouse thumbnails
const filmstripData = (highResFrames && highResFrames.length > 5) ? highResFrames : (lhr.audits['screenshot-thumbnails']?.details?.items || []);
summary = {
id: testId,
url: lhr.finalUrl || url,
timestamp: lhr.fetchTime || new Date().toISOString(),
scores: {
performance: getScore('performance'),
accessibility: getScore('accessibility'),
bestPractices: getScore('best-practices'),
seo: getScore('seo'),
},
metrics: {
lcp: getMetric('largest-contentful-paint'),
cls: getMetric('cumulative-layout-shift'),
tbt: getMetric('total-blocking-time'),
},
userAgent: lhr.userAgent,
isMobile: isMobile,
filmstrip: filmstripData
};
// Update summary file with new filmstrip
jsonPath = path.join(reportDir, `${testId}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2));
// Prepare Database Insert
const insertQuery = `
INSERT INTO test_results (
id, url, user_uuid, user_ip, is_mobile,
scores, metrics, filmstrip, timestamp
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
RETURNING id
`;
const values = [
testId,
lhr.finalUrl,
options.userUuid || null,
options.userIp || null,
isMobile,
JSON.stringify(summary.scores),
JSON.stringify(summary.metrics),
JSON.stringify(filmstripData)
];
try {
console.log('📝 Attempting DB Insert for Test:', testId);
console.log('👤 User UUID:', options.userUuid);
const db = require('../lib/db');
await db.pool.query(insertQuery, values);
console.log('✅ DB Insert Success!');
} catch (dbErr) {
console.error('❌ Failed to save result to DB:', dbErr);
// Don't fail the whole test if DB save fails, just log it
}
return summary;
} catch (error) {
if (chrome) await chrome.kill();
// Try cleanup again in error case
try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch (e) {}
throw error;
}
}
/**
* Get list of all test results (Scoped to User)
* @param {string} userUuid - User's UUID from client
* @param {string} userIp - User's IP address
*/
async function getHistory(userUuid, userIp) {
const db = require('../lib/db');
// If no identifiers provided, return empty
if (!userUuid && !userIp) return [];
try {
let query;
let params;
// Prioritize UUID if available and valid
if (userUuid && userUuid !== 'anonymous') {
query = `
SELECT * FROM test_results
WHERE user_uuid = $1
ORDER BY timestamp DESC
LIMIT 50
`;
params = [userUuid];
} else {
// Fallback to IP if strictly anonymous
query = `
SELECT * FROM test_results
WHERE user_ip = $1
ORDER BY timestamp DESC
LIMIT 50
`;
params = [userIp];
}
const res = await db.pool.query(query, params);
// Convert DB rows back to simplified history objects
return res.rows.map(row => ({
id: row.id,
url: row.url,
timestamp: row.timestamp, // JS Date
isMobile: row.is_mobile,
scores: row.scores,
metrics: row.metrics,
filmstrip: row.filmstrip || [] // Return filmstrip if available
}));
} catch (err) {
console.error('Error fetching history from DB:', err);
return [];
}
}
// Helper to generate distinct test IDs
function generateTestId() {
return crypto.randomUUID();
}
// Need os for tmpdir
const os = require('os');
module.exports = {
runTest,
getHistory,
generateTestId
};