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 +61 -11
- package/dist/e2e.d.ts +29 -0
- package/dist/e2e.js +304 -0
- package/dist/visual-diff.d.ts +11 -0
- package/dist/visual-diff.js +126 -0
- package/package.json +4 -1
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:
|
|
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
|
|
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(
|
|
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.
|
|
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": {
|