apex-auditor 0.1.3 → 0.1.6

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/dist/cli.js CHANGED
@@ -2,6 +2,10 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { runAuditsForConfig } from "./lighthouse-runner.js";
5
+ const ANSI_RESET = "\u001B[0m";
6
+ const ANSI_RED = "\u001B[31m";
7
+ const ANSI_YELLOW = "\u001B[33m";
8
+ const ANSI_GREEN = "\u001B[32m";
5
9
  function parseArgs(argv) {
6
10
  let configPath;
7
11
  for (let i = 2; i < argv.length; i += 1) {
@@ -28,18 +32,28 @@ export async function runAuditCli(argv) {
28
32
  await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
29
33
  const markdown = buildMarkdown(summary.results);
30
34
  await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
31
- // Also echo a compact table to stdout for quick viewing.
35
+ // Also echo a compact, colourised table to stdout for quick viewing.
36
+ const consoleTable = buildConsoleTable(summary.results);
32
37
  // eslint-disable-next-line no-console
33
- console.log(markdown);
38
+ console.log(consoleTable);
39
+ printRedIssues(summary.results);
34
40
  }
35
41
  function buildMarkdown(results) {
36
42
  const header = [
37
- "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Top issues |",
38
- "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|-----------|",
43
+ "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Error | Top issues |",
44
+ "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|-------|-----------|",
39
45
  ].join("\n");
40
46
  const lines = results.map((result) => buildRow(result));
41
47
  return `${header}\n${lines.join("\n")}`;
42
48
  }
49
+ function buildConsoleTable(results) {
50
+ const header = [
51
+ "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Error | Top issues |",
52
+ "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|-------|-----------|",
53
+ ].join("\n");
54
+ const lines = results.map((result) => buildConsoleRow(result));
55
+ return `${header}\n${lines.join("\n")}`;
56
+ }
43
57
  function buildRow(result) {
44
58
  const scores = result.scores;
45
59
  const metrics = result.metrics;
@@ -48,7 +62,23 @@ function buildRow(result) {
48
62
  const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs).toString() : "-";
49
63
  const cls = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
50
64
  const issues = formatTopIssues(result.opportunities);
51
- return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${issues} |`;
65
+ const error = result.runtimeErrorCode ?? (result.runtimeErrorMessage !== undefined ? result.runtimeErrorMessage : "");
66
+ return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${error} | ${issues} |`;
67
+ }
68
+ function buildConsoleRow(result) {
69
+ const scores = result.scores;
70
+ const metrics = result.metrics;
71
+ const lcpSeconds = metrics.lcpMs !== undefined ? (metrics.lcpMs / 1000).toFixed(1) : "-";
72
+ const fcpSeconds = metrics.fcpMs !== undefined ? (metrics.fcpMs / 1000).toFixed(1) : "-";
73
+ const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs).toString() : "-";
74
+ const cls = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
75
+ const issues = formatTopIssues(result.opportunities);
76
+ const error = result.runtimeErrorCode ?? (result.runtimeErrorMessage !== undefined ? result.runtimeErrorMessage : "");
77
+ const performanceText = colourScore(scores.performance);
78
+ const accessibilityText = colourScore(scores.accessibility);
79
+ const bestPracticesText = colourScore(scores.bestPractices);
80
+ const seoText = colourScore(scores.seo);
81
+ return `| ${result.label} | ${result.path} | ${result.device} | ${performanceText} | ${accessibilityText} | ${bestPracticesText} | ${seoText} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${error} | ${issues} |`;
52
82
  }
53
83
  function formatTopIssues(opportunities) {
54
84
  if (opportunities.length === 0) {
@@ -63,3 +93,57 @@ function formatTopIssues(opportunities) {
63
93
  });
64
94
  return items.join("; ");
65
95
  }
96
+ function colourScore(score) {
97
+ if (score === undefined) {
98
+ return "-";
99
+ }
100
+ const value = score;
101
+ const text = value.toString();
102
+ let colour;
103
+ if (value < 50) {
104
+ colour = ANSI_RED;
105
+ }
106
+ else if (value < 90) {
107
+ colour = ANSI_YELLOW;
108
+ }
109
+ else {
110
+ colour = ANSI_GREEN;
111
+ }
112
+ return `${colour}${text}${ANSI_RESET}`;
113
+ }
114
+ function isRedScore(score) {
115
+ return typeof score === "number" && score < 50;
116
+ }
117
+ function printRedIssues(results) {
118
+ const redResults = results.filter((result) => {
119
+ const scores = result.scores;
120
+ return (isRedScore(scores.performance) ||
121
+ isRedScore(scores.accessibility) ||
122
+ isRedScore(scores.bestPractices) ||
123
+ isRedScore(scores.seo));
124
+ });
125
+ if (redResults.length === 0) {
126
+ return;
127
+ }
128
+ // eslint-disable-next-line no-console
129
+ console.log("\nRed issues (scores below 50):");
130
+ for (const result of redResults) {
131
+ const scores = result.scores;
132
+ const badParts = [];
133
+ if (isRedScore(scores.performance)) {
134
+ badParts.push(`P:${scores.performance}`);
135
+ }
136
+ if (isRedScore(scores.accessibility)) {
137
+ badParts.push(`A:${scores.accessibility}`);
138
+ }
139
+ if (isRedScore(scores.bestPractices)) {
140
+ badParts.push(`BP:${scores.bestPractices}`);
141
+ }
142
+ if (isRedScore(scores.seo)) {
143
+ badParts.push(`SEO:${scores.seo}`);
144
+ }
145
+ const issues = formatTopIssues(result.opportunities);
146
+ // eslint-disable-next-line no-console
147
+ console.log(`- ${result.label} ${result.path} [${result.device}] – ${badParts.join(", ")} – ${issues}`);
148
+ }
149
+ }
package/dist/config.js CHANGED
@@ -27,11 +27,16 @@ function normaliseConfig(input, absolutePath) {
27
27
  const query = typeof maybeConfig.query === "string" ? maybeConfig.query : undefined;
28
28
  const chromePort = typeof maybeConfig.chromePort === "number" ? maybeConfig.chromePort : undefined;
29
29
  const runs = typeof maybeConfig.runs === "number" && maybeConfig.runs > 0 ? maybeConfig.runs : undefined;
30
+ const rawLogLevel = maybeConfig.logLevel;
31
+ const logLevel = rawLogLevel === "silent" || rawLogLevel === "error" || rawLogLevel === "info" || rawLogLevel === "verbose"
32
+ ? rawLogLevel
33
+ : undefined;
30
34
  return {
31
35
  baseUrl,
32
36
  query,
33
37
  chromePort,
34
38
  runs,
39
+ logLevel,
35
40
  pages,
36
41
  };
37
42
  }
@@ -1,3 +1,5 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
1
3
  import lighthouse from "lighthouse";
2
4
  import { launch as launchChrome } from "chrome-launcher";
3
5
  async function createChromeSession(chromePort) {
@@ -19,16 +21,55 @@ async function createChromeSession(chromePort) {
19
21
  return {
20
22
  port: chrome.port,
21
23
  close: async () => {
22
- await chrome.kill();
24
+ try {
25
+ await chrome.kill();
26
+ }
27
+ catch {
28
+ return;
29
+ }
23
30
  },
24
31
  };
25
32
  }
33
+ async function ensureUrlReachable(url) {
34
+ const parsed = new URL(url);
35
+ const client = parsed.protocol === "https:" ? httpsRequest : httpRequest;
36
+ await new Promise((resolve, reject) => {
37
+ const request = client({
38
+ hostname: parsed.hostname,
39
+ port: parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80,
40
+ path: `${parsed.pathname}${parsed.search}`,
41
+ method: "GET",
42
+ }, (response) => {
43
+ const statusCode = response.statusCode ?? 0;
44
+ response.resume();
45
+ if (statusCode >= 200 && statusCode < 400) {
46
+ resolve();
47
+ }
48
+ else {
49
+ reject(new Error(`HTTP ${statusCode}`));
50
+ }
51
+ });
52
+ request.on("error", (error) => {
53
+ reject(error);
54
+ });
55
+ request.end();
56
+ }).catch((error) => {
57
+ // eslint-disable-next-line no-console
58
+ console.error(`Could not reach ${url}. Is your dev server running?`, error);
59
+ throw error instanceof Error ? error : new Error(`URL not reachable: ${url}`);
60
+ });
61
+ }
26
62
  /**
27
63
  * Run audits for all pages defined in the config and return a structured summary.
28
64
  */
29
65
  export async function runAuditsForConfig({ config, configPath, }) {
30
66
  const runs = config.runs ?? 1;
31
67
  const results = [];
68
+ const firstPage = config.pages[0];
69
+ const healthCheckUrl = buildUrl({ baseUrl: config.baseUrl, path: firstPage.path, query: config.query });
70
+ await ensureUrlReachable(healthCheckUrl);
71
+ const totalSteps = config.pages.reduce((sum, page) => sum + page.devices.length * runs, 0);
72
+ let completedSteps = 0;
32
73
  const session = await createChromeSession(config.chromePort);
33
74
  try {
34
75
  for (const page of config.pages) {
@@ -42,8 +83,16 @@ export async function runAuditsForConfig({ config, configPath, }) {
42
83
  label: page.label,
43
84
  device,
44
85
  port: session.port,
86
+ logLevel: config.logLevel ?? "error",
45
87
  });
46
88
  summaries.push(summary);
89
+ completedSteps += 1;
90
+ logProgress({
91
+ completed: completedSteps,
92
+ total: totalSteps,
93
+ path: page.path,
94
+ device,
95
+ });
47
96
  }
48
97
  results.push(aggregateSummaries(summaries));
49
98
  }
@@ -62,11 +111,25 @@ function buildUrl({ baseUrl, path, query }) {
62
111
  const queryPart = query && query.length > 0 ? query : "";
63
112
  return `${cleanBase}${cleanPath}${queryPart}`;
64
113
  }
114
+ function logProgress({ completed, total, path, device, }) {
115
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
116
+ const message = `Running audits ${completed}/${total} (${percentage}%) – ${path} [${device}]`;
117
+ if (typeof process !== "undefined" && process.stdout && typeof process.stdout.write === "function" && process.stdout.isTTY) {
118
+ const padded = message.padEnd(80, " ");
119
+ process.stdout.write(`\r${padded}`);
120
+ if (completed === total) {
121
+ process.stdout.write("\n");
122
+ }
123
+ return;
124
+ }
125
+ // eslint-disable-next-line no-console
126
+ console.log(message);
127
+ }
65
128
  async function runSingleAudit(params) {
66
129
  const options = {
67
130
  port: params.port,
68
131
  output: "json",
69
- logLevel: "error",
132
+ logLevel: params.logLevel,
70
133
  onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
71
134
  emulatedFormFactor: params.device,
72
135
  };
@@ -87,6 +150,8 @@ async function runSingleAudit(params) {
87
150
  scores,
88
151
  metrics,
89
152
  opportunities,
153
+ runtimeErrorCode: typeof lhr.runtimeError?.code === "string" ? lhr.runtimeError.code : undefined,
154
+ runtimeErrorMessage: typeof lhr.runtimeError?.message === "string" ? lhr.runtimeError.message : undefined,
90
155
  };
91
156
  }
92
157
  function extractScores(lhr) {
@@ -168,6 +233,8 @@ function aggregateSummaries(summaries) {
168
233
  scores: aggregateScores,
169
234
  metrics: aggregateMetrics,
170
235
  opportunities,
236
+ runtimeErrorCode: base.runtimeErrorCode,
237
+ runtimeErrorMessage: base.runtimeErrorMessage,
171
238
  };
172
239
  }
173
240
  function averageOf(values) {
@@ -246,7 +246,8 @@ async function selectDetectedRoutes(routes) {
246
246
  type: "multiselect",
247
247
  name: "indexes",
248
248
  message: "Select routes to include",
249
- instructions: false,
249
+ instructions: true,
250
+ hint: "Use Space to toggle, ↑/↓ to move, and Enter to confirm.",
250
251
  min: 1,
251
252
  choices: buildRouteChoices(routes),
252
253
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
6
  "type": "module",