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
@@ -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>;
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runVitalsBudget = runVitalsBudget;
4
+ /**
5
+ * Core Web Vitals budget check.
6
+ *
7
+ * Uses Puppeteer to measure LCP, CLS, and INP against configurable
8
+ * budgets. Does NOT claim FID — INP is the modern replacement.
9
+ * Blocker-capable: budget violations surface as warnings.
10
+ */
11
+ let puppeteerModule = null;
12
+ try {
13
+ puppeteerModule = require("puppeteer");
14
+ }
15
+ catch {
16
+ // Puppeteer not available — vitals budget will be skipped
17
+ }
18
+ const DEFAULT_BUDGET = {
19
+ lcp: 2500,
20
+ cls: 0.1,
21
+ inp: 200,
22
+ };
23
+ async function runVitalsBudget(url, budget) {
24
+ if (!puppeteerModule) {
25
+ return {
26
+ passed: true,
27
+ metrics: [],
28
+ lcp: null,
29
+ cls: null,
30
+ inp: null,
31
+ url,
32
+ skipped: true,
33
+ summary: "Skipped (puppeteer not available)",
34
+ };
35
+ }
36
+ const effectiveBudget = { ...DEFAULT_BUDGET, ...budget };
37
+ console.log(" Running Core Web Vitals budget check...");
38
+ const puppeteer = puppeteerModule.default || puppeteerModule;
39
+ let browser;
40
+ try {
41
+ browser = await puppeteer.launch({
42
+ headless: true,
43
+ args: [
44
+ "--no-sandbox",
45
+ "--disable-setuid-sandbox",
46
+ "--disable-extensions",
47
+ "--disable-component-update",
48
+ ],
49
+ });
50
+ const page = await browser.newPage();
51
+ await page.setViewport({ width: 1350, height: 940 });
52
+ // Inject web-vitals measurement script before navigation
53
+ await page.evaluateOnNewDocument(() => {
54
+ window.__laxyVitals = {};
55
+ // LCP
56
+ try {
57
+ new PerformanceObserver((list) => {
58
+ const entries = list.getEntries();
59
+ const last = entries[entries.length - 1];
60
+ if (last) {
61
+ window.__laxyVitals.lcp = last.startTime;
62
+ }
63
+ }).observe({ type: "largest-contentful-paint", buffered: true });
64
+ }
65
+ catch { }
66
+ // CLS
67
+ try {
68
+ let clsValue = 0;
69
+ new PerformanceObserver((list) => {
70
+ for (const entry of list.getEntries()) {
71
+ if (!entry.hadRecentInput) {
72
+ clsValue += entry.value;
73
+ }
74
+ }
75
+ window.__laxyVitals.cls = clsValue;
76
+ }).observe({ type: "layout-shift", buffered: true });
77
+ }
78
+ catch { }
79
+ // INP (Event Timing)
80
+ try {
81
+ let worstInp = 0;
82
+ new PerformanceObserver((list) => {
83
+ for (const entry of list.getEntries()) {
84
+ const duration = entry.duration;
85
+ if (duration > worstInp) {
86
+ worstInp = duration;
87
+ }
88
+ }
89
+ window.__laxyVitals.inp = worstInp;
90
+ }).observe({ type: "event", buffered: true });
91
+ }
92
+ catch { }
93
+ });
94
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
95
+ // Wait for LCP to stabilize
96
+ await new Promise((resolve) => setTimeout(resolve, 3000));
97
+ // Simulate some interactions to get INP
98
+ try {
99
+ await page.mouse.click(100, 100);
100
+ await new Promise((resolve) => setTimeout(resolve, 500));
101
+ await page.keyboard.press("Tab");
102
+ await new Promise((resolve) => setTimeout(resolve, 500));
103
+ }
104
+ catch {
105
+ // interaction may fail on minimal pages
106
+ }
107
+ // Wait a bit more for metrics to settle
108
+ await new Promise((resolve) => setTimeout(resolve, 2000));
109
+ const vitals = await page.evaluate(() => {
110
+ const v = window.__laxyVitals;
111
+ return {
112
+ lcp: v.lcp ?? 0,
113
+ cls: v.cls ?? 0,
114
+ inp: v.inp ?? 0,
115
+ };
116
+ });
117
+ const metrics = [
118
+ {
119
+ name: "LCP",
120
+ value: Math.round(vitals.lcp),
121
+ unit: "ms",
122
+ budget: effectiveBudget.lcp,
123
+ passed: vitals.lcp <= effectiveBudget.lcp,
124
+ },
125
+ {
126
+ name: "CLS",
127
+ value: Math.round(vitals.cls * 1000) / 1000,
128
+ unit: "score",
129
+ budget: effectiveBudget.cls,
130
+ passed: vitals.cls <= effectiveBudget.cls,
131
+ },
132
+ {
133
+ name: "INP",
134
+ value: Math.round(vitals.inp),
135
+ unit: "ms",
136
+ budget: effectiveBudget.inp,
137
+ passed: vitals.inp <= effectiveBudget.inp,
138
+ },
139
+ ];
140
+ const passed = metrics.every((m) => m.passed);
141
+ const failedMetrics = metrics.filter((m) => !m.passed);
142
+ const summary = passed
143
+ ? `LCP ${Math.round(vitals.lcp)}ms, CLS ${(vitals.cls).toFixed(3)}, INP ${Math.round(vitals.inp)}ms — within budget`
144
+ : failedMetrics.map((m) => `${m.name} ${m.value}${m.unit} > ${m.budget}${m.unit}`).join("; ");
145
+ console.log(` Vitals budget: ${summary}`);
146
+ for (const m of metrics) {
147
+ const status = m.passed ? "OK" : "OVER";
148
+ console.log(` ${m.name}: ${m.value}${m.unit} / ${m.budget}${m.unit} ${status}`);
149
+ }
150
+ return { passed, metrics, lcp: Math.round(vitals.lcp), cls: Math.round(vitals.cls * 1000) / 1000, inp: Math.round(vitals.inp), url, skipped: false, summary };
151
+ }
152
+ catch (err) {
153
+ console.error(` Vitals budget error: ${err instanceof Error ? err.message : String(err)}`);
154
+ return {
155
+ passed: true,
156
+ metrics: [],
157
+ lcp: null,
158
+ cls: null,
159
+ inp: null,
160
+ url,
161
+ skipped: true,
162
+ summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
163
+ };
164
+ }
165
+ finally {
166
+ await browser?.close();
167
+ }
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laxy-verify",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Frontend verification CLI for build checks, Lighthouse, E2E, and release readiness",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",