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.
Files changed (42) hide show
  1. package/README.md +193 -64
  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 +432 -16
  11. package/dist/compare-env.d.ts +23 -0
  12. package/dist/compare-env.js +55 -0
  13. package/dist/config.d.ts +50 -0
  14. package/dist/config.js +149 -4
  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
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runSeoDeep = runSeoDeep;
4
+ async function fetchPageHtml(url) {
5
+ try {
6
+ const res = await fetch(url, {
7
+ signal: AbortSignal.timeout(10000),
8
+ headers: { "User-Agent": "laxy-verify/1.0" },
9
+ });
10
+ if (!res.ok)
11
+ return null;
12
+ return await res.text();
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function extractMetaContent(html, nameAttr) {
19
+ // Match <meta name="..." content="..."> or <meta property="..." content="...">
20
+ const regex = new RegExp(`<meta\\s+(?:name|property)=["']${nameAttr}["']\\s+content=["']([^"']*)["']`, "i");
21
+ const match = html.match(regex);
22
+ if (match)
23
+ return match[1];
24
+ // Also try content before name/property
25
+ const regex2 = new RegExp(`<meta\\s+content=["']([^"']*)["']\\s+(?:name|property)=["']${nameAttr}["']`, "i");
26
+ const match2 = html.match(regex2);
27
+ return match2 ? match2[1] : null;
28
+ }
29
+ function extractTitle(html) {
30
+ const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
31
+ return match ? match[1].trim() : null;
32
+ }
33
+ function extractCanonical(html) {
34
+ const match = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i);
35
+ return match ? match[1] : null;
36
+ }
37
+ function extractRobotsMeta(html) {
38
+ return extractMetaContent(html, "robots");
39
+ }
40
+ function hasJsonLd(html) {
41
+ return /<script[^>]+type=["']application\/ld\+json["']/i.test(html);
42
+ }
43
+ async function runSeoDeep(url) {
44
+ console.log(" Running deep SEO audit...");
45
+ const html = await fetchPageHtml(url);
46
+ if (!html) {
47
+ return {
48
+ passed: true,
49
+ checks: [],
50
+ warningCount: 0,
51
+ errorCount: 0,
52
+ url,
53
+ skipped: true,
54
+ summary: "Skipped (could not fetch page)",
55
+ };
56
+ }
57
+ const checks = [];
58
+ // Title
59
+ const title = extractTitle(html);
60
+ checks.push({
61
+ key: "title",
62
+ label: "Page title",
63
+ passed: !!title && title.length > 0 && title.length <= 60,
64
+ value: title,
65
+ severity: "error",
66
+ });
67
+ // Meta description
68
+ const description = extractMetaContent(html, "description");
69
+ checks.push({
70
+ key: "description",
71
+ label: "Meta description",
72
+ passed: !!description && description.length > 0 && description.length <= 160,
73
+ value: description,
74
+ severity: "warning",
75
+ });
76
+ // Canonical
77
+ const canonical = extractCanonical(html);
78
+ checks.push({
79
+ key: "canonical",
80
+ label: "Canonical URL",
81
+ passed: !!canonical,
82
+ value: canonical,
83
+ severity: "warning",
84
+ });
85
+ // Robots
86
+ const robots = extractRobotsMeta(html);
87
+ const robotsNoindex = robots?.includes("noindex") ?? false;
88
+ checks.push({
89
+ key: "robots",
90
+ label: "Robots meta",
91
+ passed: !robotsNoindex,
92
+ value: robots ?? "(not set — defaults to index)",
93
+ severity: "error",
94
+ });
95
+ // Open Graph
96
+ const ogTitle = extractMetaContent(html, "og:title");
97
+ const ogDescription = extractMetaContent(html, "og:description");
98
+ const ogImage = extractMetaContent(html, "og:image");
99
+ const hasOg = ogTitle || ogDescription || ogImage;
100
+ checks.push({
101
+ key: "og",
102
+ label: "Open Graph tags",
103
+ passed: !!hasOg,
104
+ value: hasOg
105
+ ? [ogTitle && "title", ogDescription && "desc", ogImage && "image"].filter(Boolean).join(", ")
106
+ : null,
107
+ severity: "warning",
108
+ });
109
+ // Twitter Card
110
+ const twitterCard = extractMetaContent(html, "twitter:card");
111
+ const twitterTitle = extractMetaContent(html, "twitter:title");
112
+ const hasTwitter = twitterCard || twitterTitle;
113
+ checks.push({
114
+ key: "twitter",
115
+ label: "Twitter Card tags",
116
+ passed: !!hasTwitter,
117
+ value: hasTwitter
118
+ ? [twitterCard && "card", twitterTitle && "title"].filter(Boolean).join(", ")
119
+ : null,
120
+ severity: "warning",
121
+ });
122
+ // JSON-LD
123
+ const jsonLd = hasJsonLd(html);
124
+ checks.push({
125
+ key: "jsonld",
126
+ label: "JSON-LD structured data",
127
+ passed: jsonLd,
128
+ value: jsonLd ? "present" : null,
129
+ severity: "warning",
130
+ });
131
+ const errorCount = checks.filter((c) => !c.passed && c.severity === "error").length;
132
+ const warningCount = checks.filter((c) => !c.passed && c.severity === "warning").length;
133
+ const passed = errorCount === 0;
134
+ const parts = [];
135
+ if (errorCount > 0)
136
+ parts.push(`${errorCount} error(s)`);
137
+ if (warningCount > 0)
138
+ parts.push(`${warningCount} warning(s)`);
139
+ const summary = parts.length > 0
140
+ ? parts.join(", ")
141
+ : "All SEO checks passed";
142
+ console.log(` SEO deep: ${summary}`);
143
+ for (const check of checks.filter((c) => !c.passed)) {
144
+ console.error(` [${check.severity}] ${check.label}: ${check.value ?? "missing"}`);
145
+ }
146
+ return { passed, checks, warningCount, errorCount, url, skipped: false, summary };
147
+ }
@@ -0,0 +1,8 @@
1
+ export interface TypecheckResult {
2
+ passed: boolean;
3
+ errorCount: number;
4
+ errors: string[];
5
+ skipped: boolean;
6
+ durationMs: number;
7
+ }
8
+ export declare function runTypecheck(projectDir: string, timeoutMs?: number): Promise<TypecheckResult>;
@@ -0,0 +1,99 @@
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.runTypecheck = runTypecheck;
37
+ /**
38
+ * TypeScript type-check verification.
39
+ *
40
+ * Runs `tsc --noEmit` to catch type errors that may slip through a
41
+ * lenient build pipeline. If no tsconfig.json is found the check is
42
+ * skipped gracefully.
43
+ */
44
+ const node_child_process_1 = require("node:child_process");
45
+ const fs = __importStar(require("node:fs"));
46
+ const path = __importStar(require("node:path"));
47
+ async function runTypecheck(projectDir, timeoutMs = 60000) {
48
+ const tsconfigPath = path.join(projectDir, "tsconfig.json");
49
+ if (!fs.existsSync(tsconfigPath)) {
50
+ return { passed: true, errorCount: 0, errors: [], skipped: true, durationMs: 0 };
51
+ }
52
+ console.log(" Running TypeScript type check (tsc --noEmit)...");
53
+ const start = Date.now();
54
+ return new Promise((resolve) => {
55
+ const chunks = [];
56
+ const proc = process.platform === "win32"
57
+ ? (0, node_child_process_1.spawn)(process.env.ComSpec || "cmd.exe", ["/d", "/c", "npx tsc --noEmit"], { stdio: ["ignore", "pipe", "pipe"], cwd: projectDir, shell: false })
58
+ : (0, node_child_process_1.spawn)("npx", ["tsc", "--noEmit"], {
59
+ shell: true,
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ cwd: projectDir,
62
+ });
63
+ const timer = setTimeout(() => {
64
+ try {
65
+ proc.kill();
66
+ }
67
+ catch { }
68
+ resolve({
69
+ passed: false,
70
+ errorCount: -1,
71
+ errors: ["Typecheck timed out"],
72
+ skipped: false,
73
+ durationMs: Date.now() - start,
74
+ });
75
+ }, timeoutMs);
76
+ proc.stdout?.on("data", (chunk) => chunks.push(chunk.toString()));
77
+ proc.stderr?.on("data", (chunk) => chunks.push(chunk.toString()));
78
+ proc.on("exit", (code) => {
79
+ clearTimeout(timer);
80
+ const durationMs = Date.now() - start;
81
+ const output = chunks.join("");
82
+ const passed = code === 0;
83
+ if (passed) {
84
+ console.log(" TypeScript: OK (no type errors)");
85
+ resolve({ passed: true, errorCount: 0, errors: [], skipped: false, durationMs });
86
+ return;
87
+ }
88
+ const lines = output.split("\n").filter((l) => l.trim().length > 0);
89
+ const errorLines = lines.filter((l) => /error TS\d+/i.test(l));
90
+ const errorCount = errorLines.length || lines.length;
91
+ const errors = errorLines.slice(0, 10);
92
+ console.log(` TypeScript: ${errorCount} type error(s)`);
93
+ for (const err of errors.slice(0, 3)) {
94
+ console.error(` ${err.trim()}`);
95
+ }
96
+ resolve({ passed: false, errorCount, errors, skipped: false, durationMs });
97
+ });
98
+ });
99
+ }
@@ -72,6 +72,16 @@ function buildVerificationEvidence(input, thresholds = exports.DEFAULT_LH_THRESH
72
72
  const mobileLighthousePassed = hasMobileLighthouseData
73
73
  ? getLighthousePass(input.mobileLighthouseScores, thresholds)
74
74
  : false;
75
+ const hasTypecheckData = !!input.typecheck && !input.typecheck.skipped;
76
+ const typecheckPassed = hasTypecheckData ? input.typecheck.passed : true;
77
+ const hasSecretScanData = !!input.secretScan && !input.secretScan.skipped;
78
+ const secretScanPassed = hasSecretScanData ? input.secretScan.passed : true;
79
+ const hasA11yDeepData = !!input.a11yDeep && !input.a11yDeep.skipped;
80
+ const a11yDeepPassed = hasA11yDeepData ? input.a11yDeep.passed : true;
81
+ const hasSeoDeepData = !!input.seoDeep && !input.seoDeep.skipped;
82
+ const seoDeepPassed = hasSeoDeepData ? input.seoDeep.passed : true;
83
+ const hasVitalsBudgetData = !!input.vitalsBudget && !input.vitalsBudget.skipped;
84
+ const vitalsBudgetPassed = hasVitalsBudgetData ? input.vitalsBudget.passed : true;
75
85
  return {
76
86
  input,
77
87
  thresholds,
@@ -92,6 +102,16 @@ function buildVerificationEvidence(input, thresholds = exports.DEFAULT_LH_THRESH
92
102
  securityPassed,
93
103
  hasMobileLighthouseData,
94
104
  mobileLighthousePassed,
105
+ hasTypecheckData,
106
+ typecheckPassed,
107
+ hasSecretScanData,
108
+ secretScanPassed,
109
+ hasA11yDeepData,
110
+ a11yDeepPassed,
111
+ hasSeoDeepData,
112
+ seoDeepPassed,
113
+ hasVitalsBudgetData,
114
+ vitalsBudgetPassed,
95
115
  };
96
116
  }
97
117
  function getImprovementRecommendations(input, thresholds = exports.DEFAULT_LH_THRESHOLDS) {
@@ -279,6 +299,82 @@ function getImprovementRecommendations(input, thresholds = exports.DEFAULT_LH_TH
279
299
  action: "Rerun Lighthouse and inspect the failing run logs before trusting this result in CI.",
280
300
  });
281
301
  }
302
+ // TypeScript type errors — blocker-capable
303
+ if (input.typecheck && !input.typecheck.skipped && !input.typecheck.passed) {
304
+ findings.push({
305
+ category: "typecheck",
306
+ severity: input.typecheck.errorCount >= 5 ? "high" : "medium",
307
+ title: `TypeScript type errors (${input.typecheck.errorCount})`,
308
+ description: "Type errors indicate potential runtime bugs that the build pipeline may not catch.",
309
+ action: "Fix the TypeScript errors and rerun verification.",
310
+ });
311
+ }
312
+ // Secret scan — blocker-capable
313
+ if (input.secretScan && !input.secretScan.skipped && !input.secretScan.passed) {
314
+ findings.push({
315
+ category: "secrets",
316
+ severity: "high",
317
+ title: `Hardcoded secrets detected (${input.secretScan.findingsCount})`,
318
+ description: `Found ${input.secretScan.findingsCount} potential secret(s) in ${input.secretScan.filesScanned} scanned files. Leaked secrets are a deployment risk.`,
319
+ action: "Move secrets to environment variables and rotate any compromised credentials.",
320
+ });
321
+ }
322
+ // Deep accessibility — blocker-capable
323
+ if (input.a11yDeep && !input.a11yDeep.skipped && !input.a11yDeep.passed) {
324
+ const critical = input.a11yDeep.criticalCount;
325
+ const serious = input.a11yDeep.seriousCount;
326
+ findings.push({
327
+ category: "accessibility",
328
+ severity: critical > 0 ? "high" : "medium",
329
+ title: `WCAG violations found (${critical} critical, ${serious} serious)`,
330
+ description: input.a11yDeep.summary,
331
+ action: "Fix the critical and serious WCAG violations before release.",
332
+ });
333
+ }
334
+ // Deep SEO — advisory
335
+ if (input.seoDeep && !input.seoDeep.skipped && !input.seoDeep.passed) {
336
+ findings.push({
337
+ category: "seo",
338
+ severity: "medium",
339
+ title: `SEO issues detected (${input.seoDeep.errorCount} errors, ${input.seoDeep.warningCount} warnings)`,
340
+ description: input.seoDeep.summary,
341
+ action: "Fix missing or incorrect SEO meta tags.",
342
+ });
343
+ }
344
+ // Vitals budget — advisory
345
+ if (input.vitalsBudget && !input.vitalsBudget.skipped && !input.vitalsBudget.passed) {
346
+ findings.push({
347
+ category: "vitals",
348
+ severity: "medium",
349
+ title: `Core Web Vitals budget exceeded`,
350
+ description: input.vitalsBudget.summary,
351
+ action: "Optimize performance to meet the Core Web Vitals budget thresholds.",
352
+ });
353
+ }
354
+ // Outdated dependencies — advisory
355
+ if (input.outdatedCheck && !input.outdatedCheck.skipped && input.outdatedCheck.majorOutdated > 0) {
356
+ findings.push({
357
+ category: "bestPractices",
358
+ severity: "medium",
359
+ title: `${input.outdatedCheck.majorOutdated} major version(s) behind latest`,
360
+ description: input.outdatedCheck.advisory,
361
+ action: "Update outdated dependencies, especially those with major version gaps.",
362
+ });
363
+ }
364
+ // Bundle size — advisory (only if thresholds exceeded)
365
+ if (input.bundleSize && !input.bundleSize.skipped) {
366
+ const firstLoad = input.bundleSize.firstLoadJsKb;
367
+ const largest = input.bundleSize.largestChunkKb;
368
+ if ((firstLoad !== null && firstLoad > 200) || (largest !== null && largest > 300)) {
369
+ findings.push({
370
+ category: "performance",
371
+ severity: "medium",
372
+ title: `Bundle size advisory threshold exceeded`,
373
+ description: input.bundleSize.advisory,
374
+ action: "Reduce bundle size by code-splitting, tree-shaking, or lazy-loading large dependencies.",
375
+ });
376
+ }
377
+ }
282
378
  const lighthouseScores = input.lighthouseScores;
283
379
  if (!lighthouseScores) {
284
380
  return findings;
@@ -388,6 +484,27 @@ function buildVerificationReport(input, options) {
388
484
  ...(evidence.hasLighthouseData
389
485
  ? [{ key: "lighthouse", label: "Lighthouse thresholds", passed: evidence.lighthousePassed }]
390
486
  : []),
487
+ ...(evidence.hasTypecheckData
488
+ ? [{ key: "typecheck", label: `TypeScript (${input.typecheck?.errorCount ?? 0} errors)`, passed: evidence.typecheckPassed }]
489
+ : []),
490
+ ...(evidence.hasSecretScanData
491
+ ? [{ key: "secret-scan", label: `Secret scan (${input.secretScan?.findingsCount ?? 0} findings)`, passed: evidence.secretScanPassed }]
492
+ : []),
493
+ ...(evidence.hasA11yDeepData
494
+ ? [{ key: "a11y-deep", label: `WCAG deep (${input.a11yDeep?.criticalCount ?? 0} critical)`, passed: evidence.a11yDeepPassed }]
495
+ : []),
496
+ ...(evidence.hasSeoDeepData
497
+ ? [{ key: "seo-deep", label: `SEO deep (${input.seoDeep?.errorCount ?? 0} errors)`, passed: evidence.seoDeepPassed }]
498
+ : []),
499
+ ...(evidence.hasVitalsBudgetData
500
+ ? [{ key: "vitals-budget", label: "Core Web Vitals budget", passed: evidence.vitalsBudgetPassed }]
501
+ : []),
502
+ ...(input.bundleSize && !input.bundleSize.skipped
503
+ ? [{ key: "bundle-size", label: `Bundle size (${input.bundleSize.framework})`, passed: true }]
504
+ : []),
505
+ ...(input.outdatedCheck && !input.outdatedCheck.skipped
506
+ ? [{ key: "outdated-check", label: `Outdated deps (${input.outdatedCheck.outdatedCount} outdated)`, passed: input.outdatedCheck.majorOutdated === 0 }]
507
+ : []),
391
508
  ];
392
509
  const nextActions = [...blockers, ...warnings].slice(0, 4).map((finding) => finding.action);
393
510
  return {
@@ -43,14 +43,60 @@ export interface VerificationInput {
43
43
  brokenCount: number;
44
44
  summary: string;
45
45
  };
46
+ typecheck?: {
47
+ passed: boolean;
48
+ errorCount: number;
49
+ skipped: boolean;
50
+ };
51
+ secretScan?: {
52
+ passed: boolean;
53
+ findingsCount: number;
54
+ filesScanned: number;
55
+ skipped: boolean;
56
+ };
57
+ a11yDeep?: {
58
+ passed: boolean;
59
+ criticalCount: number;
60
+ seriousCount: number;
61
+ summary: string;
62
+ skipped: boolean;
63
+ };
64
+ seoDeep?: {
65
+ passed: boolean;
66
+ errorCount: number;
67
+ warningCount: number;
68
+ summary: string;
69
+ skipped: boolean;
70
+ };
71
+ vitalsBudget?: {
72
+ passed: boolean;
73
+ lcp: number | null;
74
+ cls: number | null;
75
+ inp: number | null;
76
+ summary: string;
77
+ skipped: boolean;
78
+ };
79
+ bundleSize?: {
80
+ framework: string;
81
+ firstLoadJsKb: number | null;
82
+ largestChunkKb: number | null;
83
+ advisory: string;
84
+ skipped: boolean;
85
+ };
86
+ outdatedCheck?: {
87
+ outdatedCount: number;
88
+ majorOutdated: number;
89
+ advisory: string;
90
+ skipped: boolean;
91
+ };
46
92
  }
47
93
  export interface VerificationCheck {
48
- key: "build" | "e2e" | "lighthouse" | "viewport" | "visual" | "security" | "mobile-lh" | "console-errors" | "broken-links";
94
+ key: "build" | "e2e" | "lighthouse" | "viewport" | "visual" | "security" | "mobile-lh" | "console-errors" | "broken-links" | "typecheck" | "secret-scan" | "a11y-deep" | "seo-deep" | "vitals-budget" | "bundle-size" | "outdated-check";
49
95
  label: string;
50
96
  passed: boolean;
51
97
  }
52
98
  export interface VerificationFinding {
53
- category: "build" | "performance" | "accessibility" | "seo" | "bestPractices" | "e2e" | "viewport" | "visual" | "security" | "runtime";
99
+ category: "build" | "performance" | "accessibility" | "seo" | "bestPractices" | "e2e" | "viewport" | "visual" | "security" | "runtime" | "typecheck" | "secrets" | "vitals";
54
100
  severity: "critical" | "high" | "medium";
55
101
  title: string;
56
102
  description: string;
@@ -76,6 +122,16 @@ export interface VerificationEvidence {
76
122
  securityPassed: boolean;
77
123
  hasMobileLighthouseData: boolean;
78
124
  mobileLighthousePassed: boolean;
125
+ hasTypecheckData: boolean;
126
+ typecheckPassed: boolean;
127
+ hasSecretScanData: boolean;
128
+ secretScanPassed: boolean;
129
+ hasA11yDeepData: boolean;
130
+ a11yDeepPassed: boolean;
131
+ hasSeoDeepData: boolean;
132
+ seoDeepPassed: boolean;
133
+ hasVitalsBudgetData: boolean;
134
+ vitalsBudgetPassed: boolean;
79
135
  }
80
136
  export interface VerificationReport {
81
137
  tier: VerificationTier;
@@ -22,5 +22,12 @@ export interface VisualDiffResult {
22
22
  viewports: VisualDiffViewportResult[];
23
23
  summary: string;
24
24
  }
25
+ export interface VisualDiffOptions {
26
+ pixelmatchThreshold?: number;
27
+ warnThreshold?: number;
28
+ rollbackThreshold?: number;
29
+ ignoreSelectors?: string[];
30
+ disableAnimations?: boolean;
31
+ }
25
32
  export declare function formatVisualDiffSummary(result: VisualDiffResult): string;
26
- export declare function runVisualDiff(projectDir: string, url: string, label?: string): Promise<VisualDiffResult>;
33
+ export declare function runVisualDiff(projectDir: string, url: string, label?: string, options?: VisualDiffOptions): Promise<VisualDiffResult>;
@@ -58,7 +58,7 @@ function formatVisualDiffSummary(result) {
58
58
  function ensureDir(dir) {
59
59
  fs.mkdirSync(dir, { recursive: true });
60
60
  }
61
- async function captureScreenshot(url, outputPath, viewport) {
61
+ async function captureScreenshot(url, outputPath, viewport, options) {
62
62
  const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
63
63
  try {
64
64
  const page = await browser.newPage();
@@ -70,6 +70,42 @@ async function captureScreenshot(url, outputPath, viewport) {
70
70
  });
71
71
  await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
72
72
  await page.waitForSelector("body", { timeout: 5000 });
73
+ await page.evaluate(({ disableAnimations, ignoreSelectors }) => {
74
+ if (disableAnimations) {
75
+ for (const node of Array.from(document.querySelectorAll("[data-animate]"))) {
76
+ const el = node;
77
+ el.style.setProperty("animation", "none", "important");
78
+ el.style.setProperty("transition", "none", "important");
79
+ el.getAnimations?.().forEach((animation) => animation.cancel());
80
+ }
81
+ }
82
+ for (const selector of ignoreSelectors) {
83
+ for (const node of Array.from(document.querySelectorAll(selector))) {
84
+ const el = node;
85
+ el.setAttribute("data-laxy-visual-ignore", "true");
86
+ }
87
+ }
88
+ if (!document.getElementById("laxy-visual-diff-style")) {
89
+ const style = document.createElement("style");
90
+ style.id = "laxy-visual-diff-style";
91
+ style.textContent = `
92
+ [data-animate] {
93
+ animation: none !important;
94
+ transition: none !important;
95
+ }
96
+ [data-laxy-visual-ignore="true"] {
97
+ visibility: hidden !important;
98
+ animation: none !important;
99
+ transition: none !important;
100
+ }
101
+ `;
102
+ document.head.appendChild(style);
103
+ }
104
+ }, {
105
+ disableAnimations: options.disableAnimations,
106
+ ignoreSelectors: options.ignoreSelectors,
107
+ });
108
+ await new Promise((resolve) => setTimeout(resolve, 150));
73
109
  await page.screenshot({ path: outputPath, fullPage: true, type: "png" });
74
110
  }
75
111
  finally {
@@ -81,7 +117,7 @@ async function loadPixelmatch() {
81
117
  const mod = await dynamicImport("pixelmatch");
82
118
  return mod.default;
83
119
  }
84
- async function compareImages(baselinePath, currentPath, diffOutputPath) {
120
+ async function compareImages(baselinePath, currentPath, diffOutputPath, options) {
85
121
  const baselinePng = pngjs_1.PNG.sync.read(fs.readFileSync(baselinePath));
86
122
  const currentPng = pngjs_1.PNG.sync.read(fs.readFileSync(currentPath));
87
123
  const width = Math.min(baselinePng.width, currentPng.width);
@@ -99,22 +135,31 @@ async function compareImages(baselinePath, currentPath, diffOutputPath) {
99
135
  const currData = cropData(currentPng, width, height);
100
136
  const diff = new pngjs_1.PNG({ width, height });
101
137
  const pixelmatch = await loadPixelmatch();
102
- const diffPixels = pixelmatch(baseData, currData, diff.data, width, height, { threshold: 0.1 });
138
+ const diffPixels = pixelmatch(baseData, currData, diff.data, width, height, {
139
+ threshold: options.pixelmatchThreshold,
140
+ });
103
141
  ensureDir(path.dirname(diffOutputPath));
104
142
  fs.writeFileSync(diffOutputPath, pngjs_1.PNG.sync.write(diff));
105
143
  const totalPixels = width * height;
106
144
  const diffPercentage = Math.round((diffPixels / totalPixels) * 10000) / 100;
107
145
  return { diffPixels, totalPixels, diffPercentage };
108
146
  }
109
- async function runVisualDiff(projectDir, url, label = "current") {
147
+ async function runVisualDiff(projectDir, url, label = "current", options = {}) {
110
148
  const dir = path.join(projectDir, ".laxy-verify", "visual");
111
149
  ensureDir(dir);
150
+ const normalizedOptions = {
151
+ pixelmatchThreshold: options.pixelmatchThreshold ?? 0.1,
152
+ warnThreshold: options.warnThreshold ?? 30,
153
+ rollbackThreshold: options.rollbackThreshold ?? 60,
154
+ ignoreSelectors: options.ignoreSelectors ?? [],
155
+ disableAnimations: options.disableAnimations ?? true,
156
+ };
112
157
  const viewportResults = [];
113
158
  for (const viewport of VISUAL_DIFF_VIEWPORTS) {
114
159
  const baselinePath = path.join(dir, `${viewport.viewport}.baseline.png`);
115
160
  const currentPath = path.join(dir, `${label}.${viewport.viewport}.png`);
116
161
  const diffPath = path.join(dir, `${label}.${viewport.viewport}.diff.png`);
117
- await captureScreenshot(url, currentPath, viewport);
162
+ await captureScreenshot(url, currentPath, viewport, normalizedOptions);
118
163
  if (!fs.existsSync(baselinePath)) {
119
164
  fs.copyFileSync(currentPath, baselinePath);
120
165
  viewportResults.push({
@@ -130,12 +175,12 @@ async function runVisualDiff(projectDir, url, label = "current") {
130
175
  });
131
176
  continue;
132
177
  }
133
- const comparison = await compareImages(baselinePath, currentPath, diffPath);
178
+ const comparison = await compareImages(baselinePath, currentPath, diffPath, normalizedOptions);
134
179
  let verdict = "pass";
135
- if (comparison.diffPercentage >= 60) {
180
+ if (comparison.diffPercentage >= normalizedOptions.rollbackThreshold) {
136
181
  verdict = "rollback";
137
182
  }
138
- else if (comparison.diffPercentage >= 30) {
183
+ else if (comparison.diffPercentage >= normalizedOptions.warnThreshold) {
139
184
  verdict = "warn";
140
185
  }
141
186
  if (verdict === "pass") {
@@ -0,0 +1,23 @@
1
+ export interface VitalsBudgetConfig {
2
+ lcp: number;
3
+ cls: number;
4
+ inp: number;
5
+ }
6
+ export interface VitalsMetric {
7
+ name: string;
8
+ value: number;
9
+ unit: string;
10
+ budget: number;
11
+ passed: boolean;
12
+ }
13
+ export interface VitalsBudgetResult {
14
+ passed: boolean;
15
+ metrics: VitalsMetric[];
16
+ lcp: number | null;
17
+ cls: number | null;
18
+ inp: number | null;
19
+ url: string;
20
+ skipped: boolean;
21
+ summary: string;
22
+ }
23
+ export declare function runVitalsBudget(url: string, budget?: Partial<VitalsBudgetConfig>): Promise<VitalsBudgetResult>;