apex-auditor 0.1.0 → 0.1.3

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.
@@ -1,26 +1,57 @@
1
1
  import lighthouse from "lighthouse";
2
+ import { launch as launchChrome } from "chrome-launcher";
3
+ async function createChromeSession(chromePort) {
4
+ if (typeof chromePort === "number") {
5
+ return { port: chromePort };
6
+ }
7
+ const chrome = await launchChrome({
8
+ chromeFlags: [
9
+ "--headless=new",
10
+ "--disable-gpu",
11
+ "--no-sandbox",
12
+ "--disable-dev-shm-usage",
13
+ "--disable-extensions",
14
+ "--disable-default-apps",
15
+ "--no-first-run",
16
+ "--no-default-browser-check",
17
+ ],
18
+ });
19
+ return {
20
+ port: chrome.port,
21
+ close: async () => {
22
+ await chrome.kill();
23
+ },
24
+ };
25
+ }
2
26
  /**
3
27
  * Run audits for all pages defined in the config and return a structured summary.
4
28
  */
5
29
  export async function runAuditsForConfig({ config, configPath, }) {
6
- const port = config.chromePort ?? 9222;
7
30
  const runs = config.runs ?? 1;
8
31
  const results = [];
9
- for (const page of config.pages) {
10
- for (const device of page.devices) {
11
- const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
12
- const summaries = [];
13
- for (let i = 0; i < runs; i += 1) {
14
- const summary = await runSingleAudit({
15
- url,
16
- path: page.path,
17
- label: page.label,
18
- device,
19
- port,
20
- });
21
- summaries.push(summary);
32
+ const session = await createChromeSession(config.chromePort);
33
+ try {
34
+ for (const page of config.pages) {
35
+ for (const device of page.devices) {
36
+ const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
37
+ const summaries = [];
38
+ for (let index = 0; index < runs; index += 1) {
39
+ const summary = await runSingleAudit({
40
+ url,
41
+ path: page.path,
42
+ label: page.label,
43
+ device,
44
+ port: session.port,
45
+ });
46
+ summaries.push(summary);
47
+ }
48
+ results.push(aggregateSummaries(summaries));
22
49
  }
23
- results.push(aggregateSummaries(summaries));
50
+ }
51
+ }
52
+ finally {
53
+ if (session.close) {
54
+ await session.close();
24
55
  }
25
56
  }
26
57
  return { configPath, results };
@@ -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",
@@ -11,9 +12,8 @@ const PROFILE_TO_DETECTOR = {
11
12
  custom: undefined,
12
13
  };
13
14
  const DEFAULT_BASE_URL = "http://localhost:3000";
14
- const DEFAULT_CHROME_PORT = 9222;
15
15
  const DEFAULT_RUNS = 1;
16
- const DEFAULT_PROJECT_ROOT = "..";
16
+ const DEFAULT_PROJECT_ROOT = ".";
17
17
  const DEFAULT_PRESELECT_COUNT = 5;
18
18
  const DEFAULT_DEVICES = ["mobile", "desktop"];
19
19
  const PROMPT_OPTIONS = { onCancel: handleCancel };
@@ -49,13 +49,6 @@ const baseQuestions = [
49
49
  message: "Query string appended to every route (optional)",
50
50
  initial: "",
51
51
  },
52
- {
53
- type: "number",
54
- name: "chromePort",
55
- message: "Chrome remote debugging port",
56
- initial: DEFAULT_CHROME_PORT,
57
- min: 1,
58
- },
59
52
  {
60
53
  type: "number",
61
54
  name: "runs",
@@ -168,7 +161,6 @@ async function collectBaseAnswers() {
168
161
  return {
169
162
  baseUrl: answers.baseUrl.trim(),
170
163
  query: answers.query && answers.query.length > 0 ? answers.query : undefined,
171
- chromePort: answers.chromePort,
172
164
  runs: answers.runs,
173
165
  };
174
166
  }
@@ -203,18 +195,44 @@ async function maybeDetectPages(profile) {
203
195
  }
204
196
  const preferredDetector = await selectDetector(profile);
205
197
  const projectRootAnswer = await ask(projectRootQuestion);
206
- const absoluteRoot = resolve(projectRootAnswer.projectRoot);
207
- if (!(await pathExists(absoluteRoot))) {
208
- 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.`);
209
201
  return [];
210
202
  }
211
- const routes = await detectRoutes({ projectRoot: absoluteRoot, preferredDetectorId: preferredDetector });
203
+ const detectionRoot = await chooseDetectionRoot({ profile, repoRoot });
204
+ const routes = await detectRoutes({ projectRoot: detectionRoot, preferredDetectorId: preferredDetector });
212
205
  if (routes.length === 0) {
213
206
  console.log("No routes detected. Add pages manually.");
214
207
  return [];
215
208
  }
216
209
  return selectDetectedRoutes(routes);
217
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
+ }
218
236
  async function selectDetector(profile) {
219
237
  const preset = PROFILE_TO_DETECTOR[profile];
220
238
  if (preset) {
@@ -261,7 +279,6 @@ async function buildConfig() {
261
279
  return {
262
280
  baseUrl: baseAnswers.baseUrl,
263
281
  query: baseAnswers.query,
264
- chromePort: baseAnswers.chromePort,
265
282
  runs: baseAnswers.runs,
266
283
  pages,
267
284
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
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,7 +18,8 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "lighthouse": "^12.6.1",
21
+ "chrome-launcher": "^1.2.1",
22
+ "lighthouse": "^13.0.1",
22
23
  "prompts": "^2.4.2"
23
24
  },
24
25
  "devDependencies": {
@@ -26,6 +27,6 @@
26
27
  "typescript": "^5.9.3",
27
28
  "@types/node": "^22.0.0",
28
29
  "@types/prompts": "^2.4.9",
29
- "vitest": "^1.3.1"
30
+ "vitest": "^4.0.14"
30
31
  }
31
32
  }