Skip to content

Commit d78e86a

Browse files
refactor!: modularize code into separate files
1 parent 8b71ee2 commit d78e86a

File tree

10 files changed

+922
-842
lines changed

10 files changed

+922
-842
lines changed

cli.js

Lines changed: 0 additions & 840 deletions
This file was deleted.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
"version": "0.1.7",
44
"description": "CLI to analyze frontend performance using Lighthouse",
55
"type": "module",
6-
"main": "./cli.js",
6+
"main": "./src/cli.js",
77
"bin": {
8-
"frontend-performance-analyzer": "./cli.js"
8+
"frontend-performance-analyzer": "./src/cli.js"
99
},
1010
"scripts": {},
1111
"author": "Oleksandr",

src/cli.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env node
2+
import { Command } from "commander";
3+
import fs from "fs";
4+
import chalk from "chalk";
5+
import { logger, OUTPUT_LEVELS } from "./lib/logger.js";
6+
import {
7+
validateInputs,
8+
getUrlList,
9+
validateUrlAccessibility,
10+
} from "./lib/url-utils.js";
11+
import { runLighthouseAnalysis } from "./lib/lighthouse.js";
12+
import { formatConsoleMetrics } from "./report-formatters/console.js";
13+
import { exportMarkdownReport } from "./report-formatters/markdown.js";
14+
import { exportJsonReport } from "./report-formatters/json.js";
15+
16+
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
17+
const version = packageJson.version;
18+
19+
const program = new Command();
20+
21+
program
22+
.name("frontend-performance-analyzer")
23+
.description("Analyze frontend performance of a given URL")
24+
.version(version)
25+
.option("-u, --url <url...>", "One or more URLs to analyze")
26+
.option("--input <file>", "Load URLs from a .txt or .json file")
27+
.option("-o, --output <file>", "Save HTML report to file")
28+
.option("--json", "Print raw JSON report to stdout")
29+
.option("--json-file <file>", "Save JSON report to file")
30+
.option("--markdown", "Save metrics as Markdown report")
31+
.option(
32+
"--threshold <score>",
33+
"Minimum acceptable Lighthouse performance score (0-100)",
34+
parseFloat
35+
)
36+
.option("-v, --verbose", "Enable verbose output with debugging details")
37+
.option("-s, --silent", "Minimal output (errors and final results only)")
38+
.parse(process.argv);
39+
40+
const options = program.opts();
41+
42+
// Validate mutually exclusive flags
43+
if (options.verbose && options.silent) {
44+
console.error(
45+
chalk.red("❌ Error: --verbose and --silent cannot be used together")
46+
);
47+
process.exit(1);
48+
}
49+
50+
(async () => {
51+
const startTime = Date.now();
52+
logger.verbose(`Starting frontend-performance-analyzer v${version}`);
53+
logger.verbose(`Node.js version: ${process.version}`);
54+
logger.verbose(`Platform: ${process.platform} ${process.arch}`);
55+
logger.verbose(`Working directory: ${process.cwd()}`);
56+
logger.verbose(`Command line arguments: ${JSON.stringify(process.argv)}`);
57+
logger.verbose(`Options: ${JSON.stringify(options, null, 2)}`);
58+
59+
// Validate inputs before processing
60+
validateInputs(options);
61+
62+
const urls = getUrlList(options);
63+
64+
// Check URL accessibility and get only accessible ones
65+
const accessibleUrls = await validateUrlAccessibility(urls);
66+
const allResults = []; // Store all results for batch JSON export
67+
68+
logger.info(chalk.blue.bold("🚀 Starting Lighthouse analysis...\n"));
69+
70+
let successCount = 0;
71+
let failureCount = 0;
72+
73+
for (let i = 0; i < accessibleUrls.length; i++) {
74+
const url = accessibleUrls[i];
75+
const progress = `[${i + 1}/${accessibleUrls.length}]`;
76+
const urlStartTime = Date.now();
77+
78+
logger.info(chalk.blue(`${progress} 🔍 Analyzing ${url}...`));
79+
logger.verbose(
80+
`Starting analysis ${i + 1}/${
81+
accessibleUrls.length
82+
} at ${new Date().toISOString()}`
83+
);
84+
85+
try {
86+
logger.info(
87+
chalk.gray(" └─ Launching browser..."),
88+
OUTPUT_LEVELS.NORMAL
89+
);
90+
const { lhr, report } = await runLighthouseAnalysis(url, options);
91+
const urlAnalysisTime = Date.now() - urlStartTime;
92+
93+
logger.info(chalk.gray(" └─ Analysis complete!"), OUTPUT_LEVELS.NORMAL);
94+
logger.verbose(`Total analysis time for ${url}: ${urlAnalysisTime}ms`);
95+
96+
// Store result for batch processing
97+
allResults.push({ lhr, url, report });
98+
99+
if (!options.json || options.jsonFile) {
100+
formatConsoleMetrics(lhr);
101+
}
102+
103+
successCount++;
104+
105+
if (options.json) {
106+
console.log(JSON.stringify(lhr, null, 2));
107+
}
108+
109+
if (options.output) {
110+
const safeUrl = url.replace(/https?:\/\//, "").replace(/[^\w]/g, "_");
111+
const outputFile = `${safeUrl}.html`;
112+
logger.verbose(`Saving HTML report to: ${outputFile}`);
113+
fs.writeFileSync(outputFile, report);
114+
logger.info(
115+
chalk.gray(` └─ HTML report saved to ${outputFile}`),
116+
OUTPUT_LEVELS.NORMAL
117+
);
118+
}
119+
120+
if (options.markdown) {
121+
const safeUrl = url.replace(/https?:\/\//, "").replace(/[^\w]/g, "_");
122+
const markdownFile = `${safeUrl}.md`;
123+
exportMarkdownReport(lhr, markdownFile);
124+
}
125+
126+
// Individual JSON file export
127+
if (options.jsonFile && accessibleUrls.length === 1) {
128+
exportJsonReport({ lhr, url }, options.jsonFile);
129+
}
130+
131+
if (options.threshold !== undefined) {
132+
const actualScore = lhr.categories.performance.score * 100;
133+
logger.verbose(
134+
`Comparing score ${actualScore} against threshold ${options.threshold}`
135+
);
136+
if (actualScore < options.threshold) {
137+
logger.warn(
138+
chalk.red(
139+
`Score ${actualScore} is below threshold of ${options.threshold}`
140+
)
141+
);
142+
process.exitCode = 1; // does not exit immediately, just sets failure
143+
}
144+
}
145+
} catch (err) {
146+
const urlAnalysisTime = Date.now() - urlStartTime;
147+
logger.error(chalk.red(`Failed: ${err.message}`));
148+
logger.verbose(`Analysis failed for ${url} after ${urlAnalysisTime}ms`);
149+
logger.verbose(`Error details: ${err.stack}`);
150+
failureCount++;
151+
process.exitCode = 1;
152+
}
153+
154+
// Add spacing between analyses
155+
if (i < accessibleUrls.length - 1) {
156+
logger.info("", OUTPUT_LEVELS.NORMAL);
157+
}
158+
}
159+
160+
// Batch JSON export
161+
if (options.json) {
162+
logger.verbose("Performing batch JSON export to stdout");
163+
exportJsonReport(allResults);
164+
}
165+
166+
if (options.jsonFile && accessibleUrls.length > 1) {
167+
logger.verbose(`Performing batch JSON export to file: ${options.jsonFile}`);
168+
exportJsonReport(allResults, options.jsonFile);
169+
}
170+
171+
const totalTime = Date.now() - startTime;
172+
logger.verbose(`Total execution time: ${totalTime}ms`);
173+
174+
// Final summary
175+
logger.info(chalk.blue.bold("\n📋 Analysis Summary:"));
176+
logger.info(`${chalk.green("✅ Successful:")} ${successCount}`);
177+
if (failureCount > 0) {
178+
logger.info(`${chalk.red("❌ Failed:")} ${failureCount}`);
179+
}
180+
logger.info(`${chalk.blue("📊 Total analyzed:")} ${accessibleUrls.length}`);
181+
182+
if (urls.length > accessibleUrls.length) {
183+
logger.info(
184+
`${chalk.yellow("⚠️ Skipped (inaccessible):")} ${
185+
urls.length - accessibleUrls.length
186+
}`
187+
);
188+
}
189+
190+
logger.verbose(`Analysis completed at ${new Date().toISOString()}`);
191+
logger.verbose(
192+
`Performance: ${(accessibleUrls.length / (totalTime / 1000)).toFixed(
193+
2
194+
)} URLs/sec`
195+
);
196+
logger.verbose(`Total execution time: ${totalTime}ms`);
197+
})();

src/commands/analyze.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { validateInputs } from "../lib/url-utils.js";
2+
import { analyzeUrls } from "../lib/lighthouse.js";
3+
import { logger } from "../lib/logger.js";
4+
5+
export default function analyzeCommand(program) {
6+
program
7+
.command("analyze")
8+
.description("Analyze frontend performance of URLs")
9+
.option("-u, --url <url...>", "One or more URLs to analyze")
10+
.option("--input <file>", "Load URLs from a .txt or .json file")
11+
.option("-o, --output <file>", "Save HTML report to file")
12+
.option("--json", "Print raw JSON report to stdout")
13+
.option("--json-file <file>", "Save JSON report to file")
14+
.option("--markdown", "Save metrics as Markdown report")
15+
.option(
16+
"--threshold <score>",
17+
"Minimum acceptable Lighthouse performance score (0-100)",
18+
parseFloat
19+
)
20+
.option("-v, --verbose", "Enable verbose output with debugging details")
21+
.option("-s, --silent", "Minimal output (errors and final results only)")
22+
.action(async (options) => {
23+
try {
24+
validateInputs(options);
25+
const urls = getUrlList(options);
26+
const accessibleUrls = await validateUrlAccessibility(urls);
27+
await analyzeUrls(accessibleUrls, options);
28+
} catch (error) {
29+
logger.error(error.message);
30+
process.exit(1);
31+
}
32+
});
33+
}

src/lib/lighthouse.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import puppeteer from "puppeteer";
2+
import lighthouse from "lighthouse";
3+
import { logger } from "./logger.js";
4+
import { exportJsonReport } from "../report-formatters/json.js";
5+
import { exportMarkdownReport } from "../report-formatters/markdown.js";
6+
import { formatConsoleMetrics } from "../report-formatters/console.js";
7+
8+
export async function analyzeUrls(urls, options) {
9+
const results = [];
10+
11+
for (const url of urls) {
12+
try {
13+
const result = await runLighthouseAnalysis(url, options);
14+
results.push(result);
15+
16+
// Handle reports based on options
17+
if (options.json) exportJsonReport(result);
18+
if (options.markdown) exportMarkdownReport(result);
19+
// ... other report handling ...
20+
} catch (error) {
21+
logger.error(`Analysis failed for ${url}: ${error.message}`);
22+
}
23+
}
24+
25+
return results;
26+
}
27+
28+
export async function runLighthouseAnalysis(url, options) {
29+
logger.verbose(`Starting Lighthouse analysis for: ${url}`);
30+
31+
const browserStartTime = Date.now();
32+
const browser = await puppeteer.launch({
33+
headless: "new",
34+
args: [
35+
"--no-sandbox",
36+
"--disable-setuid-sandbox",
37+
"--disable-dev-shm-usage",
38+
"--remote-debugging-port=9222",
39+
],
40+
});
41+
42+
const browserLaunchTime = Date.now() - browserStartTime;
43+
logger.verbose(`Browser launched in ${browserLaunchTime}ms`);
44+
45+
// Capture console output to filter Lighthouse internal errors
46+
const originalConsoleError = console.error;
47+
const lighthouseErrors = [];
48+
49+
console.error = (...args) => {
50+
const message = args.join(" ");
51+
if (
52+
message.includes("LanternError") ||
53+
message.includes("Invalid dependency graph")
54+
) {
55+
lighthouseErrors.push(message);
56+
logger.verbose(`Lighthouse internal warning: ${message}`);
57+
// Show a cleaner warning instead of the full stack trace
58+
if (lighthouseErrors.length === 1) {
59+
logger.info(
60+
chalk.yellow(
61+
" └─ ⚠️ Lighthouse internal warning (analysis will continue)"
62+
)
63+
);
64+
}
65+
} else {
66+
originalConsoleError(...args);
67+
}
68+
};
69+
70+
try {
71+
const lighthouseStartTime = Date.now();
72+
logger.verbose("Running Lighthouse analysis...");
73+
74+
const result = await lighthouse(url, {
75+
port: 9222,
76+
output: "html",
77+
logLevel: options.verbose ? "info" : "error", // More detailed logs in verbose mode
78+
});
79+
80+
const lighthouseTime = Date.now() - lighthouseStartTime;
81+
logger.verbose(`Lighthouse analysis completed in ${lighthouseTime}ms`);
82+
83+
if (result.lhr) {
84+
logger.verbose(
85+
`Performance score: ${(
86+
result.lhr.categories.performance.score * 100
87+
).toFixed(1)}`
88+
);
89+
logger.verbose(`Lighthouse version: ${result.lhr.lighthouseVersion}`);
90+
logger.verbose(`Report generation time: ${result.lhr.timing?.total}ms`);
91+
}
92+
93+
return result;
94+
} finally {
95+
// Restore original console.error
96+
console.error = originalConsoleError;
97+
const browserCloseStart = Date.now();
98+
await browser.close();
99+
const browserCloseTime = Date.now() - browserCloseStart;
100+
logger.verbose(`Browser closed in ${browserCloseTime}ms`);
101+
}
102+
}

src/lib/logger.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import chalk from "chalk";
2+
3+
export const OUTPUT_LEVELS = {
4+
SILENT: 0,
5+
NORMAL: 1,
6+
VERBOSE: 2,
7+
};
8+
9+
export const outputLevel = OUTPUT_LEVELS.NORMAL;
10+
11+
export function configureLogger(options) {
12+
outputLevel = options.silent
13+
? OUTPUT_LEVELS.SILENT
14+
: options.verbose
15+
? OUTPUT_LEVELS.VERBOSE
16+
: OUTPUT_LEVELS.NORMAL;
17+
}
18+
19+
export const logger = {
20+
info: (message, minLevel = OUTPUT_LEVELS.NORMAL) => {
21+
if (outputLevel >= minLevel) console.log(message);
22+
},
23+
verbose: (message) => {
24+
if (outputLevel >= OUTPUT_LEVELS.VERBOSE) {
25+
console.log(chalk.gray(`[VERBOSE] ${message}`));
26+
}
27+
},
28+
error: (message) => console.error(chalk.red(`❌ ${message}`)),
29+
warn: (message, minLevel = OUTPUT_LEVELS.NORMAL) => {
30+
if (outputLevel >= minLevel) console.warn(chalk.yellow(`⚠️ ${message}`));
31+
},
32+
success: (message, minLevel = OUTPUT_LEVELS.NORMAL) => {
33+
if (outputLevel >= minLevel) console.log(chalk.green(`✅ ${message}`));
34+
},
35+
};

0 commit comments

Comments
 (0)