laxy-verify 1.2.1 → 1.2.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/README.md +193 -64
- package/dist/a11y-deep.d.ts +20 -0
- package/dist/a11y-deep.js +161 -0
- package/dist/ai-analysis.d.ts +28 -0
- package/dist/ai-analysis.js +32 -0
- package/dist/audit/broken-links.d.ts +5 -1
- package/dist/audit/broken-links.js +23 -12
- package/dist/bundle-size.d.ts +14 -0
- package/dist/bundle-size.js +209 -0
- package/dist/cli.js +432 -16
- package/dist/compare-env.d.ts +23 -0
- package/dist/compare-env.js +55 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +149 -4
- package/dist/entitlement.d.ts +2 -0
- package/dist/entitlement.js +5 -1
- package/dist/init-analysis.d.ts +6 -0
- package/dist/init-analysis.js +302 -0
- package/dist/init.js +66 -0
- package/dist/lighthouse.d.ts +31 -1
- package/dist/lighthouse.js +76 -3
- package/dist/outdated-check.d.ts +17 -0
- package/dist/outdated-check.js +123 -0
- package/dist/report-markdown.d.ts +14 -0
- package/dist/report-markdown.js +21 -0
- package/dist/route-discovery.d.ts +7 -0
- package/dist/route-discovery.js +108 -0
- package/dist/secret-scan.d.ts +15 -0
- package/dist/secret-scan.js +218 -0
- package/dist/security-audit.d.ts +9 -1
- package/dist/security-audit.js +87 -24
- package/dist/seo-deep.d.ts +24 -0
- package/dist/seo-deep.js +147 -0
- package/dist/typecheck.d.ts +8 -0
- package/dist/typecheck.js +99 -0
- package/dist/verification-core/report.js +117 -0
- package/dist/verification-core/types.d.ts +58 -2
- package/dist/visual-diff.d.ts +8 -1
- package/dist/visual-diff.js +53 -8
- package/dist/vitals-budget.d.ts +23 -0
- package/dist/vitals-budget.js +168 -0
- package/package.json +1 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.analyzeProjectForInit = analyzeProjectForInit;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
40
|
+
const IGNORED_DIRS = new Set([
|
|
41
|
+
".git",
|
|
42
|
+
".next",
|
|
43
|
+
".turbo",
|
|
44
|
+
".vercel",
|
|
45
|
+
".output",
|
|
46
|
+
".laxy-verify",
|
|
47
|
+
"coverage",
|
|
48
|
+
"dist",
|
|
49
|
+
"build",
|
|
50
|
+
"node_modules",
|
|
51
|
+
]);
|
|
52
|
+
function isSourceFile(filePath) {
|
|
53
|
+
return SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
function normalizeRoute(route) {
|
|
56
|
+
const cleaned = route
|
|
57
|
+
.replace(/\\u002F/gi, "/")
|
|
58
|
+
.replace(/\\\//g, "/")
|
|
59
|
+
.trim();
|
|
60
|
+
if (!cleaned.startsWith("/"))
|
|
61
|
+
return null;
|
|
62
|
+
const normalized = cleaned.split("?")[0]?.split("#")[0]?.replace(/\/+/g, "/") ?? "/";
|
|
63
|
+
if (!normalized || normalized === "/")
|
|
64
|
+
return "/";
|
|
65
|
+
if (normalized.startsWith("/_next/") || normalized.startsWith("/api/"))
|
|
66
|
+
return null;
|
|
67
|
+
if (/[.*:[\]]/.test(normalized))
|
|
68
|
+
return null;
|
|
69
|
+
if (/\.[a-z0-9]{2,8}$/i.test(normalized))
|
|
70
|
+
return null;
|
|
71
|
+
if (/\s/.test(normalized))
|
|
72
|
+
return null;
|
|
73
|
+
return normalized.endsWith("/") && normalized !== "/" ? normalized.slice(0, -1) : normalized;
|
|
74
|
+
}
|
|
75
|
+
function collectSourceFiles(rootDir, currentDir = rootDir, acc = []) {
|
|
76
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (IGNORED_DIRS.has(entry.name))
|
|
81
|
+
continue;
|
|
82
|
+
collectSourceFiles(rootDir, fullPath, acc);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (entry.isFile() && isSourceFile(fullPath)) {
|
|
86
|
+
acc.push(fullPath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return acc;
|
|
90
|
+
}
|
|
91
|
+
function readFileSafe(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function routeFromNextAppFile(filePath, baseDir) {
|
|
100
|
+
const relative = path.relative(baseDir, filePath).replace(/\\/g, "/");
|
|
101
|
+
if (!relative.startsWith("app/"))
|
|
102
|
+
return null;
|
|
103
|
+
if (!/\/page\.(t|j)sx?$/.test(relative) && !relative.endsWith("/page.mdx"))
|
|
104
|
+
return null;
|
|
105
|
+
const segments = relative
|
|
106
|
+
.replace(/^app\//, "")
|
|
107
|
+
.replace(/\/page\.(t|j)sx?$/, "")
|
|
108
|
+
.replace(/\/page\.mdx$/, "")
|
|
109
|
+
.split("/")
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.filter((segment) => !segment.startsWith("("))
|
|
112
|
+
.filter((segment) => !segment.startsWith("@"))
|
|
113
|
+
.filter((segment) => segment !== "page")
|
|
114
|
+
.flatMap((segment) => {
|
|
115
|
+
if (segment === "index")
|
|
116
|
+
return [];
|
|
117
|
+
const cleaned = segment.replace(/\.(t|j)sx?$/, "");
|
|
118
|
+
if (cleaned.startsWith("(") || cleaned.startsWith("[["))
|
|
119
|
+
return [];
|
|
120
|
+
if (cleaned.startsWith("["))
|
|
121
|
+
return [];
|
|
122
|
+
return cleaned ? [cleaned] : [];
|
|
123
|
+
});
|
|
124
|
+
const route = `/${segments.join("/")}`;
|
|
125
|
+
return normalizeRoute(route);
|
|
126
|
+
}
|
|
127
|
+
function routeFromNextPagesFile(filePath, baseDir) {
|
|
128
|
+
const relative = path.relative(baseDir, filePath).replace(/\\/g, "/");
|
|
129
|
+
if (!relative.startsWith("pages/"))
|
|
130
|
+
return null;
|
|
131
|
+
if (!/\.(t|j)sx?$/.test(relative))
|
|
132
|
+
return null;
|
|
133
|
+
if (/^pages\/api\//.test(relative))
|
|
134
|
+
return null;
|
|
135
|
+
const withoutExt = relative.replace(/^pages\//, "").replace(/\.(t|j)sx?$/, "");
|
|
136
|
+
const segments = withoutExt
|
|
137
|
+
.split("/")
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.filter((segment) => segment !== "index")
|
|
140
|
+
.filter((segment) => !segment.startsWith("["));
|
|
141
|
+
const route = `/${segments.join("/")}`;
|
|
142
|
+
return normalizeRoute(route);
|
|
143
|
+
}
|
|
144
|
+
function collectRoutesFromFiles(rootDir, files) {
|
|
145
|
+
const routes = new Map();
|
|
146
|
+
for (const filePath of files) {
|
|
147
|
+
const nextAppRoute = routeFromNextAppFile(filePath, rootDir);
|
|
148
|
+
if (nextAppRoute)
|
|
149
|
+
routes.set(nextAppRoute, filePath);
|
|
150
|
+
const nextPagesRoute = routeFromNextPagesFile(filePath, rootDir);
|
|
151
|
+
if (nextPagesRoute)
|
|
152
|
+
routes.set(nextPagesRoute, filePath);
|
|
153
|
+
const content = readFileSafe(filePath);
|
|
154
|
+
if (!content)
|
|
155
|
+
continue;
|
|
156
|
+
const routeRegexes = [
|
|
157
|
+
/(?:path|to|href|router\.push|navigate)\s*\(?\s*[:=]?\s*["'`]((?:\/(?!\/)[^"'`?#]+))["'`]/g,
|
|
158
|
+
/<Route[^>]*path=["'`]((?:\/(?!\/)[^"'`?#]+))["'`]/g,
|
|
159
|
+
];
|
|
160
|
+
for (const regex of routeRegexes) {
|
|
161
|
+
for (const match of content.matchAll(regex)) {
|
|
162
|
+
const route = normalizeRoute(match[1] ?? "");
|
|
163
|
+
if (route)
|
|
164
|
+
routes.set(route, filePath);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return Array.from(routes.entries())
|
|
169
|
+
.map(([route, source]) => ({ route, source }))
|
|
170
|
+
.sort((a, b) => a.route.localeCompare(b.route));
|
|
171
|
+
}
|
|
172
|
+
function buildSelectorHints(files) {
|
|
173
|
+
const contents = files.map(readFileSafe).join("\n");
|
|
174
|
+
const has = (pattern) => pattern.test(contents);
|
|
175
|
+
const dataTestIds = Array.from(contents.matchAll(/data-testid=["'`]([^"'`]+)["'`]/g)).map((match) => match[1]);
|
|
176
|
+
const dashboardDataTestId = dataTestIds.find((id) => /(dashboard|workspace|overview|app)/i.test(id));
|
|
177
|
+
const authDataTestId = dataTestIds.find((id) => /(login|signin|auth|account)/i.test(id));
|
|
178
|
+
return {
|
|
179
|
+
email: has(/name=["'`]email["'`]/i)
|
|
180
|
+
? "input[name=email]"
|
|
181
|
+
: has(/type=["'`]email["'`]/i)
|
|
182
|
+
? "input[type=email]"
|
|
183
|
+
: "input[name=email]",
|
|
184
|
+
password: has(/name=["'`](password|passcode)["'`]/i)
|
|
185
|
+
? "input[name=password]"
|
|
186
|
+
: has(/type=["'`]password["'`]/i)
|
|
187
|
+
? "input[type=password]"
|
|
188
|
+
: "input[name=password]",
|
|
189
|
+
submit: has(/type=["'`]submit["'`]/i)
|
|
190
|
+
? "button[type=submit]"
|
|
191
|
+
: has(/(sign in|login|continue|submit)/i)
|
|
192
|
+
? "button"
|
|
193
|
+
: "button[type=submit]",
|
|
194
|
+
search: has(/name=["'`](q|query|search)["'`]/i)
|
|
195
|
+
? "input[name=search]"
|
|
196
|
+
: has(/placeholder=["'`][^"'`]*(search|검색)[^"'`]*["'`]/i)
|
|
197
|
+
? "input[type=search]"
|
|
198
|
+
: "input[type=search]",
|
|
199
|
+
primaryInput: has(/textarea/i) ? "textarea" : "input",
|
|
200
|
+
dashboardVisible: dashboardDataTestId ? `[data-testid=${dashboardDataTestId}]` : "main",
|
|
201
|
+
authVisible: authDataTestId ? `[data-testid=${authDataTestId}]` : "main",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function pickFirstRoute(routes, patterns) {
|
|
205
|
+
return routes.find((route) => patterns.some((pattern) => pattern.test(route)));
|
|
206
|
+
}
|
|
207
|
+
function uniqueRoutes(routes) {
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
const result = [];
|
|
210
|
+
for (const route of routes) {
|
|
211
|
+
if (!route)
|
|
212
|
+
continue;
|
|
213
|
+
const normalized = normalizeRoute(route);
|
|
214
|
+
if (!normalized || normalized === "/" || seen.has(normalized))
|
|
215
|
+
continue;
|
|
216
|
+
seen.add(normalized);
|
|
217
|
+
result.push(normalized);
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
function analyzeProjectForInit(dir) {
|
|
222
|
+
const files = collectSourceFiles(dir);
|
|
223
|
+
const routeEntries = collectRoutesFromFiles(dir, files);
|
|
224
|
+
const routes = uniqueRoutes(routeEntries.map((entry) => entry.route));
|
|
225
|
+
const selectors = buildSelectorHints(files);
|
|
226
|
+
const loginRoute = pickFirstRoute(routes, [/\/login$/, /\/signin$/, /\/sign-in$/, /\/auth/]);
|
|
227
|
+
const dashboardRoute = pickFirstRoute(routes, [/\/dashboard$/, /\/app$/, /\/workspace$/, /\/overview$/, /\/home$/, /\/projects?$/]);
|
|
228
|
+
const signupRoute = pickFirstRoute(routes, [/\/signup$/, /\/register$/, /\/join$/, /\/sign-up$/]);
|
|
229
|
+
const searchRoute = pickFirstRoute(routes, [/\/search$/, /\/discover$/, /\/explore$/, /\/products$/, /\/items$/]);
|
|
230
|
+
const settingsRoute = pickFirstRoute(routes, [/\/settings$/, /\/profile$/, /\/account$/, /\/billing$/]);
|
|
231
|
+
const scenarios = [];
|
|
232
|
+
if (loginRoute) {
|
|
233
|
+
scenarios.push({
|
|
234
|
+
name: "로그인 후 보호 페이지 접근",
|
|
235
|
+
steps: [
|
|
236
|
+
{ goto: loginRoute },
|
|
237
|
+
{ fill: selectors.email, with: "test@example.com" },
|
|
238
|
+
{ fill: selectors.password, with: "testpass123!" },
|
|
239
|
+
{ click: selectors.submit },
|
|
240
|
+
dashboardRoute
|
|
241
|
+
? { goto: dashboardRoute }
|
|
242
|
+
: { wait: 1200 },
|
|
243
|
+
{ expect_visible: dashboardRoute ? selectors.dashboardVisible : "body" },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (signupRoute && scenarios.length < 3) {
|
|
248
|
+
scenarios.push({
|
|
249
|
+
name: "회원가입 주요 폼 제출",
|
|
250
|
+
steps: [
|
|
251
|
+
{ goto: signupRoute },
|
|
252
|
+
{ fill: selectors.email, with: "test@example.com" },
|
|
253
|
+
{ fill: selectors.password, with: "testpass123!" },
|
|
254
|
+
{ click: selectors.submit },
|
|
255
|
+
{ expect_visible: selectors.authVisible || "body" },
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (searchRoute && scenarios.length < 3) {
|
|
260
|
+
scenarios.push({
|
|
261
|
+
name: "핵심 탐색 흐름 확인",
|
|
262
|
+
steps: [
|
|
263
|
+
{ goto: searchRoute },
|
|
264
|
+
{ fill: selectors.search, with: "test query" },
|
|
265
|
+
{ click: selectors.submit },
|
|
266
|
+
{ expect_visible: "body" },
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (settingsRoute && scenarios.length < 3) {
|
|
271
|
+
scenarios.push({
|
|
272
|
+
name: "설정 화면 렌더 확인",
|
|
273
|
+
steps: [
|
|
274
|
+
{ goto: settingsRoute },
|
|
275
|
+
{ expect_visible: "body" },
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (dashboardRoute && scenarios.length < 3) {
|
|
280
|
+
scenarios.push({
|
|
281
|
+
name: "대시보드 기본 렌더",
|
|
282
|
+
steps: [
|
|
283
|
+
{ goto: dashboardRoute },
|
|
284
|
+
{ expect_visible: selectors.dashboardVisible || "main" },
|
|
285
|
+
],
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (scenarios.length === 0) {
|
|
289
|
+
const firstRoute = routes[0] ?? "/";
|
|
290
|
+
scenarios.push({
|
|
291
|
+
name: "핵심 페이지 기본 렌더",
|
|
292
|
+
steps: [
|
|
293
|
+
{ goto: firstRoute },
|
|
294
|
+
{ expect_visible: "body" },
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
scenarios: scenarios.slice(0, 3),
|
|
300
|
+
extraRoutes: routes.slice(0, 10),
|
|
301
|
+
};
|
|
302
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -37,6 +37,22 @@ exports.runInit = runInit;
|
|
|
37
37
|
const fs = __importStar(require("node:fs"));
|
|
38
38
|
const path = __importStar(require("node:path"));
|
|
39
39
|
const detect_js_1 = require("./detect.js");
|
|
40
|
+
const init_analysis_js_1 = require("./init-analysis.js");
|
|
41
|
+
function renderStringList(items, indent = " ") {
|
|
42
|
+
return items.map((item) => `${indent}- ${item}`).join("\n");
|
|
43
|
+
}
|
|
44
|
+
function renderScenarios() {
|
|
45
|
+
return `scenarios:
|
|
46
|
+
- name: "로그인 후 대시보드 접근"
|
|
47
|
+
steps:
|
|
48
|
+
- goto: /login
|
|
49
|
+
- fill: input[name=email]
|
|
50
|
+
with: test@example.com
|
|
51
|
+
- fill: input[name=password]
|
|
52
|
+
with: testpass123!
|
|
53
|
+
- click: button[type=submit]
|
|
54
|
+
- expect_visible: main`;
|
|
55
|
+
}
|
|
40
56
|
function runInit(dir) {
|
|
41
57
|
const laxyYmlPath = path.join(dir, ".laxy.yml");
|
|
42
58
|
const workflowDir = path.join(dir, ".github", "workflows");
|
|
@@ -48,6 +64,7 @@ function runInit(dir) {
|
|
|
48
64
|
else {
|
|
49
65
|
let detectedFramework = null;
|
|
50
66
|
let detectedPort = 3000;
|
|
67
|
+
const initAnalysis = (0, init_analysis_js_1.analyzeProjectForInit)(dir);
|
|
51
68
|
try {
|
|
52
69
|
const detected = (0, detect_js_1.detect)(dir);
|
|
53
70
|
detectedFramework = detected.framework ?? "auto";
|
|
@@ -56,20 +73,69 @@ function runInit(dir) {
|
|
|
56
73
|
catch {
|
|
57
74
|
// Keep defaults when auto-detection fails.
|
|
58
75
|
}
|
|
76
|
+
const extraRoutesBlock = initAnalysis.extraRoutes.length > 0
|
|
77
|
+
? `extra_routes:\n${renderStringList(initAnalysis.extraRoutes)}\n\n`
|
|
78
|
+
: "";
|
|
79
|
+
const scenarioBlocks = initAnalysis.scenarios.length > 0
|
|
80
|
+
? [
|
|
81
|
+
"scenarios:",
|
|
82
|
+
...initAnalysis.scenarios.flatMap((scenario) => [
|
|
83
|
+
` - name: "${scenario.name.replace(/"/g, '\\"')}"`,
|
|
84
|
+
" steps:",
|
|
85
|
+
...scenario.steps.flatMap((step) => {
|
|
86
|
+
if (step.goto)
|
|
87
|
+
return [` - goto: ${step.goto}`];
|
|
88
|
+
if (step.fill && step.with !== undefined) {
|
|
89
|
+
return [
|
|
90
|
+
` - fill: ${step.fill}`,
|
|
91
|
+
` with: ${step.with}`,
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
if (step.click)
|
|
95
|
+
return [` - click: ${step.click}`];
|
|
96
|
+
if (step.expect_visible)
|
|
97
|
+
return [` - expect_visible: ${step.expect_visible}`];
|
|
98
|
+
if (step.expect_text)
|
|
99
|
+
return [` - expect_text: ${step.expect_text}`];
|
|
100
|
+
if (step.wait !== undefined)
|
|
101
|
+
return [` - wait: ${step.wait}`];
|
|
102
|
+
return [];
|
|
103
|
+
}),
|
|
104
|
+
]),
|
|
105
|
+
].join("\n")
|
|
106
|
+
: renderScenarios();
|
|
59
107
|
const ymlContent = `# Generated by laxy-verify --init
|
|
60
108
|
# See https://github.com/SUNgm24/Laxy/tree/main/laxy-verify for full docs
|
|
61
109
|
framework: ${detectedFramework} # auto-detected
|
|
62
110
|
port: ${detectedPort}
|
|
63
111
|
fail_on: bronze
|
|
112
|
+
lighthouse_runs: 3
|
|
64
113
|
|
|
65
114
|
thresholds:
|
|
66
115
|
performance: 70
|
|
67
116
|
accessibility: 85
|
|
68
117
|
seo: 80
|
|
69
118
|
best_practices: 80
|
|
119
|
+
|
|
120
|
+
${extraRoutesBlock}visual_diff:
|
|
121
|
+
pixelmatch_threshold: 0.1
|
|
122
|
+
warn_threshold: 30
|
|
123
|
+
rollback_threshold: 60
|
|
124
|
+
disable_animations: true
|
|
125
|
+
ignore_selectors:
|
|
126
|
+
- "[data-dynamic]"
|
|
127
|
+
- "[data-ignore-diff]"
|
|
128
|
+
|
|
129
|
+
${scenarioBlocks}
|
|
70
130
|
`;
|
|
71
131
|
fs.writeFileSync(laxyYmlPath, ymlContent, "utf-8");
|
|
72
132
|
console.log("Created .laxy.yml");
|
|
133
|
+
if (initAnalysis.scenarios.length > 0) {
|
|
134
|
+
console.log(`Seeded ${initAnalysis.scenarios.length} draft E2E scenario(s) from your app structure.`);
|
|
135
|
+
}
|
|
136
|
+
if (initAnalysis.extraRoutes.length > 0) {
|
|
137
|
+
console.log(`Detected ${initAnalysis.extraRoutes.length} extra route(s) for SPA / multi-page coverage.`);
|
|
138
|
+
}
|
|
73
139
|
}
|
|
74
140
|
if (fs.existsSync(workflowPath)) {
|
|
75
141
|
console.log(".github/workflows/laxy-verify.yml already exists. Skipping.");
|
package/dist/lighthouse.d.ts
CHANGED
|
@@ -3,5 +3,35 @@ interface LhResult {
|
|
|
3
3
|
scores: LighthouseScores | null;
|
|
4
4
|
errors: string[];
|
|
5
5
|
}
|
|
6
|
-
export
|
|
6
|
+
export interface RouteScoreResult {
|
|
7
|
+
route: string;
|
|
8
|
+
scores: LighthouseScores | null;
|
|
9
|
+
errors: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface MultiRouteLhResult {
|
|
12
|
+
/** Weighted average: root (/) = 2x weight, other routes = 1x */
|
|
13
|
+
aggregated: LighthouseScores | null;
|
|
14
|
+
perRoute: RouteScoreResult[];
|
|
15
|
+
/** True if every route that produced scores individually passed all thresholds */
|
|
16
|
+
allRoutesPass(thresholds: {
|
|
17
|
+
performance: number;
|
|
18
|
+
accessibility: number;
|
|
19
|
+
seo: number;
|
|
20
|
+
bestPractices: number;
|
|
21
|
+
}): boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function runLighthouse(port: number, runs: number, routePath?: string): Promise<LhResult>;
|
|
24
|
+
/** Run Lighthouse against an arbitrary external URL (Pro: compare-env feature). */
|
|
25
|
+
export declare function runLighthouseOnUrl(fullUrl: string, runs: number): Promise<LhResult>;
|
|
26
|
+
/**
|
|
27
|
+
* Run Lighthouse on multiple routes and aggregate results.
|
|
28
|
+
*
|
|
29
|
+
* Aggregation strategy (weighted average):
|
|
30
|
+
* - Root route "/" receives weight 2 (most visible page)
|
|
31
|
+
* - All other routes receive weight 1
|
|
32
|
+
*
|
|
33
|
+
* Gold eligibility: every route must individually pass all thresholds.
|
|
34
|
+
* Silver/Bronze eligibility: based on the weighted-average aggregate score.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runLighthouseOnRoutes(port: number, runs: number, routes: string[]): Promise<MultiRouteLhResult>;
|
|
7
37
|
export {};
|
package/dist/lighthouse.js
CHANGED
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.runLighthouse = runLighthouse;
|
|
37
|
+
exports.runLighthouseOnUrl = runLighthouseOnUrl;
|
|
38
|
+
exports.runLighthouseOnRoutes = runLighthouseOnRoutes;
|
|
37
39
|
const node_child_process_1 = require("node:child_process");
|
|
38
40
|
const fs = __importStar(require("node:fs"));
|
|
39
41
|
const path = __importStar(require("node:path"));
|
|
@@ -114,13 +116,13 @@ const [url, reportPath, chromeDir] = process.argv.slice(2);
|
|
|
114
116
|
// .cjs 확장자로 저장해서 Node가 CommonJS로 실행하도록 함
|
|
115
117
|
fs.writeFileSync(runnerPath, source, "utf-8");
|
|
116
118
|
}
|
|
117
|
-
async function
|
|
119
|
+
async function runLighthouseCore(fullUrl, runs, label) {
|
|
118
120
|
const baseTmpDir = path.join(process.cwd(), ".laxy-tmp", `lighthouse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
119
121
|
const reportsDir = path.join(baseTmpDir, "reports");
|
|
120
122
|
const runnerPath = path.join(baseTmpDir, "run-lighthouse.cjs");
|
|
121
123
|
fs.mkdirSync(reportsDir, { recursive: true });
|
|
122
124
|
writeRunnerScript(runnerPath);
|
|
123
|
-
console.log(`\n Running Lighthouse (${runs} run${runs > 1 ? "s" : ""})...`);
|
|
125
|
+
console.log(`\n Running Lighthouse on ${label} (${runs} run${runs > 1 ? "s" : ""})...`);
|
|
124
126
|
const errors = [];
|
|
125
127
|
const allScores = [];
|
|
126
128
|
for (let runIndex = 0; runIndex < runs; runIndex++) {
|
|
@@ -129,7 +131,7 @@ async function runLighthouse(port, runs) {
|
|
|
129
131
|
const chromeDir = path.join(baseTmpDir, `chrome-${runNumber}`);
|
|
130
132
|
fs.mkdirSync(chromeDir, { recursive: true });
|
|
131
133
|
console.log(` [lh] Run ${runNumber}/${runs}`);
|
|
132
|
-
const child = (0, node_child_process_1.spawn)("node", [runnerPath,
|
|
134
|
+
const child = (0, node_child_process_1.spawn)("node", [runnerPath, fullUrl, reportPath, chromeDir], {
|
|
133
135
|
shell: false,
|
|
134
136
|
stdio: ["ignore", "pipe", "pipe"],
|
|
135
137
|
env: {
|
|
@@ -196,3 +198,74 @@ async function runLighthouse(port, runs) {
|
|
|
196
198
|
await removeDirWithRetries(baseTmpDir);
|
|
197
199
|
}
|
|
198
200
|
}
|
|
201
|
+
async function runLighthouse(port, runs, routePath = "/") {
|
|
202
|
+
const normalizedPath = routePath.startsWith("/") ? routePath : `/${routePath}`;
|
|
203
|
+
const label = normalizedPath === "/" ? "/" : normalizedPath;
|
|
204
|
+
return runLighthouseCore(`http://127.0.0.1:${port}${normalizedPath}`, runs, label);
|
|
205
|
+
}
|
|
206
|
+
/** Run Lighthouse against an arbitrary external URL (Pro: compare-env feature). */
|
|
207
|
+
async function runLighthouseOnUrl(fullUrl, runs) {
|
|
208
|
+
return runLighthouseCore(fullUrl, runs, fullUrl);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Run Lighthouse on multiple routes and aggregate results.
|
|
212
|
+
*
|
|
213
|
+
* Aggregation strategy (weighted average):
|
|
214
|
+
* - Root route "/" receives weight 2 (most visible page)
|
|
215
|
+
* - All other routes receive weight 1
|
|
216
|
+
*
|
|
217
|
+
* Gold eligibility: every route must individually pass all thresholds.
|
|
218
|
+
* Silver/Bronze eligibility: based on the weighted-average aggregate score.
|
|
219
|
+
*/
|
|
220
|
+
async function runLighthouseOnRoutes(port, runs, routes) {
|
|
221
|
+
// Deduplicate and ensure "/" comes first
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
const ordered = [];
|
|
224
|
+
for (const r of ["/", ...routes]) {
|
|
225
|
+
const normalized = r.startsWith("/") ? r : `/${r}`;
|
|
226
|
+
if (!seen.has(normalized)) {
|
|
227
|
+
seen.add(normalized);
|
|
228
|
+
ordered.push(normalized);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const perRoute = [];
|
|
232
|
+
for (const route of ordered) {
|
|
233
|
+
const result = await runLighthouse(port, runs, route);
|
|
234
|
+
perRoute.push({ route, scores: result.scores, errors: result.errors });
|
|
235
|
+
}
|
|
236
|
+
// Weighted average across routes that produced scores
|
|
237
|
+
const withScores = perRoute.filter((r) => r.scores !== null);
|
|
238
|
+
let aggregated = null;
|
|
239
|
+
if (withScores.length > 0) {
|
|
240
|
+
let totalWeight = 0;
|
|
241
|
+
let sumPerf = 0;
|
|
242
|
+
let sumA11y = 0;
|
|
243
|
+
let sumSeo = 0;
|
|
244
|
+
let sumBp = 0;
|
|
245
|
+
for (const r of withScores) {
|
|
246
|
+
const weight = r.route === "/" ? 2 : 1;
|
|
247
|
+
totalWeight += weight;
|
|
248
|
+
sumPerf += r.scores.performance * weight;
|
|
249
|
+
sumA11y += r.scores.accessibility * weight;
|
|
250
|
+
sumSeo += r.scores.seo * weight;
|
|
251
|
+
sumBp += r.scores.bestPractices * weight;
|
|
252
|
+
}
|
|
253
|
+
aggregated = {
|
|
254
|
+
performance: Math.round(sumPerf / totalWeight),
|
|
255
|
+
accessibility: Math.round(sumA11y / totalWeight),
|
|
256
|
+
seo: Math.round(sumSeo / totalWeight),
|
|
257
|
+
bestPractices: Math.round(sumBp / totalWeight),
|
|
258
|
+
};
|
|
259
|
+
console.log(` Lighthouse aggregate (weighted avg): P=${aggregated.performance} A=${aggregated.accessibility} S=${aggregated.seo} BP=${aggregated.bestPractices}`);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
aggregated,
|
|
263
|
+
perRoute,
|
|
264
|
+
allRoutesPass(thresholds) {
|
|
265
|
+
return withScores.every((r) => r.scores.performance >= thresholds.performance &&
|
|
266
|
+
r.scores.accessibility >= thresholds.accessibility &&
|
|
267
|
+
r.scores.seo >= thresholds.seo &&
|
|
268
|
+
r.scores.bestPractices >= thresholds.bestPractices);
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface OutdatedPackage {
|
|
2
|
+
name: string;
|
|
3
|
+
current: string;
|
|
4
|
+
wanted: string;
|
|
5
|
+
latest: string;
|
|
6
|
+
severity: "major" | "minor" | "patch";
|
|
7
|
+
}
|
|
8
|
+
export interface OutdatedCheckResult {
|
|
9
|
+
passed: boolean;
|
|
10
|
+
totalChecked: number;
|
|
11
|
+
outdatedCount: number;
|
|
12
|
+
majorOutdated: number;
|
|
13
|
+
packages: OutdatedPackage[];
|
|
14
|
+
advisory: string;
|
|
15
|
+
skipped: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function runOutdatedCheck(projectDir: string, timeoutMs?: number): Promise<OutdatedCheckResult>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runOutdatedCheck = runOutdatedCheck;
|
|
4
|
+
/**
|
|
5
|
+
* Outdated dependency checker.
|
|
6
|
+
*
|
|
7
|
+
* Runs `npm outdated --json` and reports packages that are behind
|
|
8
|
+
* their latest version. Advisory-only — does not block deployment.
|
|
9
|
+
*/
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
function classifySemverDiff(current, latest) {
|
|
12
|
+
const parseVer = (v) => {
|
|
13
|
+
const cleaned = v.replace(/^\^|~|>=?|v/g, "").split(".")[0] ?? "0";
|
|
14
|
+
return parseInt(cleaned, 10) || 0;
|
|
15
|
+
};
|
|
16
|
+
const parseMinor = (v) => {
|
|
17
|
+
const parts = v.replace(/^\^|~|>=?|v/g, "").split(".");
|
|
18
|
+
return parseInt(parts[1] ?? "0", 10) || 0;
|
|
19
|
+
};
|
|
20
|
+
const curMajor = parseVer(current);
|
|
21
|
+
const latMajor = parseVer(latest);
|
|
22
|
+
if (latMajor > curMajor)
|
|
23
|
+
return "major";
|
|
24
|
+
const curMinor = parseMinor(current);
|
|
25
|
+
const latMinor = parseMinor(latest);
|
|
26
|
+
if (latMinor > curMinor)
|
|
27
|
+
return "minor";
|
|
28
|
+
return "patch";
|
|
29
|
+
}
|
|
30
|
+
async function runOutdatedCheck(projectDir, timeoutMs = 30000) {
|
|
31
|
+
console.log(" Running outdated dependency check...");
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
const proc = process.platform === "win32"
|
|
35
|
+
? (0, node_child_process_1.spawn)(process.env.ComSpec || "cmd.exe", ["/d", "/c", "npm outdated --json"], { stdio: ["ignore", "pipe", "pipe"], cwd: projectDir, shell: false })
|
|
36
|
+
: (0, node_child_process_1.spawn)("npm", ["outdated", "--json"], {
|
|
37
|
+
shell: true,
|
|
38
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
39
|
+
cwd: projectDir,
|
|
40
|
+
});
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
try {
|
|
43
|
+
proc.kill();
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
resolve({
|
|
47
|
+
passed: true,
|
|
48
|
+
totalChecked: 0,
|
|
49
|
+
outdatedCount: 0,
|
|
50
|
+
majorOutdated: 0,
|
|
51
|
+
packages: [],
|
|
52
|
+
advisory: "Outdated check timed out",
|
|
53
|
+
skipped: false,
|
|
54
|
+
});
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
proc.stdout?.on("data", (chunk) => chunks.push(chunk.toString()));
|
|
57
|
+
proc.stderr?.on("data", () => { });
|
|
58
|
+
proc.on("exit", (code) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
const output = chunks.join("");
|
|
61
|
+
// npm outdated exits with code 1 when outdated packages exist
|
|
62
|
+
// and code 0 when everything is up to date
|
|
63
|
+
if (code === 0 || !output.trim()) {
|
|
64
|
+
console.log(" Outdated: all packages up to date");
|
|
65
|
+
resolve({
|
|
66
|
+
passed: true,
|
|
67
|
+
totalChecked: 0,
|
|
68
|
+
outdatedCount: 0,
|
|
69
|
+
majorOutdated: 0,
|
|
70
|
+
packages: [],
|
|
71
|
+
advisory: "All packages up to date",
|
|
72
|
+
skipped: false,
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const data = JSON.parse(output);
|
|
78
|
+
const packages = [];
|
|
79
|
+
for (const [name, info] of Object.entries(data)) {
|
|
80
|
+
if (!info.current || !info.latest)
|
|
81
|
+
continue;
|
|
82
|
+
const severity = classifySemverDiff(info.current, info.latest);
|
|
83
|
+
packages.push({
|
|
84
|
+
name,
|
|
85
|
+
current: info.current,
|
|
86
|
+
wanted: info.wanted ?? info.current,
|
|
87
|
+
latest: info.latest,
|
|
88
|
+
severity,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const majorOutdated = packages.filter((p) => p.severity === "major").length;
|
|
92
|
+
const outdatedCount = packages.length;
|
|
93
|
+
const advisory = majorOutdated > 0
|
|
94
|
+
? `${majorOutdated} major version(s) behind latest; ${outdatedCount} total outdated`
|
|
95
|
+
: `${outdatedCount} package(s) behind latest (no major)`;
|
|
96
|
+
console.log(` Outdated: ${advisory}`);
|
|
97
|
+
for (const pkg of packages.filter((p) => p.severity === "major").slice(0, 5)) {
|
|
98
|
+
console.log(` ${pkg.name}: ${pkg.current} → ${pkg.latest} (${pkg.severity})`);
|
|
99
|
+
}
|
|
100
|
+
resolve({
|
|
101
|
+
passed: majorOutdated === 0,
|
|
102
|
+
totalChecked: 0,
|
|
103
|
+
outdatedCount,
|
|
104
|
+
majorOutdated,
|
|
105
|
+
packages,
|
|
106
|
+
advisory,
|
|
107
|
+
skipped: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
resolve({
|
|
112
|
+
passed: true,
|
|
113
|
+
totalChecked: 0,
|
|
114
|
+
outdatedCount: 0,
|
|
115
|
+
majorOutdated: 0,
|
|
116
|
+
packages: [],
|
|
117
|
+
advisory: "Could not parse npm outdated output",
|
|
118
|
+
skipped: false,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|