laxy-verify 1.1.30 → 1.1.32
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 +19 -1
- package/dist/cli.js +51 -13
- package/dist/comment.d.ts +2 -1
- package/dist/comment.js +39 -3
- package/dist/detect.js +18 -0
- 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 +6 -1
- package/dist/visual-diff.d.ts +15 -0
- package/dist/visual-diff.js +82 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,24 @@ This is most useful if you ship frontend apps and want a practical gate before:
|
|
|
63
63
|
- QA handoff
|
|
64
64
|
- production release
|
|
65
65
|
|
|
66
|
+
## vs other tools
|
|
67
|
+
|
|
68
|
+
No single competitor covers this combination. Here is where each tool stops:
|
|
69
|
+
|
|
70
|
+
| | laxy-verify | LHCI | Checkly | Percy / Argos | Unlighthouse | QA Wolf |
|
|
71
|
+
|--|--|--|--|--|--|--|
|
|
72
|
+
| Build failure detection | yes | no | no | no | no | no |
|
|
73
|
+
| Auto-generated E2E | yes | no | write your own | no | no | yes |
|
|
74
|
+
| Security audit | yes | no | no | no | no | no |
|
|
75
|
+
| Lighthouse (multi-run avg) | yes | yes | no | no | yes | no |
|
|
76
|
+
| Visual diff | yes | no | no | yes | no | no |
|
|
77
|
+
| Multi-viewport checks | yes | no | separate config | no | yes | no |
|
|
78
|
+
| Ship/hold release decision | yes | score only | no | no | no | no |
|
|
79
|
+
| Zero config to start | yes | no | no | no | partial | no |
|
|
80
|
+
| Free full coverage | yes | yes | limited | limited | yes | enterprise |
|
|
81
|
+
|
|
82
|
+
LHCI gives you Lighthouse. Percy gives you visual diffs. Checkly watches your production uptime. None of them produce a merge or release decision from one command.
|
|
83
|
+
|
|
66
84
|
## The failures it is meant to catch
|
|
67
85
|
|
|
68
86
|
Use `laxy-verify` when you want to catch things like:
|
|
@@ -143,7 +161,7 @@ env:
|
|
|
143
161
|
## Example output
|
|
144
162
|
|
|
145
163
|
```text
|
|
146
|
-
Decision:
|
|
164
|
+
Decision: client-ready
|
|
147
165
|
Grade: Gold
|
|
148
166
|
|
|
149
167
|
Passed:
|
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/detect.js
CHANGED
|
@@ -42,6 +42,21 @@ const FRAMEWORK_DEFAULT_PORTS = {
|
|
|
42
42
|
cra: 3000,
|
|
43
43
|
sveltekit: 5173,
|
|
44
44
|
};
|
|
45
|
+
function isLaxyVerifyPackage(pkg) {
|
|
46
|
+
if (pkg.name === "laxy-verify")
|
|
47
|
+
return true;
|
|
48
|
+
const bin = pkg.bin;
|
|
49
|
+
if (typeof bin === "object" && bin !== null) {
|
|
50
|
+
for (const value of Object.values(bin)) {
|
|
51
|
+
if (typeof value === "string" && value.includes("dist/cli.js")) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const scripts = pkg.scripts ?? {};
|
|
57
|
+
return Object.values(scripts).some((script) => typeof script === "string" &&
|
|
58
|
+
(script.includes("node dist/cli.js") || script.includes("laxy-verify")));
|
|
59
|
+
}
|
|
45
60
|
function detectPackageManager(dir) {
|
|
46
61
|
if (fs.existsSync(path.join(dir, "pnpm-lock.yaml")))
|
|
47
62
|
return "pnpm";
|
|
@@ -114,6 +129,9 @@ function detect(dir) {
|
|
|
114
129
|
throw new Error(`Not a Node.js project: no package.json found at ${pkgPath}`);
|
|
115
130
|
}
|
|
116
131
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
132
|
+
if (isLaxyVerifyPackage(pkg)) {
|
|
133
|
+
throw new Error("Cannot run laxy-verify against the laxy-verify package itself. Run it from the frontend app you actually want to verify.");
|
|
134
|
+
}
|
|
117
135
|
if (!pkg.scripts || !pkg.scripts.build) {
|
|
118
136
|
throw new Error("No 'build' script found in package.json");
|
|
119
137
|
}
|
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
|
+
}
|
|
@@ -311,7 +311,7 @@ function buildVerificationReport(input, options) {
|
|
|
311
311
|
const warnings = findings.filter((finding) => finding.severity === "medium");
|
|
312
312
|
const hasWarnings = warnings.length > 0;
|
|
313
313
|
const coreChecksPassed = evidence.buildPassed && evidence.e2ePassedAll && evidence.lighthousePassed;
|
|
314
|
-
const
|
|
314
|
+
const teamEvidenceComplete = evidence.hasMultiViewportData &&
|
|
315
315
|
evidence.multiViewportPassed &&
|
|
316
316
|
evidence.hasComparableVisualDiffData &&
|
|
317
317
|
evidence.visualDiffPassed;
|
|
@@ -330,6 +330,11 @@ function buildVerificationReport(input, options) {
|
|
|
330
330
|
confidence = "medium";
|
|
331
331
|
summary = "Blocking verification issues were found. Hold release until the blockers are fixed.";
|
|
332
332
|
}
|
|
333
|
+
else if (tier === "team" && coreChecksPassed && !hasWarnings && teamEvidenceComplete) {
|
|
334
|
+
verdict = "release-ready";
|
|
335
|
+
confidence = "high";
|
|
336
|
+
summary = "All core checks and release-evidence checks passed. This run is strong enough to call release-ready.";
|
|
337
|
+
}
|
|
333
338
|
else if (coreChecksPassed && !hasWarnings) {
|
|
334
339
|
verdict = "client-ready";
|
|
335
340
|
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
|
}
|