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 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: release-ready
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: ${result.visualDiff.diffPercentage}% (${result.visualDiff.verdict})`);
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/psungmin24/laxy-verify
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
- (0, serve_js_1.stopDevServer)(servePid);
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.hasBaseline
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
- await (0, comment_js_1.postPRComment)(resultObj);
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
- async function postPRComment(result) {
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/psungmin24/laxy-verify)`;
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
  }
@@ -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: "team", // Free users get Pro+ features by default
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/psungmin24/laxy-verify for full docs
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: psungmin24/laxy-verify@v1
95
+ - uses: browser-actions/setup-chrome@v1.7.2
96
+ - uses: actions/setup-node@v4
96
97
  with:
97
- github-token: \${{ secrets.GITHUB_TOKEN }}
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 { VisualDiffResult } from "./visual-diff.js";
4
+ import { type VisualDiffResult } from "./visual-diff.js";
5
5
  export interface MarkdownReportResult {
6
6
  grade: string;
7
7
  timestamp: string;
@@ -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 | ${result.visualDiff.hasBaseline ? `${result.visualDiff.diffPercentage}% (${result.visualDiff.verdict})` : "Baseline seeded"} |`);
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)(process.env.ComSpec || "cmd.exe", ["/d", "/c", command], {
169
+ ? (0, node_child_process_1.spawn)("node", [wrapperPath], {
80
170
  stdio: ["ignore", "pipe", "pipe"],
81
- env: { ...process.env, PORT: String(port) },
82
- cwd,
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
- (0, tree_kill_1.default)(proc.pid, "SIGKILL");
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
- (0, tree_kill_1.default)(pid, "SIGKILL");
245
+ await killProcessTree(pid);
148
246
  }
@@ -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 proPlusEvidenceComplete = evidence.hasMultiViewportData &&
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";
@@ -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>;
@@ -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({ width: 1440, height: 960 });
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 baselinePath = path.join(dir, "baseline.png");
94
- const currentPath = path.join(dir, `${label}.png`);
95
- const diffPath = path.join(dir, `${label}.diff.png`);
96
- await captureScreenshot(url, currentPath);
97
- if (!fs.existsSync(baselinePath)) {
98
- fs.copyFileSync(currentPath, baselinePath);
99
- return {
100
- hasBaseline: false,
101
- diffPercentage: 0,
102
- verdict: "pass",
103
- diffPixels: 0,
104
- totalPixels: 0,
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: true,
123
- diffPercentage: comparison.diffPercentage,
124
- verdict,
125
- diffPixels: comparison.diffPixels,
126
- totalPixels: comparison.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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laxy-verify",
3
- "version": "1.1.30",
3
+ "version": "1.1.32",
4
4
  "description": "Frontend verification CLI for build checks, Lighthouse, E2E, and release readiness",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",