apex-auditor 0.1.1 → 0.1.5

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
@@ -34,8 +34,8 @@ export async function runAuditCli(argv) {
34
34
  }
35
35
  function buildMarkdown(results) {
36
36
  const header = [
37
- "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Top issues |",
38
- "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|-----------|",
37
+ "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Error | Top issues |",
38
+ "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|-------|-----------|",
39
39
  ].join("\n");
40
40
  const lines = results.map((result) => buildRow(result));
41
41
  return `${header}\n${lines.join("\n")}`;
@@ -48,7 +48,8 @@ function buildRow(result) {
48
48
  const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs).toString() : "-";
49
49
  const cls = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
50
50
  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} |`;
51
+ const error = result.runtimeErrorCode ?? (result.runtimeErrorMessage !== undefined ? result.runtimeErrorMessage : "");
52
+ return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${error} | ${issues} |`;
52
53
  }
53
54
  function formatTopIssues(opportunities) {
54
55
  if (opportunities.length === 0) {
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,10 +1,12 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
1
3
  import lighthouse from "lighthouse";
2
- import chromeLauncher from "chrome-launcher";
4
+ import { launch as launchChrome } from "chrome-launcher";
3
5
  async function createChromeSession(chromePort) {
4
6
  if (typeof chromePort === "number") {
5
7
  return { port: chromePort };
6
8
  }
7
- const chrome = await chromeLauncher.launch({
9
+ const chrome = await launchChrome({
8
10
  chromeFlags: [
9
11
  "--headless=new",
10
12
  "--disable-gpu",
@@ -19,16 +21,53 @@ 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);
32
71
  const session = await createChromeSession(config.chromePort);
33
72
  try {
34
73
  for (const page of config.pages) {
@@ -42,6 +81,7 @@ export async function runAuditsForConfig({ config, configPath, }) {
42
81
  label: page.label,
43
82
  device,
44
83
  port: session.port,
84
+ logLevel: config.logLevel ?? "error",
45
85
  });
46
86
  summaries.push(summary);
47
87
  }
@@ -66,7 +106,7 @@ async function runSingleAudit(params) {
66
106
  const options = {
67
107
  port: params.port,
68
108
  output: "json",
69
- logLevel: "error",
109
+ logLevel: params.logLevel,
70
110
  onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
71
111
  emulatedFormFactor: params.device,
72
112
  };
@@ -87,6 +127,8 @@ async function runSingleAudit(params) {
87
127
  scores,
88
128
  metrics,
89
129
  opportunities,
130
+ runtimeErrorCode: typeof lhr.runtimeError?.code === "string" ? lhr.runtimeError.code : undefined,
131
+ runtimeErrorMessage: typeof lhr.runtimeError?.message === "string" ? lhr.runtimeError.message : undefined,
90
132
  };
91
133
  }
92
134
  function extractScores(lhr) {
@@ -168,6 +210,8 @@ function aggregateSummaries(summaries) {
168
210
  scores: aggregateScores,
169
211
  metrics: aggregateMetrics,
170
212
  opportunities,
213
+ runtimeErrorCode: base.runtimeErrorCode,
214
+ runtimeErrorMessage: base.runtimeErrorMessage,
171
215
  };
172
216
  }
173
217
  function averageOf(values) {
@@ -0,0 +1,92 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { pathExists } from "./fs-utils.js";
4
+ const DEFAULT_MAX_DEPTH = 3;
5
+ const IGNORED_DIRECTORIES = [
6
+ "node_modules",
7
+ ".git",
8
+ ".next",
9
+ ".turbo",
10
+ ".vercel",
11
+ "dist",
12
+ "build",
13
+ "out",
14
+ "coverage",
15
+ ".cache",
16
+ ];
17
+ /**
18
+ * Discover Next.js projects below the given repository root.
19
+ * This performs a shallow breadth-first search and looks for either
20
+ * a next.config.* file or a package.json that depends on "next".
21
+ */
22
+ export async function discoverNextProjects(options) {
23
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
24
+ const queue = [{ path: options.repoRoot, depth: 0 }];
25
+ const visited = new Set();
26
+ const projects = [];
27
+ while (queue.length > 0) {
28
+ const current = queue.shift();
29
+ if (visited.has(current.path)) {
30
+ continue;
31
+ }
32
+ visited.add(current.path);
33
+ if (await isNextProjectRoot(current.path)) {
34
+ const name = basename(current.path) || ".";
35
+ projects.push({ name, root: current.path, framework: "next" });
36
+ continue;
37
+ }
38
+ if (current.depth >= maxDepth) {
39
+ continue;
40
+ }
41
+ let entries;
42
+ try {
43
+ entries = await readdir(current.path, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) {
50
+ continue;
51
+ }
52
+ if (IGNORED_DIRECTORIES.includes(entry.name)) {
53
+ continue;
54
+ }
55
+ const childPath = join(current.path, entry.name);
56
+ queue.push({ path: childPath, depth: current.depth + 1 });
57
+ }
58
+ }
59
+ return projects;
60
+ }
61
+ async function isNextProjectRoot(directory) {
62
+ const nextConfigCandidates = [
63
+ "next.config.js",
64
+ "next.config.mjs",
65
+ "next.config.cjs",
66
+ "next.config.ts",
67
+ ];
68
+ for (const candidate of nextConfigCandidates) {
69
+ const configPath = join(directory, candidate);
70
+ if (await pathExists(configPath)) {
71
+ return true;
72
+ }
73
+ }
74
+ const packageJsonPath = join(directory, "package.json");
75
+ if (!(await pathExists(packageJsonPath))) {
76
+ return false;
77
+ }
78
+ try {
79
+ const raw = await readFile(packageJsonPath, "utf8");
80
+ const parsed = JSON.parse(raw);
81
+ if (!parsed || typeof parsed !== "object") {
82
+ return false;
83
+ }
84
+ const maybePackage = parsed;
85
+ const hasNextDependency = Boolean((maybePackage.dependencies && typeof maybePackage.dependencies.next === "string") ||
86
+ (maybePackage.devDependencies && typeof maybePackage.devDependencies.next === "string"));
87
+ return hasNextDependency;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
@@ -42,15 +42,43 @@ export async function detectRoutes(options) {
42
42
  function createNextAppDetector() {
43
43
  return {
44
44
  id: SOURCE_NEXT_APP,
45
- canDetect: async (options) => pathExists(join(options.projectRoot, "app")),
46
- detect: async (options) => detectAppRoutes(join(options.projectRoot, "app"), options.limit),
45
+ canDetect: async (options) => {
46
+ const roots = await findNextAppRoots(options.projectRoot);
47
+ return roots.length > 0;
48
+ },
49
+ detect: async (options) => {
50
+ const roots = await findNextAppRoots(options.projectRoot);
51
+ const allRoutes = [];
52
+ for (const root of roots) {
53
+ const routes = await detectAppRoutes(root, options.limit);
54
+ allRoutes.push(...routes);
55
+ if (allRoutes.length >= options.limit) {
56
+ break;
57
+ }
58
+ }
59
+ return allRoutes;
60
+ },
47
61
  };
48
62
  }
49
63
  function createNextPagesDetector() {
50
64
  return {
51
65
  id: SOURCE_NEXT_PAGES,
52
- canDetect: async (options) => pathExists(join(options.projectRoot, "pages")),
53
- detect: async (options) => detectPagesRoutes(join(options.projectRoot, "pages"), options.limit),
66
+ canDetect: async (options) => {
67
+ const roots = await findNextPagesRoots(options.projectRoot);
68
+ return roots.length > 0;
69
+ },
70
+ detect: async (options) => {
71
+ const roots = await findNextPagesRoots(options.projectRoot);
72
+ const allRoutes = [];
73
+ for (const root of roots) {
74
+ const routes = await detectPagesRoutes(root, options.limit);
75
+ allRoutes.push(...routes);
76
+ if (allRoutes.length >= options.limit) {
77
+ break;
78
+ }
79
+ }
80
+ return allRoutes;
81
+ },
54
82
  };
55
83
  }
56
84
  function createRemixRoutesDetector() {
@@ -67,6 +95,90 @@ function createSpaHtmlDetector() {
67
95
  detect: async (options) => detectSpaRoutes(options.projectRoot, options.limit),
68
96
  };
69
97
  }
98
+ async function findNextAppRoots(projectRoot) {
99
+ const roots = [];
100
+ const seen = new Set();
101
+ const addRoot = (candidate) => {
102
+ if (!seen.has(candidate)) {
103
+ seen.add(candidate);
104
+ roots.push(candidate);
105
+ }
106
+ };
107
+ const directCandidates = [
108
+ join(projectRoot, "app"),
109
+ join(projectRoot, "src", "app"),
110
+ ];
111
+ for (const candidate of directCandidates) {
112
+ if (await pathExists(candidate)) {
113
+ addRoot(candidate);
114
+ }
115
+ }
116
+ const containers = ["apps", "packages"];
117
+ for (const container of containers) {
118
+ const containerPath = join(projectRoot, container);
119
+ if (!(await pathExists(containerPath))) {
120
+ continue;
121
+ }
122
+ const entries = await readdir(containerPath, { withFileTypes: true });
123
+ for (const entry of entries) {
124
+ if (!entry.isDirectory()) {
125
+ continue;
126
+ }
127
+ const appRoot = join(containerPath, entry.name, "app");
128
+ const srcAppRoot = join(containerPath, entry.name, "src", "app");
129
+ if (await pathExists(appRoot)) {
130
+ addRoot(appRoot);
131
+ continue;
132
+ }
133
+ if (await pathExists(srcAppRoot)) {
134
+ addRoot(srcAppRoot);
135
+ }
136
+ }
137
+ }
138
+ return roots;
139
+ }
140
+ async function findNextPagesRoots(projectRoot) {
141
+ const roots = [];
142
+ const seen = new Set();
143
+ const addRoot = (candidate) => {
144
+ if (!seen.has(candidate)) {
145
+ seen.add(candidate);
146
+ roots.push(candidate);
147
+ }
148
+ };
149
+ const directCandidates = [
150
+ join(projectRoot, "pages"),
151
+ join(projectRoot, "src", "pages"),
152
+ ];
153
+ for (const candidate of directCandidates) {
154
+ if (await pathExists(candidate)) {
155
+ addRoot(candidate);
156
+ }
157
+ }
158
+ const containers = ["apps", "packages"];
159
+ for (const container of containers) {
160
+ const containerPath = join(projectRoot, container);
161
+ if (!(await pathExists(containerPath))) {
162
+ continue;
163
+ }
164
+ const entries = await readdir(containerPath, { withFileTypes: true });
165
+ for (const entry of entries) {
166
+ if (!entry.isDirectory()) {
167
+ continue;
168
+ }
169
+ const pagesRoot = join(containerPath, entry.name, "pages");
170
+ const srcPagesRoot = join(containerPath, entry.name, "src", "pages");
171
+ if (await pathExists(pagesRoot)) {
172
+ addRoot(pagesRoot);
173
+ continue;
174
+ }
175
+ if (await pathExists(srcPagesRoot)) {
176
+ addRoot(srcPagesRoot);
177
+ }
178
+ }
179
+ }
180
+ return roots;
181
+ }
70
182
  async function detectAppRoutes(appRoot, limit) {
71
183
  const files = await collectRouteFiles(appRoot, limit, isAppPageFile);
72
184
  return files.map((file) => buildRoute(file, appRoot, formatAppRoutePath, SOURCE_NEXT_APP));
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import prompts from "prompts";
4
4
  import { detectRoutes } from "./route-detectors.js";
5
5
  import { pathExists } from "./fs-utils.js";
6
+ import { discoverNextProjects } from "./project-discovery.js";
6
7
  const PROFILE_TO_DETECTOR = {
7
8
  "next-app": "next-app",
8
9
  "next-pages": "next-pages",
@@ -194,18 +195,44 @@ async function maybeDetectPages(profile) {
194
195
  }
195
196
  const preferredDetector = await selectDetector(profile);
196
197
  const projectRootAnswer = await ask(projectRootQuestion);
197
- const absoluteRoot = resolve(projectRootAnswer.projectRoot);
198
- if (!(await pathExists(absoluteRoot))) {
199
- console.log(`No project found at ${absoluteRoot}. Skipping auto-detection.`);
198
+ const repoRoot = resolve(projectRootAnswer.projectRoot);
199
+ if (!(await pathExists(repoRoot))) {
200
+ console.log(`No project found at ${repoRoot}. Skipping auto-detection.`);
200
201
  return [];
201
202
  }
202
- const routes = await detectRoutes({ projectRoot: absoluteRoot, preferredDetectorId: preferredDetector });
203
+ const detectionRoot = await chooseDetectionRoot({ profile, repoRoot });
204
+ const routes = await detectRoutes({ projectRoot: detectionRoot, preferredDetectorId: preferredDetector });
203
205
  if (routes.length === 0) {
204
206
  console.log("No routes detected. Add pages manually.");
205
207
  return [];
206
208
  }
207
209
  return selectDetectedRoutes(routes);
208
210
  }
211
+ async function chooseDetectionRoot({ profile, repoRoot, }) {
212
+ if (profile !== "next-app" && profile !== "next-pages") {
213
+ return repoRoot;
214
+ }
215
+ const projects = await discoverNextProjects({ repoRoot });
216
+ if (projects.length === 0) {
217
+ return repoRoot;
218
+ }
219
+ if (projects.length === 1) {
220
+ const onlyProject = projects[0];
221
+ console.log(`Detected Next.js app at ${onlyProject.root}.`);
222
+ return onlyProject.root;
223
+ }
224
+ const choices = projects.map((project) => ({
225
+ title: `${project.name} (${project.root})`,
226
+ value: project.root,
227
+ }));
228
+ const answer = await ask({
229
+ type: "select",
230
+ name: "projectRoot",
231
+ message: "Multiple Next.js apps found. Which one do you want to audit?",
232
+ choices,
233
+ });
234
+ return answer.projectRoot ?? repoRoot;
235
+ }
209
236
  async function selectDetector(profile) {
210
237
  const preset = PROFILE_TO_DETECTOR[profile];
211
238
  if (preset) {
@@ -219,7 +246,8 @@ async function selectDetectedRoutes(routes) {
219
246
  type: "multiselect",
220
247
  name: "indexes",
221
248
  message: "Select routes to include",
222
- instructions: false,
249
+ instructions: true,
250
+ hint: "Use Space to toggle, ↑/↓ to move, and Enter to confirm.",
223
251
  min: 1,
224
252
  choices: buildRouteChoices(routes),
225
253
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.1.1",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
6
  "type": "module",
@@ -18,8 +18,8 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "chrome-launcher": "^0.15.2",
22
- "lighthouse": "^12.6.1",
21
+ "chrome-launcher": "^1.2.1",
22
+ "lighthouse": "^13.0.1",
23
23
  "prompts": "^2.4.2"
24
24
  },
25
25
  "devDependencies": {
@@ -27,6 +27,6 @@
27
27
  "typescript": "^5.9.3",
28
28
  "@types/node": "^22.0.0",
29
29
  "@types/prompts": "^2.4.9",
30
- "vitest": "^1.3.1"
30
+ "vitest": "^4.0.14"
31
31
  }
32
32
  }