laxy-verify 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +181 -47
- package/dist/a11y-deep.d.ts +20 -0
- package/dist/a11y-deep.js +161 -0
- package/dist/ai-analysis.d.ts +28 -0
- package/dist/ai-analysis.js +32 -0
- package/dist/audit/broken-links.d.ts +5 -1
- package/dist/audit/broken-links.js +23 -12
- package/dist/bundle-size.d.ts +14 -0
- package/dist/bundle-size.js +209 -0
- package/dist/cli.js +391 -13
- package/dist/compare-env.d.ts +23 -0
- package/dist/compare-env.js +55 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +106 -1
- package/dist/entitlement.d.ts +2 -0
- package/dist/entitlement.js +5 -1
- package/dist/init-analysis.d.ts +6 -0
- package/dist/init-analysis.js +302 -0
- package/dist/init.js +66 -0
- package/dist/lighthouse.d.ts +31 -1
- package/dist/lighthouse.js +76 -3
- package/dist/outdated-check.d.ts +17 -0
- package/dist/outdated-check.js +123 -0
- package/dist/report-markdown.d.ts +14 -0
- package/dist/report-markdown.js +21 -0
- package/dist/route-discovery.d.ts +7 -0
- package/dist/route-discovery.js +108 -0
- package/dist/secret-scan.d.ts +15 -0
- package/dist/secret-scan.js +218 -0
- package/dist/security-audit.d.ts +9 -1
- package/dist/security-audit.js +87 -24
- package/dist/seo-deep.d.ts +24 -0
- package/dist/seo-deep.js +147 -0
- package/dist/typecheck.d.ts +8 -0
- package/dist/typecheck.js +99 -0
- package/dist/verification-core/report.js +117 -0
- package/dist/verification-core/types.d.ts +58 -2
- package/dist/visual-diff.d.ts +8 -1
- package/dist/visual-diff.js +53 -8
- package/dist/vitals-budget.d.ts +23 -0
- package/dist/vitals-budget.js +168 -0
- package/package.json +1 -1
package/dist/visual-diff.js
CHANGED
|
@@ -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, {
|
|
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 >=
|
|
180
|
+
if (comparison.diffPercentage >= normalizedOptions.rollbackThreshold) {
|
|
136
181
|
verdict = "rollback";
|
|
137
182
|
}
|
|
138
|
-
else if (comparison.diffPercentage >=
|
|
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
|
+
}
|