find-bottleneck-perf 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/index.js +114 -0
- package/package.json +44 -0
- package/src/analyzer.js +432 -0
- package/src/extractor.js +302 -0
- package/src/reporter.js +249 -0
- package/src/thresholds.js +138 -0
- package/src/utils.js +103 -0
package/src/extractor.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metric Extractor
|
|
3
|
+
*
|
|
4
|
+
* Uses Puppeteer to load a URL and extract comprehensive performance metrics
|
|
5
|
+
* from the browser's Performance API and network activity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const puppeteer = require('puppeteer');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract all performance metrics from a URL.
|
|
12
|
+
* @param {string} url - URL to analyze
|
|
13
|
+
* @param {object} options - Configuration options
|
|
14
|
+
* @param {object} options.networkCondition - Network throttling preset
|
|
15
|
+
* @returns {Promise<object>} Extracted metrics
|
|
16
|
+
*/
|
|
17
|
+
async function extractMetrics(url, options = {}) {
|
|
18
|
+
const {
|
|
19
|
+
timeout = 60000,
|
|
20
|
+
waitUntil = 'networkidle2',
|
|
21
|
+
viewport = { width: 1920, height: 1080 },
|
|
22
|
+
networkCondition = null, // Network throttling preset
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
let browser;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
browser = await puppeteer.launch({
|
|
29
|
+
headless: 'new',
|
|
30
|
+
args: [
|
|
31
|
+
'--no-sandbox',
|
|
32
|
+
'--disable-setuid-sandbox',
|
|
33
|
+
'--disable-blink-features=AutomationControlled', // Hide automation
|
|
34
|
+
'--disable-http2', // Avoid HTTP2 protocol errors
|
|
35
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
36
|
+
'--disable-web-security',
|
|
37
|
+
'--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const page = await browser.newPage();
|
|
42
|
+
await page.setViewport(viewport);
|
|
43
|
+
|
|
44
|
+
// === Apply Network Throttling via CDP ===
|
|
45
|
+
if (networkCondition) {
|
|
46
|
+
const client = await page.target().createCDPSession();
|
|
47
|
+
await client.send('Network.enable');
|
|
48
|
+
await client.send('Network.emulateNetworkConditions', {
|
|
49
|
+
offline: networkCondition.offline || false,
|
|
50
|
+
downloadThroughput: networkCondition.downloadThroughput,
|
|
51
|
+
uploadThroughput: networkCondition.uploadThroughput,
|
|
52
|
+
latency: networkCondition.latency,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// === Network Request Tracking ===
|
|
57
|
+
const resources = {
|
|
58
|
+
scripts: [], // JS files
|
|
59
|
+
stylesheets: [], // CSS files
|
|
60
|
+
images: [], // Images
|
|
61
|
+
fonts: [], // Font files
|
|
62
|
+
apis: [], // XHR/Fetch requests
|
|
63
|
+
other: [], // Everything else
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let failedRequests = 0;
|
|
67
|
+
|
|
68
|
+
await page.setRequestInterception(true);
|
|
69
|
+
page.on('request', (req) => req.continue());
|
|
70
|
+
|
|
71
|
+
page.on('response', async (response) => {
|
|
72
|
+
const reqUrl = response.url();
|
|
73
|
+
const resourceType = response.request().resourceType();
|
|
74
|
+
const status = response.status();
|
|
75
|
+
|
|
76
|
+
// Track failed requests
|
|
77
|
+
if (status >= 400) {
|
|
78
|
+
failedRequests++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Get response size (safely)
|
|
82
|
+
let size = 0;
|
|
83
|
+
try {
|
|
84
|
+
const buffer = await response.buffer();
|
|
85
|
+
size = buffer.length;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// Response body unavailable (e.g., redirects)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const timing = response.timing();
|
|
91
|
+
const duration = timing ? timing.receiveHeadersEnd : 0;
|
|
92
|
+
|
|
93
|
+
const entry = { url: reqUrl, size, duration, status };
|
|
94
|
+
|
|
95
|
+
switch (resourceType) {
|
|
96
|
+
case 'script':
|
|
97
|
+
resources.scripts.push(entry);
|
|
98
|
+
break;
|
|
99
|
+
case 'stylesheet':
|
|
100
|
+
resources.stylesheets.push(entry);
|
|
101
|
+
break;
|
|
102
|
+
case 'image':
|
|
103
|
+
resources.images.push(entry);
|
|
104
|
+
break;
|
|
105
|
+
case 'font':
|
|
106
|
+
resources.fonts.push(entry);
|
|
107
|
+
break;
|
|
108
|
+
case 'xhr':
|
|
109
|
+
case 'fetch':
|
|
110
|
+
resources.apis.push(entry);
|
|
111
|
+
break;
|
|
112
|
+
default:
|
|
113
|
+
resources.other.push(entry);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// === Page Load ===
|
|
118
|
+
const loadStart = Date.now();
|
|
119
|
+
await page.goto(url, { waitUntil, timeout });
|
|
120
|
+
const totalLoadTime = Date.now() - loadStart;
|
|
121
|
+
|
|
122
|
+
// === Extract Navigation Timing ===
|
|
123
|
+
const navTiming = await page.evaluate(() => {
|
|
124
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
125
|
+
if (!nav) return null;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
// Connection phases
|
|
129
|
+
dnsLookup: nav.domainLookupEnd - nav.domainLookupStart,
|
|
130
|
+
tcpConnection: nav.connectEnd - nav.connectStart,
|
|
131
|
+
tlsHandshake: nav.secureConnectionStart > 0
|
|
132
|
+
? nav.connectEnd - nav.secureConnectionStart
|
|
133
|
+
: 0,
|
|
134
|
+
|
|
135
|
+
// Request/Response
|
|
136
|
+
ttfb: nav.responseStart - nav.requestStart,
|
|
137
|
+
responseTime: nav.responseEnd - nav.responseStart,
|
|
138
|
+
|
|
139
|
+
// Document Processing
|
|
140
|
+
domInteractive: nav.domInteractive,
|
|
141
|
+
domContentLoaded: nav.domContentLoadedEventEnd,
|
|
142
|
+
domComplete: nav.domComplete,
|
|
143
|
+
loadEvent: nav.loadEventEnd,
|
|
144
|
+
|
|
145
|
+
// Derived: Server processing time (TTFB minus network overhead)
|
|
146
|
+
serverProcessing: nav.responseStart - nav.connectEnd,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// === Extract Paint Timing ===
|
|
151
|
+
const paintTiming = await page.evaluate(() => {
|
|
152
|
+
const entries = performance.getEntriesByType('paint');
|
|
153
|
+
const fcp = entries.find(e => e.name === 'first-contentful-paint');
|
|
154
|
+
const fp = entries.find(e => e.name === 'first-paint');
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
firstPaint: fp ? fp.startTime : 0,
|
|
158
|
+
firstContentfulPaint: fcp ? fcp.startTime : 0,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// === Extract LCP ===
|
|
163
|
+
const lcp = await page.evaluate(() => {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
let lcpValue = 0;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
new PerformanceObserver((entryList) => {
|
|
169
|
+
const entries = entryList.getEntries();
|
|
170
|
+
lcpValue = entries[entries.length - 1]?.startTime || 0;
|
|
171
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// LCP not supported
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Wait for LCP to stabilize
|
|
177
|
+
setTimeout(() => resolve(lcpValue), 2000);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// === Extract Long Tasks (Total Blocking Time) ===
|
|
182
|
+
const longTasks = await page.evaluate(() => {
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
let totalBlocking = 0;
|
|
185
|
+
let taskCount = 0;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
new PerformanceObserver((list) => {
|
|
189
|
+
for (const entry of list.getEntries()) {
|
|
190
|
+
// Long task is > 50ms, blocking time is the excess
|
|
191
|
+
const blocking = entry.duration - 50;
|
|
192
|
+
totalBlocking += blocking > 0 ? blocking : 0;
|
|
193
|
+
taskCount++;
|
|
194
|
+
}
|
|
195
|
+
}).observe({ type: 'longtask', buffered: true });
|
|
196
|
+
} catch (e) {
|
|
197
|
+
// Long tasks not supported
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setTimeout(() => resolve({ totalBlocking, taskCount }), 1500);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// === Extract Resource Hints & Render Blocking ===
|
|
205
|
+
const renderBlocking = await page.evaluate(() => {
|
|
206
|
+
// Count render-blocking resources (sync scripts in head, blocking stylesheets)
|
|
207
|
+
const blockingScripts = document.querySelectorAll('head script:not([async]):not([defer])').length;
|
|
208
|
+
const blockingStyles = document.querySelectorAll('link[rel="stylesheet"]').length;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
blockingScripts,
|
|
212
|
+
blockingStyles,
|
|
213
|
+
total: blockingScripts + blockingStyles,
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await browser.close();
|
|
218
|
+
|
|
219
|
+
// === Aggregate Resource Sizes ===
|
|
220
|
+
const sumSize = (arr) => arr.reduce((sum, r) => sum + r.size, 0);
|
|
221
|
+
const avgDuration = (arr) => arr.length > 0
|
|
222
|
+
? arr.reduce((sum, r) => sum + r.duration, 0) / arr.length
|
|
223
|
+
: 0;
|
|
224
|
+
const maxDuration = (arr) => arr.length > 0
|
|
225
|
+
? Math.max(...arr.map(r => r.duration))
|
|
226
|
+
: 0;
|
|
227
|
+
|
|
228
|
+
// === Compile Final Metrics ===
|
|
229
|
+
return {
|
|
230
|
+
url,
|
|
231
|
+
timestamp: new Date().toISOString(),
|
|
232
|
+
|
|
233
|
+
// Navigation Timing
|
|
234
|
+
navigation: {
|
|
235
|
+
dnsLookup: Math.round(navTiming?.dnsLookup || 0),
|
|
236
|
+
tcpConnection: Math.round(navTiming?.tcpConnection || 0),
|
|
237
|
+
tlsHandshake: Math.round(navTiming?.tlsHandshake || 0),
|
|
238
|
+
ttfb: Math.round(navTiming?.ttfb || 0),
|
|
239
|
+
responseTime: Math.round(navTiming?.responseTime || 0),
|
|
240
|
+
serverProcessing: Math.round(navTiming?.serverProcessing || 0),
|
|
241
|
+
domInteractive: Math.round(navTiming?.domInteractive || 0),
|
|
242
|
+
domContentLoaded: Math.round(navTiming?.domContentLoaded || 0),
|
|
243
|
+
domComplete: Math.round(navTiming?.domComplete || 0),
|
|
244
|
+
totalLoadTime,
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// Paint & Vitals
|
|
248
|
+
vitals: {
|
|
249
|
+
firstPaint: Math.round(paintTiming.firstPaint),
|
|
250
|
+
firstContentfulPaint: Math.round(paintTiming.firstContentfulPaint),
|
|
251
|
+
largestContentfulPaint: Math.round(lcp),
|
|
252
|
+
totalBlockingTime: Math.round(longTasks.totalBlocking),
|
|
253
|
+
longTaskCount: longTasks.taskCount,
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// Resource Breakdown
|
|
257
|
+
resources: {
|
|
258
|
+
javascript: {
|
|
259
|
+
count: resources.scripts.length,
|
|
260
|
+
totalBytes: sumSize(resources.scripts),
|
|
261
|
+
},
|
|
262
|
+
css: {
|
|
263
|
+
count: resources.stylesheets.length,
|
|
264
|
+
totalBytes: sumSize(resources.stylesheets),
|
|
265
|
+
},
|
|
266
|
+
images: {
|
|
267
|
+
count: resources.images.length,
|
|
268
|
+
totalBytes: sumSize(resources.images),
|
|
269
|
+
},
|
|
270
|
+
fonts: {
|
|
271
|
+
count: resources.fonts.length,
|
|
272
|
+
totalBytes: sumSize(resources.fonts),
|
|
273
|
+
},
|
|
274
|
+
other: {
|
|
275
|
+
count: resources.other.length,
|
|
276
|
+
totalBytes: sumSize(resources.other),
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
// API Performance
|
|
281
|
+
api: {
|
|
282
|
+
callCount: resources.apis.length,
|
|
283
|
+
avgLatency: Math.round(avgDuration(resources.apis)),
|
|
284
|
+
maxLatency: Math.round(maxDuration(resources.apis)),
|
|
285
|
+
failedCount: resources.apis.filter(r => r.status >= 400).length,
|
|
286
|
+
totalFailedRequests: failedRequests,
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// Render Blocking
|
|
290
|
+
renderBlocking,
|
|
291
|
+
|
|
292
|
+
// Derived Metrics (calculated in analyzer)
|
|
293
|
+
derived: null,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (browser) await browser.close();
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { extractMetrics };
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance Reporter
|
|
3
|
+
*
|
|
4
|
+
* Formats analysis results for terminal output with colors,
|
|
5
|
+
* tables, and clear visual hierarchy.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { colors, colorize, formatMs, formatBytes, getStatus, progressBar } = require('./utils');
|
|
9
|
+
const T = require('./thresholds');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Print the full analysis report to terminal.
|
|
13
|
+
* @param {object} analysis - Analysis result from analyzer
|
|
14
|
+
*/
|
|
15
|
+
function printReport(analysis) {
|
|
16
|
+
const { verdict, scores, evidence, metrics } = analysis;
|
|
17
|
+
const nav = metrics.navigation;
|
|
18
|
+
const vitals = metrics.vitals;
|
|
19
|
+
const derived = metrics.derived;
|
|
20
|
+
const api = metrics.api;
|
|
21
|
+
const res = metrics.resources;
|
|
22
|
+
|
|
23
|
+
console.log('\n' + '═'.repeat(60));
|
|
24
|
+
console.log(colorize(' PERF-BLAME-FINDER │ Performance Analysis Report', 'cyan'));
|
|
25
|
+
console.log('═'.repeat(60));
|
|
26
|
+
console.log(colorize(` URL: ${metrics.url}`, 'dim'));
|
|
27
|
+
console.log(colorize(` Analyzed: ${metrics.timestamp}`, 'dim'));
|
|
28
|
+
console.log('─'.repeat(60));
|
|
29
|
+
|
|
30
|
+
// === VERDICT ===
|
|
31
|
+
printVerdict(verdict, scores);
|
|
32
|
+
|
|
33
|
+
// === METRICS SUMMARY ===
|
|
34
|
+
printMetricsSummary(nav, vitals, derived, api, res);
|
|
35
|
+
|
|
36
|
+
// === EVIDENCE ===
|
|
37
|
+
printEvidence(evidence);
|
|
38
|
+
|
|
39
|
+
// === RECOMMENDATIONS ===
|
|
40
|
+
const { getRecommendations } = require('./analyzer');
|
|
41
|
+
const recs = getRecommendations(verdict, evidence);
|
|
42
|
+
printRecommendations(recs);
|
|
43
|
+
|
|
44
|
+
console.log('═'.repeat(60) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printVerdict(verdict, scores) {
|
|
48
|
+
console.log('\n' + colorize('┌─ THE VERDICT', 'bright'));
|
|
49
|
+
console.log('│');
|
|
50
|
+
|
|
51
|
+
let verdictColor, verdictIcon;
|
|
52
|
+
switch (verdict) {
|
|
53
|
+
case 'Optimized':
|
|
54
|
+
verdictColor = 'green';
|
|
55
|
+
verdictIcon = '✅';
|
|
56
|
+
break;
|
|
57
|
+
case 'Frontend':
|
|
58
|
+
verdictColor = 'blue';
|
|
59
|
+
verdictIcon = '🖥️';
|
|
60
|
+
break;
|
|
61
|
+
case 'Backend':
|
|
62
|
+
verdictColor = 'yellow';
|
|
63
|
+
verdictIcon = '⚙️';
|
|
64
|
+
break;
|
|
65
|
+
case 'Infra':
|
|
66
|
+
verdictColor = 'red';
|
|
67
|
+
verdictIcon = '🌐';
|
|
68
|
+
break;
|
|
69
|
+
case 'Mixed':
|
|
70
|
+
verdictColor = 'magenta';
|
|
71
|
+
verdictIcon = '⚠️';
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
verdictColor = 'white';
|
|
75
|
+
verdictIcon = '❓';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(`│ ${verdictIcon} Primary Bottleneck: ${colorize(verdict.toUpperCase(), verdictColor)}`);
|
|
79
|
+
console.log('│');
|
|
80
|
+
console.log(`│ Scores: Frontend=${scores.Frontend} Backend=${scores.Backend} Infra=${scores.Infra}`);
|
|
81
|
+
console.log('└' + '─'.repeat(59));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function printMetricsSummary(nav, vitals, derived, api, res) {
|
|
85
|
+
console.log('\n' + colorize('┌─ METRICS SUMMARY', 'bright'));
|
|
86
|
+
|
|
87
|
+
// Navigation Timing
|
|
88
|
+
console.log('│');
|
|
89
|
+
console.log('│ ' + colorize('Navigation Timing', 'cyan'));
|
|
90
|
+
printMetricRow(' DNS Lookup', nav.dnsLookup, 'ms', T.DNS);
|
|
91
|
+
printMetricRow(' TCP Connect', nav.tcpConnection, 'ms', T.TCP);
|
|
92
|
+
printMetricRow(' TLS Handshake', nav.tlsHandshake, 'ms', T.TLS);
|
|
93
|
+
printMetricRow(' TTFB', nav.ttfb, 'ms', T.TTFB);
|
|
94
|
+
printMetricRow(' Server Processing', nav.serverProcessing, 'ms', T.TTFB);
|
|
95
|
+
printMetricRow(' DOM Content Loaded', nav.domContentLoaded, 'ms');
|
|
96
|
+
printMetricRow(' Total Load Time', nav.totalLoadTime, 'ms');
|
|
97
|
+
|
|
98
|
+
// Core Web Vitals
|
|
99
|
+
console.log('│');
|
|
100
|
+
console.log('│ ' + colorize('Core Web Vitals', 'cyan'));
|
|
101
|
+
printMetricRow(' First Contentful Paint', vitals.firstContentfulPaint, 'ms', T.FCP);
|
|
102
|
+
printMetricRow(' Largest Contentful Paint', vitals.largestContentfulPaint, 'ms', T.LCP);
|
|
103
|
+
printMetricRow(' Total Blocking Time', vitals.totalBlockingTime, 'ms', T.JS_EXECUTION);
|
|
104
|
+
printMetricRow(' Long Task Count', vitals.longTaskCount);
|
|
105
|
+
|
|
106
|
+
// Resources
|
|
107
|
+
console.log('│');
|
|
108
|
+
console.log('│ ' + colorize('Resource Sizes', 'cyan'));
|
|
109
|
+
printMetricRow(' JavaScript', derived.jsSizeMB, 'MB', T.BUNDLE_SIZE_MB);
|
|
110
|
+
printMetricRow(' CSS', derived.cssSizeMB, 'MB', T.CSS_SIZE_MB);
|
|
111
|
+
printMetricRow(' Images', derived.imageSizeMB, 'MB', T.IMAGE_SIZE_MB);
|
|
112
|
+
printMetricRow(' Total', derived.totalSizeMB, 'MB');
|
|
113
|
+
|
|
114
|
+
// API
|
|
115
|
+
console.log('│');
|
|
116
|
+
console.log('│ ' + colorize('API Performance', 'cyan'));
|
|
117
|
+
printMetricRow(' Request Count', api.callCount, '', T.API_CALLS);
|
|
118
|
+
printMetricRow(' Avg Latency', api.avgLatency, 'ms', T.API_LATENCY);
|
|
119
|
+
printMetricRow(' Max Latency', api.maxLatency, 'ms');
|
|
120
|
+
printMetricRow(' Failed Requests', api.failedCount);
|
|
121
|
+
|
|
122
|
+
// Derived
|
|
123
|
+
console.log('│');
|
|
124
|
+
console.log('│ ' + colorize('Derived Metrics', 'cyan'));
|
|
125
|
+
printMetricRow(' Network Overhead', derived.networkOverhead, 'ms');
|
|
126
|
+
printMetricRow(' Network Overhead Ratio', (derived.networkOverheadRatio * 100).toFixed(0), '%');
|
|
127
|
+
printMetricRow(' Client Render Time', derived.clientRenderTime, 'ms', T.CLIENT_RENDER_TIME);
|
|
128
|
+
|
|
129
|
+
console.log('└' + '─'.repeat(59));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function printMetricRow(label, value, unit = '', threshold = null) {
|
|
133
|
+
const formattedValue = typeof value === 'number'
|
|
134
|
+
? (unit === 'MB' ? value.toFixed(2) : Math.round(value))
|
|
135
|
+
: value;
|
|
136
|
+
|
|
137
|
+
let status = ' ';
|
|
138
|
+
if (threshold) {
|
|
139
|
+
// Determine status based on threshold
|
|
140
|
+
if (threshold.GOOD !== undefined && threshold.POOR !== undefined) {
|
|
141
|
+
if (value <= threshold.GOOD) status = colorize('●', 'green');
|
|
142
|
+
else if (value >= threshold.POOR) status = colorize('●', 'red');
|
|
143
|
+
else status = colorize('●', 'yellow');
|
|
144
|
+
} else if (threshold.NORMAL !== undefined) {
|
|
145
|
+
// For counts like API calls
|
|
146
|
+
if (value <= threshold.NORMAL) status = colorize('●', 'green');
|
|
147
|
+
else if (value >= threshold.EXCESSIVE) status = colorize('●', 'red');
|
|
148
|
+
else status = colorize('●', 'yellow');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const valueStr = `${formattedValue}${unit}`;
|
|
153
|
+
console.log(`│ ${status} ${label.padEnd(25)} ${valueStr.padStart(10)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function printEvidence(evidence) {
|
|
157
|
+
console.log('\n' + colorize('┌─ EVIDENCE', 'bright'));
|
|
158
|
+
|
|
159
|
+
if (evidence.length === 0) {
|
|
160
|
+
console.log('│ No significant issues detected.');
|
|
161
|
+
} else {
|
|
162
|
+
// Group by category
|
|
163
|
+
const grouped = {};
|
|
164
|
+
for (const e of evidence) {
|
|
165
|
+
if (!grouped[e.category]) grouped[e.category] = [];
|
|
166
|
+
grouped[e.category].push(e);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [category, items] of Object.entries(grouped)) {
|
|
170
|
+
console.log('│');
|
|
171
|
+
const catColor = category === 'Frontend' ? 'blue'
|
|
172
|
+
: category === 'Backend' ? 'yellow'
|
|
173
|
+
: 'red';
|
|
174
|
+
console.log('│ ' + colorize(`[${category}]`, catColor));
|
|
175
|
+
|
|
176
|
+
for (const item of items) {
|
|
177
|
+
const severityIcon = item.severity === 'high' ? colorize('▸', 'red') : colorize('▸', 'yellow');
|
|
178
|
+
console.log(`│ ${severityIcon} ${item.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log('└' + '─'.repeat(59));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function printRecommendations(recs) {
|
|
187
|
+
console.log('\n' + colorize('┌─ RECOMMENDATIONS', 'bright'));
|
|
188
|
+
console.log('│');
|
|
189
|
+
|
|
190
|
+
for (const rec of recs) {
|
|
191
|
+
console.log(`│ 👉 ${rec}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log('└' + '─'.repeat(59));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Export full analysis as JSON.
|
|
199
|
+
* @param {object} analysis - Full analysis result
|
|
200
|
+
* @param {string} filepath - Output file path
|
|
201
|
+
*/
|
|
202
|
+
function exportJson(analysis, filepath) {
|
|
203
|
+
const fs = require('fs');
|
|
204
|
+
const { getRecommendations } = require('./analyzer');
|
|
205
|
+
|
|
206
|
+
const { verdict, scores, evidence, metrics } = analysis;
|
|
207
|
+
const recommendations = getRecommendations(verdict, evidence);
|
|
208
|
+
|
|
209
|
+
// Build clean export object with all data
|
|
210
|
+
const exportData = {
|
|
211
|
+
url: metrics.url,
|
|
212
|
+
timestamp: metrics.timestamp,
|
|
213
|
+
|
|
214
|
+
// The verdict
|
|
215
|
+
verdict: {
|
|
216
|
+
primary: verdict,
|
|
217
|
+
scores: scores,
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
// All metrics
|
|
221
|
+
metrics: {
|
|
222
|
+
navigation: metrics.navigation,
|
|
223
|
+
vitals: metrics.vitals,
|
|
224
|
+
resources: metrics.resources,
|
|
225
|
+
api: metrics.api,
|
|
226
|
+
renderBlocking: metrics.renderBlocking,
|
|
227
|
+
derived: metrics.derived,
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
// Evidence (cleaned for JSON)
|
|
231
|
+
evidence: evidence.map(e => ({
|
|
232
|
+
category: e.category,
|
|
233
|
+
severity: e.severity,
|
|
234
|
+
metric: e.metric,
|
|
235
|
+
value: e.value,
|
|
236
|
+
threshold: e.threshold,
|
|
237
|
+
message: e.message,
|
|
238
|
+
})),
|
|
239
|
+
|
|
240
|
+
// Recommendations
|
|
241
|
+
recommendations: recommendations,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
fs.writeFileSync(filepath, JSON.stringify(exportData, null, 2));
|
|
245
|
+
console.log(colorize(`\n📁 Full report exported to: ${filepath}`, 'green'));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { printReport, exportJson };
|
|
249
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threshold configuration for performance metrics.
|
|
3
|
+
* All time values are in milliseconds, sizes in MB.
|
|
4
|
+
*
|
|
5
|
+
* These are opinionated, production-realistic values based on:
|
|
6
|
+
* - Google's Core Web Vitals guidelines
|
|
7
|
+
* - Real-world SRE experience
|
|
8
|
+
* - Perceived performance research
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
// === Navigation Timing ===
|
|
13
|
+
TTFB: {
|
|
14
|
+
GOOD: 200, // Fast server, edge-cached
|
|
15
|
+
MODERATE: 400, // Typical origin response
|
|
16
|
+
POOR: 600, // Noticeable delay
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
DNS: {
|
|
20
|
+
GOOD: 20, // Cached or fast resolver
|
|
21
|
+
POOR: 100, // Slow DNS or cold lookup
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
TCP: {
|
|
25
|
+
GOOD: 50, // Low-latency connection
|
|
26
|
+
POOR: 200, // High-latency or congested
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
TLS: {
|
|
30
|
+
GOOD: 50, // Session resumption
|
|
31
|
+
POOR: 150, // Full handshake
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// === Core Web Vitals ===
|
|
35
|
+
LCP: {
|
|
36
|
+
GOOD: 2500, // Google's "good" threshold
|
|
37
|
+
POOR: 4000, // Google's "poor" threshold
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
FCP: {
|
|
41
|
+
GOOD: 1800,
|
|
42
|
+
POOR: 3000,
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// === JavaScript ===
|
|
46
|
+
JS_EXECUTION: {
|
|
47
|
+
GOOD: 200, // Minimal blocking
|
|
48
|
+
MODERATE: 500, // Noticeable but acceptable
|
|
49
|
+
POOR: 800, // Heavy blocking
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
BUNDLE_SIZE_MB: {
|
|
53
|
+
GOOD: 0.5, // Well-optimized
|
|
54
|
+
MODERATE: 1.0, // Typical SPA
|
|
55
|
+
POOR: 2.0, // Bloated
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// === API Performance ===
|
|
59
|
+
API_LATENCY: {
|
|
60
|
+
GOOD: 100, // Snappy API
|
|
61
|
+
MODERATE: 200, // Acceptable
|
|
62
|
+
POOR: 300, // Sluggish
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
API_CALLS: {
|
|
66
|
+
NORMAL: 15, // Reasonable for initial load
|
|
67
|
+
HIGH: 30, // Chatty
|
|
68
|
+
EXCESSIVE: 50, // Problematic
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// === Resource Sizes (MB) ===
|
|
72
|
+
CSS_SIZE_MB: {
|
|
73
|
+
GOOD: 0.1,
|
|
74
|
+
POOR: 0.3,
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
IMAGE_SIZE_MB: {
|
|
78
|
+
GOOD: 1.0,
|
|
79
|
+
POOR: 3.0,
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// === Derived Ratios ===
|
|
83
|
+
NETWORK_OVERHEAD_RATIO: {
|
|
84
|
+
HIGH: 0.5, // > 50% of TTFB is network overhead
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
CLIENT_RENDER_TIME: {
|
|
88
|
+
GOOD: 1500, // Fast render after TTFB
|
|
89
|
+
POOR: 2500, // Slow client-side processing
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Network Throttling Presets
|
|
95
|
+
* Based on Chrome DevTools network conditions
|
|
96
|
+
*
|
|
97
|
+
* downloadThroughput/uploadThroughput: bytes per second
|
|
98
|
+
* latency: additional RTT in ms
|
|
99
|
+
*/
|
|
100
|
+
module.exports.NETWORK_PRESETS = {
|
|
101
|
+
'no-throttle': {
|
|
102
|
+
name: 'No Throttle',
|
|
103
|
+
downloadThroughput: -1, // Unlimited
|
|
104
|
+
uploadThroughput: -1,
|
|
105
|
+
latency: 0,
|
|
106
|
+
},
|
|
107
|
+
'slow-3g': {
|
|
108
|
+
name: 'Slow 3G',
|
|
109
|
+
downloadThroughput: 500 * 1024 / 8, // 500 Kbps
|
|
110
|
+
uploadThroughput: 500 * 1024 / 8,
|
|
111
|
+
latency: 400, // 400ms RTT
|
|
112
|
+
},
|
|
113
|
+
'fast-3g': {
|
|
114
|
+
name: 'Fast 3G',
|
|
115
|
+
downloadThroughput: 1.6 * 1024 * 1024 / 8, // 1.6 Mbps
|
|
116
|
+
uploadThroughput: 750 * 1024 / 8,
|
|
117
|
+
latency: 150, // 150ms RTT
|
|
118
|
+
},
|
|
119
|
+
'4g': {
|
|
120
|
+
name: 'Regular 4G',
|
|
121
|
+
downloadThroughput: 4 * 1024 * 1024 / 8, // 4 Mbps
|
|
122
|
+
uploadThroughput: 3 * 1024 * 1024 / 8,
|
|
123
|
+
latency: 20, // 20ms RTT
|
|
124
|
+
},
|
|
125
|
+
'wifi': {
|
|
126
|
+
name: 'WiFi',
|
|
127
|
+
downloadThroughput: 30 * 1024 * 1024 / 8, // 30 Mbps
|
|
128
|
+
uploadThroughput: 15 * 1024 * 1024 / 8,
|
|
129
|
+
latency: 2, // 2ms RTT
|
|
130
|
+
},
|
|
131
|
+
'offline': {
|
|
132
|
+
name: 'Offline',
|
|
133
|
+
offline: true,
|
|
134
|
+
downloadThroughput: 0,
|
|
135
|
+
uploadThroughput: 0,
|
|
136
|
+
latency: 0,
|
|
137
|
+
},
|
|
138
|
+
};
|