@vibecheckai/cli 3.4.0 → 3.5.0

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.
Files changed (166) hide show
  1. package/bin/registry.js +243 -152
  2. package/bin/runners/cli-utils.js +2 -33
  3. package/bin/runners/context/generators/cursor.js +49 -2
  4. package/bin/runners/lib/agent-firewall/learning/learning-engine.js +849 -0
  5. package/bin/runners/lib/analyzers.js +544 -19
  6. package/bin/runners/lib/audit-logger.js +532 -0
  7. package/bin/runners/lib/authority/authorities/architecture.js +364 -0
  8. package/bin/runners/lib/authority/authorities/compliance.js +341 -0
  9. package/bin/runners/lib/authority/authorities/human.js +343 -0
  10. package/bin/runners/lib/authority/authorities/quality.js +420 -0
  11. package/bin/runners/lib/authority/authorities/security.js +228 -0
  12. package/bin/runners/lib/authority/index.js +293 -0
  13. package/bin/runners/lib/authority-badge.js +425 -425
  14. package/bin/runners/lib/bundle/bundle-intelligence.js +846 -0
  15. package/bin/runners/lib/cli-charts.js +368 -0
  16. package/bin/runners/lib/cli-config-display.js +405 -0
  17. package/bin/runners/lib/cli-demo.js +275 -0
  18. package/bin/runners/lib/cli-errors.js +438 -0
  19. package/bin/runners/lib/cli-help-formatter.js +439 -0
  20. package/bin/runners/lib/cli-interactive-menu.js +509 -0
  21. package/bin/runners/lib/cli-prompts.js +441 -0
  22. package/bin/runners/lib/cli-scan-cards.js +362 -0
  23. package/bin/runners/lib/compliance-reporter.js +710 -0
  24. package/bin/runners/lib/conductor/index.js +671 -0
  25. package/bin/runners/lib/easy/README.md +123 -0
  26. package/bin/runners/lib/easy/index.js +140 -0
  27. package/bin/runners/lib/easy/interactive-wizard.js +788 -0
  28. package/bin/runners/lib/easy/one-click-firewall.js +564 -0
  29. package/bin/runners/lib/easy/zero-config-reality.js +714 -0
  30. package/bin/runners/lib/engines/accessibility-engine.js +218 -18
  31. package/bin/runners/lib/engines/api-consistency-engine.js +335 -30
  32. package/bin/runners/lib/engines/async-patterns-engine.js +444 -0
  33. package/bin/runners/lib/engines/bundle-size-engine.js +433 -0
  34. package/bin/runners/lib/engines/confidence-scoring.js +276 -0
  35. package/bin/runners/lib/engines/context-detection.js +264 -0
  36. package/bin/runners/lib/engines/cross-file-analysis-engine.js +292 -27
  37. package/bin/runners/lib/engines/database-patterns-engine.js +429 -0
  38. package/bin/runners/lib/engines/duplicate-code-engine.js +354 -0
  39. package/bin/runners/lib/engines/empty-catch-engine.js +127 -17
  40. package/bin/runners/lib/engines/env-variables-engine.js +458 -0
  41. package/bin/runners/lib/engines/error-handling-engine.js +437 -0
  42. package/bin/runners/lib/engines/false-positive-prevention.js +630 -0
  43. package/bin/runners/lib/engines/framework-adapters/index.js +607 -0
  44. package/bin/runners/lib/engines/framework-detection.js +508 -0
  45. package/bin/runners/lib/engines/import-order-engine.js +429 -0
  46. package/bin/runners/lib/engines/mock-data-engine.js +53 -10
  47. package/bin/runners/lib/engines/naming-conventions-engine.js +544 -0
  48. package/bin/runners/lib/engines/noise-reduction-engine.js +452 -0
  49. package/bin/runners/lib/engines/orchestrator.js +334 -0
  50. package/bin/runners/lib/engines/performance-issues-engine.js +176 -36
  51. package/bin/runners/lib/engines/react-patterns-engine.js +457 -0
  52. package/bin/runners/lib/engines/security-vulnerabilities-engine.js +382 -54
  53. package/bin/runners/lib/engines/type-aware-engine.js +263 -39
  54. package/bin/runners/lib/engines/vibecheck-engines/index.js +122 -13
  55. package/bin/runners/lib/engines/vibecheck-engines/lib/ai-hallucination-engine.js +806 -0
  56. package/bin/runners/lib/engines/vibecheck-engines/lib/hardcoded-secrets-engine.js +373 -73
  57. package/bin/runners/lib/engines/vibecheck-engines/lib/smart-fix-engine.js +577 -0
  58. package/bin/runners/lib/engines/vibecheck-engines/lib/vibe-score-engine.js +543 -0
  59. package/bin/runners/lib/engines/vibecheck-engines.js +514 -0
  60. package/bin/runners/lib/enhanced-features/index.js +305 -0
  61. package/bin/runners/lib/enhanced-output.js +631 -0
  62. package/bin/runners/lib/enterprise.js +300 -0
  63. package/bin/runners/lib/entitlements-v2.js +103 -11
  64. package/bin/runners/lib/firewall/command-validator.js +351 -0
  65. package/bin/runners/lib/firewall/config.js +341 -0
  66. package/bin/runners/lib/firewall/content-validator.js +519 -0
  67. package/bin/runners/lib/firewall/index.js +101 -0
  68. package/bin/runners/lib/firewall/path-validator.js +256 -0
  69. package/bin/runners/lib/html-proof-report.js +350 -700
  70. package/bin/runners/lib/intelligence/cross-repo-intelligence.js +817 -0
  71. package/bin/runners/lib/mcp-utils.js +425 -0
  72. package/bin/runners/lib/missions/plan.js +46 -6
  73. package/bin/runners/lib/missions/templates.js +232 -0
  74. package/bin/runners/lib/output/index.js +1022 -0
  75. package/bin/runners/lib/policy-engine.js +652 -0
  76. package/bin/runners/lib/polish/autofix/accessibility-fixes.js +333 -0
  77. package/bin/runners/lib/polish/autofix/async-handlers.js +273 -0
  78. package/bin/runners/lib/polish/autofix/dead-code.js +280 -0
  79. package/bin/runners/lib/polish/autofix/imports-optimizer.js +344 -0
  80. package/bin/runners/lib/polish/autofix/index.js +200 -0
  81. package/bin/runners/lib/polish/autofix/remove-consoles.js +209 -0
  82. package/bin/runners/lib/polish/autofix/strengthen-types.js +245 -0
  83. package/bin/runners/lib/polish/backend-checks.js +148 -0
  84. package/bin/runners/lib/polish/documentation-checks.js +111 -0
  85. package/bin/runners/lib/polish/frontend-checks.js +168 -0
  86. package/bin/runners/lib/polish/index.js +71 -0
  87. package/bin/runners/lib/polish/infrastructure-checks.js +131 -0
  88. package/bin/runners/lib/polish/library-detection.js +175 -0
  89. package/bin/runners/lib/polish/performance-checks.js +100 -0
  90. package/bin/runners/lib/polish/security-checks.js +148 -0
  91. package/bin/runners/lib/polish/utils.js +203 -0
  92. package/bin/runners/lib/prompt-builder.js +540 -0
  93. package/bin/runners/lib/proof-certificate.js +634 -0
  94. package/bin/runners/lib/reality/accessibility-audit.js +946 -0
  95. package/bin/runners/lib/reality/api-contract-validator.js +1012 -0
  96. package/bin/runners/lib/reality/chaos-engineering.js +1084 -0
  97. package/bin/runners/lib/reality/performance-tracker.js +1077 -0
  98. package/bin/runners/lib/reality/scenario-generator.js +1404 -0
  99. package/bin/runners/lib/reality/visual-regression.js +852 -0
  100. package/bin/runners/lib/reality-profiler.js +717 -0
  101. package/bin/runners/lib/replay/flight-recorder-viewer.js +1160 -0
  102. package/bin/runners/lib/review/ai-code-review.js +832 -0
  103. package/bin/runners/lib/rules/custom-rule-engine.js +985 -0
  104. package/bin/runners/lib/sbom-generator.js +641 -0
  105. package/bin/runners/lib/scan-output-enhanced.js +512 -0
  106. package/bin/runners/lib/scan-output.js +47 -0
  107. package/bin/runners/lib/security/owasp-scanner.js +939 -0
  108. package/bin/runners/lib/terminal-ui.js +113 -1
  109. package/bin/runners/lib/unified-cli-output.js +603 -430
  110. package/bin/runners/lib/validators/contract-validator.js +283 -0
  111. package/bin/runners/lib/validators/dead-export-detector.js +279 -0
  112. package/bin/runners/lib/validators/dep-audit.js +245 -0
  113. package/bin/runners/lib/validators/env-validator.js +319 -0
  114. package/bin/runners/lib/validators/index.js +120 -0
  115. package/bin/runners/lib/validators/license-checker.js +252 -0
  116. package/bin/runners/lib/validators/route-validator.js +290 -0
  117. package/bin/runners/runAIAgent.js +5 -10
  118. package/bin/runners/runAgent.js +3 -0
  119. package/bin/runners/runApprove.js +1233 -1200
  120. package/bin/runners/runAuth.js +22 -1
  121. package/bin/runners/runAuthority.js +528 -0
  122. package/bin/runners/runCheckpoint.js +4 -24
  123. package/bin/runners/runClassify.js +862 -859
  124. package/bin/runners/runConductor.js +772 -0
  125. package/bin/runners/runContainer.js +366 -0
  126. package/bin/runners/runContext.js +3 -0
  127. package/bin/runners/runDoctor.js +28 -41
  128. package/bin/runners/runEasy.js +410 -0
  129. package/bin/runners/runFirewall.js +3 -0
  130. package/bin/runners/runFirewallHook.js +3 -0
  131. package/bin/runners/runFix.js +76 -66
  132. package/bin/runners/runGuard.js +411 -18
  133. package/bin/runners/runIaC.js +372 -0
  134. package/bin/runners/runInit.js +10 -60
  135. package/bin/runners/runMcp.js +11 -12
  136. package/bin/runners/runPolish.js +240 -64
  137. package/bin/runners/runPromptFirewall.js +5 -12
  138. package/bin/runners/runProve.js +20 -55
  139. package/bin/runners/runReality.js +68 -59
  140. package/bin/runners/runReport.js +31 -5
  141. package/bin/runners/runRuntime.js +5 -8
  142. package/bin/runners/runScan.js +194 -1286
  143. package/bin/runners/runShip.js +695 -47
  144. package/bin/runners/runTruth.js +3 -0
  145. package/bin/runners/runValidate.js +7 -11
  146. package/bin/runners/runVibe.js +791 -0
  147. package/bin/runners/runWatch.js +14 -23
  148. package/bin/vibecheck.js +175 -56
  149. package/mcp-server/index.js +190 -14
  150. package/mcp-server/package.json +1 -1
  151. package/mcp-server/tools-v3.js +397 -64
  152. package/mcp-server/tools.js +495 -0
  153. package/package.json +1 -1
  154. package/bin/runners/lib/engines/vibecheck-engines/lib/ast-cache.js +0 -164
  155. package/bin/runners/lib/engines/vibecheck-engines/lib/code-quality-engine.js +0 -291
  156. package/bin/runners/lib/engines/vibecheck-engines/lib/console-logs-engine.js +0 -83
  157. package/bin/runners/lib/engines/vibecheck-engines/lib/dead-code-engine.js +0 -198
  158. package/bin/runners/lib/engines/vibecheck-engines/lib/deprecated-api-engine.js +0 -275
  159. package/bin/runners/lib/engines/vibecheck-engines/lib/empty-catch-engine.js +0 -167
  160. package/bin/runners/lib/engines/vibecheck-engines/lib/file-filter.js +0 -217
  161. package/bin/runners/lib/engines/vibecheck-engines/lib/mock-data-engine.js +0 -140
  162. package/bin/runners/lib/engines/vibecheck-engines/lib/parallel-processor.js +0 -164
  163. package/bin/runners/lib/engines/vibecheck-engines/lib/performance-issues-engine.js +0 -234
  164. package/bin/runners/lib/engines/vibecheck-engines/lib/type-aware-engine.js +0 -217
  165. package/bin/runners/lib/engines/vibecheck-engines/lib/unsafe-regex-engine.js +0 -78
  166. package/mcp-server/index-v1.js +0 -698
@@ -0,0 +1,1077 @@
1
+ /**
2
+ * Performance Regression Detection Engine
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════════
5
+ * COMPETITIVE MOAT FEATURE - Automatic Performance Regression Detection
6
+ * ═══════════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * This engine tracks Core Web Vitals and performance metrics during Reality Mode
9
+ * runs, comparing against baselines to detect regressions.
10
+ *
11
+ * Metrics Tracked:
12
+ * - LCP (Largest Contentful Paint) - Loading performance
13
+ * - FID (First Input Delay) - Interactivity
14
+ * - CLS (Cumulative Layout Shift) - Visual stability
15
+ * - TTFB (Time to First Byte) - Server response
16
+ * - FCP (First Contentful Paint) - Initial render
17
+ * - TTI (Time to Interactive) - Full interactivity
18
+ * - TBT (Total Blocking Time) - Main thread blocking
19
+ * - Memory usage - JS heap size
20
+ * - Bundle impact - Resource loading
21
+ *
22
+ * Features:
23
+ * - Baseline comparison with configurable thresholds
24
+ * - Trend analysis (degradation over time)
25
+ * - Budget enforcement (fail if exceeding budgets)
26
+ * - Detailed breakdowns (by resource type, domain)
27
+ * - Interaction performance (click-to-response)
28
+ */
29
+
30
+ "use strict";
31
+
32
+ const fs = require("fs");
33
+ const path = require("path");
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════════════
36
+ // PERFORMANCE THRESHOLDS (based on Google's recommendations)
37
+ // ═══════════════════════════════════════════════════════════════════════════════
38
+
39
+ const THRESHOLDS = {
40
+ // Core Web Vitals
41
+ LCP: {
42
+ good: 2500, // ms - Good
43
+ needsImprovement: 4000, // ms - Needs improvement
44
+ poor: Infinity // ms - Poor
45
+ },
46
+ FID: {
47
+ good: 100,
48
+ needsImprovement: 300,
49
+ poor: Infinity
50
+ },
51
+ CLS: {
52
+ good: 0.1,
53
+ needsImprovement: 0.25,
54
+ poor: Infinity
55
+ },
56
+
57
+ // Additional metrics
58
+ TTFB: {
59
+ good: 200,
60
+ needsImprovement: 500,
61
+ poor: Infinity
62
+ },
63
+ FCP: {
64
+ good: 1800,
65
+ needsImprovement: 3000,
66
+ poor: Infinity
67
+ },
68
+ TTI: {
69
+ good: 3800,
70
+ needsImprovement: 7300,
71
+ poor: Infinity
72
+ },
73
+ TBT: {
74
+ good: 200,
75
+ needsImprovement: 600,
76
+ poor: Infinity
77
+ },
78
+
79
+ // Memory
80
+ heapSize: {
81
+ good: 50 * 1024 * 1024, // 50MB
82
+ needsImprovement: 100 * 1024 * 1024, // 100MB
83
+ poor: Infinity
84
+ },
85
+
86
+ // Bundle sizes
87
+ totalTransfer: {
88
+ good: 1024 * 1024, // 1MB
89
+ needsImprovement: 3 * 1024 * 1024, // 3MB
90
+ poor: Infinity
91
+ },
92
+ jsSize: {
93
+ good: 300 * 1024, // 300KB
94
+ needsImprovement: 500 * 1024, // 500KB
95
+ poor: Infinity
96
+ }
97
+ };
98
+
99
+ // Regression thresholds (% increase that triggers alert)
100
+ const REGRESSION_THRESHOLDS = {
101
+ LCP: 0.2, // 20% regression
102
+ FID: 0.25, // 25% regression
103
+ CLS: 0.5, // 50% regression (CLS is more variable)
104
+ TTFB: 0.3, // 30% regression
105
+ FCP: 0.2, // 20% regression
106
+ TTI: 0.2, // 20% regression
107
+ TBT: 0.25, // 25% regression
108
+ heapSize: 0.3, // 30% regression
109
+ totalTransfer: 0.15, // 15% regression
110
+ jsSize: 0.1 // 10% regression (bundle bloat is bad)
111
+ };
112
+
113
+ // ═══════════════════════════════════════════════════════════════════════════════
114
+ // PERFORMANCE TRACKER CLASS
115
+ // ═══════════════════════════════════════════════════════════════════════════════
116
+
117
+ class PerformanceTracker {
118
+ constructor(options = {}) {
119
+ this.projectRoot = options.projectRoot || process.cwd();
120
+ this.baselinePath = options.baselinePath ||
121
+ path.join(this.projectRoot, ".vibecheck", "performance-baselines");
122
+ this.historyPath = options.historyPath ||
123
+ path.join(this.projectRoot, ".vibecheck", "performance-history");
124
+ this.thresholds = { ...THRESHOLDS, ...options.thresholds };
125
+ this.regressionThresholds = { ...REGRESSION_THRESHOLDS, ...options.regressionThresholds };
126
+ this.page = null;
127
+ this.cdpSession = null;
128
+ this.metrics = [];
129
+ this.resources = [];
130
+ }
131
+
132
+ /**
133
+ * Set the Playwright page and enable performance tracking
134
+ */
135
+ async setPage(page) {
136
+ this.page = page;
137
+
138
+ // Create CDP session for detailed metrics
139
+ try {
140
+ this.cdpSession = await page.context().newCDPSession(page);
141
+ await this.cdpSession.send("Performance.enable");
142
+ } catch {
143
+ // CDP not available (e.g., Firefox)
144
+ this.cdpSession = null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Start tracking for a page load
150
+ */
151
+ async startTracking() {
152
+ this.metrics = [];
153
+ this.resources = [];
154
+ this.startTime = Date.now();
155
+
156
+ // Track resource loading
157
+ if (this.page) {
158
+ this.page.on("response", this.trackResource.bind(this));
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Track individual resource
164
+ */
165
+ async trackResource(response) {
166
+ try {
167
+ const request = response.request();
168
+ const timing = await response.timing();
169
+
170
+ this.resources.push({
171
+ url: request.url(),
172
+ method: request.method(),
173
+ resourceType: request.resourceType(),
174
+ status: response.status(),
175
+ transferSize: (await response.headers())["content-length"] || 0,
176
+ timing: {
177
+ dns: timing?.dnsEnd - timing?.dnsStart || 0,
178
+ connect: timing?.connectEnd - timing?.connectStart || 0,
179
+ ssl: timing?.sslEnd - timing?.sslStart || 0,
180
+ ttfb: timing?.responseStart - timing?.requestStart || 0,
181
+ download: timing?.responseEnd - timing?.responseStart || 0,
182
+ total: timing?.responseEnd - timing?.requestStart || 0
183
+ }
184
+ });
185
+ } catch {
186
+ // Ignore tracking errors
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Collect all performance metrics
192
+ */
193
+ async collectMetrics() {
194
+ if (!this.page) {
195
+ throw new Error("Page not set. Call setPage() first.");
196
+ }
197
+
198
+ const metrics = {
199
+ timestamp: new Date().toISOString(),
200
+ url: this.page.url(),
201
+
202
+ // Core Web Vitals
203
+ coreWebVitals: await this.collectCoreWebVitals(),
204
+
205
+ // Navigation timing
206
+ navigation: await this.collectNavigationTiming(),
207
+
208
+ // Memory
209
+ memory: await this.collectMemoryMetrics(),
210
+
211
+ // Resources
212
+ resources: this.analyzeResources(),
213
+
214
+ // Long tasks (main thread blocking)
215
+ longTasks: await this.collectLongTasks(),
216
+
217
+ // Layout shifts
218
+ layoutShifts: await this.collectLayoutShifts()
219
+ };
220
+
221
+ this.metrics.push(metrics);
222
+ return metrics;
223
+ }
224
+
225
+ /**
226
+ * Collect Core Web Vitals
227
+ */
228
+ async collectCoreWebVitals() {
229
+ return await this.page.evaluate(() => {
230
+ return new Promise((resolve) => {
231
+ const vitals = {
232
+ LCP: null,
233
+ FID: null,
234
+ CLS: 0
235
+ };
236
+
237
+ // LCP
238
+ new PerformanceObserver((list) => {
239
+ const entries = list.getEntries();
240
+ const lastEntry = entries[entries.length - 1];
241
+ vitals.LCP = lastEntry?.startTime || null;
242
+ }).observe({ type: "largest-contentful-paint", buffered: true });
243
+
244
+ // FID (approximated with first-input if available)
245
+ new PerformanceObserver((list) => {
246
+ const entries = list.getEntries();
247
+ if (entries.length > 0) {
248
+ vitals.FID = entries[0].processingStart - entries[0].startTime;
249
+ }
250
+ }).observe({ type: "first-input", buffered: true });
251
+
252
+ // CLS
253
+ new PerformanceObserver((list) => {
254
+ for (const entry of list.getEntries()) {
255
+ if (!entry.hadRecentInput) {
256
+ vitals.CLS += entry.value;
257
+ }
258
+ }
259
+ }).observe({ type: "layout-shift", buffered: true });
260
+
261
+ // Wait a bit for metrics to populate
262
+ setTimeout(() => resolve(vitals), 1000);
263
+ });
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Collect navigation timing metrics
269
+ */
270
+ async collectNavigationTiming() {
271
+ return await this.page.evaluate(() => {
272
+ const nav = performance.getEntriesByType("navigation")[0];
273
+ if (!nav) return null;
274
+
275
+ return {
276
+ TTFB: nav.responseStart - nav.requestStart,
277
+ FCP: performance.getEntriesByType("paint")
278
+ .find(e => e.name === "first-contentful-paint")?.startTime || null,
279
+ domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
280
+ load: nav.loadEventEnd - nav.startTime,
281
+
282
+ // Breakdown
283
+ dns: nav.domainLookupEnd - nav.domainLookupStart,
284
+ tcp: nav.connectEnd - nav.connectStart,
285
+ ssl: nav.secureConnectionStart > 0
286
+ ? nav.connectEnd - nav.secureConnectionStart
287
+ : 0,
288
+ request: nav.responseStart - nav.requestStart,
289
+ response: nav.responseEnd - nav.responseStart,
290
+ processing: nav.domComplete - nav.responseEnd,
291
+
292
+ // Transfer sizes
293
+ transferSize: nav.transferSize,
294
+ encodedBodySize: nav.encodedBodySize,
295
+ decodedBodySize: nav.decodedBodySize
296
+ };
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Collect memory metrics
302
+ */
303
+ async collectMemoryMetrics() {
304
+ return await this.page.evaluate(() => {
305
+ if (performance.memory) {
306
+ return {
307
+ usedJSHeapSize: performance.memory.usedJSHeapSize,
308
+ totalJSHeapSize: performance.memory.totalJSHeapSize,
309
+ jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
310
+ };
311
+ }
312
+ return null;
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Analyze collected resources
318
+ */
319
+ analyzeResources() {
320
+ const byType = {};
321
+ let totalTransfer = 0;
322
+ let totalRequests = 0;
323
+
324
+ for (const resource of this.resources) {
325
+ const type = resource.resourceType;
326
+ const size = parseInt(resource.transferSize) || 0;
327
+
328
+ if (!byType[type]) {
329
+ byType[type] = { count: 0, size: 0, avgTiming: 0 };
330
+ }
331
+
332
+ byType[type].count++;
333
+ byType[type].size += size;
334
+ byType[type].avgTiming += resource.timing.total;
335
+
336
+ totalTransfer += size;
337
+ totalRequests++;
338
+ }
339
+
340
+ // Calculate averages
341
+ for (const type of Object.keys(byType)) {
342
+ if (byType[type].count > 0) {
343
+ byType[type].avgTiming /= byType[type].count;
344
+ }
345
+ }
346
+
347
+ return {
348
+ totalRequests,
349
+ totalTransfer,
350
+ byType,
351
+ jsSize: byType.script?.size || 0,
352
+ cssSize: byType.stylesheet?.size || 0,
353
+ imageSize: byType.image?.size || 0,
354
+ fontSize: byType.font?.size || 0
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Collect long tasks (blocking main thread)
360
+ */
361
+ async collectLongTasks() {
362
+ return await this.page.evaluate(() => {
363
+ return new Promise((resolve) => {
364
+ const longTasks = [];
365
+
366
+ new PerformanceObserver((list) => {
367
+ for (const entry of list.getEntries()) {
368
+ longTasks.push({
369
+ startTime: entry.startTime,
370
+ duration: entry.duration,
371
+ name: entry.name
372
+ });
373
+ }
374
+ }).observe({ type: "longtask", buffered: true });
375
+
376
+ setTimeout(() => {
377
+ const totalBlockingTime = longTasks
378
+ .filter(t => t.duration > 50)
379
+ .reduce((sum, t) => sum + (t.duration - 50), 0);
380
+
381
+ resolve({
382
+ count: longTasks.length,
383
+ totalBlockingTime,
384
+ tasks: longTasks.slice(0, 10) // Top 10
385
+ });
386
+ }, 500);
387
+ });
388
+ });
389
+ }
390
+
391
+ /**
392
+ * Collect layout shift details
393
+ */
394
+ async collectLayoutShifts() {
395
+ return await this.page.evaluate(() => {
396
+ return new Promise((resolve) => {
397
+ const shifts = [];
398
+ let cumulativeScore = 0;
399
+
400
+ new PerformanceObserver((list) => {
401
+ for (const entry of list.getEntries()) {
402
+ if (!entry.hadRecentInput) {
403
+ cumulativeScore += entry.value;
404
+ shifts.push({
405
+ value: entry.value,
406
+ startTime: entry.startTime,
407
+ sources: entry.sources?.map(s => ({
408
+ node: s.node?.nodeName,
409
+ previousRect: s.previousRect,
410
+ currentRect: s.currentRect
411
+ })) || []
412
+ });
413
+ }
414
+ }
415
+ }).observe({ type: "layout-shift", buffered: true });
416
+
417
+ setTimeout(() => {
418
+ resolve({
419
+ cumulativeScore,
420
+ shiftCount: shifts.length,
421
+ largestShift: Math.max(...shifts.map(s => s.value), 0),
422
+ shifts: shifts.slice(0, 5) // Top 5
423
+ });
424
+ }, 500);
425
+ });
426
+ });
427
+ }
428
+
429
+ // ═══════════════════════════════════════════════════════════════════════════
430
+ // BASELINE COMPARISON
431
+ // ═══════════════════════════════════════════════════════════════════════════
432
+
433
+ /**
434
+ * Compare metrics against baseline
435
+ */
436
+ async compareWithBaseline(metrics, pageName) {
437
+ const baseline = this.loadBaseline(pageName);
438
+
439
+ if (!baseline) {
440
+ return {
441
+ hasBaseline: false,
442
+ message: "No baseline found. Current metrics will be saved as baseline."
443
+ };
444
+ }
445
+
446
+ const comparison = {
447
+ hasBaseline: true,
448
+ regressions: [],
449
+ improvements: [],
450
+ unchanged: [],
451
+ details: {}
452
+ };
453
+
454
+ // Compare Core Web Vitals
455
+ this.compareMetric(comparison, "LCP",
456
+ metrics.coreWebVitals?.LCP,
457
+ baseline.coreWebVitals?.LCP
458
+ );
459
+
460
+ this.compareMetric(comparison, "FID",
461
+ metrics.coreWebVitals?.FID,
462
+ baseline.coreWebVitals?.FID
463
+ );
464
+
465
+ this.compareMetric(comparison, "CLS",
466
+ metrics.coreWebVitals?.CLS,
467
+ baseline.coreWebVitals?.CLS
468
+ );
469
+
470
+ // Compare navigation timing
471
+ this.compareMetric(comparison, "TTFB",
472
+ metrics.navigation?.TTFB,
473
+ baseline.navigation?.TTFB
474
+ );
475
+
476
+ this.compareMetric(comparison, "FCP",
477
+ metrics.navigation?.FCP,
478
+ baseline.navigation?.FCP
479
+ );
480
+
481
+ // Compare memory
482
+ this.compareMetric(comparison, "heapSize",
483
+ metrics.memory?.usedJSHeapSize,
484
+ baseline.memory?.usedJSHeapSize
485
+ );
486
+
487
+ // Compare bundle sizes
488
+ this.compareMetric(comparison, "totalTransfer",
489
+ metrics.resources?.totalTransfer,
490
+ baseline.resources?.totalTransfer
491
+ );
492
+
493
+ this.compareMetric(comparison, "jsSize",
494
+ metrics.resources?.jsSize,
495
+ baseline.resources?.jsSize
496
+ );
497
+
498
+ // Compare TBT
499
+ this.compareMetric(comparison, "TBT",
500
+ metrics.longTasks?.totalBlockingTime,
501
+ baseline.longTasks?.totalBlockingTime
502
+ );
503
+
504
+ return comparison;
505
+ }
506
+
507
+ /**
508
+ * Compare individual metric
509
+ */
510
+ compareMetric(comparison, name, current, baseline) {
511
+ if (current === null || current === undefined ||
512
+ baseline === null || baseline === undefined) {
513
+ return;
514
+ }
515
+
516
+ const threshold = this.regressionThresholds[name] || 0.2;
517
+ const change = (current - baseline) / baseline;
518
+ const changePercent = (change * 100).toFixed(1);
519
+
520
+ const detail = {
521
+ name,
522
+ current,
523
+ baseline,
524
+ change: changePercent + "%",
525
+ status: "unchanged"
526
+ };
527
+
528
+ // Check if regression
529
+ if (change > threshold) {
530
+ detail.status = "regression";
531
+ detail.severity = this.getSeverity(name, current);
532
+ comparison.regressions.push(detail);
533
+ }
534
+ // Check if improvement
535
+ else if (change < -threshold) {
536
+ detail.status = "improvement";
537
+ comparison.improvements.push(detail);
538
+ }
539
+ else {
540
+ comparison.unchanged.push(detail);
541
+ }
542
+
543
+ comparison.details[name] = detail;
544
+ }
545
+
546
+ /**
547
+ * Get severity based on absolute value vs thresholds
548
+ */
549
+ getSeverity(metricName, value) {
550
+ const threshold = this.thresholds[metricName];
551
+ if (!threshold) return "WARN";
552
+
553
+ if (value <= threshold.good) return "INFO";
554
+ if (value <= threshold.needsImprovement) return "WARN";
555
+ return "BLOCK";
556
+ }
557
+
558
+ // ═══════════════════════════════════════════════════════════════════════════
559
+ // BASELINE MANAGEMENT
560
+ // ═══════════════════════════════════════════════════════════════════════════
561
+
562
+ /**
563
+ * Save metrics as baseline
564
+ */
565
+ saveBaseline(metrics, pageName) {
566
+ if (!fs.existsSync(this.baselinePath)) {
567
+ fs.mkdirSync(this.baselinePath, { recursive: true });
568
+ }
569
+
570
+ const fileName = this.sanitizeName(pageName) + ".json";
571
+ const filePath = path.join(this.baselinePath, fileName);
572
+
573
+ fs.writeFileSync(filePath, JSON.stringify({
574
+ ...metrics,
575
+ savedAt: new Date().toISOString()
576
+ }, null, 2));
577
+
578
+ return filePath;
579
+ }
580
+
581
+ /**
582
+ * Load baseline metrics
583
+ */
584
+ loadBaseline(pageName) {
585
+ const fileName = this.sanitizeName(pageName) + ".json";
586
+ const filePath = path.join(this.baselinePath, fileName);
587
+
588
+ if (!fs.existsSync(filePath)) {
589
+ return null;
590
+ }
591
+
592
+ try {
593
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
594
+ } catch {
595
+ return null;
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Save to history for trend analysis
601
+ */
602
+ saveToHistory(metrics, pageName) {
603
+ const historyDir = path.join(this.historyPath, this.sanitizeName(pageName));
604
+
605
+ if (!fs.existsSync(historyDir)) {
606
+ fs.mkdirSync(historyDir, { recursive: true });
607
+ }
608
+
609
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
610
+ const fileName = `${timestamp}.json`;
611
+ const filePath = path.join(historyDir, fileName);
612
+
613
+ fs.writeFileSync(filePath, JSON.stringify(metrics, null, 2));
614
+
615
+ // Keep only last 30 entries
616
+ this.pruneHistory(historyDir, 30);
617
+
618
+ return filePath;
619
+ }
620
+
621
+ /**
622
+ * Prune old history entries
623
+ */
624
+ pruneHistory(historyDir, keepCount) {
625
+ const files = fs.readdirSync(historyDir)
626
+ .filter(f => f.endsWith(".json"))
627
+ .sort()
628
+ .reverse();
629
+
630
+ for (const file of files.slice(keepCount)) {
631
+ fs.unlinkSync(path.join(historyDir, file));
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Get trend analysis
637
+ */
638
+ getTrendAnalysis(pageName) {
639
+ const historyDir = path.join(this.historyPath, this.sanitizeName(pageName));
640
+
641
+ if (!fs.existsSync(historyDir)) {
642
+ return null;
643
+ }
644
+
645
+ const files = fs.readdirSync(historyDir)
646
+ .filter(f => f.endsWith(".json"))
647
+ .sort()
648
+ .reverse()
649
+ .slice(0, 10); // Last 10 runs
650
+
651
+ if (files.length < 2) {
652
+ return null;
653
+ }
654
+
655
+ const history = files.map(f => {
656
+ const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), "utf8"));
657
+ return {
658
+ timestamp: data.timestamp,
659
+ LCP: data.coreWebVitals?.LCP,
660
+ FID: data.coreWebVitals?.FID,
661
+ CLS: data.coreWebVitals?.CLS,
662
+ TTFB: data.navigation?.TTFB,
663
+ jsSize: data.resources?.jsSize,
664
+ heapSize: data.memory?.usedJSHeapSize
665
+ };
666
+ });
667
+
668
+ // Calculate trends
669
+ const trends = {};
670
+ const metrics = ["LCP", "FID", "CLS", "TTFB", "jsSize", "heapSize"];
671
+
672
+ for (const metric of metrics) {
673
+ const values = history.map(h => h[metric]).filter(v => v !== null && v !== undefined);
674
+ if (values.length >= 2) {
675
+ const recent = values.slice(0, 3).reduce((a, b) => a + b, 0) / Math.min(3, values.length);
676
+ const older = values.slice(-3).reduce((a, b) => a + b, 0) / Math.min(3, values.length);
677
+ const change = ((recent - older) / older) * 100;
678
+
679
+ trends[metric] = {
680
+ direction: change > 5 ? "degrading" : change < -5 ? "improving" : "stable",
681
+ changePercent: change.toFixed(1),
682
+ recentAvg: recent,
683
+ historicalAvg: older
684
+ };
685
+ }
686
+ }
687
+
688
+ return {
689
+ sampleCount: history.length,
690
+ timeRange: {
691
+ oldest: history[history.length - 1]?.timestamp,
692
+ newest: history[0]?.timestamp
693
+ },
694
+ trends,
695
+ history: history.slice(0, 5) // Last 5 for display
696
+ };
697
+ }
698
+
699
+ // ═══════════════════════════════════════════════════════════════════════════
700
+ // INTERACTION PERFORMANCE
701
+ // ═══════════════════════════════════════════════════════════════════════════
702
+
703
+ /**
704
+ * Measure interaction performance (click-to-response)
705
+ */
706
+ async measureInteraction(selector, action = "click") {
707
+ if (!this.page) {
708
+ throw new Error("Page not set");
709
+ }
710
+
711
+ const startTime = Date.now();
712
+
713
+ // Track network requests during interaction
714
+ const requests = [];
715
+ const requestHandler = (request) => {
716
+ requests.push({
717
+ url: request.url(),
718
+ type: request.resourceType(),
719
+ startTime: Date.now() - startTime
720
+ });
721
+ };
722
+
723
+ this.page.on("request", requestHandler);
724
+
725
+ // Track DOM changes
726
+ const domChangePromise = this.page.evaluate(() => {
727
+ return new Promise((resolve) => {
728
+ const observer = new MutationObserver((mutations) => {
729
+ observer.disconnect();
730
+ resolve({
731
+ changeCount: mutations.length,
732
+ timestamp: performance.now()
733
+ });
734
+ });
735
+
736
+ observer.observe(document.body, {
737
+ childList: true,
738
+ subtree: true,
739
+ attributes: true
740
+ });
741
+
742
+ // Timeout after 5s
743
+ setTimeout(() => {
744
+ observer.disconnect();
745
+ resolve({ changeCount: 0, timeout: true });
746
+ }, 5000);
747
+ });
748
+ });
749
+
750
+ // Perform interaction
751
+ const element = await this.page.$(selector);
752
+ if (!element) {
753
+ throw new Error(`Element not found: ${selector}`);
754
+ }
755
+
756
+ if (action === "click") {
757
+ await element.click();
758
+ } else if (action === "hover") {
759
+ await element.hover();
760
+ }
761
+
762
+ // Wait for response
763
+ const domChange = await domChangePromise;
764
+ const endTime = Date.now();
765
+
766
+ this.page.off("request", requestHandler);
767
+
768
+ return {
769
+ selector,
770
+ action,
771
+ duration: endTime - startTime,
772
+ domChangeTime: domChange.timestamp,
773
+ domChangeCount: domChange.changeCount,
774
+ networkRequests: requests.length,
775
+ requests: requests.slice(0, 5),
776
+ rating: this.rateInteractionSpeed(endTime - startTime)
777
+ };
778
+ }
779
+
780
+ /**
781
+ * Rate interaction speed
782
+ */
783
+ rateInteractionSpeed(duration) {
784
+ if (duration < 100) return { rating: "instant", color: "green" };
785
+ if (duration < 300) return { rating: "fast", color: "green" };
786
+ if (duration < 1000) return { rating: "acceptable", color: "yellow" };
787
+ if (duration < 3000) return { rating: "slow", color: "orange" };
788
+ return { rating: "very slow", color: "red" };
789
+ }
790
+
791
+ // ═══════════════════════════════════════════════════════════════════════════
792
+ // BUDGETS
793
+ // ═══════════════════════════════════════════════════════════════════════════
794
+
795
+ /**
796
+ * Check against performance budgets
797
+ */
798
+ checkBudgets(metrics, budgets = {}) {
799
+ const defaultBudgets = {
800
+ LCP: 2500,
801
+ FID: 100,
802
+ CLS: 0.1,
803
+ TTFB: 200,
804
+ totalTransfer: 1024 * 1024, // 1MB
805
+ jsSize: 300 * 1024, // 300KB
806
+ requestCount: 50
807
+ };
808
+
809
+ const effectiveBudgets = { ...defaultBudgets, ...budgets };
810
+ const violations = [];
811
+ const passing = [];
812
+
813
+ // Check each budget
814
+ if (metrics.coreWebVitals?.LCP > effectiveBudgets.LCP) {
815
+ violations.push({
816
+ metric: "LCP",
817
+ budget: effectiveBudgets.LCP,
818
+ actual: metrics.coreWebVitals.LCP,
819
+ overage: ((metrics.coreWebVitals.LCP / effectiveBudgets.LCP - 1) * 100).toFixed(0) + "%"
820
+ });
821
+ } else {
822
+ passing.push({ metric: "LCP", within: true });
823
+ }
824
+
825
+ if (metrics.coreWebVitals?.CLS > effectiveBudgets.CLS) {
826
+ violations.push({
827
+ metric: "CLS",
828
+ budget: effectiveBudgets.CLS,
829
+ actual: metrics.coreWebVitals.CLS,
830
+ overage: ((metrics.coreWebVitals.CLS / effectiveBudgets.CLS - 1) * 100).toFixed(0) + "%"
831
+ });
832
+ } else {
833
+ passing.push({ metric: "CLS", within: true });
834
+ }
835
+
836
+ if (metrics.resources?.totalTransfer > effectiveBudgets.totalTransfer) {
837
+ violations.push({
838
+ metric: "Total Transfer",
839
+ budget: this.formatBytes(effectiveBudgets.totalTransfer),
840
+ actual: this.formatBytes(metrics.resources.totalTransfer),
841
+ overage: ((metrics.resources.totalTransfer / effectiveBudgets.totalTransfer - 1) * 100).toFixed(0) + "%"
842
+ });
843
+ } else {
844
+ passing.push({ metric: "Total Transfer", within: true });
845
+ }
846
+
847
+ if (metrics.resources?.jsSize > effectiveBudgets.jsSize) {
848
+ violations.push({
849
+ metric: "JS Size",
850
+ budget: this.formatBytes(effectiveBudgets.jsSize),
851
+ actual: this.formatBytes(metrics.resources.jsSize),
852
+ overage: ((metrics.resources.jsSize / effectiveBudgets.jsSize - 1) * 100).toFixed(0) + "%"
853
+ });
854
+ } else {
855
+ passing.push({ metric: "JS Size", within: true });
856
+ }
857
+
858
+ if (metrics.resources?.totalRequests > effectiveBudgets.requestCount) {
859
+ violations.push({
860
+ metric: "Request Count",
861
+ budget: effectiveBudgets.requestCount,
862
+ actual: metrics.resources.totalRequests,
863
+ overage: ((metrics.resources.totalRequests / effectiveBudgets.requestCount - 1) * 100).toFixed(0) + "%"
864
+ });
865
+ } else {
866
+ passing.push({ metric: "Request Count", within: true });
867
+ }
868
+
869
+ return {
870
+ passed: violations.length === 0,
871
+ violations,
872
+ passing,
873
+ summary: `${passing.length}/${passing.length + violations.length} budgets met`
874
+ };
875
+ }
876
+
877
+ // ═══════════════════════════════════════════════════════════════════════════
878
+ // REPORTING
879
+ // ═══════════════════════════════════════════════════════════════════════════
880
+
881
+ /**
882
+ * Generate performance report
883
+ */
884
+ generateReport(metrics, comparison, budgetCheck) {
885
+ return {
886
+ timestamp: new Date().toISOString(),
887
+ url: metrics.url,
888
+
889
+ // Scores
890
+ scores: this.calculateScores(metrics),
891
+
892
+ // Core Web Vitals summary
893
+ coreWebVitals: {
894
+ LCP: this.formatMetric("LCP", metrics.coreWebVitals?.LCP),
895
+ FID: this.formatMetric("FID", metrics.coreWebVitals?.FID),
896
+ CLS: this.formatMetric("CLS", metrics.coreWebVitals?.CLS)
897
+ },
898
+
899
+ // Comparison with baseline
900
+ comparison: comparison?.hasBaseline ? {
901
+ regressions: comparison.regressions,
902
+ improvements: comparison.improvements,
903
+ summary: `${comparison.regressions.length} regressions, ${comparison.improvements.length} improvements`
904
+ } : null,
905
+
906
+ // Budget check
907
+ budgets: budgetCheck,
908
+
909
+ // Detailed metrics
910
+ details: {
911
+ navigation: metrics.navigation,
912
+ resources: metrics.resources,
913
+ memory: metrics.memory,
914
+ longTasks: metrics.longTasks,
915
+ layoutShifts: metrics.layoutShifts
916
+ },
917
+
918
+ // Recommendations
919
+ recommendations: this.generateRecommendations(metrics)
920
+ };
921
+ }
922
+
923
+ /**
924
+ * Calculate overall scores
925
+ */
926
+ calculateScores(metrics) {
927
+ const scores = {};
928
+
929
+ // LCP score (0-100)
930
+ const lcp = metrics.coreWebVitals?.LCP;
931
+ if (lcp !== null) {
932
+ if (lcp <= 2500) scores.LCP = 100;
933
+ else if (lcp <= 4000) scores.LCP = Math.round(100 - ((lcp - 2500) / 1500) * 50);
934
+ else scores.LCP = Math.max(0, Math.round(50 - ((lcp - 4000) / 4000) * 50));
935
+ }
936
+
937
+ // CLS score
938
+ const cls = metrics.coreWebVitals?.CLS;
939
+ if (cls !== null) {
940
+ if (cls <= 0.1) scores.CLS = 100;
941
+ else if (cls <= 0.25) scores.CLS = Math.round(100 - ((cls - 0.1) / 0.15) * 50);
942
+ else scores.CLS = Math.max(0, Math.round(50 - (cls - 0.25) * 100));
943
+ }
944
+
945
+ // Overall score
946
+ const validScores = Object.values(scores).filter(s => s !== undefined);
947
+ scores.overall = validScores.length > 0
948
+ ? Math.round(validScores.reduce((a, b) => a + b, 0) / validScores.length)
949
+ : null;
950
+
951
+ return scores;
952
+ }
953
+
954
+ /**
955
+ * Format metric for display
956
+ */
957
+ formatMetric(name, value) {
958
+ if (value === null || value === undefined) {
959
+ return { value: "N/A", rating: "unknown" };
960
+ }
961
+
962
+ const threshold = this.thresholds[name];
963
+ let rating = "unknown";
964
+
965
+ if (threshold) {
966
+ if (value <= threshold.good) rating = "good";
967
+ else if (value <= threshold.needsImprovement) rating = "needs-improvement";
968
+ else rating = "poor";
969
+ }
970
+
971
+ let formatted = value;
972
+ if (name === "CLS") {
973
+ formatted = value.toFixed(3);
974
+ } else if (name === "LCP" || name === "FID" || name === "TTFB" || name === "FCP") {
975
+ formatted = value.toFixed(0) + "ms";
976
+ }
977
+
978
+ return { value: formatted, rating };
979
+ }
980
+
981
+ /**
982
+ * Generate recommendations based on metrics
983
+ */
984
+ generateRecommendations(metrics) {
985
+ const recommendations = [];
986
+
987
+ // LCP recommendations
988
+ const lcp = metrics.coreWebVitals?.LCP;
989
+ if (lcp > 4000) {
990
+ recommendations.push({
991
+ metric: "LCP",
992
+ severity: "high",
993
+ message: "Largest Contentful Paint is poor",
994
+ suggestions: [
995
+ "Optimize server response time",
996
+ "Remove render-blocking resources",
997
+ "Optimize images with lazy loading",
998
+ "Use a CDN for static assets"
999
+ ]
1000
+ });
1001
+ }
1002
+
1003
+ // CLS recommendations
1004
+ const cls = metrics.coreWebVitals?.CLS;
1005
+ if (cls > 0.25) {
1006
+ recommendations.push({
1007
+ metric: "CLS",
1008
+ severity: "high",
1009
+ message: "Cumulative Layout Shift is poor",
1010
+ suggestions: [
1011
+ "Set explicit dimensions on images and videos",
1012
+ "Reserve space for dynamic content",
1013
+ "Avoid inserting content above existing content",
1014
+ "Use CSS transform for animations"
1015
+ ]
1016
+ });
1017
+ }
1018
+
1019
+ // JS size recommendations
1020
+ const jsSize = metrics.resources?.jsSize;
1021
+ if (jsSize > 500 * 1024) {
1022
+ recommendations.push({
1023
+ metric: "JS Size",
1024
+ severity: "medium",
1025
+ message: `JavaScript bundle is large (${this.formatBytes(jsSize)})`,
1026
+ suggestions: [
1027
+ "Enable code splitting",
1028
+ "Remove unused dependencies",
1029
+ "Use tree shaking",
1030
+ "Consider lazy loading non-critical JS"
1031
+ ]
1032
+ });
1033
+ }
1034
+
1035
+ // TBT recommendations
1036
+ const tbt = metrics.longTasks?.totalBlockingTime;
1037
+ if (tbt > 300) {
1038
+ recommendations.push({
1039
+ metric: "TBT",
1040
+ severity: "medium",
1041
+ message: `High Total Blocking Time (${tbt.toFixed(0)}ms)`,
1042
+ suggestions: [
1043
+ "Break up long tasks",
1044
+ "Defer non-critical JavaScript",
1045
+ "Use web workers for heavy computation",
1046
+ "Optimize third-party scripts"
1047
+ ]
1048
+ });
1049
+ }
1050
+
1051
+ return recommendations;
1052
+ }
1053
+
1054
+ // ═══════════════════════════════════════════════════════════════════════════
1055
+ // UTILITIES
1056
+ // ═══════════════════════════════════════════════════════════════════════════
1057
+
1058
+ sanitizeName(name) {
1059
+ return name.replace(/[^a-zA-Z0-9-_]/g, "-").toLowerCase();
1060
+ }
1061
+
1062
+ formatBytes(bytes) {
1063
+ if (bytes < 1024) return bytes + " B";
1064
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
1065
+ return (bytes / (1024 * 1024)).toFixed(2) + " MB";
1066
+ }
1067
+ }
1068
+
1069
+ // ═══════════════════════════════════════════════════════════════════════════════
1070
+ // EXPORTS
1071
+ // ═══════════════════════════════════════════════════════════════════════════════
1072
+
1073
+ module.exports = {
1074
+ PerformanceTracker,
1075
+ THRESHOLDS,
1076
+ REGRESSION_THRESHOLDS
1077
+ };