mirror of
https://github.com/DeNNiiInc/Web-Page-Performance-Test.git
synced 2026-04-17 20:05:58 +00:00
V2 Feature overhaul and improvements
This commit is contained in:
82
lib/analysis/carbon-calculator.js
Normal file
82
lib/analysis/carbon-calculator.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Carbon Calculator
|
||||
* Based on the Sustainable Web Design model.
|
||||
*/
|
||||
|
||||
// 1 byte = X kWh?
|
||||
// The model: 0.81 kWh / GB for data transfer (2021/2022 estimate)
|
||||
// Carbon intensity: ~442g/kWh (global average)
|
||||
//
|
||||
// Formula:
|
||||
// Energy = (Data Transfer (GB) * 0.81) * 0.75 (system boundary)
|
||||
// Carbon = Energy * 442g
|
||||
|
||||
const SWD_KWH_PER_GB = 0.81;
|
||||
const CARBON_INTENSITY_GLOBAL = 442; // g/kWh
|
||||
|
||||
/**
|
||||
* Checks if a domain is hosted on green infrastructure.
|
||||
* @param {string} domain
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function checkGreenHosting(domain) {
|
||||
try {
|
||||
const res = await fetch(`https://api.thegreenwebfoundation.org/greencheck/${domain}`);
|
||||
const data = await res.json();
|
||||
return data.green || false;
|
||||
} catch (e) {
|
||||
console.warn('Green hosting check failed:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the carbon footprint of a page load.
|
||||
* @param {number} bytes - Total transfer size in bytes.
|
||||
* @param {boolean} isGreen - Whether the host is green.
|
||||
* @returns {object} - { co2: number (grams), rating: string }
|
||||
*/
|
||||
function calculateCarbon(bytes, isGreen) {
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
let energy = gb * SWD_KWH_PER_GB * 0.75; // kWh
|
||||
|
||||
// Carbon intensity adjustment
|
||||
// If green, we assume a lower intensity or offset (often considered 0 or low, but let's say 50g/kWh for production/network overhead)
|
||||
// The standard generally keeps network intensity the same but reduced datacenter intensity.
|
||||
// For simplicity/standard SWD: Green hosting reduces the data center portion.
|
||||
// Let's use simple factor: if green, reduce total by 10-20%?
|
||||
// Actually, SWD v3 splits it:
|
||||
// Consumer device: 52%
|
||||
// Network: 14%
|
||||
// Data Center: 15%
|
||||
// Hardware production: 19%
|
||||
//
|
||||
// Green hosting only affects Data Center (15%).
|
||||
// So if green, we assume 0 emissions for that 15%, reducing total by 15%.
|
||||
|
||||
const intensity = isGreen ? CARBON_INTENSITY_GLOBAL * 0.85 : CARBON_INTENSITY_GLOBAL;
|
||||
|
||||
const co2 = energy * intensity;
|
||||
|
||||
// Rating (Eco-Index style)
|
||||
// < 0.5g -> A+
|
||||
// < 1g -> A
|
||||
// < 2g -> B
|
||||
// < 3g -> C
|
||||
// > 5g -> F
|
||||
let rating = 'F';
|
||||
if(co2 < 0.5) rating = 'A+';
|
||||
else if(co2 < 1.0) rating = 'A';
|
||||
else if(co2 < 2.0) rating = 'B';
|
||||
else if(co2 < 3.0) rating = 'C';
|
||||
else if(co2 < 4.0) rating = 'D';
|
||||
else if(co2 < 5.0) rating = 'E';
|
||||
|
||||
return {
|
||||
co2: parseFloat(co2.toFixed(3)),
|
||||
rating,
|
||||
energy: parseFloat(energy.toFixed(5))
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { checkGreenHosting, calculateCarbon };
|
||||
45
lib/comparison/stitcher.js
Normal file
45
lib/comparison/stitcher.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Stitches two videos side-by-side.
|
||||
* @param {string} video1 Path to first video
|
||||
* @param {string} video2 Path to second video
|
||||
* @param {string} outputPath Path to save output
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
function stitchVideos(video1, video2, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs.existsSync(video1) || !fs.existsSync(video2)) {
|
||||
return reject(new Error('One or both input videos not found'));
|
||||
}
|
||||
|
||||
ffmpeg()
|
||||
.input(video1)
|
||||
.input(video2)
|
||||
.complexFilter([
|
||||
{
|
||||
filter: 'hstack',
|
||||
options: { inputs: 2 },
|
||||
outputs: 'v'
|
||||
}
|
||||
])
|
||||
.map('v') // Map the video stream from filter
|
||||
// We assume no audio for speed tests usually, or we ignore it
|
||||
.outputOptions([
|
||||
'-c:v libx264',
|
||||
'-crf 23',
|
||||
'-preset fast'
|
||||
])
|
||||
.save(outputPath)
|
||||
.on('end', () => {
|
||||
resolve(outputPath);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
console.error('FFmpeg error:', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { stitchVideos };
|
||||
67
lib/comparison/video-generator.js
Normal file
67
lib/comparison/video-generator.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* Creates an MP4 video from an array of frame objects.
|
||||
* @param {Array} frames - Array of { data: base64 } objects
|
||||
* @param {number} outputFps - Frames per second
|
||||
* @returns {Promise<string>} - Path to generated video file
|
||||
*/
|
||||
async function createVideoFromFrames(frames, outputFps = 10) {
|
||||
if (!frames || frames.length === 0) throw new Error('No frames provided');
|
||||
|
||||
// Filter out invalid frames if any
|
||||
const validFrames = frames.filter(f => f && f.data);
|
||||
if (validFrames.length === 0) throw new Error('No valid frames found');
|
||||
|
||||
const tempDir = path.join(os.tmpdir(), 'frames-' + uuidv4());
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Save frames as images
|
||||
for (let i = 0; i < validFrames.length; i++) {
|
||||
const frame = validFrames[i];
|
||||
// Remove header if present
|
||||
const base64Data = frame.data.replace(/^data:image\/\w+;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const filePath = path.join(tempDir, `frame-${i.toString().padStart(5, '0')}.jpg`);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
}
|
||||
|
||||
const outputPath = path.join(os.tmpdir(), `video-${uuidv4()}.mp4`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg()
|
||||
.input(path.join(tempDir, 'frame-%05d.jpg'))
|
||||
.inputFPS(outputFps)
|
||||
.output(outputPath)
|
||||
.outputOptions([
|
||||
'-c:v libx264',
|
||||
'-pix_fmt yuv420p',
|
||||
'-crf 23',
|
||||
'-preset fast',
|
||||
'-movflags +faststart' // Optimize for web playback
|
||||
])
|
||||
.on('end', () => {
|
||||
// Cleanup frames
|
||||
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch(e) {}
|
||||
resolve(outputPath);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
// Cleanup frames
|
||||
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch(e) {}
|
||||
console.error('FFmpeg video generation error:', err);
|
||||
reject(err);
|
||||
})
|
||||
.run();
|
||||
});
|
||||
} catch (e) {
|
||||
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch(err) {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createVideoFromFrames };
|
||||
81
lib/diagnostics/traceroute.js
Normal file
81
lib/diagnostics/traceroute.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const { exec } = require('child_process');
|
||||
const dns = require('dns');
|
||||
|
||||
/**
|
||||
* Executes a traceroute to the specified host.
|
||||
* @param {string} host - The hostname or IP to trace.
|
||||
* @returns {Promise<string>} - The raw stdout output of the traceroute command.
|
||||
*/
|
||||
function runTraceroute(host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Basic validation to prevent command injection
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
|
||||
return reject(new Error('Invalid hostname format.'));
|
||||
}
|
||||
|
||||
// Resolve domain to ensure it exists before running system command (optional safety)
|
||||
dns.lookup(host, (err) => {
|
||||
if (err) return reject(new Error(`Could not resolve host: ${host}`));
|
||||
|
||||
// Detect platform
|
||||
const isWin = process.platform === 'win32';
|
||||
const command = isWin ? `tracert -d -h 20 ${host}` : `traceroute -n -m 20 ${host}`;
|
||||
|
||||
exec(command, { timeout: 60000 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
// Traceroute might return error code if it doesn't reach dest, but stdout is valuable
|
||||
if (stdout) resolve(stdout);
|
||||
else reject(error || stderr);
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const geoip = require('geoip-lite');
|
||||
|
||||
/**
|
||||
* Parses traceroute output and adds geolocation data.
|
||||
* @param {string} output - Raw traceroute output.
|
||||
* @returns {Array} - Array of hop objects with IP, RTT, and Location.
|
||||
*/
|
||||
function parseAndLocate(output) {
|
||||
const lines = output.split('\n');
|
||||
const hops = [];
|
||||
|
||||
// Regex to match lines like: " 1 192.168.1.1 2.5 ms"
|
||||
// Linux: 1 _gateway (10.0.2.2) 0.224 ms 0.180 ms 0.120 ms
|
||||
// Windows: 1 <1 ms <1 ms <1 ms 192.168.1.1
|
||||
const ipRegex = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
|
||||
|
||||
lines.forEach(line => {
|
||||
const match = line.match(ipRegex);
|
||||
if (match) {
|
||||
const ip = match[0];
|
||||
const geo = geoip.lookup(ip);
|
||||
|
||||
hops.push({
|
||||
raw: line.trim(),
|
||||
ip: ip,
|
||||
lat: geo ? geo.ll[0] : null,
|
||||
lon: geo ? geo.ll[1] : null,
|
||||
city: geo ? geo.city : null,
|
||||
country: geo ? geo.country : null
|
||||
});
|
||||
}
|
||||
});
|
||||
return hops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes traceroute and returns structured data.
|
||||
*/
|
||||
async function traceAndLocate(host) {
|
||||
const rawOutput = await runTraceroute(host);
|
||||
const hops = parseAndLocate(rawOutput);
|
||||
return { raw: rawOutput, hops };
|
||||
}
|
||||
|
||||
module.exports = { runTraceroute, traceAndLocate };
|
||||
@@ -174,9 +174,37 @@ async function _executeTest(url, options) {
|
||||
},
|
||||
userAgent: lhr.userAgent,
|
||||
isMobile: isMobile,
|
||||
filmstrip: captureFilmstrip ? (lhr.audits['screenshot-thumbnails']?.details?.items || []) : []
|
||||
filmstrip: captureFilmstrip ? (lhr.audits['screenshot-thumbnails']?.details?.items || []) : [],
|
||||
details: {
|
||||
lcpElement: lhr.audits['largest-contentful-paint-element']?.details?.items?.[0] || null,
|
||||
clsShifts: lhr.audits['layout-shifts']?.details?.items || [],
|
||||
longTasks: lhr.audits['long-tasks']?.details?.items || []
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate Carbon Footprint
|
||||
try {
|
||||
const { checkGreenHosting, calculateCarbon } = require('./analysis/carbon-calculator');
|
||||
let transferSize = 0;
|
||||
const networkItems = lhr.audits['network-requests']?.details?.items || [];
|
||||
networkItems.forEach(item => {
|
||||
transferSize += item.transferSize || 0;
|
||||
});
|
||||
|
||||
const urlObj = new URL(lhr.finalUrl || url);
|
||||
const isGreen = await checkGreenHosting(urlObj.hostname);
|
||||
const carbonData = calculateCarbon(transferSize, isGreen);
|
||||
|
||||
summary.carbon = {
|
||||
co2: carbonData.co2,
|
||||
rating: carbonData.rating,
|
||||
isGreen: isGreen,
|
||||
transferSize: transferSize
|
||||
};
|
||||
} catch (carbonErr) {
|
||||
console.warn('Carbon calculation failed:', carbonErr);
|
||||
}
|
||||
|
||||
// Save JSON Summary
|
||||
let jsonPath = path.join(reportDir, `${testId}.json`);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2));
|
||||
@@ -263,7 +291,7 @@ async function _executeTest(url, options) {
|
||||
options.userIp || null,
|
||||
isMobile,
|
||||
JSON.stringify(summary.scores),
|
||||
JSON.stringify(summary.metrics),
|
||||
JSON.stringify({ ...summary.metrics, carbon: summary.carbon, details: summary.details }),
|
||||
JSON.stringify(filmstripData)
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user