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.
- package/dist/lighthouse-runner.js +46 -15
- package/dist/project-discovery.js +92 -0
- package/dist/route-detectors.js +116 -4
- package/dist/wizard-cli.js +32 -15
- package/package.json +4 -3
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/route-detectors.js
CHANGED
|
@@ -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) =>
|
|
46
|
-
|
|
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) =>
|
|
53
|
-
|
|
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));
|
package/dist/wizard-cli.js
CHANGED
|
@@ -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
|
|
207
|
-
if (!(await pathExists(
|
|
208
|
-
console.log(`No project found at ${
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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": "^
|
|
30
|
+
"vitest": "^4.0.14"
|
|
30
31
|
}
|
|
31
32
|
}
|