laxy-verify 1.1.31 → 1.1.33
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 +1 -1
- package/dist/cli.js +51 -13
- package/dist/comment.d.ts +2 -1
- package/dist/comment.js +39 -3
- package/dist/e2e.js +28 -2
- package/dist/entitlement.js +1 -1
- package/dist/init.js +40 -3
- package/dist/report-markdown.d.ts +1 -1
- package/dist/report-markdown.js +2 -1
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +106 -8
- package/dist/trend.d.ts +49 -0
- package/dist/trend.js +147 -0
- package/dist/verification-core/report.js +12 -2
- package/dist/visual-diff.d.ts +15 -0
- package/dist/visual-diff.js +82 -35
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/cli.js
CHANGED
|
@@ -59,7 +59,34 @@ const visual_diff_js_1 = require("./visual-diff.js");
|
|
|
59
59
|
const security_audit_js_1 = require("./security-audit.js");
|
|
60
60
|
const broken_links_js_1 = require("./audit/broken-links.js");
|
|
61
61
|
const index_js_1 = require("./verification-core/index.js");
|
|
62
|
+
const trend_js_1 = require("./trend.js");
|
|
62
63
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
64
|
+
let activeDevServerPid;
|
|
65
|
+
let activeDevServerCleanup = null;
|
|
66
|
+
async function cleanupActiveDevServer() {
|
|
67
|
+
if (activeDevServerPid === undefined)
|
|
68
|
+
return;
|
|
69
|
+
if (activeDevServerCleanup) {
|
|
70
|
+
await activeDevServerCleanup;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const pid = activeDevServerPid;
|
|
74
|
+
activeDevServerPid = undefined;
|
|
75
|
+
activeDevServerCleanup = (0, serve_js_1.stopDevServer)(pid).finally(() => {
|
|
76
|
+
activeDevServerCleanup = null;
|
|
77
|
+
});
|
|
78
|
+
await activeDevServerCleanup;
|
|
79
|
+
}
|
|
80
|
+
function installSignalCleanupHandlers() {
|
|
81
|
+
const handleSignal = (signal) => {
|
|
82
|
+
void cleanupActiveDevServer().finally(() => {
|
|
83
|
+
const exitCode = signal === "SIGINT" ? 130 : 143;
|
|
84
|
+
exitGracefully(exitCode);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
process.once("SIGINT", () => handleSignal("SIGINT"));
|
|
88
|
+
process.once("SIGTERM", () => handleSignal("SIGTERM"));
|
|
89
|
+
}
|
|
63
90
|
function shouldFailVerificationResult(report, failOn) {
|
|
64
91
|
if (failOn === "unverified")
|
|
65
92
|
return false;
|
|
@@ -200,7 +227,7 @@ function consoleOutput(result) {
|
|
|
200
227
|
}
|
|
201
228
|
}
|
|
202
229
|
if (result.visualDiff) {
|
|
203
|
-
console.log(` Visual diff: ${
|
|
230
|
+
console.log(` Visual diff: ${(0, visual_diff_js_1.formatVisualDiffSummary)(result.visualDiff)}`);
|
|
204
231
|
}
|
|
205
232
|
if (result.security) {
|
|
206
233
|
console.log(` Security: ${result.security.summary}`);
|
|
@@ -271,6 +298,7 @@ function consoleOutput(result) {
|
|
|
271
298
|
console.log(` Exit code: ${result.exitCode}`);
|
|
272
299
|
}
|
|
273
300
|
async function run() {
|
|
301
|
+
installSignalCleanupHandlers();
|
|
274
302
|
const args = parseArgs();
|
|
275
303
|
if (args.help) {
|
|
276
304
|
console.log(`
|
|
@@ -281,10 +309,10 @@ async function run() {
|
|
|
281
309
|
npx laxy-verify [project-dir] [options]
|
|
282
310
|
npx laxy-verify <subcommand>
|
|
283
311
|
|
|
284
|
-
Subcommands:
|
|
285
|
-
login [email] Log in to connect this CLI to your Laxy account
|
|
286
|
-
logout Remove saved credentials
|
|
287
|
-
whoami Show current login status
|
|
312
|
+
Subcommands:
|
|
313
|
+
login [email] Log in to connect this CLI to your Laxy account
|
|
314
|
+
logout Remove saved credentials
|
|
315
|
+
whoami Show current login status
|
|
288
316
|
|
|
289
317
|
Options:
|
|
290
318
|
--init Generate .laxy.yml + GitHub workflow file
|
|
@@ -294,8 +322,8 @@ async function run() {
|
|
|
294
322
|
--config <path> Path to .laxy.yml
|
|
295
323
|
--fail-on unverified | bronze | silver | gold
|
|
296
324
|
--skip-lighthouse Skip Lighthouse but still run build and E2E
|
|
297
|
-
--plan-override free | pro | team (testing metadata only)
|
|
298
|
-
--multi-viewport Lighthouse on desktop/tablet/mobile
|
|
325
|
+
--plan-override free | pro | team (testing metadata only)
|
|
326
|
+
--multi-viewport Lighthouse on desktop/tablet/mobile
|
|
299
327
|
--crawl Crawl the app to discover routes before E2E
|
|
300
328
|
--badge Print shields.io badge markdown
|
|
301
329
|
--help Show this help
|
|
@@ -311,7 +339,7 @@ async function run() {
|
|
|
311
339
|
npx laxy-verify . --ci # CI mode
|
|
312
340
|
npx laxy-verify . --fail-on silver # Block Bronze or worse
|
|
313
341
|
|
|
314
|
-
Docs: https://github.com/
|
|
342
|
+
Docs: https://github.com/SUNgm24/Laxy/tree/main/laxy-verify
|
|
315
343
|
`);
|
|
316
344
|
exitGracefully(0);
|
|
317
345
|
return;
|
|
@@ -459,6 +487,7 @@ async function run() {
|
|
|
459
487
|
try {
|
|
460
488
|
const serve = await (0, serve_js_1.startDevServer)(devCmd, port, config.dev_timeout, args.projectDir);
|
|
461
489
|
servePid = serve.pid;
|
|
490
|
+
activeDevServerPid = serve.pid;
|
|
462
491
|
const verifyUrl = `http://127.0.0.1:${port}/`;
|
|
463
492
|
const verificationTier = (0, index_js_1.planToVerificationTier)(effectiveFeatures.plan);
|
|
464
493
|
if (!args.skipLighthouse) {
|
|
@@ -597,7 +626,7 @@ async function run() {
|
|
|
597
626
|
}
|
|
598
627
|
finally {
|
|
599
628
|
if (servePid) {
|
|
600
|
-
|
|
629
|
+
await cleanupActiveDevServer();
|
|
601
630
|
}
|
|
602
631
|
}
|
|
603
632
|
}
|
|
@@ -610,9 +639,7 @@ async function run() {
|
|
|
610
639
|
.map((scenario) => `E2E: ${scenario.name}${scenario.error ? ` - ${scenario.error}` : ""}`)
|
|
611
640
|
: []), ...e2eConsoleErrors.slice(0, 2).map((e) => `Console: ${e}`), ...e2eCoverageGaps.slice(0, 2).map((gap) => `E2E coverage: ${gap}`), ...(viewportSummary.count > 0 && viewportSummary.summary ? [`Viewport: ${viewportSummary.summary}`] : []), ...(visualDiffResult
|
|
612
641
|
? [
|
|
613
|
-
visualDiffResult
|
|
614
|
-
? `Visual diff: ${visualDiffResult.diffPercentage}% (${visualDiffResult.verdict})`
|
|
615
|
-
: "Visual diff: baseline seeded",
|
|
642
|
+
`Visual diff: ${(0, visual_diff_js_1.formatVisualDiffSummary)(visualDiffResult)}`,
|
|
616
643
|
]
|
|
617
644
|
: []));
|
|
618
645
|
const verificationReport = (0, index_js_1.buildVerificationReport)({
|
|
@@ -697,7 +724,18 @@ async function run() {
|
|
|
697
724
|
if (inGitHubActions) {
|
|
698
725
|
try {
|
|
699
726
|
if (process.env.GITHUB_EVENT_NAME === "pull_request") {
|
|
700
|
-
|
|
727
|
+
let trendDelta = null;
|
|
728
|
+
const baseSnapshot = (0, trend_js_1.loadBaseSnapshot)((0, trend_js_1.getBaseResultPath)(args.projectDir));
|
|
729
|
+
if (baseSnapshot) {
|
|
730
|
+
const currentSnapshot = {
|
|
731
|
+
grade: resultObj.grade,
|
|
732
|
+
lighthouse: resultObj.lighthouse,
|
|
733
|
+
e2e: resultObj.e2e ? { passed: resultObj.e2e.passed, total: resultObj.e2e.total } : null,
|
|
734
|
+
timestamp: resultObj.timestamp,
|
|
735
|
+
};
|
|
736
|
+
trendDelta = (0, trend_js_1.computeTrendDelta)(currentSnapshot, baseSnapshot);
|
|
737
|
+
}
|
|
738
|
+
await (0, comment_js_1.postPRComment)(resultObj, trendDelta);
|
|
701
739
|
resultObj.github = { status: "comment_posted", grade: resultObj.grade };
|
|
702
740
|
}
|
|
703
741
|
await (0, status_js_1.createStatusCheck)({ grade: resultObj.grade, exitCode: resultObj.exitCode });
|
package/dist/comment.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TrendDelta } from "./trend.js";
|
|
1
2
|
interface LaxyResult {
|
|
2
3
|
grade: string;
|
|
3
4
|
lighthouse: {
|
|
@@ -16,5 +17,5 @@ interface LaxyResult {
|
|
|
16
17
|
exitCode: number;
|
|
17
18
|
config_fail_on: string;
|
|
18
19
|
}
|
|
19
|
-
export declare function postPRComment(result: LaxyResult): Promise<void>;
|
|
20
|
+
export declare function postPRComment(result: LaxyResult, trend?: TrendDelta | null): Promise<void>;
|
|
20
21
|
export {};
|
package/dist/comment.js
CHANGED
|
@@ -36,7 +36,42 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.postPRComment = postPRComment;
|
|
37
37
|
const fs = __importStar(require("node:fs"));
|
|
38
38
|
const github_js_1 = require("./github.js");
|
|
39
|
-
|
|
39
|
+
function renderTrendTable(delta) {
|
|
40
|
+
const lines = [
|
|
41
|
+
"| Check | This PR | vs Base |",
|
|
42
|
+
"|---|---|---|",
|
|
43
|
+
];
|
|
44
|
+
const gradeTrendStr = delta.grade.current !== delta.grade.base
|
|
45
|
+
? ` (was ${delta.grade.base})`
|
|
46
|
+
: "";
|
|
47
|
+
lines.push(`| Grade | **${delta.grade.current}**${gradeTrendStr} | ${delta.grade.base} |`);
|
|
48
|
+
function fmtDelta(d) {
|
|
49
|
+
if (d === null)
|
|
50
|
+
return "—";
|
|
51
|
+
if (d > 0)
|
|
52
|
+
return `+${d}`;
|
|
53
|
+
if (d < 0)
|
|
54
|
+
return `${d}`;
|
|
55
|
+
return "—";
|
|
56
|
+
}
|
|
57
|
+
if (delta.performance.current !== null) {
|
|
58
|
+
lines.push(`| Performance | ${delta.performance.current} | ${fmtDelta(delta.performance.delta)} |`);
|
|
59
|
+
}
|
|
60
|
+
if (delta.accessibility.current !== null) {
|
|
61
|
+
lines.push(`| Accessibility | ${delta.accessibility.current} | ${fmtDelta(delta.accessibility.delta)} |`);
|
|
62
|
+
}
|
|
63
|
+
if (delta.seo.current !== null) {
|
|
64
|
+
lines.push(`| SEO | ${delta.seo.current} | ${fmtDelta(delta.seo.delta)} |`);
|
|
65
|
+
}
|
|
66
|
+
if (delta.bestPractices.current !== null) {
|
|
67
|
+
lines.push(`| Best Practices | ${delta.bestPractices.current} | ${fmtDelta(delta.bestPractices.delta)} |`);
|
|
68
|
+
}
|
|
69
|
+
if (delta.e2e.current !== null) {
|
|
70
|
+
lines.push(`| E2E | ${delta.e2e.current} | ${delta.e2e.base ?? "—"} (base) |`);
|
|
71
|
+
}
|
|
72
|
+
return `${lines.join("\n")}\n`;
|
|
73
|
+
}
|
|
74
|
+
async function postPRComment(result, trend) {
|
|
40
75
|
const ctx = (0, github_js_1.getGitHubContext)();
|
|
41
76
|
if (!ctx || ctx.eventName !== "pull_request")
|
|
42
77
|
return;
|
|
@@ -54,6 +89,7 @@ async function postPRComment(result) {
|
|
|
54
89
|
if (lh) {
|
|
55
90
|
lhTable = `| Performance | Accessibility | SEO | Best Practices |\n|---|---|---|---|\n| ${lh.performance} / ${t.performance} | ${lh.accessibility} / ${t.accessibility} | ${lh.seo} / ${t.seo} | ${lh.bestPractices} / ${t.bestPractices} |\n\n`;
|
|
56
91
|
}
|
|
92
|
+
const trendTable = trend ? `${renderTrendTable(trend)}\n` : "";
|
|
57
93
|
const passed = result.exitCode === 0;
|
|
58
94
|
const statusLabel = passed ? "[PASS]" : "[HOLD]";
|
|
59
95
|
const headline = passed ? "No deployment blockers found" : "Deployment blockers found";
|
|
@@ -66,12 +102,12 @@ async function postPRComment(result) {
|
|
|
66
102
|
|
|
67
103
|
${summary}
|
|
68
104
|
|
|
69
|
-
${lhTable}**Fail-on threshold**: ${result.config_fail_on ?? "bronze"}
|
|
105
|
+
${trendTable}${lhTable}**Fail-on threshold**: ${result.config_fail_on ?? "bronze"}
|
|
70
106
|
|
|
71
107
|
Grade remains available for automation, but this check should be read first as a merge and release blocker gate.
|
|
72
108
|
|
|
73
109
|
---
|
|
74
|
-
[Open laxy-verify docs](https://github.com/
|
|
110
|
+
[Open laxy-verify docs](https://github.com/SUNgm24/Laxy/tree/main/laxy-verify)`;
|
|
75
111
|
const [owner, repo] = ctx.repository.split("/");
|
|
76
112
|
const url = `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`;
|
|
77
113
|
try {
|
package/dist/e2e.js
CHANGED
|
@@ -82,6 +82,30 @@ function getRequiredFillTarget(selectors) {
|
|
|
82
82
|
/^textarea\[aria-required=['"]true['"]\]$/,
|
|
83
83
|
]);
|
|
84
84
|
}
|
|
85
|
+
function getFillValue(selector) {
|
|
86
|
+
const s = selector.toLowerCase();
|
|
87
|
+
if (/type=['"]email['"]|name.*email|placeholder.*email/.test(s))
|
|
88
|
+
return "test@example.com";
|
|
89
|
+
if (/type=['"]tel['"]|name.*(phone|tel)|placeholder.*(phone|tel)/.test(s))
|
|
90
|
+
return "555-0100";
|
|
91
|
+
if (/type=['"]number['"]/.test(s))
|
|
92
|
+
return "42";
|
|
93
|
+
if (/type=['"]password['"]/.test(s))
|
|
94
|
+
return "TestPass123!";
|
|
95
|
+
if (/type=['"]url['"]/.test(s))
|
|
96
|
+
return "https://example.com";
|
|
97
|
+
if (/type=['"]search['"]|name.*(query|search)|placeholder.*(search|query)/.test(s))
|
|
98
|
+
return "test query";
|
|
99
|
+
if (/name.*username|placeholder.*username/.test(s))
|
|
100
|
+
return "testuser";
|
|
101
|
+
if (/name.*name|placeholder.*name/.test(s))
|
|
102
|
+
return "Test User";
|
|
103
|
+
if (/textarea|name.*message|placeholder.*message/.test(s))
|
|
104
|
+
return "This is a test message.";
|
|
105
|
+
if (/name.*title|placeholder.*title/.test(s))
|
|
106
|
+
return "Test Title";
|
|
107
|
+
return "test input";
|
|
108
|
+
}
|
|
85
109
|
function getVerificationCoverageGaps(scenarios, tier) {
|
|
86
110
|
void tier;
|
|
87
111
|
const names = new Set(scenarios.map((scenario) => scenario.name));
|
|
@@ -117,11 +141,12 @@ function buildVerifyScenarios(snapshot, tier) {
|
|
|
117
141
|
},
|
|
118
142
|
];
|
|
119
143
|
if (fillTarget && likelyFormSurface) {
|
|
144
|
+
const fillValue = getFillValue(fillTarget);
|
|
120
145
|
const formScenario = {
|
|
121
146
|
name: "Primary form interaction",
|
|
122
147
|
steps: [
|
|
123
148
|
{ type: "check_visible", selector: fillTarget, description: "Input surface should be visible" },
|
|
124
|
-
{ type: "clear_fill", selector: fillTarget, value:
|
|
149
|
+
{ type: "clear_fill", selector: fillTarget, value: fillValue, description: `Fill a core input field with ${fillValue}` },
|
|
125
150
|
],
|
|
126
151
|
};
|
|
127
152
|
if (clickTarget) {
|
|
@@ -164,11 +189,12 @@ function buildVerifyScenarios(snapshot, tier) {
|
|
|
164
189
|
});
|
|
165
190
|
}
|
|
166
191
|
if (clickTarget && fillTarget && clickTarget !== fillTarget) {
|
|
192
|
+
const fillValue = getFillValue(fillTarget);
|
|
167
193
|
scenarios.push({
|
|
168
194
|
name: "Repeated interaction stability",
|
|
169
195
|
steps: [
|
|
170
196
|
{ type: "check_visible", selector: fillTarget, description: "Input surface should still exist" },
|
|
171
|
-
{ type: "clear_fill", selector: fillTarget, value:
|
|
197
|
+
{ type: "clear_fill", selector: fillTarget, value: fillValue, description: `Repeat the core input with ${fillValue}` },
|
|
172
198
|
{ type: "click", selector: clickTarget, description: "Trigger the CTA again" },
|
|
173
199
|
{ type: "wait", duration: 800, description: "Wait for repeated interaction response" },
|
|
174
200
|
{ type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Surface should still hold after repeat" },
|
package/dist/entitlement.js
CHANGED
|
@@ -13,7 +13,7 @@ exports.printPlanBanner = printPlanBanner;
|
|
|
13
13
|
*/
|
|
14
14
|
const auth_js_1 = require("./auth.js");
|
|
15
15
|
const FREE_FEATURES = {
|
|
16
|
-
plan: "
|
|
16
|
+
plan: "free",
|
|
17
17
|
// Automation features — not available without login
|
|
18
18
|
github_actions: false,
|
|
19
19
|
queue_priority: false,
|
package/dist/init.js
CHANGED
|
@@ -57,7 +57,7 @@ function runInit(dir) {
|
|
|
57
57
|
// Keep defaults when auto-detection fails.
|
|
58
58
|
}
|
|
59
59
|
const ymlContent = `# Generated by laxy-verify --init
|
|
60
|
-
# See https://github.com/
|
|
60
|
+
# See https://github.com/SUNgm24/Laxy/tree/main/laxy-verify for full docs
|
|
61
61
|
framework: ${detectedFramework} # auto-detected
|
|
62
62
|
port: ${detectedPort}
|
|
63
63
|
fail_on: bronze
|
|
@@ -92,9 +92,46 @@ jobs:
|
|
|
92
92
|
runs-on: ubuntu-latest
|
|
93
93
|
steps:
|
|
94
94
|
- uses: actions/checkout@v4
|
|
95
|
-
- uses:
|
|
95
|
+
- uses: browser-actions/setup-chrome@v1.7.2
|
|
96
|
+
- uses: actions/setup-node@v4
|
|
96
97
|
with:
|
|
97
|
-
|
|
98
|
+
node-version: "20"
|
|
99
|
+
- name: Install app dependencies
|
|
100
|
+
shell: bash
|
|
101
|
+
run: |
|
|
102
|
+
if [ -f "pnpm-lock.yaml" ]; then npm i -g pnpm && pnpm install --frozen-lockfile
|
|
103
|
+
elif [ -f "yarn.lock" ]; then yarn install --frozen-lockfile
|
|
104
|
+
elif [ -f "bun.lockb" ]; then npm i -g bun && bun install --frozen-lockfile
|
|
105
|
+
else npm ci
|
|
106
|
+
fi
|
|
107
|
+
- name: Download base branch result
|
|
108
|
+
if: github.event_name == 'pull_request'
|
|
109
|
+
uses: actions/download-artifact@v4
|
|
110
|
+
with:
|
|
111
|
+
name: laxy-result-base
|
|
112
|
+
path: .
|
|
113
|
+
continue-on-error: true
|
|
114
|
+
- name: Rename base result for trend comparison
|
|
115
|
+
if: github.event_name == 'pull_request'
|
|
116
|
+
shell: bash
|
|
117
|
+
run: |
|
|
118
|
+
if [ -f .laxy-result.json ]; then
|
|
119
|
+
mv .laxy-result.json .laxy-result-base.json
|
|
120
|
+
fi
|
|
121
|
+
continue-on-error: true
|
|
122
|
+
- name: Run laxy-verify
|
|
123
|
+
shell: bash
|
|
124
|
+
run: npx laxy-verify@latest . --format json --ci
|
|
125
|
+
env:
|
|
126
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
127
|
+
- name: Upload result for trend tracking
|
|
128
|
+
if: always()
|
|
129
|
+
uses: actions/upload-artifact@v4
|
|
130
|
+
with:
|
|
131
|
+
name: laxy-result-base
|
|
132
|
+
path: .laxy-result.json
|
|
133
|
+
retention-days: 30
|
|
134
|
+
overwrite: true
|
|
98
135
|
`;
|
|
99
136
|
fs.writeFileSync(workflowPath, workflowContent, "utf-8");
|
|
100
137
|
console.log("Created .github/workflows/laxy-verify.yml");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { E2EScenarioResult } from "./e2e.js";
|
|
2
2
|
import type { LighthouseScores } from "./grade.js";
|
|
3
3
|
import type { TierVerificationView, VerificationReport } from "./verification-core/index.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type VisualDiffResult } from "./visual-diff.js";
|
|
5
5
|
export interface MarkdownReportResult {
|
|
6
6
|
grade: string;
|
|
7
7
|
timestamp: string;
|
package/dist/report-markdown.js
CHANGED
|
@@ -37,6 +37,7 @@ exports.shouldWriteMarkdownReport = shouldWriteMarkdownReport;
|
|
|
37
37
|
exports.getMarkdownReportPath = getMarkdownReportPath;
|
|
38
38
|
exports.buildMarkdownReport = buildMarkdownReport;
|
|
39
39
|
const path = __importStar(require("node:path"));
|
|
40
|
+
const visual_diff_js_1 = require("./visual-diff.js");
|
|
40
41
|
function titleCasePlan(plan) {
|
|
41
42
|
switch (plan) {
|
|
42
43
|
case "pro":
|
|
@@ -159,7 +160,7 @@ function renderMetrics(result) {
|
|
|
159
160
|
lines.push(`| Multi-viewport | ${reportInput.multiViewportPassed ? "Passed" : "Needs work"}${reportInput.multiViewportSummary ? `, ${reportInput.multiViewportSummary}` : ""} |`);
|
|
160
161
|
}
|
|
161
162
|
if (result.visualDiff) {
|
|
162
|
-
lines.push(`| Visual diff | ${
|
|
163
|
+
lines.push(`| Visual diff | ${(0, visual_diff_js_1.formatVisualDiffSummary)(result.visualDiff)} |`);
|
|
163
164
|
}
|
|
164
165
|
lines.push("");
|
|
165
166
|
return `${lines.join("\n")}\n`;
|
package/dist/serve.d.ts
CHANGED
|
@@ -10,4 +10,4 @@ export interface ServeResult {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function probeServerStatus(port: number): Promise<number | null>;
|
|
12
12
|
export declare function startDevServer(command: string, port: number, timeoutSec: number, cwd?: string): Promise<ServeResult>;
|
|
13
|
-
export declare function stopDevServer(pid: number): void
|
|
13
|
+
export declare function stopDevServer(pid: number): Promise<void>;
|
package/dist/serve.js
CHANGED
|
@@ -41,7 +41,10 @@ exports.probeServerStatus = probeServerStatus;
|
|
|
41
41
|
exports.startDevServer = startDevServer;
|
|
42
42
|
exports.stopDevServer = stopDevServer;
|
|
43
43
|
const node_child_process_1 = require("node:child_process");
|
|
44
|
+
const fs = __importStar(require("node:fs"));
|
|
44
45
|
const http = __importStar(require("node:http"));
|
|
46
|
+
const os = __importStar(require("node:os"));
|
|
47
|
+
const path = __importStar(require("node:path"));
|
|
45
48
|
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
46
49
|
class PortConflictError extends Error {
|
|
47
50
|
constructor(port) {
|
|
@@ -57,6 +60,90 @@ class DevServerTimeoutError extends Error {
|
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
exports.DevServerTimeoutError = DevServerTimeoutError;
|
|
63
|
+
async function killProcessTree(pid) {
|
|
64
|
+
await new Promise((resolve) => {
|
|
65
|
+
let settled = false;
|
|
66
|
+
const finish = () => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
resolve();
|
|
71
|
+
};
|
|
72
|
+
const fallbackTimer = setTimeout(finish, 5000);
|
|
73
|
+
fallbackTimer.unref?.();
|
|
74
|
+
try {
|
|
75
|
+
(0, tree_kill_1.default)(pid, "SIGKILL", () => {
|
|
76
|
+
clearTimeout(fallbackTimer);
|
|
77
|
+
finish();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
clearTimeout(fallbackTimer);
|
|
82
|
+
finish();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function writeWindowsDevWrapper(command, port, cwd) {
|
|
87
|
+
const wrapperDir = path.join(os.tmpdir(), "laxy-verify");
|
|
88
|
+
fs.mkdirSync(wrapperDir, { recursive: true });
|
|
89
|
+
const wrapperPath = path.join(wrapperDir, `dev-wrapper-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cjs`);
|
|
90
|
+
const source = `"use strict";
|
|
91
|
+
const { spawn } = require("node:child_process");
|
|
92
|
+
|
|
93
|
+
const parentPid = Number(process.env.LAXY_VERIFY_PARENT_PID || "0");
|
|
94
|
+
const command = ${JSON.stringify(command)};
|
|
95
|
+
const childCwd = ${JSON.stringify(cwd ?? null)};
|
|
96
|
+
const port = ${JSON.stringify(String(port))};
|
|
97
|
+
|
|
98
|
+
function killChildTree(pid) {
|
|
99
|
+
try {
|
|
100
|
+
const killer = spawn(process.env.ComSpec || "cmd.exe", ["/d", "/c", "taskkill /T /F /PID " + pid], {
|
|
101
|
+
stdio: "ignore",
|
|
102
|
+
});
|
|
103
|
+
killer.on("exit", () => process.exit(1));
|
|
104
|
+
} catch {
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const child = spawn(process.env.ComSpec || "cmd.exe", ["/d", "/c", command], {
|
|
110
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
111
|
+
cwd: childCwd || undefined,
|
|
112
|
+
env: { ...process.env, PORT: port },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
child.stdout?.on("data", (chunk) => process.stdout.write(chunk));
|
|
116
|
+
child.stderr?.on("data", (chunk) => process.stderr.write(chunk));
|
|
117
|
+
|
|
118
|
+
child.on("error", (err) => {
|
|
119
|
+
process.stderr.write((err && err.message ? err.message : String(err)) + "\\n");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
child.on("exit", (code) => {
|
|
124
|
+
clearInterval(watchdog);
|
|
125
|
+
process.exit(code ?? 1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const watchdog = setInterval(() => {
|
|
129
|
+
if (!parentPid) return;
|
|
130
|
+
try {
|
|
131
|
+
process.kill(parentPid, 0);
|
|
132
|
+
} catch {
|
|
133
|
+
clearInterval(watchdog);
|
|
134
|
+
if (child.pid) {
|
|
135
|
+
killChildTree(child.pid);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}, 1000);
|
|
141
|
+
|
|
142
|
+
watchdog.unref?.();
|
|
143
|
+
`;
|
|
144
|
+
fs.writeFileSync(wrapperPath, source, "utf-8");
|
|
145
|
+
return wrapperPath;
|
|
146
|
+
}
|
|
60
147
|
function httpGet(url) {
|
|
61
148
|
return new Promise((resolve) => {
|
|
62
149
|
http
|
|
@@ -75,11 +162,17 @@ async function startDevServer(command, port, timeoutSec, cwd) {
|
|
|
75
162
|
return new Promise((resolve, reject) => {
|
|
76
163
|
console.log(`Starting dev server: ${command}${cwd ? ` (cwd: ${cwd})` : ""}`);
|
|
77
164
|
let settled = false;
|
|
165
|
+
const wrapperPath = process.platform === "win32"
|
|
166
|
+
? writeWindowsDevWrapper(command, port, cwd)
|
|
167
|
+
: null;
|
|
78
168
|
const proc = process.platform === "win32"
|
|
79
|
-
? (0, node_child_process_1.spawn)(
|
|
169
|
+
? (0, node_child_process_1.spawn)("node", [wrapperPath], {
|
|
80
170
|
stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
-
env: {
|
|
82
|
-
|
|
171
|
+
env: {
|
|
172
|
+
...process.env,
|
|
173
|
+
PORT: String(port),
|
|
174
|
+
LAXY_VERIFY_PARENT_PID: String(process.pid),
|
|
175
|
+
},
|
|
83
176
|
})
|
|
84
177
|
: (0, node_child_process_1.spawn)(command, {
|
|
85
178
|
shell: true,
|
|
@@ -109,6 +202,9 @@ async function startDevServer(command, port, timeoutSec, cwd) {
|
|
|
109
202
|
}
|
|
110
203
|
});
|
|
111
204
|
proc.on("exit", (code) => {
|
|
205
|
+
if (wrapperPath) {
|
|
206
|
+
fs.rmSync(wrapperPath, { force: true });
|
|
207
|
+
}
|
|
112
208
|
if (settled)
|
|
113
209
|
return;
|
|
114
210
|
settled = true;
|
|
@@ -120,8 +216,9 @@ async function startDevServer(command, port, timeoutSec, cwd) {
|
|
|
120
216
|
if (settled)
|
|
121
217
|
return;
|
|
122
218
|
settled = true;
|
|
123
|
-
if (proc.pid)
|
|
124
|
-
|
|
219
|
+
if (proc.pid) {
|
|
220
|
+
await killProcessTree(proc.pid);
|
|
221
|
+
}
|
|
125
222
|
reject(new DevServerTimeoutError(port, timeoutSec));
|
|
126
223
|
return;
|
|
127
224
|
}
|
|
@@ -137,12 +234,13 @@ async function startDevServer(command, port, timeoutSec, cwd) {
|
|
|
137
234
|
}
|
|
138
235
|
console.log(`Dev server returned HTTP ${status}, waiting for a healthy app surface...`);
|
|
139
236
|
}
|
|
140
|
-
setTimeout(poll, 500);
|
|
237
|
+
const nextPoll = setTimeout(poll, 500);
|
|
238
|
+
nextPoll.unref?.();
|
|
141
239
|
};
|
|
142
240
|
poll();
|
|
143
241
|
});
|
|
144
242
|
}
|
|
145
|
-
function stopDevServer(pid) {
|
|
243
|
+
async function stopDevServer(pid) {
|
|
146
244
|
console.log(`Stopping dev server (PID ${pid})`);
|
|
147
|
-
|
|
245
|
+
await killProcessTree(pid);
|
|
148
246
|
}
|
package/dist/trend.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface TrendSnapshot {
|
|
2
|
+
grade: string;
|
|
3
|
+
lighthouse: {
|
|
4
|
+
performance: number;
|
|
5
|
+
accessibility: number;
|
|
6
|
+
seo: number;
|
|
7
|
+
bestPractices: number;
|
|
8
|
+
} | null;
|
|
9
|
+
e2e?: {
|
|
10
|
+
passed: number;
|
|
11
|
+
total: number;
|
|
12
|
+
} | null;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
}
|
|
15
|
+
export interface TrendDelta {
|
|
16
|
+
grade: {
|
|
17
|
+
current: string;
|
|
18
|
+
base: string;
|
|
19
|
+
changed: boolean;
|
|
20
|
+
};
|
|
21
|
+
performance: {
|
|
22
|
+
current: number | null;
|
|
23
|
+
base: number | null;
|
|
24
|
+
delta: number | null;
|
|
25
|
+
};
|
|
26
|
+
accessibility: {
|
|
27
|
+
current: number | null;
|
|
28
|
+
base: number | null;
|
|
29
|
+
delta: number | null;
|
|
30
|
+
};
|
|
31
|
+
seo: {
|
|
32
|
+
current: number | null;
|
|
33
|
+
base: number | null;
|
|
34
|
+
delta: number | null;
|
|
35
|
+
};
|
|
36
|
+
bestPractices: {
|
|
37
|
+
current: number | null;
|
|
38
|
+
base: number | null;
|
|
39
|
+
delta: number | null;
|
|
40
|
+
};
|
|
41
|
+
e2e: {
|
|
42
|
+
current: string | null;
|
|
43
|
+
base: string | null;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export declare function loadBaseSnapshot(baseResultPath: string): TrendSnapshot | null;
|
|
47
|
+
export declare function computeTrendDelta(current: TrendSnapshot, base: TrendSnapshot): TrendDelta;
|
|
48
|
+
export declare function renderTrendSection(delta: TrendDelta): string;
|
|
49
|
+
export declare function getBaseResultPath(projectDir: string): string;
|
package/dist/trend.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.loadBaseSnapshot = loadBaseSnapshot;
|
|
37
|
+
exports.computeTrendDelta = computeTrendDelta;
|
|
38
|
+
exports.renderTrendSection = renderTrendSection;
|
|
39
|
+
exports.getBaseResultPath = getBaseResultPath;
|
|
40
|
+
const fs = __importStar(require("node:fs"));
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
42
|
+
const GRADE_ORDER = ["Unverified", "Bronze", "Silver", "Gold", "Platinum"];
|
|
43
|
+
function gradeIndex(grade) {
|
|
44
|
+
const idx = GRADE_ORDER.indexOf(grade);
|
|
45
|
+
return idx === -1 ? 0 : idx;
|
|
46
|
+
}
|
|
47
|
+
function formatDelta(delta) {
|
|
48
|
+
if (delta === null)
|
|
49
|
+
return "";
|
|
50
|
+
if (delta > 0)
|
|
51
|
+
return ` (+${delta})`;
|
|
52
|
+
if (delta < 0)
|
|
53
|
+
return ` (${delta})`;
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
function gradeTrend(current, base) {
|
|
57
|
+
const ci = gradeIndex(current);
|
|
58
|
+
const bi = gradeIndex(base);
|
|
59
|
+
if (ci > bi)
|
|
60
|
+
return ` (was ${base})`;
|
|
61
|
+
if (ci < bi)
|
|
62
|
+
return ` (was ${base})`;
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
function loadBaseSnapshot(baseResultPath) {
|
|
66
|
+
if (!fs.existsSync(baseResultPath))
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
const raw = JSON.parse(fs.readFileSync(baseResultPath, "utf-8"));
|
|
70
|
+
return {
|
|
71
|
+
grade: raw.grade ?? "Unverified",
|
|
72
|
+
lighthouse: raw.lighthouse ?? null,
|
|
73
|
+
e2e: raw.e2e ? { passed: raw.e2e.passed, total: raw.e2e.total } : null,
|
|
74
|
+
timestamp: raw.timestamp ?? "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function computeTrendDelta(current, base) {
|
|
82
|
+
const lhCurrent = current.lighthouse;
|
|
83
|
+
const lhBase = base.lighthouse;
|
|
84
|
+
function lhDelta(key) {
|
|
85
|
+
if (!lhCurrent || !lhBase)
|
|
86
|
+
return null;
|
|
87
|
+
return lhCurrent[key] - lhBase[key];
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
grade: {
|
|
91
|
+
current: current.grade,
|
|
92
|
+
base: base.grade,
|
|
93
|
+
changed: current.grade !== base.grade,
|
|
94
|
+
},
|
|
95
|
+
performance: {
|
|
96
|
+
current: lhCurrent?.performance ?? null,
|
|
97
|
+
base: lhBase?.performance ?? null,
|
|
98
|
+
delta: lhDelta("performance"),
|
|
99
|
+
},
|
|
100
|
+
accessibility: {
|
|
101
|
+
current: lhCurrent?.accessibility ?? null,
|
|
102
|
+
base: lhBase?.accessibility ?? null,
|
|
103
|
+
delta: lhDelta("accessibility"),
|
|
104
|
+
},
|
|
105
|
+
seo: {
|
|
106
|
+
current: lhCurrent?.seo ?? null,
|
|
107
|
+
base: lhBase?.seo ?? null,
|
|
108
|
+
delta: lhDelta("seo"),
|
|
109
|
+
},
|
|
110
|
+
bestPractices: {
|
|
111
|
+
current: lhCurrent?.bestPractices ?? null,
|
|
112
|
+
base: lhBase?.bestPractices ?? null,
|
|
113
|
+
delta: lhDelta("bestPractices"),
|
|
114
|
+
},
|
|
115
|
+
e2e: {
|
|
116
|
+
current: current.e2e ? `${current.e2e.passed}/${current.e2e.total}` : null,
|
|
117
|
+
base: base.e2e ? `${base.e2e.passed}/${base.e2e.total}` : null,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function renderTrendSection(delta) {
|
|
122
|
+
const lines = ["## vs Base Branch", ""];
|
|
123
|
+
lines.push("| Check | This PR | Base |");
|
|
124
|
+
lines.push("|---|---|---|");
|
|
125
|
+
const gradeTrendStr = gradeTrend(delta.grade.current, delta.grade.base);
|
|
126
|
+
lines.push(`| Grade | **${delta.grade.current}**${gradeTrendStr} | ${delta.grade.base} |`);
|
|
127
|
+
if (delta.performance.current !== null) {
|
|
128
|
+
lines.push(`| Performance | ${delta.performance.current}${formatDelta(delta.performance.delta)} | ${delta.performance.base ?? "—"} |`);
|
|
129
|
+
}
|
|
130
|
+
if (delta.accessibility.current !== null) {
|
|
131
|
+
lines.push(`| Accessibility | ${delta.accessibility.current}${formatDelta(delta.accessibility.delta)} | ${delta.accessibility.base ?? "—"} |`);
|
|
132
|
+
}
|
|
133
|
+
if (delta.seo.current !== null) {
|
|
134
|
+
lines.push(`| SEO | ${delta.seo.current}${formatDelta(delta.seo.delta)} | ${delta.seo.base ?? "—"} |`);
|
|
135
|
+
}
|
|
136
|
+
if (delta.bestPractices.current !== null) {
|
|
137
|
+
lines.push(`| Best Practices | ${delta.bestPractices.current}${formatDelta(delta.bestPractices.delta)} | ${delta.bestPractices.base ?? "—"} |`);
|
|
138
|
+
}
|
|
139
|
+
if (delta.e2e.current !== null) {
|
|
140
|
+
lines.push(`| E2E | ${delta.e2e.current} | ${delta.e2e.base ?? "—"} |`);
|
|
141
|
+
}
|
|
142
|
+
lines.push("");
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
function getBaseResultPath(projectDir) {
|
|
146
|
+
return path.join(projectDir, ".laxy-result-base.json");
|
|
147
|
+
}
|
|
@@ -29,7 +29,12 @@ function getVerificationGrade(input, thresholds = exports.DEFAULT_LH_THRESHOLDS)
|
|
|
29
29
|
input.e2eTotal > 0 &&
|
|
30
30
|
input.e2ePassed === input.e2eTotal;
|
|
31
31
|
const lighthousePassed = getLighthousePass(input.lighthouseScores, thresholds);
|
|
32
|
-
|
|
32
|
+
// Gold disqualifiers: console errors or critical security vulnerabilities
|
|
33
|
+
// mean the app has known problems — Gold should require a clean run.
|
|
34
|
+
const hasConsoleErrors = typeof input.e2eConsoleErrorCount === "number" && input.e2eConsoleErrorCount > 0;
|
|
35
|
+
const hasCriticalSecurity = !!input.securityAudit && input.securityAudit.critical > 0;
|
|
36
|
+
const goldDisqualified = hasConsoleErrors || hasCriticalSecurity;
|
|
37
|
+
if (buildPassed && e2ePassedAll && lighthousePassed && !goldDisqualified)
|
|
33
38
|
return "gold";
|
|
34
39
|
if (buildPassed && e2ePassedAll)
|
|
35
40
|
return "silver";
|
|
@@ -311,7 +316,7 @@ function buildVerificationReport(input, options) {
|
|
|
311
316
|
const warnings = findings.filter((finding) => finding.severity === "medium");
|
|
312
317
|
const hasWarnings = warnings.length > 0;
|
|
313
318
|
const coreChecksPassed = evidence.buildPassed && evidence.e2ePassedAll && evidence.lighthousePassed;
|
|
314
|
-
const
|
|
319
|
+
const teamEvidenceComplete = evidence.hasMultiViewportData &&
|
|
315
320
|
evidence.multiViewportPassed &&
|
|
316
321
|
evidence.hasComparableVisualDiffData &&
|
|
317
322
|
evidence.visualDiffPassed;
|
|
@@ -330,6 +335,11 @@ function buildVerificationReport(input, options) {
|
|
|
330
335
|
confidence = "medium";
|
|
331
336
|
summary = "Blocking verification issues were found. Hold release until the blockers are fixed.";
|
|
332
337
|
}
|
|
338
|
+
else if (tier === "team" && coreChecksPassed && !hasWarnings && teamEvidenceComplete) {
|
|
339
|
+
verdict = "release-ready";
|
|
340
|
+
confidence = "high";
|
|
341
|
+
summary = "All core checks and release-evidence checks passed. This run is strong enough to call release-ready.";
|
|
342
|
+
}
|
|
333
343
|
else if (coreChecksPassed && !hasWarnings) {
|
|
334
344
|
verdict = "client-ready";
|
|
335
345
|
confidence = "high";
|
package/dist/visual-diff.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
export type VisualDiffViewportName = "desktop" | "tablet" | "mobile";
|
|
2
|
+
export interface VisualDiffViewportResult {
|
|
3
|
+
viewport: VisualDiffViewportName;
|
|
4
|
+
hasBaseline: boolean;
|
|
5
|
+
diffPercentage: number;
|
|
6
|
+
verdict: "pass" | "warn" | "rollback";
|
|
7
|
+
diffPixels: number;
|
|
8
|
+
totalPixels: number;
|
|
9
|
+
baselinePath: string;
|
|
10
|
+
currentPath: string;
|
|
11
|
+
diffPath: string;
|
|
12
|
+
}
|
|
1
13
|
export interface VisualDiffResult {
|
|
2
14
|
hasBaseline: boolean;
|
|
3
15
|
diffPercentage: number;
|
|
@@ -7,5 +19,8 @@ export interface VisualDiffResult {
|
|
|
7
19
|
baselinePath: string;
|
|
8
20
|
currentPath: string;
|
|
9
21
|
diffPath: string;
|
|
22
|
+
viewports: VisualDiffViewportResult[];
|
|
23
|
+
summary: string;
|
|
10
24
|
}
|
|
25
|
+
export declare function formatVisualDiffSummary(result: VisualDiffResult): string;
|
|
11
26
|
export declare function runVisualDiff(projectDir: string, url: string, label?: string): Promise<VisualDiffResult>;
|
package/dist/visual-diff.js
CHANGED
|
@@ -36,19 +36,38 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.formatVisualDiffSummary = formatVisualDiffSummary;
|
|
39
40
|
exports.runVisualDiff = runVisualDiff;
|
|
40
41
|
const fs = __importStar(require("node:fs"));
|
|
41
42
|
const path = __importStar(require("node:path"));
|
|
42
43
|
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
43
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
|
+
}
|
|
44
58
|
function ensureDir(dir) {
|
|
45
59
|
fs.mkdirSync(dir, { recursive: true });
|
|
46
60
|
}
|
|
47
|
-
async function captureScreenshot(url, outputPath) {
|
|
61
|
+
async function captureScreenshot(url, outputPath, viewport) {
|
|
48
62
|
const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
|
|
49
63
|
try {
|
|
50
64
|
const page = await browser.newPage();
|
|
51
|
-
await page.setViewport({
|
|
65
|
+
await page.setViewport({
|
|
66
|
+
width: viewport.width,
|
|
67
|
+
height: viewport.height,
|
|
68
|
+
isMobile: viewport.isMobile ?? false,
|
|
69
|
+
deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
|
|
70
|
+
});
|
|
52
71
|
await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
|
|
53
72
|
await page.waitForSelector("body", { timeout: 5000 });
|
|
54
73
|
await page.screenshot({ path: outputPath, fullPage: true, type: "png" });
|
|
@@ -90,42 +109,70 @@ async function compareImages(baselinePath, currentPath, diffOutputPath) {
|
|
|
90
109
|
async function runVisualDiff(projectDir, url, label = "current") {
|
|
91
110
|
const dir = path.join(projectDir, ".laxy-verify", "visual");
|
|
92
111
|
ensureDir(dir);
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
|
105
151
|
baselinePath,
|
|
106
152
|
currentPath,
|
|
107
|
-
diffPath
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
const comparison = await compareImages(baselinePath, currentPath, diffPath);
|
|
111
|
-
let verdict = "pass";
|
|
112
|
-
if (comparison.diffPercentage >= 60) {
|
|
113
|
-
verdict = "rollback";
|
|
114
|
-
}
|
|
115
|
-
else if (comparison.diffPercentage >= 30) {
|
|
116
|
-
verdict = "warn";
|
|
117
|
-
}
|
|
118
|
-
if (verdict === "pass") {
|
|
119
|
-
fs.copyFileSync(currentPath, baselinePath);
|
|
153
|
+
diffPath,
|
|
154
|
+
});
|
|
120
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);
|
|
121
166
|
return {
|
|
122
|
-
hasBaseline:
|
|
123
|
-
diffPercentage:
|
|
124
|
-
verdict,
|
|
125
|
-
diffPixels:
|
|
126
|
-
totalPixels
|
|
127
|
-
baselinePath,
|
|
128
|
-
currentPath,
|
|
129
|
-
diffPath,
|
|
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,
|
|
130
177
|
};
|
|
131
178
|
}
|