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.
Files changed (42) hide show
  1. package/README.md +181 -47
  2. package/dist/a11y-deep.d.ts +20 -0
  3. package/dist/a11y-deep.js +161 -0
  4. package/dist/ai-analysis.d.ts +28 -0
  5. package/dist/ai-analysis.js +32 -0
  6. package/dist/audit/broken-links.d.ts +5 -1
  7. package/dist/audit/broken-links.js +23 -12
  8. package/dist/bundle-size.d.ts +14 -0
  9. package/dist/bundle-size.js +209 -0
  10. package/dist/cli.js +391 -13
  11. package/dist/compare-env.d.ts +23 -0
  12. package/dist/compare-env.js +55 -0
  13. package/dist/config.d.ts +37 -0
  14. package/dist/config.js +106 -1
  15. package/dist/entitlement.d.ts +2 -0
  16. package/dist/entitlement.js +5 -1
  17. package/dist/init-analysis.d.ts +6 -0
  18. package/dist/init-analysis.js +302 -0
  19. package/dist/init.js +66 -0
  20. package/dist/lighthouse.d.ts +31 -1
  21. package/dist/lighthouse.js +76 -3
  22. package/dist/outdated-check.d.ts +17 -0
  23. package/dist/outdated-check.js +123 -0
  24. package/dist/report-markdown.d.ts +14 -0
  25. package/dist/report-markdown.js +21 -0
  26. package/dist/route-discovery.d.ts +7 -0
  27. package/dist/route-discovery.js +108 -0
  28. package/dist/secret-scan.d.ts +15 -0
  29. package/dist/secret-scan.js +218 -0
  30. package/dist/security-audit.d.ts +9 -1
  31. package/dist/security-audit.js +87 -24
  32. package/dist/seo-deep.d.ts +24 -0
  33. package/dist/seo-deep.js +147 -0
  34. package/dist/typecheck.d.ts +8 -0
  35. package/dist/typecheck.js +99 -0
  36. package/dist/verification-core/report.js +117 -0
  37. package/dist/verification-core/types.d.ts +58 -2
  38. package/dist/visual-diff.d.ts +8 -1
  39. package/dist/visual-diff.js +53 -8
  40. package/dist/vitals-budget.d.ts +23 -0
  41. package/dist/vitals-budget.js +168 -0
  42. 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: 1,
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
  }
@@ -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;
@@ -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,6 @@
1
+ import type { UserScenario } from "./config.js";
2
+ export interface InitAnalysisResult {
3
+ scenarios: UserScenario[];
4
+ extraRoutes: string[];
5
+ }
6
+ export declare function analyzeProjectForInit(dir: string): InitAnalysisResult;
@@ -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.");
@@ -3,5 +3,35 @@ interface LhResult {
3
3
  scores: LighthouseScores | null;
4
4
  errors: string[];
5
5
  }
6
- export declare function runLighthouse(port: number, runs: number): Promise<LhResult>;
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 {};