laxy-verify 1.1.23 → 1.1.24
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 +37 -8
- package/dist/audit/broken-links.d.ts +21 -0
- package/dist/audit/broken-links.js +86 -0
- package/dist/cli.js +59 -51
- package/dist/crawler.js +3 -3
- package/dist/e2e.d.ts +2 -1
- package/dist/e2e.js +9 -9
- package/dist/entitlement.d.ts +4 -7
- package/dist/entitlement.js +16 -13
- package/dist/report-markdown.js +3 -7
- package/dist/verification-core/report.js +18 -52
- package/dist/verification-core/tier-policy.js +8 -8
- package/dist/verification-core/types.d.ts +7 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,15 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
`laxy-verify` is a deployment blocker gate for frontend apps.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Free**: Run verification manually on any project.
|
|
6
|
+
**Pro**: GitHub Actions auto-runs it on every PR + Slack/Discord alerts when things break.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
Run it on a frontend app:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd your-project
|
|
14
|
+
npx laxy-verify .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Generate config plus GitHub Actions workflow:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx laxy-verify --init
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That creates:
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
- `.laxy.yml`
|
|
26
|
+
- `.github/workflows/laxy-verify.yml`
|
|
27
|
+
|
|
28
|
+
Log in to unlock Pro features:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx laxy-verify login
|
|
32
|
+
npx laxy-verify whoami
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For CI, set `LAXY_TOKEN` instead of interactive login:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
env:
|
|
39
|
+
LAXY_TOKEN: ${{ secrets.LAXY_TOKEN }}
|
|
40
|
+
```
|
|
12
41
|
|
|
13
|
-
## Why
|
|
42
|
+
## Why laxy-verify?
|
|
14
43
|
|
|
15
44
|
Most teams already have some mix of:
|
|
16
45
|
|
|
@@ -26,7 +55,7 @@ The gap is not "can these tools exist together?" The gap is "who turns that pile
|
|
|
26
55
|
- one command instead of a custom build plus audit plus smoke-check script stack
|
|
27
56
|
- one result file instead of scattered logs
|
|
28
57
|
- one blocker-first decision instead of "build passed, but do we actually trust this release?"
|
|
29
|
-
-
|
|
58
|
+
- **Pro**: GitHub Actions auto-run + Slack/Discord alerts so you never miss a failure
|
|
30
59
|
|
|
31
60
|
This is most useful if you ship frontend apps and want a practical gate before:
|
|
32
61
|
|
|
@@ -201,7 +230,7 @@ Options:
|
|
|
201
230
|
--config <path>
|
|
202
231
|
--fail-on unverified|bronze|silver|gold
|
|
203
232
|
--skip-lighthouse
|
|
204
|
-
--plan-override free|pro|
|
|
233
|
+
--plan-override free|pro|team
|
|
205
234
|
--badge
|
|
206
235
|
--init
|
|
207
236
|
--multi-viewport
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broken-links audit.
|
|
3
|
+
* Uses the crawl result to find all internal links, then validates each
|
|
4
|
+
* with an HTTP HEAD/GET request. Links that return 4xx/5xx or timeout are
|
|
5
|
+
* reported as blockers.
|
|
6
|
+
*/
|
|
7
|
+
import { type CrawlResult } from "../crawler.js";
|
|
8
|
+
export interface BrokenLink {
|
|
9
|
+
url: string;
|
|
10
|
+
path: string;
|
|
11
|
+
status: number;
|
|
12
|
+
statusText: string;
|
|
13
|
+
severity: "critical" | "high";
|
|
14
|
+
}
|
|
15
|
+
export interface BrokenLinksResult {
|
|
16
|
+
brokenLinks: BrokenLink[];
|
|
17
|
+
checkedCount: number;
|
|
18
|
+
hasBrokenLinks: boolean;
|
|
19
|
+
summary: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function auditBrokenLinks(crawlResult: CrawlResult, baseUrl: string, abortSignal?: AbortSignal): Promise<BrokenLinksResult>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.auditBrokenLinks = auditBrokenLinks;
|
|
4
|
+
const TIMEOUT_MS = 5000;
|
|
5
|
+
const VALID_OK_STATUS = [200, 201, 202, 203, 204, 301, 302, 303, 307, 308];
|
|
6
|
+
function isSuccessStatus(n) {
|
|
7
|
+
return VALID_OK_STATUS.includes(n);
|
|
8
|
+
}
|
|
9
|
+
async function auditBrokenLinks(crawlResult, baseUrl, abortSignal) {
|
|
10
|
+
const origin = new URL(baseUrl).origin;
|
|
11
|
+
const allUrls = [];
|
|
12
|
+
for (const page of crawlResult.pages) {
|
|
13
|
+
for (const href of page.internalLinks) {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(href, baseUrl).href;
|
|
16
|
+
if (!allUrls.includes(url))
|
|
17
|
+
allUrls.push(url);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// skip malformed URLs
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const uniqueUrls = allUrls;
|
|
25
|
+
const brokenLinks = [];
|
|
26
|
+
await Promise.all(uniqueUrls.map(async (url) => {
|
|
27
|
+
if (abortSignal?.aborted)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
32
|
+
let status = 0;
|
|
33
|
+
let statusText = "";
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(url, {
|
|
36
|
+
method: "HEAD",
|
|
37
|
+
redirect: "follow",
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
});
|
|
40
|
+
status = res.status;
|
|
41
|
+
statusText = res.statusText;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Fall back to GET if HEAD is not allowed
|
|
45
|
+
const controller2 = new AbortController();
|
|
46
|
+
const timer2 = setTimeout(() => controller2.abort(), TIMEOUT_MS);
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
method: "GET",
|
|
50
|
+
redirect: "follow",
|
|
51
|
+
signal: controller2.signal,
|
|
52
|
+
});
|
|
53
|
+
status = res.status;
|
|
54
|
+
statusText = res.statusText;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
status = 0;
|
|
58
|
+
statusText = "timeout or network error";
|
|
59
|
+
}
|
|
60
|
+
clearTimeout(timer2);
|
|
61
|
+
}
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
if (!isSuccessStatus(status)) {
|
|
64
|
+
const severity = status >= 500 ? "critical" : "high";
|
|
65
|
+
let path = url;
|
|
66
|
+
try {
|
|
67
|
+
path = new URL(url).pathname;
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
brokenLinks.push({ url, path, status, statusText, severity });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// skip
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
const summary = brokenLinks.length === 0
|
|
78
|
+
? `All ${uniqueUrls.length} links OK`
|
|
79
|
+
: `${brokenLinks.length} broken link(s) found`;
|
|
80
|
+
return {
|
|
81
|
+
brokenLinks,
|
|
82
|
+
checkedCount: uniqueUrls.length,
|
|
83
|
+
hasBrokenLinks: brokenLinks.length > 0,
|
|
84
|
+
summary,
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -57,6 +57,7 @@ const playwright_runner_js_1 = require("./playwright-runner.js");
|
|
|
57
57
|
const report_markdown_js_1 = require("./report-markdown.js");
|
|
58
58
|
const visual_diff_js_1 = require("./visual-diff.js");
|
|
59
59
|
const security_audit_js_1 = require("./security-audit.js");
|
|
60
|
+
const broken_links_js_1 = require("./audit/broken-links.js");
|
|
60
61
|
const index_js_1 = require("./verification-core/index.js");
|
|
61
62
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
62
63
|
function shouldFailVerificationResult(report, failOn) {
|
|
@@ -64,7 +65,7 @@ function shouldFailVerificationResult(report, failOn) {
|
|
|
64
65
|
return false;
|
|
65
66
|
if (report.verdict === "build-failed" || report.verdict === "hold")
|
|
66
67
|
return true;
|
|
67
|
-
if (report.tier === "
|
|
68
|
+
if (report.tier === "team" && report.verdict === "investigate")
|
|
68
69
|
return true;
|
|
69
70
|
return (0, grade_js_1.isWorseOrEqual)(report.grade, failOn);
|
|
70
71
|
}
|
|
@@ -210,18 +211,14 @@ function consoleOutput(result) {
|
|
|
210
211
|
}
|
|
211
212
|
if (result.verification) {
|
|
212
213
|
const view = result.verification.view;
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
// verbose_failure: Pro에서 서버가 활성화하면 blockers 전체 설명 표시
|
|
216
|
-
// failure_analysis: Pro+에서 서버가 활성화하면 warnings 전체 설명 + evidence 전체 표시
|
|
217
|
-
const verboseFailure = isPro && (result._verbose_failure ?? isPro);
|
|
218
|
-
const failureAnalysis = isProPlus && (result._failure_analysis ?? isProPlus);
|
|
214
|
+
const verboseFailure = result._verbose_failure ?? true;
|
|
215
|
+
const failureAnalysis = result._failure_analysis ?? true;
|
|
219
216
|
console.log(` Verification depth: ${view.tier}`);
|
|
220
217
|
console.log(` Decision question: ${view.question}`);
|
|
221
218
|
console.log(` Decision: ${view.verdict} (${view.confidence})`);
|
|
222
219
|
console.log(` Why it stopped here: ${view.summary}`);
|
|
223
|
-
//
|
|
224
|
-
if (
|
|
220
|
+
// Passed/Failed checks summary
|
|
221
|
+
if (view.passes.length > 0) {
|
|
225
222
|
const passedChecks = view.passes.filter((p) => p.passed).map((p) => p.label);
|
|
226
223
|
const failedChecks = view.passes.filter((p) => !p.passed).map((p) => p.label);
|
|
227
224
|
if (passedChecks.length > 0)
|
|
@@ -238,8 +235,8 @@ function consoleOutput(result) {
|
|
|
238
235
|
console.log(` Fix: ${blocker.action}`);
|
|
239
236
|
}
|
|
240
237
|
}
|
|
241
|
-
// Warnings:
|
|
242
|
-
if (
|
|
238
|
+
// Warnings: always show all
|
|
239
|
+
if (view.warnings.length > 0) {
|
|
243
240
|
console.log(" Risks to review:");
|
|
244
241
|
for (const warning of view.warnings) {
|
|
245
242
|
console.log(` - ${warning.title}`);
|
|
@@ -297,8 +294,8 @@ async function run() {
|
|
|
297
294
|
--config <path> Path to .laxy.yml
|
|
298
295
|
--fail-on unverified | bronze | silver | gold
|
|
299
296
|
--skip-lighthouse Skip Lighthouse but still run build and E2E
|
|
300
|
-
--plan-override free | pro |
|
|
301
|
-
--multi-viewport
|
|
297
|
+
--plan-override free | pro | team (downgrade testing only)
|
|
298
|
+
--multi-viewport team: Lighthouse on desktop/tablet/mobile
|
|
302
299
|
--crawl Crawl the app to discover routes before E2E
|
|
303
300
|
--badge Print shields.io badge markdown
|
|
304
301
|
--help Show this help
|
|
@@ -423,12 +420,11 @@ async function run() {
|
|
|
423
420
|
}
|
|
424
421
|
const features = entitlements ?? {
|
|
425
422
|
plan: "free",
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
fast_lane: false,
|
|
423
|
+
// All verification features run on every plan
|
|
424
|
+
// Automation features — not available on Free
|
|
425
|
+
github_actions: false,
|
|
426
|
+
queue_priority: false,
|
|
427
|
+
parallel_execution: false,
|
|
432
428
|
};
|
|
433
429
|
let effectiveFeatures = features;
|
|
434
430
|
if (args.planOverride) {
|
|
@@ -442,7 +438,8 @@ async function run() {
|
|
|
442
438
|
return;
|
|
443
439
|
}
|
|
444
440
|
}
|
|
445
|
-
|
|
441
|
+
// Always run 3 Lighthouse passes for median score
|
|
442
|
+
if (config.lighthouse_runs < 3) {
|
|
446
443
|
config = { ...config, lighthouse_runs: 3 };
|
|
447
444
|
}
|
|
448
445
|
let multiViewportScores = null;
|
|
@@ -455,6 +452,7 @@ async function run() {
|
|
|
455
452
|
let visualDiffResult = null;
|
|
456
453
|
let securityAuditResult = null;
|
|
457
454
|
let mobileLighthouseScores = null;
|
|
455
|
+
let brokenLinksResult = null;
|
|
458
456
|
const failureEvidence = [];
|
|
459
457
|
if (buildResult.success) {
|
|
460
458
|
let servePid;
|
|
@@ -482,15 +480,11 @@ async function run() {
|
|
|
482
480
|
console.error(`Lighthouse error: ${lhErr instanceof Error ? lhErr.message : String(lhErr)}`);
|
|
483
481
|
}
|
|
484
482
|
}
|
|
485
|
-
if (!args.skipLighthouse && args.multiViewport
|
|
486
|
-
console.log("\n Note: --multi-viewport requires Pro+. Run laxy-verify login with a paid account to unlock it.");
|
|
487
|
-
}
|
|
488
|
-
else if (!args.skipLighthouse && effectiveFeatures.multi_viewport) {
|
|
483
|
+
if (!args.skipLighthouse && args.multiViewport) {
|
|
489
484
|
try {
|
|
490
485
|
multiViewportScores = await (0, multi_viewport_js_1.runMultiViewportLighthouse)(port);
|
|
491
486
|
(0, multi_viewport_js_1.printMultiViewportResults)(multiViewportScores, adjustedThresholds);
|
|
492
487
|
allViewportsOk = (0, multi_viewport_js_1.allViewportsPass)(multiViewportScores, adjustedThresholds);
|
|
493
|
-
// Surface screenshot diff issues in failure evidence
|
|
494
488
|
if (multiViewportScores.screenshotDiffs) {
|
|
495
489
|
for (const diff of multiViewportScores.screenshotDiffs) {
|
|
496
490
|
if (!diff.baselineCreated && diff.diffPercent > 10) {
|
|
@@ -503,10 +497,9 @@ async function run() {
|
|
|
503
497
|
console.error(`Multi-viewport error: ${mvErr instanceof Error ? mvErr.message : String(mvErr)}`);
|
|
504
498
|
}
|
|
505
499
|
}
|
|
506
|
-
//
|
|
500
|
+
// Mobile Lighthouse check (runs when multi-viewport is not enabled)
|
|
507
501
|
if (!args.skipLighthouse &&
|
|
508
|
-
|
|
509
|
-
!effectiveFeatures.multi_viewport) {
|
|
502
|
+
!args.multiViewport) {
|
|
510
503
|
try {
|
|
511
504
|
mobileLighthouseScores = await (0, multi_viewport_js_1.runMobileLighthouse)(port);
|
|
512
505
|
if (mobileLighthouseScores) {
|
|
@@ -521,18 +514,16 @@ async function run() {
|
|
|
521
514
|
console.error(`Mobile Lighthouse error: ${mobileLhErr instanceof Error ? mobileLhErr.message : String(mobileLhErr)}`);
|
|
522
515
|
}
|
|
523
516
|
}
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
failureEvidence.push(`Security: ${securityAuditResult.summary}`);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
catch (secErr) {
|
|
533
|
-
console.error(`Security audit error: ${secErr instanceof Error ? secErr.message : String(secErr)}`);
|
|
517
|
+
// Security audit (npm audit)
|
|
518
|
+
try {
|
|
519
|
+
securityAuditResult = await (0, security_audit_js_1.runSecurityAudit)(args.projectDir);
|
|
520
|
+
if (securityAuditResult.critical > 0 || securityAuditResult.high > 0) {
|
|
521
|
+
failureEvidence.push(`Security: ${securityAuditResult.summary}`);
|
|
534
522
|
}
|
|
535
523
|
}
|
|
524
|
+
catch (secErr) {
|
|
525
|
+
console.error(`Security audit error: ${secErr instanceof Error ? secErr.message : String(secErr)}`);
|
|
526
|
+
}
|
|
536
527
|
const crawlEnabled = args.crawl || config.crawl;
|
|
537
528
|
const crawlOpts = crawlEnabled
|
|
538
529
|
? { enabled: true, maxDepth: config.max_crawl_depth, maxPages: config.max_crawl_pages }
|
|
@@ -549,9 +540,21 @@ async function run() {
|
|
|
549
540
|
};
|
|
550
541
|
e2eCoverageGaps = e2eRuns.coverageGaps;
|
|
551
542
|
e2eConsoleErrors = e2eRuns.consoleErrors;
|
|
552
|
-
//
|
|
553
|
-
if (
|
|
554
|
-
|
|
543
|
+
// Broken links audit — runs after E2E so we have the crawl result
|
|
544
|
+
if (e2eRuns.crawlResult && e2eRuns.crawlResult.totalLinks > 0) {
|
|
545
|
+
try {
|
|
546
|
+
brokenLinksResult = await (0, broken_links_js_1.auditBrokenLinks)(e2eRuns.crawlResult, verifyUrl);
|
|
547
|
+
if (brokenLinksResult.hasBrokenLinks) {
|
|
548
|
+
failureEvidence.push(`Broken links: ${brokenLinksResult.summary}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (blErr) {
|
|
552
|
+
console.error(`Broken links audit error: ${blErr instanceof Error ? blErr.message : String(blErr)}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// E2E stability: run a second time if first run passed all
|
|
556
|
+
if (e2eRuns.passed === e2eRuns.results.length && e2eRuns.results.length > 0) {
|
|
557
|
+
console.log(" Running stability pass (run 2/2)...");
|
|
555
558
|
const e2eRuns2 = await (0, e2e_js_1.runVerifyE2E)(verifyUrl, verificationTier, config.scenarios, crawlOpts);
|
|
556
559
|
if (e2eRuns2.passed < e2eRuns2.results.length) {
|
|
557
560
|
e2eStabilityPassed = false;
|
|
@@ -563,7 +566,7 @@ async function run() {
|
|
|
563
566
|
failureEvidence.push(`E2E stability: second run failed (${failedNames})`);
|
|
564
567
|
}
|
|
565
568
|
else {
|
|
566
|
-
console.log("
|
|
569
|
+
console.log(" Stability pass: OK (2/2 runs passed)");
|
|
567
570
|
}
|
|
568
571
|
}
|
|
569
572
|
if (e2eRuns.coverageGaps.length > 0) {
|
|
@@ -599,13 +602,11 @@ async function run() {
|
|
|
599
602
|
console.log(" Note: Cross-browser testing requires playwright. Run: npm install -D playwright && npx playwright install");
|
|
600
603
|
}
|
|
601
604
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
console.error(`Visual diff error: ${visualErr instanceof Error ? visualErr.message : String(visualErr)}`);
|
|
608
|
-
}
|
|
605
|
+
try {
|
|
606
|
+
visualDiffResult = await (0, visual_diff_js_1.runVisualDiff)(args.projectDir, verifyUrl, "verify");
|
|
607
|
+
}
|
|
608
|
+
catch (visualErr) {
|
|
609
|
+
console.error(`Visual diff error: ${visualErr instanceof Error ? visualErr.message : String(visualErr)}`);
|
|
609
610
|
}
|
|
610
611
|
}
|
|
611
612
|
catch (serveErr) {
|
|
@@ -657,6 +658,13 @@ async function run() {
|
|
|
657
658
|
summary: securityAuditResult.summary,
|
|
658
659
|
}
|
|
659
660
|
: undefined,
|
|
661
|
+
brokenLinksAudit: brokenLinksResult
|
|
662
|
+
? {
|
|
663
|
+
checkedCount: brokenLinksResult.checkedCount,
|
|
664
|
+
brokenCount: brokenLinksResult.brokenLinks.length,
|
|
665
|
+
summary: brokenLinksResult.summary,
|
|
666
|
+
}
|
|
667
|
+
: undefined,
|
|
660
668
|
failureEvidence,
|
|
661
669
|
}, {
|
|
662
670
|
tier: verificationTier,
|
|
@@ -685,8 +693,8 @@ async function run() {
|
|
|
685
693
|
exitCode,
|
|
686
694
|
config_fail_on: config.fail_on,
|
|
687
695
|
_plan: effectiveFeatures.plan,
|
|
688
|
-
_verbose_failure:
|
|
689
|
-
_failure_analysis:
|
|
696
|
+
_verbose_failure: true,
|
|
697
|
+
_failure_analysis: true,
|
|
690
698
|
verification: {
|
|
691
699
|
tier: verificationTier,
|
|
692
700
|
report: verificationReport,
|
package/dist/crawler.js
CHANGED
|
@@ -220,7 +220,7 @@ async function crawlApp(baseUrl, options) {
|
|
|
220
220
|
*/
|
|
221
221
|
function buildScenariosFromCrawl(crawlResult, tier) {
|
|
222
222
|
const scenarios = [];
|
|
223
|
-
const limit = tier === "
|
|
223
|
+
const limit = tier === "team" ? 6 : tier === "pro" ? 4 : 2;
|
|
224
224
|
// Scenario 1: Root page render
|
|
225
225
|
const rootPage = crawlResult.pages.find((p) => p.path === "/");
|
|
226
226
|
if (rootPage) {
|
|
@@ -310,8 +310,8 @@ function buildScenariosFromCrawl(crawlResult, tier) {
|
|
|
310
310
|
],
|
|
311
311
|
});
|
|
312
312
|
}
|
|
313
|
-
// Scenario: Button interactions (
|
|
314
|
-
if (tier === "
|
|
313
|
+
// Scenario: Button interactions (Team only)
|
|
314
|
+
if (tier === "team") {
|
|
315
315
|
for (const page of crawlResult.pages) {
|
|
316
316
|
if (scenarios.length >= limit)
|
|
317
317
|
break;
|
package/dist/e2e.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { VerificationTier } from "./verification-core/types.js";
|
|
2
2
|
import type { UserScenario } from "./config.js";
|
|
3
|
-
import { type CrawlOptions } from "./crawler.js";
|
|
3
|
+
import { type CrawlOptions, type CrawlResult } from "./crawler.js";
|
|
4
4
|
export interface E2EStep {
|
|
5
5
|
type: "click" | "fill" | "check_visible" | "check_text" | "wait" | "scroll" | "clear_fill" | "check_validation" | "check_healthy_page" | "goto";
|
|
6
6
|
selector?: string;
|
|
@@ -44,5 +44,6 @@ export declare function runVerifyE2E(url: string, tier: VerificationTier, userSc
|
|
|
44
44
|
failed: number;
|
|
45
45
|
coverageGaps: string[];
|
|
46
46
|
consoleErrors: string[];
|
|
47
|
+
crawlResult?: CrawlResult;
|
|
47
48
|
}>;
|
|
48
49
|
export {};
|
package/dist/e2e.js
CHANGED
|
@@ -40,7 +40,7 @@ function getScenarioLimit(tier) {
|
|
|
40
40
|
switch (tier) {
|
|
41
41
|
case "pro":
|
|
42
42
|
return 4;
|
|
43
|
-
case "
|
|
43
|
+
case "team":
|
|
44
44
|
return 5;
|
|
45
45
|
default:
|
|
46
46
|
return 2;
|
|
@@ -95,8 +95,8 @@ function getVerificationCoverageGaps(scenarios, tier) {
|
|
|
95
95
|
if (tier !== "free" && !hasPrimaryAction) {
|
|
96
96
|
gaps.push("No primary action scenario was detected, so the verify run could not validate a real user action.");
|
|
97
97
|
}
|
|
98
|
-
if (tier === "
|
|
99
|
-
gaps.push("Too few meaningful scenarios were detected for a release-confidence pass, so this run stayed shallower than
|
|
98
|
+
if (tier === "team" && scenarios.length < 4) {
|
|
99
|
+
gaps.push("Too few meaningful scenarios were detected for a release-confidence pass, so this run stayed shallower than Team expects.");
|
|
100
100
|
}
|
|
101
101
|
return gaps;
|
|
102
102
|
}
|
|
@@ -168,7 +168,7 @@ function buildVerifyScenarios(snapshot, tier) {
|
|
|
168
168
|
],
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
|
-
if (tier === "
|
|
171
|
+
if (tier === "team" && clickTarget && fillTarget && clickTarget !== fillTarget) {
|
|
172
172
|
scenarios.push({
|
|
173
173
|
name: "Repeated interaction stability",
|
|
174
174
|
steps: [
|
|
@@ -511,6 +511,7 @@ async function executeScenario(url, scenario) {
|
|
|
511
511
|
async function runVerifyE2E(url, tier, userScenarios, crawlOptions) {
|
|
512
512
|
let scenarios;
|
|
513
513
|
let coverageGaps;
|
|
514
|
+
let crawlResultUsed;
|
|
514
515
|
if (userScenarios && userScenarios.length > 0) {
|
|
515
516
|
console.log(` Using ${userScenarios.length} user-defined scenario(s) from .laxy.yml`);
|
|
516
517
|
scenarios = convertUserScenarios(userScenarios);
|
|
@@ -518,11 +519,10 @@ async function runVerifyE2E(url, tier, userScenarios, crawlOptions) {
|
|
|
518
519
|
}
|
|
519
520
|
else if (crawlOptions?.enabled) {
|
|
520
521
|
console.log(" Crawling app to discover routes and interactions...");
|
|
521
|
-
|
|
522
|
-
console.log(` Crawled ${
|
|
523
|
-
scenarios = (0, crawler_js_1.buildScenariosFromCrawl)(
|
|
522
|
+
crawlResultUsed = await (0, crawler_js_1.crawlApp)(url, crawlOptions);
|
|
523
|
+
console.log(` Crawled ${crawlResultUsed.crawledCount} page(s), found ${crawlResultUsed.totalLinks} internal link(s)`);
|
|
524
|
+
scenarios = (0, crawler_js_1.buildScenariosFromCrawl)(crawlResultUsed, tier);
|
|
524
525
|
if (scenarios.length === 0) {
|
|
525
|
-
// Fallback to DOM snapshot if crawl found nothing useful
|
|
526
526
|
const snapshot = await captureDomSnapshot(url);
|
|
527
527
|
scenarios = buildVerifyScenarios(snapshot, tier);
|
|
528
528
|
}
|
|
@@ -540,5 +540,5 @@ async function runVerifyE2E(url, tier, userScenarios, crawlOptions) {
|
|
|
540
540
|
const passed = results.filter((result) => result.passed).length;
|
|
541
541
|
const failed = results.length - passed;
|
|
542
542
|
const consoleErrors = Array.from(new Set(results.flatMap((r) => r.consoleErrors ?? []))).slice(0, 10);
|
|
543
|
-
return { scenarios, results, passed, failed, coverageGaps, consoleErrors };
|
|
543
|
+
return { scenarios, results, passed, failed, coverageGaps, consoleErrors, crawlResult: crawlResultUsed };
|
|
544
544
|
}
|
package/dist/entitlement.d.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
export interface EntitlementFeatures {
|
|
2
2
|
plan: string;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
multi_viewport: boolean;
|
|
7
|
-
failure_analysis: boolean;
|
|
8
|
-
fast_lane: boolean;
|
|
3
|
+
github_actions: boolean;
|
|
4
|
+
queue_priority: boolean;
|
|
5
|
+
parallel_execution: boolean;
|
|
9
6
|
}
|
|
10
|
-
export type TestablePlan = "free" | "pro" | "
|
|
7
|
+
export type TestablePlan = "free" | "pro" | "team";
|
|
11
8
|
export declare function normalizePlan(plan?: string | null): TestablePlan;
|
|
12
9
|
export declare function getEntitlements(): Promise<EntitlementFeatures>;
|
|
13
10
|
export declare function applyPlanOverride(features: EntitlementFeatures, overridePlan?: TestablePlan): EntitlementFeatures;
|
package/dist/entitlement.js
CHANGED
|
@@ -14,27 +14,23 @@ exports.printPlanBanner = printPlanBanner;
|
|
|
14
14
|
const auth_js_1 = require("./auth.js");
|
|
15
15
|
const FREE_FEATURES = {
|
|
16
16
|
plan: "free",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
failure_analysis: true,
|
|
22
|
-
fast_lane: true,
|
|
17
|
+
// Automation features — not available on Free
|
|
18
|
+
github_actions: false,
|
|
19
|
+
queue_priority: false,
|
|
20
|
+
parallel_execution: false,
|
|
23
21
|
};
|
|
24
22
|
let cache = null;
|
|
25
23
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
26
24
|
const PLAN_RANK = {
|
|
27
25
|
free: 0,
|
|
28
26
|
pro: 1,
|
|
29
|
-
pro_plus: 2,
|
|
30
27
|
team: 2,
|
|
31
|
-
enterprise: 2,
|
|
32
28
|
};
|
|
33
29
|
function normalizePlan(plan) {
|
|
34
30
|
if (plan === "pro")
|
|
35
31
|
return "pro";
|
|
36
|
-
if (plan === "
|
|
37
|
-
return "
|
|
32
|
+
if (plan === "team")
|
|
33
|
+
return "team";
|
|
38
34
|
return "free";
|
|
39
35
|
}
|
|
40
36
|
function getPlanRank(plan) {
|
|
@@ -68,15 +64,22 @@ async function getEntitlements() {
|
|
|
68
64
|
function applyPlanOverride(features, overridePlan) {
|
|
69
65
|
if (!overridePlan)
|
|
70
66
|
return features;
|
|
71
|
-
|
|
67
|
+
const isTeam = overridePlan === "team";
|
|
68
|
+
return {
|
|
69
|
+
...features,
|
|
70
|
+
plan: overridePlan,
|
|
71
|
+
// All verification features run on every plan
|
|
72
|
+
// Automation features — Team only
|
|
73
|
+
github_actions: isTeam,
|
|
74
|
+
queue_priority: isTeam,
|
|
75
|
+
parallel_execution: isTeam,
|
|
76
|
+
};
|
|
72
77
|
}
|
|
73
78
|
function printPlanBanner(features) {
|
|
74
79
|
const planLabels = {
|
|
75
80
|
free: "Free",
|
|
76
81
|
pro: "Pro",
|
|
77
|
-
pro_plus: "Pro+",
|
|
78
82
|
team: "Team",
|
|
79
|
-
enterprise: "Enterprise",
|
|
80
83
|
};
|
|
81
84
|
const label = planLabels[features.plan] ?? features.plan;
|
|
82
85
|
if (features.plan !== "free") {
|
package/dist/report-markdown.js
CHANGED
|
@@ -41,12 +41,8 @@ function titleCasePlan(plan) {
|
|
|
41
41
|
switch (plan) {
|
|
42
42
|
case "pro":
|
|
43
43
|
return "Pro";
|
|
44
|
-
case "pro_plus":
|
|
45
|
-
return "Pro+";
|
|
46
44
|
case "team":
|
|
47
45
|
return "Team";
|
|
48
|
-
case "enterprise":
|
|
49
|
-
return "Enterprise";
|
|
50
46
|
default:
|
|
51
47
|
return "Free";
|
|
52
48
|
}
|
|
@@ -64,7 +60,7 @@ function formatTimestamp(iso) {
|
|
|
64
60
|
return date.toISOString().replace("T", " ").replace(".000Z", " UTC");
|
|
65
61
|
}
|
|
66
62
|
function sentenceForVerdict(view) {
|
|
67
|
-
const isReleaseTier = view.tier === "
|
|
63
|
+
const isReleaseTier = view.tier === "team";
|
|
68
64
|
switch (view.verdict) {
|
|
69
65
|
case "client-ready":
|
|
70
66
|
return "Yes. This run collected enough evidence to support a client-ready call.";
|
|
@@ -102,7 +98,7 @@ function defaultNextActions(result) {
|
|
|
102
98
|
case "release-ready":
|
|
103
99
|
return ["Ship this version, or archive this report as release evidence."];
|
|
104
100
|
case "investigate":
|
|
105
|
-
return view.tier === "
|
|
101
|
+
return view.tier === "team"
|
|
106
102
|
? ["Collect the missing verification evidence, then rerun the command before release."]
|
|
107
103
|
: ["Collect the missing verification evidence, then rerun the command before sending this to a client."];
|
|
108
104
|
case "build-failed":
|
|
@@ -185,7 +181,7 @@ function getReportFlavor(view) {
|
|
|
185
181
|
switch (view.tier) {
|
|
186
182
|
case "pro":
|
|
187
183
|
return "delivery";
|
|
188
|
-
case "
|
|
184
|
+
case "team":
|
|
189
185
|
return "release";
|
|
190
186
|
default:
|
|
191
187
|
return "generic";
|
|
@@ -202,6 +202,16 @@ function getImprovementRecommendations(input, thresholds = exports.DEFAULT_LH_TH
|
|
|
202
202
|
});
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
|
+
// Broken links audit
|
|
206
|
+
if (input.brokenLinksAudit && input.brokenLinksAudit.brokenCount > 0) {
|
|
207
|
+
findings.push({
|
|
208
|
+
category: "security",
|
|
209
|
+
severity: "high",
|
|
210
|
+
title: `Broken links detected (${input.brokenLinksAudit.brokenCount}/${input.brokenLinksAudit.checkedCount})`,
|
|
211
|
+
description: `${input.brokenLinksAudit.brokenCount} link(s) returned non-OK status codes. Users will hit 404/500 errors.`,
|
|
212
|
+
action: "Fix or remove the broken links and rerun the verification.",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
205
215
|
// Mobile Lighthouse — Pro tier mobile check
|
|
206
216
|
if (input.mobileLighthouseScores) {
|
|
207
217
|
const mobileScores = input.mobileLighthouseScores;
|
|
@@ -317,67 +327,23 @@ function buildVerificationReport(input, options) {
|
|
|
317
327
|
}
|
|
318
328
|
else if (blockers.length > 0) {
|
|
319
329
|
verdict = "hold";
|
|
320
|
-
confidence = tier === "free" ? "low" : "medium";
|
|
321
|
-
summary = "Blocking verification issues were found. Hold release until the blockers are fixed.";
|
|
322
|
-
}
|
|
323
|
-
else if (tier === "pro_plus" &&
|
|
324
|
-
coreChecksPassed &&
|
|
325
|
-
evidence.e2eStabilityPassed &&
|
|
326
|
-
proPlusEvidenceComplete &&
|
|
327
|
-
!hasWarnings) {
|
|
328
|
-
verdict = "release-ready";
|
|
329
|
-
confidence = "high";
|
|
330
|
-
summary = "Core verification checks passed. This run supports a release-ready call.";
|
|
331
|
-
}
|
|
332
|
-
else if (tier === "pro_plus" &&
|
|
333
|
-
coreChecksPassed &&
|
|
334
|
-
evidence.e2eStabilityPassed &&
|
|
335
|
-
proPlusEvidenceComplete &&
|
|
336
|
-
hasWarnings) {
|
|
337
|
-
verdict = "investigate";
|
|
338
|
-
confidence = "medium";
|
|
339
|
-
summary = "Core checks passed, but non-blocking warnings remain. Review warnings before calling this run release-ready.";
|
|
340
|
-
}
|
|
341
|
-
else if (tier === "pro_plus" &&
|
|
342
|
-
coreChecksPassed &&
|
|
343
|
-
(!evidence.hasMultiViewportData || !evidence.hasComparableVisualDiffData)) {
|
|
344
|
-
verdict = "investigate";
|
|
345
330
|
confidence = "medium";
|
|
346
|
-
|
|
347
|
-
if (!evidence.hasMultiViewportData)
|
|
348
|
-
missingEvidence.push("multi-viewport evidence");
|
|
349
|
-
if (!evidence.hasComparableVisualDiffData)
|
|
350
|
-
missingEvidence.push("visual diff evidence");
|
|
351
|
-
summary = `Core checks passed, but release-ready confidence still needs ${missingEvidence.join(" and ")}.`;
|
|
331
|
+
summary = "Blocking verification issues were found. Hold release until the blockers are fixed.";
|
|
352
332
|
}
|
|
353
|
-
else if (
|
|
333
|
+
else if (coreChecksPassed && !hasWarnings) {
|
|
354
334
|
verdict = "client-ready";
|
|
355
|
-
confidence = "
|
|
356
|
-
summary = "No blocking issues found. Build, E2E, and Lighthouse checks passed.
|
|
335
|
+
confidence = "high";
|
|
336
|
+
summary = "No blocking issues found. Build, E2E, and Lighthouse checks passed.";
|
|
357
337
|
}
|
|
358
|
-
else if (
|
|
338
|
+
else if (coreChecksPassed && hasWarnings) {
|
|
359
339
|
verdict = "investigate";
|
|
360
340
|
confidence = "medium";
|
|
361
341
|
summary = "Core checks passed, but warning-level risks remain. Review warnings before calling this run client-ready.";
|
|
362
342
|
}
|
|
363
|
-
else if (tier === "free") {
|
|
364
|
-
if (evidence.hasConsoleErrors) {
|
|
365
|
-
verdict = "quick-pass";
|
|
366
|
-
confidence = "low";
|
|
367
|
-
summary = "Build passed, but runtime console errors were detected. The app may crash or misbehave for users.";
|
|
368
|
-
}
|
|
369
|
-
else {
|
|
370
|
-
verdict = "quick-pass";
|
|
371
|
-
confidence = evidence.hasLighthouseData && evidence.lighthousePassed ? "medium" : "low";
|
|
372
|
-
summary = "No immediate hard blockers were found in the quick verification pass.";
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
343
|
else {
|
|
376
|
-
verdict = "
|
|
377
|
-
confidence = "medium";
|
|
378
|
-
summary =
|
|
379
|
-
? "The build is standing. Lighthouse was skipped, so review the remaining verification evidence before release."
|
|
380
|
-
: "The build is standing, but deeper verification evidence should be reviewed before release.";
|
|
344
|
+
verdict = "quick-pass";
|
|
345
|
+
confidence = evidence.hasLighthouseData && evidence.lighthousePassed ? "medium" : "low";
|
|
346
|
+
summary = "No immediate hard blockers were found in the quick verification pass.";
|
|
381
347
|
}
|
|
382
348
|
const passes = [
|
|
383
349
|
{ key: "build", label: "Production build", passed: evidence.buildPassed },
|
|
@@ -21,13 +21,13 @@ const TIER_POLICIES = {
|
|
|
21
21
|
maxBlockers: 5,
|
|
22
22
|
maxWarnings: 5,
|
|
23
23
|
},
|
|
24
|
-
|
|
25
|
-
tier: "
|
|
24
|
+
team: {
|
|
25
|
+
tier: "team",
|
|
26
26
|
showDetailedLighthouse: true,
|
|
27
27
|
showDetailedE2E: true,
|
|
28
28
|
showReportExport: true,
|
|
29
|
-
maxBlockers:
|
|
30
|
-
maxWarnings:
|
|
29
|
+
maxBlockers: 10,
|
|
30
|
+
maxWarnings: 10,
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
function getTierPolicy(tier = "free") {
|
|
@@ -36,16 +36,16 @@ function getTierPolicy(tier = "free") {
|
|
|
36
36
|
function planToVerificationTier(plan) {
|
|
37
37
|
if (plan === "pro")
|
|
38
38
|
return "pro";
|
|
39
|
-
if (plan === "
|
|
40
|
-
return "
|
|
39
|
+
if (plan === "team")
|
|
40
|
+
return "team";
|
|
41
41
|
return "free";
|
|
42
42
|
}
|
|
43
43
|
function getVerificationTierQuestion(tier) {
|
|
44
44
|
switch (tier) {
|
|
45
45
|
case "pro":
|
|
46
46
|
return "Ready to show a client?";
|
|
47
|
-
case "
|
|
48
|
-
return "Ready for
|
|
47
|
+
case "team":
|
|
48
|
+
return "Ready for team deployment?";
|
|
49
49
|
default:
|
|
50
50
|
return "Any critical issues right now?";
|
|
51
51
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type VerificationGrade = "gold" | "silver" | "bronze" | "unverified";
|
|
2
|
-
export type VerificationTier = "free" | "pro" | "
|
|
2
|
+
export type VerificationTier = "free" | "pro" | "team";
|
|
3
3
|
export type ReleaseVerdict = "quick-pass" | "client-ready" | "investigate" | "hold" | "release-ready" | "build-failed";
|
|
4
4
|
export interface LighthouseThresholds {
|
|
5
5
|
performance: number;
|
|
@@ -38,9 +38,14 @@ export interface VerificationInput {
|
|
|
38
38
|
high: number;
|
|
39
39
|
summary: string;
|
|
40
40
|
};
|
|
41
|
+
brokenLinksAudit?: {
|
|
42
|
+
checkedCount: number;
|
|
43
|
+
brokenCount: number;
|
|
44
|
+
summary: string;
|
|
45
|
+
};
|
|
41
46
|
}
|
|
42
47
|
export interface VerificationCheck {
|
|
43
|
-
key: "build" | "e2e" | "lighthouse" | "viewport" | "visual" | "security" | "mobile-lh" | "console-errors";
|
|
48
|
+
key: "build" | "e2e" | "lighthouse" | "viewport" | "visual" | "security" | "mobile-lh" | "console-errors" | "broken-links";
|
|
44
49
|
label: string;
|
|
45
50
|
passed: boolean;
|
|
46
51
|
}
|