laxy-verify 1.2.2 → 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 +181 -47
- 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 +391 -13
- package/dist/compare-env.d.ts +23 -0
- package/dist/compare-env.js +55 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +106 -1
- 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
package/dist/config.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface UserScenario {
|
|
|
18
18
|
name: string;
|
|
19
19
|
steps: UserScenarioStep[];
|
|
20
20
|
}
|
|
21
|
+
export interface VisualDiffConfig {
|
|
22
|
+
pixelmatchThreshold: number;
|
|
23
|
+
warnThreshold: number;
|
|
24
|
+
rollbackThreshold: number;
|
|
25
|
+
ignoreSelectors: string[];
|
|
26
|
+
disableAnimations: boolean;
|
|
27
|
+
}
|
|
21
28
|
export interface LaxyConfig {
|
|
22
29
|
framework: string;
|
|
23
30
|
build_command: string;
|
|
@@ -34,6 +41,29 @@ export interface LaxyConfig {
|
|
|
34
41
|
max_crawl_depth: number;
|
|
35
42
|
max_crawl_pages: number;
|
|
36
43
|
browsers: string[];
|
|
44
|
+
/** Explicit list of routes to audit with Lighthouse (e.g. ["/", "/about", "/pricing"]). */
|
|
45
|
+
lighthouse_routes?: string[];
|
|
46
|
+
/** Extra routes to audit even if the crawler cannot discover them (e.g. SPA router.push routes). */
|
|
47
|
+
extra_routes?: string[];
|
|
48
|
+
/** Maximum number of crawl-discovered routes to run Lighthouse on. Default: 5. */
|
|
49
|
+
max_lighthouse_routes: number;
|
|
50
|
+
visual_diff: VisualDiffConfig;
|
|
51
|
+
/** Run TypeScript type check (tsc --noEmit). Default: false. */
|
|
52
|
+
typecheck: boolean;
|
|
53
|
+
/** Run secret/credential leak scan. Default: false. */
|
|
54
|
+
secret_scan: boolean;
|
|
55
|
+
/** Paths to ignore during secret scan. */
|
|
56
|
+
secret_scan_ignore_paths: string[];
|
|
57
|
+
/** Run bundle size analysis. Default: false. */
|
|
58
|
+
bundle_size: boolean;
|
|
59
|
+
/** Run outdated dependency check. Default: false. */
|
|
60
|
+
outdated_check: boolean;
|
|
61
|
+
/** Run deep accessibility audit (axe-core). Default: false. */
|
|
62
|
+
a11y_deep: boolean;
|
|
63
|
+
/** Run deep SEO audit. Default: false. */
|
|
64
|
+
seo_deep: boolean;
|
|
65
|
+
/** Run Core Web Vitals budget check. Default: false. */
|
|
66
|
+
vitals_budget: boolean;
|
|
37
67
|
}
|
|
38
68
|
export declare class ConfigParseError extends Error {
|
|
39
69
|
constructor(msg: string);
|
|
@@ -56,6 +86,13 @@ export interface LoadConfigOptions {
|
|
|
56
86
|
cliFlags?: {
|
|
57
87
|
failOn?: FailOn;
|
|
58
88
|
skipLighthouse?: boolean;
|
|
89
|
+
typecheck?: boolean;
|
|
90
|
+
secretScan?: boolean;
|
|
91
|
+
bundleSize?: boolean;
|
|
92
|
+
outdatedCheck?: boolean;
|
|
93
|
+
a11yDeep?: boolean;
|
|
94
|
+
seoDeep?: boolean;
|
|
95
|
+
vitalsBudget?: boolean;
|
|
59
96
|
};
|
|
60
97
|
ciMode: boolean;
|
|
61
98
|
teamThresholds?: TeamThresholdsConfig | null;
|
package/dist/config.js
CHANGED
|
@@ -48,7 +48,7 @@ const DEFAULT_CONFIG = {
|
|
|
48
48
|
port: 3000,
|
|
49
49
|
build_timeout: 300,
|
|
50
50
|
dev_timeout: 60,
|
|
51
|
-
lighthouse_runs:
|
|
51
|
+
lighthouse_runs: 3,
|
|
52
52
|
thresholds: {
|
|
53
53
|
performance: 70,
|
|
54
54
|
accessibility: 85,
|
|
@@ -60,6 +60,22 @@ const DEFAULT_CONFIG = {
|
|
|
60
60
|
max_crawl_depth: 3,
|
|
61
61
|
max_crawl_pages: 10,
|
|
62
62
|
browsers: ["chromium"],
|
|
63
|
+
max_lighthouse_routes: 5,
|
|
64
|
+
visual_diff: {
|
|
65
|
+
pixelmatchThreshold: 0.1,
|
|
66
|
+
warnThreshold: 30,
|
|
67
|
+
rollbackThreshold: 60,
|
|
68
|
+
ignoreSelectors: [],
|
|
69
|
+
disableAnimations: true,
|
|
70
|
+
},
|
|
71
|
+
typecheck: false,
|
|
72
|
+
secret_scan: false,
|
|
73
|
+
secret_scan_ignore_paths: [],
|
|
74
|
+
bundle_size: false,
|
|
75
|
+
outdated_check: false,
|
|
76
|
+
a11y_deep: false,
|
|
77
|
+
seo_deep: false,
|
|
78
|
+
vitals_budget: false,
|
|
63
79
|
};
|
|
64
80
|
const VALID_FAIL_ON = ["unverified", "bronze", "silver", "gold"];
|
|
65
81
|
class ConfigParseError extends Error {
|
|
@@ -105,6 +121,47 @@ function parseYaml(filePath) {
|
|
|
105
121
|
if (browsers.length > 0)
|
|
106
122
|
result.browsers = browsers;
|
|
107
123
|
}
|
|
124
|
+
if (Array.isArray(raw.lighthouse_routes)) {
|
|
125
|
+
const routes = raw.lighthouse_routes
|
|
126
|
+
.filter((r) => typeof r === "string" && r.startsWith("/"))
|
|
127
|
+
.slice(0, 20);
|
|
128
|
+
if (routes.length > 0)
|
|
129
|
+
result.lighthouse_routes = routes;
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(raw.extra_routes)) {
|
|
132
|
+
const routes = raw.extra_routes
|
|
133
|
+
.filter((r) => typeof r === "string" && r.startsWith("/"))
|
|
134
|
+
.slice(0, 20);
|
|
135
|
+
if (routes.length > 0)
|
|
136
|
+
result.extra_routes = routes;
|
|
137
|
+
}
|
|
138
|
+
if (typeof raw.max_lighthouse_routes === "number") {
|
|
139
|
+
result.max_lighthouse_routes = Math.max(1, Math.min(20, raw.max_lighthouse_routes));
|
|
140
|
+
}
|
|
141
|
+
if (typeof raw.visual_diff === "object" &&
|
|
142
|
+
raw.visual_diff !== null &&
|
|
143
|
+
!Array.isArray(raw.visual_diff)) {
|
|
144
|
+
const vd = raw.visual_diff;
|
|
145
|
+
const visualDiff = {};
|
|
146
|
+
if (typeof vd.pixelmatch_threshold === "number") {
|
|
147
|
+
visualDiff.pixelmatchThreshold = Math.max(0, Math.min(1, vd.pixelmatch_threshold));
|
|
148
|
+
}
|
|
149
|
+
if (typeof vd.warn_threshold === "number") {
|
|
150
|
+
visualDiff.warnThreshold = Math.max(0, Math.min(100, vd.warn_threshold));
|
|
151
|
+
}
|
|
152
|
+
if (typeof vd.rollback_threshold === "number") {
|
|
153
|
+
visualDiff.rollbackThreshold = Math.max(0, Math.min(100, vd.rollback_threshold));
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(vd.ignore_selectors)) {
|
|
156
|
+
visualDiff.ignoreSelectors = vd.ignore_selectors
|
|
157
|
+
.filter((selector) => typeof selector === "string" && selector.trim().length > 0)
|
|
158
|
+
.slice(0, 30);
|
|
159
|
+
}
|
|
160
|
+
if (typeof vd.disable_animations === "boolean") {
|
|
161
|
+
visualDiff.disableAnimations = vd.disable_animations;
|
|
162
|
+
}
|
|
163
|
+
result.visual_diff = visualDiff;
|
|
164
|
+
}
|
|
108
165
|
if (typeof raw.fail_on === "string") {
|
|
109
166
|
const f = raw.fail_on;
|
|
110
167
|
if (!VALID_FAIL_ON.includes(f)) {
|
|
@@ -164,6 +221,24 @@ function parseYaml(filePath) {
|
|
|
164
221
|
result.scenarios = scenarios;
|
|
165
222
|
}
|
|
166
223
|
}
|
|
224
|
+
if (typeof raw.typecheck === "boolean")
|
|
225
|
+
result.typecheck = raw.typecheck;
|
|
226
|
+
if (typeof raw.secret_scan === "boolean")
|
|
227
|
+
result.secret_scan = raw.secret_scan;
|
|
228
|
+
if (Array.isArray(raw.secret_scan_ignore_paths)) {
|
|
229
|
+
result.secret_scan_ignore_paths = raw.secret_scan_ignore_paths
|
|
230
|
+
.filter((p) => typeof p === "string");
|
|
231
|
+
}
|
|
232
|
+
if (typeof raw.bundle_size === "boolean")
|
|
233
|
+
result.bundle_size = raw.bundle_size;
|
|
234
|
+
if (typeof raw.outdated_check === "boolean")
|
|
235
|
+
result.outdated_check = raw.outdated_check;
|
|
236
|
+
if (typeof raw.a11y_deep === "boolean")
|
|
237
|
+
result.a11y_deep = raw.a11y_deep;
|
|
238
|
+
if (typeof raw.seo_deep === "boolean")
|
|
239
|
+
result.seo_deep = raw.seo_deep;
|
|
240
|
+
if (typeof raw.vitals_budget === "boolean")
|
|
241
|
+
result.vitals_budget = raw.vitals_budget;
|
|
167
242
|
return result;
|
|
168
243
|
}
|
|
169
244
|
/**
|
|
@@ -222,6 +297,21 @@ function loadConfig(options) {
|
|
|
222
297
|
max_crawl_depth: base.max_crawl_depth ?? DEFAULT_CONFIG.max_crawl_depth,
|
|
223
298
|
max_crawl_pages: base.max_crawl_pages ?? DEFAULT_CONFIG.max_crawl_pages,
|
|
224
299
|
browsers: base.browsers ?? DEFAULT_CONFIG.browsers,
|
|
300
|
+
lighthouse_routes: base.lighthouse_routes,
|
|
301
|
+
extra_routes: base.extra_routes,
|
|
302
|
+
max_lighthouse_routes: base.max_lighthouse_routes ?? DEFAULT_CONFIG.max_lighthouse_routes,
|
|
303
|
+
visual_diff: {
|
|
304
|
+
...DEFAULT_CONFIG.visual_diff,
|
|
305
|
+
...(base.visual_diff ?? {}),
|
|
306
|
+
},
|
|
307
|
+
typecheck: base.typecheck ?? DEFAULT_CONFIG.typecheck,
|
|
308
|
+
secret_scan: base.secret_scan ?? DEFAULT_CONFIG.secret_scan,
|
|
309
|
+
secret_scan_ignore_paths: base.secret_scan_ignore_paths ?? DEFAULT_CONFIG.secret_scan_ignore_paths,
|
|
310
|
+
bundle_size: base.bundle_size ?? DEFAULT_CONFIG.bundle_size,
|
|
311
|
+
outdated_check: base.outdated_check ?? DEFAULT_CONFIG.outdated_check,
|
|
312
|
+
a11y_deep: base.a11y_deep ?? DEFAULT_CONFIG.a11y_deep,
|
|
313
|
+
seo_deep: base.seo_deep ?? DEFAULT_CONFIG.seo_deep,
|
|
314
|
+
vitals_budget: base.vitals_budget ?? DEFAULT_CONFIG.vitals_budget,
|
|
225
315
|
};
|
|
226
316
|
config.thresholds = {
|
|
227
317
|
...DEFAULT_CONFIG.thresholds,
|
|
@@ -251,5 +341,20 @@ function loadConfig(options) {
|
|
|
251
341
|
if (options.cliFlags?.skipLighthouse) {
|
|
252
342
|
// Effectively disables Lighthouse grading
|
|
253
343
|
}
|
|
344
|
+
// CLI flag overrides for new checks
|
|
345
|
+
if (options.cliFlags?.typecheck !== undefined)
|
|
346
|
+
config.typecheck = options.cliFlags.typecheck;
|
|
347
|
+
if (options.cliFlags?.secretScan !== undefined)
|
|
348
|
+
config.secret_scan = options.cliFlags.secretScan;
|
|
349
|
+
if (options.cliFlags?.bundleSize !== undefined)
|
|
350
|
+
config.bundle_size = options.cliFlags.bundleSize;
|
|
351
|
+
if (options.cliFlags?.outdatedCheck !== undefined)
|
|
352
|
+
config.outdated_check = options.cliFlags.outdatedCheck;
|
|
353
|
+
if (options.cliFlags?.a11yDeep !== undefined)
|
|
354
|
+
config.a11y_deep = options.cliFlags.a11yDeep;
|
|
355
|
+
if (options.cliFlags?.seoDeep !== undefined)
|
|
356
|
+
config.seo_deep = options.cliFlags.seoDeep;
|
|
357
|
+
if (options.cliFlags?.vitalsBudget !== undefined)
|
|
358
|
+
config.vitals_budget = options.cliFlags.vitalsBudget;
|
|
254
359
|
return { ...config, ciMode };
|
|
255
360
|
}
|
package/dist/entitlement.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface EntitlementFeatures {
|
|
|
3
3
|
github_actions: boolean;
|
|
4
4
|
queue_priority: boolean;
|
|
5
5
|
parallel_execution: boolean;
|
|
6
|
+
ai_failure_analysis: boolean;
|
|
7
|
+
compare_env: boolean;
|
|
6
8
|
}
|
|
7
9
|
export type TestablePlan = "free" | "pro" | "team";
|
|
8
10
|
export declare function normalizePlan(plan?: string | null): TestablePlan;
|
package/dist/entitlement.js
CHANGED
|
@@ -18,6 +18,8 @@ const FREE_FEATURES = {
|
|
|
18
18
|
github_actions: false,
|
|
19
19
|
queue_priority: false,
|
|
20
20
|
parallel_execution: false,
|
|
21
|
+
ai_failure_analysis: false,
|
|
22
|
+
compare_env: false,
|
|
21
23
|
};
|
|
22
24
|
let cache = null;
|
|
23
25
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -70,8 +72,10 @@ function applyPlanOverride(features, overridePlan) {
|
|
|
70
72
|
...features,
|
|
71
73
|
plan: overridePlan,
|
|
72
74
|
// All verification features run on every plan
|
|
73
|
-
// github_actions — Pro and Team
|
|
75
|
+
// github_actions, ai_failure_analysis, compare_env — Pro and Team
|
|
74
76
|
github_actions: isPro,
|
|
77
|
+
ai_failure_analysis: isPro,
|
|
78
|
+
compare_env: isPro,
|
|
75
79
|
// queue_priority, parallel_execution — Team only
|
|
76
80
|
queue_priority: isTeam,
|
|
77
81
|
parallel_execution: isTeam,
|
|
@@ -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 {};
|