laxy-verify 1.1.8 → 1.1.9

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/dist/cli.js CHANGED
@@ -52,6 +52,8 @@ const status_js_1 = require("./status.js");
52
52
  const auth_js_1 = require("./auth.js");
53
53
  const entitlement_js_1 = require("./entitlement.js");
54
54
  const multi_viewport_js_1 = require("./multi-viewport.js");
55
+ const e2e_js_1 = require("./e2e.js");
56
+ const visual_diff_js_1 = require("./visual-diff.js");
55
57
  const index_js_1 = require("./verification-core/index.js");
56
58
  const package_json_1 = __importDefault(require("../package.json"));
57
59
  function exitGracefully(code) {
@@ -188,6 +190,12 @@ function consoleOutput(result) {
188
190
  else {
189
191
  console.log(` Lighthouse: skipped`);
190
192
  }
193
+ if (result.e2e) {
194
+ console.log(` E2E: ${result.e2e.passed}/${result.e2e.total} passed`);
195
+ }
196
+ if (result.visualDiff) {
197
+ console.log(` Visual diff: ${result.visualDiff.diffPercentage}% (${result.visualDiff.verdict})`);
198
+ }
191
199
  if (result.verification) {
192
200
  const view = result.verification.view;
193
201
  console.log(` Verification tier: ${view.tier}`);
@@ -401,11 +409,15 @@ async function run() {
401
409
  // Phase 2: Dev server + Lighthouse (only if build succeeded and not skipped)
402
410
  let multiViewportScores = null;
403
411
  let allViewportsOk = false;
412
+ let e2eResult;
413
+ let visualDiffResult = null;
404
414
  if (buildResult.success && !args.skipLighthouse) {
405
415
  let servePid;
406
416
  try {
407
417
  const serve = await (0, serve_js_1.startDevServer)(devCmd, port, config.dev_timeout, args.projectDir);
408
418
  servePid = serve.pid;
419
+ const verifyUrl = `http://127.0.0.1:${port}/`;
420
+ const verificationTier = (0, index_js_1.planToVerificationTier)(features.plan);
409
421
  try {
410
422
  const lhResult = await (0, lighthouse_js_1.runLighthouse)(port, config.lighthouse_runs);
411
423
  scores = lhResult.scores ?? undefined;
@@ -436,6 +448,26 @@ async function run() {
436
448
  console.error(`Multi-viewport error: ${mvErr instanceof Error ? mvErr.message : String(mvErr)}`);
437
449
  }
438
450
  }
451
+ try {
452
+ const e2eRuns = await (0, e2e_js_1.runVerifyE2E)(verifyUrl, verificationTier);
453
+ e2eResult = {
454
+ passed: e2eRuns.passed,
455
+ failed: e2eRuns.failed,
456
+ total: e2eRuns.results.length,
457
+ results: e2eRuns.results,
458
+ };
459
+ }
460
+ catch (e2eErr) {
461
+ console.error(`E2E error: ${e2eErr instanceof Error ? e2eErr.message : String(e2eErr)}`);
462
+ }
463
+ if (verificationTier === "pro_plus") {
464
+ try {
465
+ visualDiffResult = await (0, visual_diff_js_1.runVisualDiff)(args.projectDir, verifyUrl, "verify");
466
+ }
467
+ catch (visualErr) {
468
+ console.error(`Visual diff error: ${visualErr instanceof Error ? visualErr.message : String(visualErr)}`);
469
+ }
470
+ }
439
471
  }
440
472
  catch (serveErr) {
441
473
  console.error(`Dev server error: ${serveErr instanceof Error ? serveErr.message : String(serveErr)}`);
@@ -446,28 +478,38 @@ async function run() {
446
478
  }
447
479
  }
448
480
  }
449
- // Calculate grade
450
- const gradeResult = (0, grade_js_1.calculateGrade)({
451
- buildSuccess: buildResult.success,
452
- scores,
453
- thresholds: adjustedThresholds,
454
- failOn: config.fail_on,
455
- goldEligible: features.gold_grade && allViewportsOk,
456
- });
457
481
  const verificationTier = (0, index_js_1.planToVerificationTier)(features.plan);
458
482
  const viewportSummary = summarizeViewportIssues(multiViewportScores, adjustedThresholds);
459
483
  const failureEvidence = [
460
484
  ...buildResult.errors.slice(0, 3).map((error) => `Build: ${error}`),
485
+ ...(e2eResult
486
+ ? e2eResult.results
487
+ .filter((scenario) => !scenario.passed)
488
+ .slice(0, 2)
489
+ .map((scenario) => `E2E: ${scenario.name}${scenario.error ? ` - ${scenario.error}` : ""}`)
490
+ : []),
461
491
  ...(viewportSummary.count > 0 && viewportSummary.summary
462
492
  ? [`Viewport: ${viewportSummary.summary}`]
463
493
  : []),
494
+ ...(visualDiffResult
495
+ ? [
496
+ visualDiffResult.hasBaseline
497
+ ? `Visual diff: ${visualDiffResult.diffPercentage}% (${visualDiffResult.verdict})`
498
+ : "Visual diff: baseline seeded",
499
+ ]
500
+ : []),
464
501
  ];
465
502
  const verificationReport = (0, index_js_1.buildVerificationReport)({
466
503
  buildSuccess: buildResult.success,
467
504
  buildErrors: buildResult.errors,
505
+ e2ePassed: e2eResult?.passed,
506
+ e2eTotal: e2eResult?.total,
468
507
  viewportIssues: multiViewportScores ? viewportSummary.count : undefined,
469
508
  multiViewportPassed: multiViewportScores ? allViewportsOk : undefined,
470
509
  multiViewportSummary: multiViewportScores ? viewportSummary.summary : undefined,
510
+ visualDiffVerdict: visualDiffResult?.verdict,
511
+ visualDiffPercentage: visualDiffResult?.diffPercentage,
512
+ hasVisualBaseline: visualDiffResult?.hasBaseline,
471
513
  lighthouseScores: scores,
472
514
  failureEvidence,
473
515
  }, {
@@ -475,20 +517,28 @@ async function run() {
475
517
  thresholds: adjustedThresholds,
476
518
  });
477
519
  const verificationView = (0, index_js_1.getTierVerificationView)(verificationReport);
520
+ const unifiedGrade = verificationReport.grade;
521
+ const exitCode = config.fail_on === "unverified"
522
+ ? 0
523
+ : (0, grade_js_1.isWorseOrEqual)(unifiedGrade, config.fail_on)
524
+ ? 1
525
+ : 0;
478
526
  // Build result object
479
527
  const resultObj = {
480
- grade: gradeResult.grade.charAt(0).toUpperCase() + gradeResult.grade.slice(1), // Capitalize
528
+ grade: unifiedGrade.charAt(0).toUpperCase() + unifiedGrade.slice(1),
481
529
  timestamp: new Date().toISOString(),
482
530
  build: {
483
531
  success: buildResult.success,
484
532
  durationMs: buildResult.durationMs,
485
533
  errors: buildResult.errors,
486
534
  },
535
+ e2e: e2eResult,
487
536
  lighthouse: lighthouseResult,
537
+ visualDiff: visualDiffResult,
488
538
  thresholds: adjustedThresholds,
489
539
  ciMode: config.ciMode,
490
540
  framework: detected.framework,
491
- exitCode: gradeResult.exitCode,
541
+ exitCode,
492
542
  config_fail_on: config.fail_on,
493
543
  _plan: features.plan,
494
544
  verification: {
@@ -524,7 +574,7 @@ async function run() {
524
574
  if (inGitHubActions && process.env.GITHUB_OUTPUT) {
525
575
  fs.appendFileSync(process.env.GITHUB_OUTPUT, `grade=${resultObj.grade}\n`);
526
576
  }
527
- exitGracefully(gradeResult.exitCode);
577
+ exitGracefully(exitCode);
528
578
  }
529
579
  run().catch((err) => {
530
580
  console.error(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
package/dist/e2e.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { VerificationTier } from "./verification-core/types.js";
2
+ export interface E2EStep {
3
+ type: "click" | "fill" | "check_visible" | "wait" | "scroll" | "clear_fill";
4
+ selector?: string;
5
+ value?: string;
6
+ duration?: number;
7
+ description: string;
8
+ }
9
+ export interface E2EScenario {
10
+ name: string;
11
+ steps: E2EStep[];
12
+ }
13
+ export interface E2EStepResult {
14
+ description: string;
15
+ passed: boolean;
16
+ error?: string;
17
+ }
18
+ export interface E2EScenarioResult {
19
+ name: string;
20
+ passed: boolean;
21
+ steps: E2EStepResult[];
22
+ error?: string;
23
+ }
24
+ export declare function runVerifyE2E(url: string, tier: VerificationTier): Promise<{
25
+ scenarios: E2EScenario[];
26
+ results: E2EScenarioResult[];
27
+ passed: number;
28
+ failed: number;
29
+ }>;
package/dist/e2e.js ADDED
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runVerifyE2E = runVerifyE2E;
7
+ const puppeteer_1 = __importDefault(require("puppeteer"));
8
+ function normalize(value) {
9
+ return value.toLowerCase().replace(/\s+/g, " ").trim();
10
+ }
11
+ function pickSelector(candidates, patterns) {
12
+ for (const pattern of patterns) {
13
+ const match = candidates.find((candidate) => pattern.test(normalize(candidate)));
14
+ if (match)
15
+ return match;
16
+ }
17
+ return undefined;
18
+ }
19
+ function getScenarioLimit(tier) {
20
+ switch (tier) {
21
+ case "pro":
22
+ return 4;
23
+ case "pro_plus":
24
+ return 5;
25
+ default:
26
+ return 2;
27
+ }
28
+ }
29
+ function getVisibleAnchor(selectors) {
30
+ return pickSelector(selectors, [/^main$/, /^form$/, /^section$/, /^h1$/, /^h2$/, /^button/, /^a\[href/]) || "body";
31
+ }
32
+ function getClickTarget(selectors) {
33
+ return pickSelector(selectors, [
34
+ /^button\[type=['"]submit['"]\]/,
35
+ /^input\[type=['"]submit['"]\]/,
36
+ /^button\[aria-label.*(submit|continue|login|sign in|save|start|search|next)/,
37
+ /^input\[type=['"]checkbox['"]\]/,
38
+ /^\[role=['"]checkbox['"]\]$/,
39
+ /^a\[href=['"]\//,
40
+ /^button/,
41
+ /^\[role=['"]button['"]\]$/,
42
+ ]);
43
+ }
44
+ function getFillTarget(selectors) {
45
+ return pickSelector(selectors, [
46
+ /^input\[type=['"]email['"]\]/,
47
+ /^input\[type=['"]text['"]\]/,
48
+ /^input\[name.*(email|query|search|name)/,
49
+ /^input\[placeholder.*(email|search|name|message|title)/,
50
+ /^textarea$/,
51
+ /^input/,
52
+ ]);
53
+ }
54
+ function getFeedbackTarget(selectors) {
55
+ return pickSelector(selectors, [
56
+ /^\[role=['"]alert['"]\]$/,
57
+ /^\[role=['"]status['"]\]$/,
58
+ /^\[aria-live=['"](polite|assertive)['"]\]$/,
59
+ /^\[data-testid.*(error|success|toast|alert|notice|result)/,
60
+ /^\.(error|alert|toast|notice|success|status)/,
61
+ ]);
62
+ }
63
+ function getValidationProbeSelector(feedbackTarget) {
64
+ return (feedbackTarget ||
65
+ "[role='alert'], [role='status'], [aria-live='polite'], [aria-live='assertive'], [data-testid*='error'], [data-testid*='success']");
66
+ }
67
+ function buildVerifyScenarios(snapshot, tier) {
68
+ const selectors = [...snapshot.selectors, ...snapshot.structures];
69
+ const visibleAnchor = getVisibleAnchor(selectors);
70
+ const fillTarget = getFillTarget(selectors);
71
+ const clickTarget = getClickTarget(selectors);
72
+ const feedbackTarget = getFeedbackTarget(selectors);
73
+ const localLinkTarget = pickSelector(selectors, [/^a\[href=['"]\//]);
74
+ const likelyFormSurface = selectors.some((selector) => /^form$/.test(normalize(selector))) ||
75
+ selectors.some((selector) => /^input|^textarea/.test(normalize(selector)));
76
+ const scenarios = [
77
+ {
78
+ name: "Initial render",
79
+ steps: [
80
+ { type: "wait", duration: 1200, description: "Wait for hydration" },
81
+ { type: "check_visible", selector: "body", description: "Body should render" },
82
+ { type: "check_visible", selector: visibleAnchor, description: "Core UI should stay visible" },
83
+ ],
84
+ },
85
+ ];
86
+ if (fillTarget && likelyFormSurface) {
87
+ const formScenario = {
88
+ name: "Primary form interaction",
89
+ steps: [
90
+ { type: "check_visible", selector: fillTarget, description: "Input surface should be visible" },
91
+ { type: "clear_fill", selector: fillTarget, value: "laxy verify", description: "Fill a core input field" },
92
+ ],
93
+ };
94
+ if (clickTarget) {
95
+ formScenario.steps.push({ type: "click", selector: clickTarget, description: "Trigger the primary CTA" }, { type: "wait", duration: 800, description: "Wait for UI response" }, { type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Feedback or surface should remain visible" });
96
+ }
97
+ scenarios.push(formScenario);
98
+ }
99
+ else if (clickTarget) {
100
+ scenarios.push({
101
+ name: "Primary CTA interaction",
102
+ steps: [
103
+ { type: "check_visible", selector: clickTarget, description: "CTA should be visible" },
104
+ { type: "click", selector: clickTarget, description: "Trigger the primary CTA" },
105
+ { type: "wait", duration: 800, description: "Wait for UI response" },
106
+ { type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Core surface should stay visible" },
107
+ ],
108
+ });
109
+ }
110
+ if (tier !== "free" && fillTarget && clickTarget) {
111
+ scenarios.push({
112
+ name: "Validation feedback",
113
+ steps: [
114
+ { type: "clear_fill", selector: fillTarget, value: "", description: "Clear the required input" },
115
+ { type: "click", selector: clickTarget, description: "Try the CTA without valid input" },
116
+ { type: "wait", duration: 700, description: "Wait for validation" },
117
+ { type: "check_visible", selector: getValidationProbeSelector(feedbackTarget), description: "Validation feedback should appear" },
118
+ ],
119
+ });
120
+ }
121
+ if (tier !== "free" && localLinkTarget) {
122
+ scenarios.push({
123
+ name: "Internal navigation",
124
+ steps: [
125
+ { type: "check_visible", selector: localLinkTarget, description: "Internal link should be visible" },
126
+ { type: "click", selector: localLinkTarget, description: "Navigate using an internal link" },
127
+ { type: "wait", duration: 1000, description: "Wait for navigation" },
128
+ { type: "check_visible", selector: "body", description: "Destination page should render" },
129
+ ],
130
+ });
131
+ }
132
+ if (tier === "pro_plus" && clickTarget && fillTarget && clickTarget !== fillTarget) {
133
+ scenarios.push({
134
+ name: "Repeated interaction stability",
135
+ steps: [
136
+ { type: "check_visible", selector: fillTarget, description: "Input surface should still exist" },
137
+ { type: "clear_fill", selector: fillTarget, value: "release confidence", description: "Repeat the core input" },
138
+ { type: "click", selector: clickTarget, description: "Trigger the CTA again" },
139
+ { type: "wait", duration: 800, description: "Wait for repeated interaction response" },
140
+ { type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Surface should still hold after repeat" },
141
+ ],
142
+ });
143
+ }
144
+ scenarios.push({
145
+ name: "Scroll stability",
146
+ steps: [
147
+ { type: "check_visible", selector: visibleAnchor, description: "Initial content should render" },
148
+ { type: "scroll", selector: "body", description: "Page should scroll" },
149
+ { type: "wait", duration: 500, description: "Wait after scrolling" },
150
+ { type: "check_visible", selector: "body", description: "Page should remain stable after scroll" },
151
+ ],
152
+ });
153
+ return scenarios.slice(0, getScenarioLimit(tier));
154
+ }
155
+ async function captureDomSnapshot(url) {
156
+ const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
157
+ try {
158
+ const page = await browser.newPage();
159
+ await page.setViewport({ width: 1280, height: 720 });
160
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
161
+ await page.waitForSelector("body", { timeout: 5000 });
162
+ const snapshot = await page.evaluate(() => {
163
+ const selectors = [];
164
+ const structures = [];
165
+ const nodes = Array.from(document.querySelectorAll("*")).slice(0, 250);
166
+ for (const node of nodes) {
167
+ const tag = node.tagName.toLowerCase();
168
+ const role = node.getAttribute("role");
169
+ const type = node.getAttribute("type");
170
+ const name = node.getAttribute("name");
171
+ const placeholder = node.getAttribute("placeholder");
172
+ const href = node.getAttribute("href");
173
+ const ariaLabel = node.getAttribute("aria-label");
174
+ const dataTestId = node.getAttribute("data-testid");
175
+ const ariaLive = node.getAttribute("aria-live");
176
+ if (["main", "form", "section", "header", "nav", "footer", "h1", "h2"].includes(tag)) {
177
+ structures.push(tag);
178
+ }
179
+ if (tag === "button")
180
+ selectors.push("button");
181
+ if (role === "button")
182
+ selectors.push("[role='button']");
183
+ if (type === "submit")
184
+ selectors.push(`${tag}[type='submit']`);
185
+ if (type === "checkbox")
186
+ selectors.push(`${tag}[type='checkbox']`);
187
+ if (tag === "input" || tag === "textarea")
188
+ selectors.push(tag);
189
+ if (type)
190
+ selectors.push(`${tag}[type='${type}']`);
191
+ if (name)
192
+ selectors.push(`${tag}[name='${name}']`);
193
+ if (placeholder)
194
+ selectors.push(`${tag}[placeholder='${placeholder}']`);
195
+ if (href && href.startsWith("/"))
196
+ selectors.push(`a[href='${href}']`);
197
+ if (ariaLabel)
198
+ selectors.push(`${tag}[aria-label='${ariaLabel}']`);
199
+ if (role === "alert" || role === "status")
200
+ selectors.push(`[role='${role}']`);
201
+ if (ariaLive)
202
+ selectors.push(`[aria-live='${ariaLive}']`);
203
+ if (dataTestId)
204
+ selectors.push(`[data-testid='${dataTestId}']`);
205
+ }
206
+ return {
207
+ selectors: Array.from(new Set(selectors)),
208
+ structures: Array.from(new Set(structures)),
209
+ };
210
+ });
211
+ return snapshot;
212
+ }
213
+ finally {
214
+ await browser.close();
215
+ }
216
+ }
217
+ async function executeScenario(url, scenario) {
218
+ const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
219
+ const stepResults = [];
220
+ try {
221
+ const page = await browser.newPage();
222
+ await page.setViewport({ width: 1280, height: 720 });
223
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
224
+ await page.waitForSelector("body", { timeout: 5000 });
225
+ await new Promise((resolve) => setTimeout(resolve, 1000));
226
+ for (const step of scenario.steps) {
227
+ const result = { description: step.description, passed: false };
228
+ try {
229
+ switch (step.type) {
230
+ case "click":
231
+ if (!step.selector)
232
+ throw new Error("Missing selector");
233
+ await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
234
+ await page.click(step.selector);
235
+ break;
236
+ case "fill":
237
+ case "clear_fill":
238
+ if (!step.selector)
239
+ throw new Error("Missing selector");
240
+ await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
241
+ await page.click(step.selector, { clickCount: 3 });
242
+ await page.keyboard.press("Backspace");
243
+ if (step.value) {
244
+ await page.type(step.selector, step.value);
245
+ }
246
+ break;
247
+ case "check_visible":
248
+ if (!step.selector)
249
+ throw new Error("Missing selector");
250
+ await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
251
+ break;
252
+ case "wait":
253
+ await new Promise((resolve) => setTimeout(resolve, step.duration ?? 1000));
254
+ break;
255
+ case "scroll":
256
+ if (step.selector && step.selector !== "body") {
257
+ await page.$eval(step.selector, (element) => {
258
+ element.scrollIntoView({ behavior: "instant", block: "center" });
259
+ });
260
+ }
261
+ else {
262
+ await page.evaluate(() => window.scrollBy(0, 300));
263
+ }
264
+ break;
265
+ }
266
+ result.passed = true;
267
+ }
268
+ catch (error) {
269
+ result.passed = false;
270
+ result.error = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
271
+ }
272
+ stepResults.push(result);
273
+ if (!result.passed)
274
+ break;
275
+ }
276
+ return {
277
+ name: scenario.name,
278
+ passed: stepResults.every((step) => step.passed),
279
+ steps: stepResults,
280
+ };
281
+ }
282
+ catch (error) {
283
+ return {
284
+ name: scenario.name,
285
+ passed: false,
286
+ steps: stepResults,
287
+ error: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
288
+ };
289
+ }
290
+ finally {
291
+ await browser.close();
292
+ }
293
+ }
294
+ async function runVerifyE2E(url, tier) {
295
+ const snapshot = await captureDomSnapshot(url);
296
+ const scenarios = buildVerifyScenarios(snapshot, tier);
297
+ const results = [];
298
+ for (const scenario of scenarios) {
299
+ results.push(await executeScenario(url, scenario));
300
+ }
301
+ const passed = results.filter((result) => result.passed).length;
302
+ const failed = results.length - passed;
303
+ return { scenarios, results, passed, failed };
304
+ }
@@ -0,0 +1,11 @@
1
+ export interface VisualDiffResult {
2
+ hasBaseline: boolean;
3
+ diffPercentage: number;
4
+ verdict: "pass" | "warn" | "rollback";
5
+ diffPixels: number;
6
+ totalPixels: number;
7
+ baselinePath: string;
8
+ currentPath: string;
9
+ diffPath: string;
10
+ }
11
+ export declare function runVisualDiff(projectDir: string, url: string, label?: string): Promise<VisualDiffResult>;
@@ -0,0 +1,126 @@
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.runVisualDiff = runVisualDiff;
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ const puppeteer_1 = __importDefault(require("puppeteer"));
43
+ const pngjs_1 = require("pngjs");
44
+ const pixelmatch_1 = __importDefault(require("pixelmatch"));
45
+ function ensureDir(dir) {
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ }
48
+ async function captureScreenshot(url, outputPath) {
49
+ const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
50
+ try {
51
+ const page = await browser.newPage();
52
+ await page.setViewport({ width: 1440, height: 960 });
53
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
54
+ await page.waitForSelector("body", { timeout: 5000 });
55
+ await page.screenshot({ path: outputPath, fullPage: true, type: "png" });
56
+ }
57
+ finally {
58
+ await browser.close();
59
+ }
60
+ }
61
+ function compareImages(baselinePath, currentPath, diffOutputPath) {
62
+ const baselinePng = pngjs_1.PNG.sync.read(fs.readFileSync(baselinePath));
63
+ const currentPng = pngjs_1.PNG.sync.read(fs.readFileSync(currentPath));
64
+ const width = Math.min(baselinePng.width, currentPng.width);
65
+ const height = Math.min(baselinePng.height, currentPng.height);
66
+ const cropData = (png, w, h) => {
67
+ if (png.width === w && png.height === h)
68
+ return png.data;
69
+ const cropped = Buffer.alloc(w * h * 4);
70
+ for (let y = 0; y < h; y++) {
71
+ png.data.copy(cropped, y * w * 4, y * png.width * 4, y * png.width * 4 + w * 4);
72
+ }
73
+ return cropped;
74
+ };
75
+ const baseData = cropData(baselinePng, width, height);
76
+ const currData = cropData(currentPng, width, height);
77
+ const diff = new pngjs_1.PNG({ width, height });
78
+ const diffPixels = (0, pixelmatch_1.default)(baseData, currData, diff.data, width, height, { threshold: 0.1 });
79
+ ensureDir(path.dirname(diffOutputPath));
80
+ fs.writeFileSync(diffOutputPath, pngjs_1.PNG.sync.write(diff));
81
+ const totalPixels = width * height;
82
+ const diffPercentage = Math.round((diffPixels / totalPixels) * 10000) / 100;
83
+ return { diffPixels, totalPixels, diffPercentage };
84
+ }
85
+ async function runVisualDiff(projectDir, url, label = "current") {
86
+ const dir = path.join(projectDir, ".laxy-verify", "visual");
87
+ ensureDir(dir);
88
+ const baselinePath = path.join(dir, "baseline.png");
89
+ const currentPath = path.join(dir, `${label}.png`);
90
+ const diffPath = path.join(dir, `${label}.diff.png`);
91
+ await captureScreenshot(url, currentPath);
92
+ if (!fs.existsSync(baselinePath)) {
93
+ fs.copyFileSync(currentPath, baselinePath);
94
+ return {
95
+ hasBaseline: false,
96
+ diffPercentage: 0,
97
+ verdict: "pass",
98
+ diffPixels: 0,
99
+ totalPixels: 0,
100
+ baselinePath,
101
+ currentPath,
102
+ diffPath: "",
103
+ };
104
+ }
105
+ const comparison = compareImages(baselinePath, currentPath, diffPath);
106
+ let verdict = "pass";
107
+ if (comparison.diffPercentage >= 60) {
108
+ verdict = "rollback";
109
+ }
110
+ else if (comparison.diffPercentage >= 30) {
111
+ verdict = "warn";
112
+ }
113
+ if (verdict === "pass") {
114
+ fs.copyFileSync(currentPath, baselinePath);
115
+ }
116
+ return {
117
+ hasBaseline: true,
118
+ diffPercentage: comparison.diffPercentage,
119
+ verdict,
120
+ diffPixels: comparison.diffPixels,
121
+ totalPixels: comparison.totalPixels,
122
+ baselinePath,
123
+ currentPath,
124
+ diffPath,
125
+ };
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laxy-verify",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "Frontend quality gate: build + Lighthouse verification",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -18,6 +18,9 @@
18
18
  "dependencies": {
19
19
  "@lhci/cli": "^0.14.0",
20
20
  "js-yaml": "^4.1.0",
21
+ "pixelmatch": "^7.1.0",
22
+ "pngjs": "^7.0.0",
23
+ "puppeteer": "^24.40.0",
21
24
  "tree-kill": "^1.2.2"
22
25
  },
23
26
  "devDependencies": {