laxy-verify 1.2.2 → 1.3.0

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.
@@ -1,178 +1,223 @@
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
- var __importDefault = (this && this.__importDefault) || function (mod) {
36
- return (mod && mod.__esModule) ? mod : { "default": mod };
37
- };
38
- Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.formatVisualDiffSummary = formatVisualDiffSummary;
40
- exports.runVisualDiff = runVisualDiff;
41
- const fs = __importStar(require("node:fs"));
42
- const path = __importStar(require("node:path"));
43
- const puppeteer_1 = __importDefault(require("puppeteer"));
44
- const pngjs_1 = require("pngjs");
45
- const VISUAL_DIFF_VIEWPORTS = [
46
- { viewport: "desktop", width: 1280, height: 720 },
47
- { viewport: "tablet", width: 768, height: 1024 },
48
- { viewport: "mobile", width: 375, height: 812, isMobile: true, deviceScaleFactor: 2 },
49
- ];
50
- function formatViewportResult(viewport) {
51
- return viewport.hasBaseline
52
- ? `${viewport.viewport} ${viewport.diffPercentage}% (${viewport.verdict})`
53
- : `${viewport.viewport} baseline seeded`;
54
- }
55
- function formatVisualDiffSummary(result) {
56
- return result.viewports.map(formatViewportResult).join(", ");
57
- }
58
- function ensureDir(dir) {
59
- fs.mkdirSync(dir, { recursive: true });
60
- }
61
- async function captureScreenshot(url, outputPath, viewport) {
62
- const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
63
- try {
64
- const page = await browser.newPage();
65
- await page.setViewport({
66
- width: viewport.width,
67
- height: viewport.height,
68
- isMobile: viewport.isMobile ?? false,
69
- deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
70
- });
71
- await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
72
- await page.waitForSelector("body", { timeout: 5000 });
73
- await page.screenshot({ path: outputPath, fullPage: true, type: "png" });
74
- }
75
- finally {
76
- await browser.close();
77
- }
78
- }
79
- async function loadPixelmatch() {
80
- const dynamicImport = new Function("specifier", "return import(specifier)");
81
- const mod = await dynamicImport("pixelmatch");
82
- return mod.default;
83
- }
84
- async function compareImages(baselinePath, currentPath, diffOutputPath) {
85
- const baselinePng = pngjs_1.PNG.sync.read(fs.readFileSync(baselinePath));
86
- const currentPng = pngjs_1.PNG.sync.read(fs.readFileSync(currentPath));
87
- const width = Math.min(baselinePng.width, currentPng.width);
88
- const height = Math.min(baselinePng.height, currentPng.height);
89
- const cropData = (png, w, h) => {
90
- if (png.width === w && png.height === h)
91
- return png.data;
92
- const cropped = Buffer.alloc(w * h * 4);
93
- for (let y = 0; y < h; y++) {
94
- png.data.copy(cropped, y * w * 4, y * png.width * 4, y * png.width * 4 + w * 4);
95
- }
96
- return cropped;
97
- };
98
- const baseData = cropData(baselinePng, width, height);
99
- const currData = cropData(currentPng, width, height);
100
- const diff = new pngjs_1.PNG({ width, height });
101
- const pixelmatch = await loadPixelmatch();
102
- const diffPixels = pixelmatch(baseData, currData, diff.data, width, height, { threshold: 0.1 });
103
- ensureDir(path.dirname(diffOutputPath));
104
- fs.writeFileSync(diffOutputPath, pngjs_1.PNG.sync.write(diff));
105
- const totalPixels = width * height;
106
- const diffPercentage = Math.round((diffPixels / totalPixels) * 10000) / 100;
107
- return { diffPixels, totalPixels, diffPercentage };
108
- }
109
- async function runVisualDiff(projectDir, url, label = "current") {
110
- const dir = path.join(projectDir, ".laxy-verify", "visual");
111
- ensureDir(dir);
112
- const viewportResults = [];
113
- for (const viewport of VISUAL_DIFF_VIEWPORTS) {
114
- const baselinePath = path.join(dir, `${viewport.viewport}.baseline.png`);
115
- const currentPath = path.join(dir, `${label}.${viewport.viewport}.png`);
116
- const diffPath = path.join(dir, `${label}.${viewport.viewport}.diff.png`);
117
- await captureScreenshot(url, currentPath, viewport);
118
- if (!fs.existsSync(baselinePath)) {
119
- fs.copyFileSync(currentPath, baselinePath);
120
- viewportResults.push({
121
- viewport: viewport.viewport,
122
- hasBaseline: false,
123
- diffPercentage: 0,
124
- verdict: "pass",
125
- diffPixels: 0,
126
- totalPixels: 0,
127
- baselinePath,
128
- currentPath,
129
- diffPath: "",
130
- });
131
- continue;
132
- }
133
- const comparison = await compareImages(baselinePath, currentPath, diffPath);
134
- let verdict = "pass";
135
- if (comparison.diffPercentage >= 60) {
136
- verdict = "rollback";
137
- }
138
- else if (comparison.diffPercentage >= 30) {
139
- verdict = "warn";
140
- }
141
- if (verdict === "pass") {
142
- fs.copyFileSync(currentPath, baselinePath);
143
- }
144
- viewportResults.push({
145
- viewport: viewport.viewport,
146
- hasBaseline: true,
147
- diffPercentage: comparison.diffPercentage,
148
- verdict,
149
- diffPixels: comparison.diffPixels,
150
- totalPixels: comparison.totalPixels,
151
- baselinePath,
152
- currentPath,
153
- diffPath,
154
- });
155
- }
156
- const comparableResults = viewportResults.filter((viewport) => viewport.hasBaseline);
157
- const summary = viewportResults.map(formatViewportResult).join(", ");
158
- const worstVerdict = comparableResults.some((viewport) => viewport.verdict === "rollback")
159
- ? "rollback"
160
- : comparableResults.some((viewport) => viewport.verdict === "warn")
161
- ? "warn"
162
- : "pass";
163
- const maxDiffPercentage = comparableResults.reduce((max, viewport) => Math.max(max, viewport.diffPercentage), 0);
164
- const totalDiffPixels = comparableResults.reduce((sum, viewport) => sum + viewport.diffPixels, 0);
165
- const totalPixels = comparableResults.reduce((sum, viewport) => sum + viewport.totalPixels, 0);
166
- return {
167
- hasBaseline: comparableResults.length === viewportResults.length,
168
- diffPercentage: Math.round(maxDiffPercentage * 100) / 100,
169
- verdict: worstVerdict,
170
- diffPixels: totalDiffPixels,
171
- totalPixels,
172
- baselinePath: viewportResults[0]?.baselinePath ?? "",
173
- currentPath: viewportResults[0]?.currentPath ?? "",
174
- diffPath: viewportResults[0]?.diffPath ?? "",
175
- viewports: viewportResults,
176
- summary,
177
- };
178
- }
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
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.formatVisualDiffSummary = formatVisualDiffSummary;
40
+ exports.runVisualDiff = runVisualDiff;
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
43
+ const puppeteer_1 = __importDefault(require("puppeteer"));
44
+ const pngjs_1 = require("pngjs");
45
+ const VISUAL_DIFF_VIEWPORTS = [
46
+ { viewport: "desktop", width: 1280, height: 720 },
47
+ { viewport: "tablet", width: 768, height: 1024 },
48
+ { viewport: "mobile", width: 375, height: 812, isMobile: true, deviceScaleFactor: 2 },
49
+ ];
50
+ function formatViewportResult(viewport) {
51
+ return viewport.hasBaseline
52
+ ? `${viewport.viewport} ${viewport.diffPercentage}% (${viewport.verdict})`
53
+ : `${viewport.viewport} baseline seeded`;
54
+ }
55
+ function formatVisualDiffSummary(result) {
56
+ return result.viewports.map(formatViewportResult).join(", ");
57
+ }
58
+ function ensureDir(dir) {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ }
61
+ async function captureScreenshot(url, outputPath, viewport, options) {
62
+ const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
63
+ try {
64
+ const page = await browser.newPage();
65
+ await page.setViewport({
66
+ width: viewport.width,
67
+ height: viewport.height,
68
+ isMobile: viewport.isMobile ?? false,
69
+ deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
70
+ });
71
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
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));
109
+ await page.screenshot({ path: outputPath, fullPage: true, type: "png" });
110
+ }
111
+ finally {
112
+ await browser.close();
113
+ }
114
+ }
115
+ async function loadPixelmatch() {
116
+ const dynamicImport = new Function("specifier", "return import(specifier)");
117
+ const mod = await dynamicImport("pixelmatch");
118
+ return mod.default;
119
+ }
120
+ async function compareImages(baselinePath, currentPath, diffOutputPath, options) {
121
+ const baselinePng = pngjs_1.PNG.sync.read(fs.readFileSync(baselinePath));
122
+ const currentPng = pngjs_1.PNG.sync.read(fs.readFileSync(currentPath));
123
+ const width = Math.min(baselinePng.width, currentPng.width);
124
+ const height = Math.min(baselinePng.height, currentPng.height);
125
+ const cropData = (png, w, h) => {
126
+ if (png.width === w && png.height === h)
127
+ return png.data;
128
+ const cropped = Buffer.alloc(w * h * 4);
129
+ for (let y = 0; y < h; y++) {
130
+ png.data.copy(cropped, y * w * 4, y * png.width * 4, y * png.width * 4 + w * 4);
131
+ }
132
+ return cropped;
133
+ };
134
+ const baseData = cropData(baselinePng, width, height);
135
+ const currData = cropData(currentPng, width, height);
136
+ const diff = new pngjs_1.PNG({ width, height });
137
+ const pixelmatch = await loadPixelmatch();
138
+ const diffPixels = pixelmatch(baseData, currData, diff.data, width, height, {
139
+ threshold: options.pixelmatchThreshold,
140
+ });
141
+ ensureDir(path.dirname(diffOutputPath));
142
+ fs.writeFileSync(diffOutputPath, pngjs_1.PNG.sync.write(diff));
143
+ const totalPixels = width * height;
144
+ const diffPercentage = Math.round((diffPixels / totalPixels) * 10000) / 100;
145
+ return { diffPixels, totalPixels, diffPercentage };
146
+ }
147
+ async function runVisualDiff(projectDir, url, label = "current", options = {}) {
148
+ const dir = path.join(projectDir, ".laxy-verify", "visual");
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
+ };
157
+ const viewportResults = [];
158
+ for (const viewport of VISUAL_DIFF_VIEWPORTS) {
159
+ const baselinePath = path.join(dir, `${viewport.viewport}.baseline.png`);
160
+ const currentPath = path.join(dir, `${label}.${viewport.viewport}.png`);
161
+ const diffPath = path.join(dir, `${label}.${viewport.viewport}.diff.png`);
162
+ await captureScreenshot(url, currentPath, viewport, normalizedOptions);
163
+ if (!fs.existsSync(baselinePath)) {
164
+ fs.copyFileSync(currentPath, baselinePath);
165
+ viewportResults.push({
166
+ viewport: viewport.viewport,
167
+ hasBaseline: false,
168
+ diffPercentage: 0,
169
+ verdict: "pass",
170
+ diffPixels: 0,
171
+ totalPixels: 0,
172
+ baselinePath,
173
+ currentPath,
174
+ diffPath: "",
175
+ });
176
+ continue;
177
+ }
178
+ const comparison = await compareImages(baselinePath, currentPath, diffPath, normalizedOptions);
179
+ let verdict = "pass";
180
+ if (comparison.diffPercentage >= normalizedOptions.rollbackThreshold) {
181
+ verdict = "rollback";
182
+ }
183
+ else if (comparison.diffPercentage >= normalizedOptions.warnThreshold) {
184
+ verdict = "warn";
185
+ }
186
+ if (verdict === "pass") {
187
+ fs.copyFileSync(currentPath, baselinePath);
188
+ }
189
+ viewportResults.push({
190
+ viewport: viewport.viewport,
191
+ hasBaseline: true,
192
+ diffPercentage: comparison.diffPercentage,
193
+ verdict,
194
+ diffPixels: comparison.diffPixels,
195
+ totalPixels: comparison.totalPixels,
196
+ baselinePath,
197
+ currentPath,
198
+ diffPath,
199
+ });
200
+ }
201
+ const comparableResults = viewportResults.filter((viewport) => viewport.hasBaseline);
202
+ const summary = viewportResults.map(formatViewportResult).join(", ");
203
+ const worstVerdict = comparableResults.some((viewport) => viewport.verdict === "rollback")
204
+ ? "rollback"
205
+ : comparableResults.some((viewport) => viewport.verdict === "warn")
206
+ ? "warn"
207
+ : "pass";
208
+ const maxDiffPercentage = comparableResults.reduce((max, viewport) => Math.max(max, viewport.diffPercentage), 0);
209
+ const totalDiffPixels = comparableResults.reduce((sum, viewport) => sum + viewport.diffPixels, 0);
210
+ const totalPixels = comparableResults.reduce((sum, viewport) => sum + viewport.totalPixels, 0);
211
+ return {
212
+ hasBaseline: comparableResults.length === viewportResults.length,
213
+ diffPercentage: Math.round(maxDiffPercentage * 100) / 100,
214
+ verdict: worstVerdict,
215
+ diffPixels: totalDiffPixels,
216
+ totalPixels,
217
+ baselinePath: viewportResults[0]?.baselinePath ?? "",
218
+ currentPath: viewportResults[0]?.currentPath ?? "",
219
+ diffPath: viewportResults[0]?.diffPath ?? "",
220
+ viewports: viewportResults,
221
+ summary,
222
+ };
223
+ }
@@ -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.3.0",
4
4
  "description": "Frontend verification CLI for build checks, Lighthouse, E2E, and release readiness",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",