File size: 11,682 Bytes
81e3673
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
#!/usr/bin/env python3
"""
Lighthouse Regression Detection Script

This script compares current Lighthouse results against a historical baseline
to detect performance regressions. It checks for:

1. Performance score regression (>20% degradation)
2. Core Web Vitals regression (>20% degradation):
   - First Contentful Paint (FCP)
   - Largest Contentful Paint (LCP)
   - Total Blocking Time (TBT)
   - Cumulative Layout Shift (CLS)

Usage:
    python check_lighthouse_regression.py \
        --current .lighthouseci/lhr-report.json \
        --baseline backend/tests/performance_regression/lighthouse_baseline.json \
        --threshold 0.2

Exit Codes:
    0: No regression detected
    1: Regression detected
    2: Error (missing files, invalid JSON, etc.)
"""

import argparse
import json
import sys
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple


# ============================================================================
# Lighthouse Metric Parsing
# ============================================================================

def parse_lighthouse_metrics(report_path: str) -> Dict[str, Any]:
    """Parse Lighthouse JSON report and extract key metrics.

    Args:
        report_path: Path to Lighthouse JSON report

    Returns:
        dict: Extracted metrics including scores and Core Web Vitals

    Raises:
        FileNotFoundError: If report file doesn't exist
        json.JSONDecodeError: If report is invalid JSON
        KeyError: If expected metrics are missing
    """
    with open(report_path, 'r') as f:
        report = json.load(f)

    categories = report.get('categories', {})
    audits = report.get('audits', {})

    # Extract performance score (0-100 scale)
    performance_score = categories.get('performance', {}).get('score', 0)
    if performance_score is not None:
        performance_score = performance_score * 100  # Convert to 0-100 scale

    # Extract Core Web Vitals
    metrics = {
        'performance_score': performance_score,
        'accessibility_score': categories.get('accessibility', {}).get('score', 0) * 100,
        'best_practices_score': categories.get('best-practices', {}).get('score', 0) * 100,
        'seo_score': categories.get('seo', {}).get('score', 0) * 100,
        'first_contentful_paint': audits.get('first-contentful-paint', {}).get('numericValue'),
        'largest_contentful_paint': audits.get('largest-contentful-paint', {}).get('numericValue'),
        'total_blocking_time': audits.get('total-blocking-time', {}).get('numericValue'),
        'cumulative_layout_shift': audits.get('cumulative-layout-shift', {}).get('numericValue'),
        'speed_index': audits.get('speed-index', {}).get('numericValue'),
    }

    return metrics


# ============================================================================
# Regression Detection
# ============================================================================

def check_regression(
    current_metrics: Dict[str, Any],
    baseline_metrics: Dict[str, Any],
    threshold: float = 0.2
) -> Tuple[bool, List[str]]:
    """Check for performance regressions by comparing current vs baseline.

    Args:
        current_metrics: Current Lighthouse metrics
        baseline_metrics: Baseline Lighthouse metrics
        threshold: Regression threshold (default 0.2 = 20%)

    Returns:
        tuple: (regression_detected, list of regression messages)
    """
    regressions = []

    # Check performance score (lower is bad)
    current_score = current_metrics.get('performance_score', 0)
    baseline_score = baseline_metrics.get('performance_score', 0)

    if baseline_score > 0 and current_score < baseline_score * (1 - threshold):
        regression_percent = ((baseline_score - current_score) / baseline_score) * 100
        regressions.append(
            f"REGRESSION: Performance score {current_score:.0f} < baseline {baseline_score:.0f} "
            f"({regression_percent:.1f}% degradation)"
        )

    # Check Core Web Vitals (higher is bad for timing metrics)
    vitals_to_check = [
        ('first_contentful_paint', 'FCP'),
        ('largest_contentful_paint', 'LCP'),
        ('total_blocking_time', 'TBT'),
        ('cumulative_layout_shift', 'CLS'),
    ]

    for metric_key, metric_name in vitals_to_check:
        current_value = current_metrics.get(metric_key)
        baseline_value = baseline_metrics.get(metric_key)

        # Skip if baseline value is missing or zero
        if baseline_value is None or baseline_value == 0:
            continue

        # Skip if current value is missing
        if current_value is None:
            continue

        # Check for regression (current > baseline * (1 + threshold))
        if current_value > baseline_value * (1 + threshold):
            regression_percent = ((current_value - baseline_value) / baseline_value) * 100
            unit = 'ms' if metric_key != 'cumulative_layout_shift' else ''

            regressions.append(
                f"REGRESSION: {metric_name} {current_value:.0f}{unit} > "
                f"baseline {baseline_value:.0f}{unit} "
                f"({regression_percent:.1f}% degradation)"
            )

    return len(regressions) > 0, regressions


# ============================================================================
# CLI Interface
# ============================================================================

def parse_args() -> argparse.Namespace:
    """Parse command-line arguments."""
    parser = argparse.ArgumentParser(
        description='Check Lighthouse results for performance regressions',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
    # Check for regressions with default 20%% threshold
    %(prog)s --current .lighthouseci/lhr-report.json \\
             --baseline backend/tests/performance_regression/lighthouse_baseline.json

    # Use custom threshold (15%%)
    %(prog)s --current .lighthouseci/lhr-report.json \\
             --baseline backend/tests/performance_regression/lighthouse_baseline.json \\
             --threshold 0.15

Exit Codes:
    0: No regression detected
    1: Regression detected
    2: Error (missing files, invalid JSON)
        """
    )

    parser.add_argument(
        '--current',
        required=True,
        help='Path to current Lighthouse JSON report'
    )

    parser.add_argument(
        '--baseline',
        required=True,
        help='Path to baseline Lighthouse JSON file'
    )

    parser.add_argument(
        '--threshold',
        type=float,
        default=0.2,
        help='Regression threshold (default: 0.2 = 20%%)'
    )

    return parser.parse_args()


def main() -> int:
    """Main entry point for CLI."""
    args = parse_args()

    # Validate threshold
    if args.threshold <= 0 or args.threshold >= 1:
        print(f"ERROR: Threshold must be between 0 and 1, got {args.threshold}", file=sys.stderr)
        return 2

    # Check if current report exists
    current_path = Path(args.current)
    if not current_path.exists():
        print(f"ERROR: Current report not found: {args.current}", file=sys.stderr)
        return 2

    # Check if baseline exists
    baseline_path = Path(args.baseline)
    if not baseline_path.exists():
        print(f"ERROR: Baseline file not found: {args.baseline}", file=sys.stderr)
        return 2

    # Parse current metrics
    try:
        current_metrics = parse_lighthouse_metrics(args.current)
    except FileNotFoundError:
        print(f"ERROR: Current report not found: {args.current}", file=sys.stderr)
        return 2
    except json.JSONDecodeError as e:
        print(f"ERROR: Invalid JSON in current report: {e}", file=sys.stderr)
        return 2
    except Exception as e:
        print(f"ERROR: Failed to parse current report: {e}", file=sys.stderr)
        return 2

    # Parse baseline metrics
    try:
        # Baseline file might be in different format (simplified JSON)
        with open(args.baseline, 'r') as f:
            baseline_data = json.load(f)

        # Handle both full Lighthouse report and simplified baseline format
        if 'metrics' in baseline_data:
            # Simplified baseline format
            baseline_metrics = baseline_data['metrics']
        elif 'categories' in baseline_data:
            # Full Lighthouse report format
            baseline_metrics = parse_lighthouse_metrics(args.baseline)
        else:
            # Assume it's already a metrics dict
            baseline_metrics = baseline_data

    except FileNotFoundError:
        print(f"ERROR: Baseline file not found: {args.baseline}", file=sys.stderr)
        return 2
    except json.JSONDecodeError as e:
        print(f"ERROR: Invalid JSON in baseline: {e}", file=sys.stderr)
        return 2
    except Exception as e:
        print(f"ERROR: Failed to parse baseline: {e}", file=sys.stderr)
        return 2

    # Print comparison header
    print("=" * 80)
    print("Lighthouse Regression Detection")
    print("=" * 80)
    print(f"Current:  {args.current}")
    print(f"Baseline: {args.baseline}")
    print(f"Threshold: {args.threshold * 100:.0f}%")
    print("=" * 80)

    # Print current metrics
    print("\n[Current Metrics]")
    print(f"  Performance Score:  {current_metrics.get('performance_score', 0):.0f}/100")
    print(f"  Accessibility Score: {current_metrics.get('accessibility_score', 0):.0f}/100")
    print(f"  Best Practices:      {current_metrics.get('best_practices_score', 0):.0f}/100")
    print(f"  SEO Score:           {current_metrics.get('seo_score', 0):.0f}/100")
    print(f"  FCP:                 {current_metrics.get('first_contentful_paint', 0) or 0:.0f}ms")
    print(f"  LCP:                 {current_metrics.get('largest_contentful_paint', 0) or 0:.0f}ms")
    print(f"  TBT:                 {current_metrics.get('total_blocking_time', 0) or 0:.0f}ms")
    print(f"  CLS:                 {current_metrics.get('cumulative_layout_shift', 0) or 0:.3f}")
    print(f"  Speed Index:         {current_metrics.get('speed_index', 0) or 0:.0f}ms")

    # Print baseline metrics
    print("\n[Baseline Metrics]")
    print(f"  Performance Score:  {baseline_metrics.get('performance_score', 0):.0f}/100")
    print(f"  Accessibility Score: {baseline_metrics.get('accessibility_score', 0):.0f}/100")
    print(f"  Best Practices:      {baseline_metrics.get('best_practices_score', 0):.0f}/100")
    print(f"  SEO Score:           {baseline_metrics.get('seo_score', 0):.0f}/100")
    print(f"  FCP:                 {baseline_metrics.get('first_contentful_paint', 0) or 0:.0f}ms")
    print(f"  LCP:                 {baseline_metrics.get('largest_contentful_paint', 0) or 0:.0f}ms")
    print(f"  TBT:                 {baseline_metrics.get('total_blocking_time', 0) or 0:.0f}ms")
    print(f"  CLS:                 {baseline_metrics.get('cumulative_layout_shift', 0) or 0:.3f}")
    print(f"  Speed Index:         {baseline_metrics.get('speed_index', 0) or 0:.0f}ms")

    # Check for regressions
    has_regression, regression_messages = check_regression(
        current_metrics,
        baseline_metrics,
        args.threshold
    )

    # Print regression results
    print("\n" + "=" * 80)
    if has_regression:
        print("REGRESSION DETECTED!")
        print("=" * 80)
        for msg in regression_messages:
            print(f"  {msg}")
        print("=" * 80)
        print("\nAction Required: Investigate performance degradation")
        return 1
    else:
        print("NO REGRESSION DETECTED")
        print("=" * 80)
        print("\nAll metrics within acceptable threshold")
        return 0


if __name__ == '__main__':
    sys.exit(main())