@veraxhq/verax 0.1.0 → 0.2.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 (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +296 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Loading State Sensor
3
+ * Detects and tracks loading indicators like spinners, aria-busy, and disabled buttons
4
+ * Deterministically detects unresolved loading states
5
+ */
6
+
7
+ export class LoadingSensor {
8
+ constructor(options = {}) {
9
+ this.loadingTimeout = options.loadingTimeout || 5000; // 5s deterministic timeout
10
+ }
11
+
12
+ /**
13
+ * Start monitoring loading state and return a window ID
14
+ */
15
+ startWindow(page) {
16
+ const windowId = `loading_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
17
+
18
+ const state = {
19
+ id: windowId,
20
+ loadingStartTime: null,
21
+ isCurrentlyLoading: false,
22
+ loadingIndicators: [],
23
+ resolveTime: null,
24
+ unresolved: false,
25
+ maxLoadingDuration: 0
26
+ };
27
+
28
+ // Monitor for loading indicators
29
+ const checkLoading = async () => {
30
+ try {
31
+ const indicators = await page.evaluate(() => {
32
+ const found = [];
33
+
34
+ // Check aria-busy
35
+ const ariaBusy = document.querySelectorAll('[aria-busy="true"]');
36
+ if (ariaBusy.length > 0) {
37
+ found.push({ type: 'aria-busy', count: ariaBusy.length });
38
+ }
39
+
40
+ // Check progress bars
41
+ const progressBars = document.querySelectorAll('[role="progressbar"]');
42
+ if (progressBars.length > 0) {
43
+ found.push({ type: 'progressbar', count: progressBars.length });
44
+ }
45
+
46
+ // Check spinners/loaders by class
47
+ const spinners = document.querySelectorAll(
48
+ '[class*="spin"], [class*="load"], [class*="progress"], [class*="skeleton"]'
49
+ );
50
+ if (spinners.length > 0) {
51
+ found.push({ type: 'spinner-class', count: spinners.length });
52
+ }
53
+
54
+ // Check disabled submit buttons (often indicates pending submission)
55
+ const disabledSubmits = document.querySelectorAll('button[type="submit"]:disabled');
56
+ if (disabledSubmits.length > 0) {
57
+ found.push({ type: 'disabled-submit', count: disabledSubmits.length });
58
+ }
59
+
60
+ return found;
61
+ });
62
+
63
+ const hasLoading = indicators.length > 0;
64
+
65
+ if (hasLoading && !state.isCurrentlyLoading) {
66
+ // Loading started
67
+ state.isCurrentlyLoading = true;
68
+ state.loadingStartTime = Date.now();
69
+ state.loadingIndicators = indicators;
70
+ } else if (!hasLoading && state.isCurrentlyLoading) {
71
+ // Loading resolved
72
+ state.isCurrentlyLoading = false;
73
+ state.resolveTime = Date.now();
74
+ state.maxLoadingDuration = state.resolveTime - state.loadingStartTime;
75
+ } else if (hasLoading && state.isCurrentlyLoading) {
76
+ // Still loading, update indicators
77
+ state.loadingIndicators = indicators;
78
+ }
79
+ } catch (e) {
80
+ // Silently ignore evaluation errors
81
+ }
82
+ };
83
+
84
+ // Set up interval to check loading state (every 100ms for deterministic detection)
85
+ const intervalId = setInterval(checkLoading, 100);
86
+
87
+ // Immediately check once
88
+ checkLoading();
89
+
90
+ // Store interval for cleanup
91
+ state._intervalId = intervalId;
92
+ state._checkLoading = checkLoading;
93
+
94
+ return { windowId, state };
95
+ }
96
+
97
+ /**
98
+ * Stop monitoring and get the loading state summary
99
+ */
100
+ async stopWindow(windowId, state) {
101
+ if (!state || !state._intervalId) {
102
+ return {
103
+ id: windowId,
104
+ loadingIndicators: [],
105
+ isLoading: false,
106
+ unresolved: false,
107
+ duration: 0,
108
+ timeout: false,
109
+ hasLoadingIndicators: false
110
+ };
111
+ }
112
+
113
+ clearInterval(state._intervalId);
114
+
115
+ // Final check
116
+ if (state._checkLoading) {
117
+ await state._checkLoading();
118
+ }
119
+
120
+ // Determine if loading is unresolved (exceeded timeout)
121
+ const isStillLoading = state.isCurrentlyLoading === true;
122
+ const now = Date.now();
123
+ const loadingDuration = state.loadingStartTime ? (now - state.loadingStartTime) : 0;
124
+ const exceededTimeout = state.loadingStartTime && (now - state.loadingStartTime) > this.loadingTimeout;
125
+ const unresolved = isStillLoading && exceededTimeout;
126
+ const timeout = exceededTimeout;
127
+
128
+ return {
129
+ id: state.id,
130
+ loadingIndicators: state.loadingIndicators || [],
131
+ isLoading: state.isCurrentlyLoading,
132
+ unresolved: unresolved,
133
+ duration: state.resolveTime ? state.maxLoadingDuration : loadingDuration,
134
+ timeout: timeout,
135
+ resolveTime: state.resolveTime,
136
+ hasLoadingIndicators: (state.loadingIndicators && state.loadingIndicators.length > 0) || isStillLoading
137
+ };
138
+ }
139
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * NAVIGATION INTELLIGENCE v2 — Navigation Sensor
3
+ *
4
+ * Captures navigation state changes per interaction:
5
+ * - URL changes (beforeUrl → afterUrl)
6
+ * - History API state (length, pushState, replaceState)
7
+ * - SPA Router events (Next.js, React Router)
8
+ * - Blocked navigation signals (preventDefault, guards)
9
+ *
10
+ * Provides runtime evidence for navigation failure detection.
11
+ */
12
+
13
+ export class NavigationSensor {
14
+ constructor() {
15
+ this.windows = new Map();
16
+ this.nextWindowId = 0;
17
+ }
18
+
19
+ /**
20
+ * Start a navigation observation window.
21
+ *
22
+ * @param {Object} page - Playwright page
23
+ * @returns {number} - Window ID
24
+ */
25
+ startWindow(page) {
26
+ const windowId = this.nextWindowId++;
27
+
28
+ const state = {
29
+ windowId,
30
+ beforeUrl: null,
31
+ afterUrl: null,
32
+ beforeHistoryLength: null,
33
+ afterHistoryLength: null,
34
+ historyChanges: [],
35
+ routerEvents: [],
36
+ blockedNavigations: [],
37
+ started: Date.now()
38
+ };
39
+
40
+ this.windows.set(windowId, state);
41
+
42
+ // Capture initial state immediately
43
+ this._captureBeforeState(page, state);
44
+
45
+ // Set up listeners for navigation events
46
+ this._attachListeners(page, state);
47
+
48
+ return windowId;
49
+ }
50
+
51
+ /**
52
+ * Capture before-state synchronously.
53
+ *
54
+ * @param {Object} page - Playwright page
55
+ * @param {Object} state - Window state
56
+ */
57
+ async _captureBeforeState(page, state) {
58
+ try {
59
+ state.beforeUrl = page.url();
60
+ state.beforeHistoryLength = await page.evaluate(() => window.history.length).catch(() => null);
61
+ } catch (e) {
62
+ // Ignore errors during capture
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Attach navigation listeners.
68
+ *
69
+ * @param {Object} page - Playwright page
70
+ * @param {Object} state - Window state
71
+ */
72
+ _attachListeners(page, state) {
73
+ // Listen for history API calls
74
+ page.on('console', (msg) => {
75
+ const text = msg.text();
76
+
77
+ // Custom markers from injected tracking script
78
+ if (text.startsWith('[NAV]')) {
79
+ try {
80
+ const data = JSON.parse(text.substring(5));
81
+ if (data.type === 'history') {
82
+ state.historyChanges.push({
83
+ method: data.method,
84
+ url: data.url,
85
+ timestamp: Date.now()
86
+ });
87
+ } else if (data.type === 'router') {
88
+ state.routerEvents.push({
89
+ event: data.event,
90
+ url: data.url,
91
+ timestamp: Date.now()
92
+ });
93
+ } else if (data.type === 'blocked') {
94
+ state.blockedNavigations.push({
95
+ reason: data.reason,
96
+ url: data.url,
97
+ timestamp: Date.now()
98
+ });
99
+ }
100
+ } catch (e) {
101
+ // Invalid JSON, ignore
102
+ }
103
+ }
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Stop a navigation observation window.
109
+ *
110
+ * @param {number} windowId - Window ID
111
+ * @param {Object} page - Playwright page
112
+ * @returns {Object} - Navigation summary
113
+ */
114
+ async stopWindow(windowId, page) {
115
+ const state = this.windows.get(windowId);
116
+
117
+ if (!state) {
118
+ return this._emptyNavigationSummary(windowId);
119
+ }
120
+
121
+ // Capture after state
122
+ try {
123
+ state.afterUrl = page.url();
124
+ state.afterHistoryLength = await page.evaluate(() => window.history.length).catch(() => null);
125
+ } catch (e) {
126
+ state.afterUrl = state.beforeUrl;
127
+ state.afterHistoryLength = state.beforeHistoryLength;
128
+ }
129
+
130
+ const duration = Date.now() - state.started;
131
+
132
+ // Compute deltas
133
+ const urlChanged = state.beforeUrl !== state.afterUrl;
134
+ const historyLengthDelta = (state.afterHistoryLength !== null && state.beforeHistoryLength !== null)
135
+ ? state.afterHistoryLength - state.beforeHistoryLength
136
+ : null;
137
+
138
+ const summary = {
139
+ windowId,
140
+ beforeUrl: state.beforeUrl,
141
+ afterUrl: state.afterUrl,
142
+ urlChanged,
143
+ beforeHistoryLength: state.beforeHistoryLength,
144
+ afterHistoryLength: state.afterHistoryLength,
145
+ historyLengthDelta,
146
+ historyChanges: state.historyChanges,
147
+ routerEvents: state.routerEvents,
148
+ blockedNavigations: state.blockedNavigations,
149
+ hasNavigationActivity: urlChanged || historyLengthDelta !== 0 || state.historyChanges.length > 0,
150
+ duration
151
+ };
152
+
153
+ this.windows.delete(windowId);
154
+ return summary;
155
+ }
156
+
157
+ /**
158
+ * Return empty summary for invalid window ID.
159
+ *
160
+ * @param {number} windowId - Window ID
161
+ * @returns {Object} - Empty summary
162
+ */
163
+ _emptyNavigationSummary(windowId) {
164
+ return {
165
+ windowId,
166
+ beforeUrl: null,
167
+ afterUrl: null,
168
+ urlChanged: false,
169
+ beforeHistoryLength: null,
170
+ afterHistoryLength: null,
171
+ historyLengthDelta: null,
172
+ historyChanges: [],
173
+ routerEvents: [],
174
+ blockedNavigations: [],
175
+ hasNavigationActivity: false,
176
+ duration: 0
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Inject navigation tracking script into page.
182
+ * Call this before interaction to capture history/router events.
183
+ *
184
+ * @param {Object} page - Playwright page
185
+ */
186
+ async injectTrackingScript(page) {
187
+ try {
188
+ await page.evaluate(() => {
189
+ // Skip if already injected
190
+ if (window.__veraxNavTracking) return;
191
+ window.__veraxNavTracking = true;
192
+
193
+ // Intercept history API
194
+ const originalPushState = window.history.pushState;
195
+ const originalReplaceState = window.history.replaceState;
196
+
197
+ window.history.pushState = function(...args) {
198
+ console.log('[NAV]' + JSON.stringify({
199
+ type: 'history',
200
+ method: 'pushState',
201
+ url: args[2] || window.location.href
202
+ }));
203
+ return originalPushState.apply(this, args);
204
+ };
205
+
206
+ window.history.replaceState = function(...args) {
207
+ console.log('[NAV]' + JSON.stringify({
208
+ type: 'history',
209
+ method: 'replaceState',
210
+ url: args[2] || window.location.href
211
+ }));
212
+ return originalReplaceState.apply(this, args);
213
+ };
214
+
215
+ // Listen for popstate (back/forward)
216
+ window.addEventListener('popstate', () => {
217
+ console.log('[NAV]' + JSON.stringify({
218
+ type: 'history',
219
+ method: 'popstate',
220
+ url: window.location.href
221
+ }));
222
+ });
223
+
224
+ // Next.js Router events
225
+ if (window.next?.router) {
226
+ window.next.router.events.on('routeChangeStart', (url) => {
227
+ console.log('[NAV]' + JSON.stringify({
228
+ type: 'router',
229
+ event: 'routeChangeStart',
230
+ url
231
+ }));
232
+ });
233
+
234
+ window.next.router.events.on('routeChangeComplete', (url) => {
235
+ console.log('[NAV]' + JSON.stringify({
236
+ type: 'router',
237
+ event: 'routeChangeComplete',
238
+ url
239
+ }));
240
+ });
241
+
242
+ window.next.router.events.on('routeChangeError', (err, url) => {
243
+ console.log('[NAV]' + JSON.stringify({
244
+ type: 'blocked',
245
+ reason: 'routeChangeError',
246
+ url
247
+ }));
248
+ });
249
+ }
250
+ });
251
+ } catch (e) {
252
+ // Ignore injection errors (page might not be ready)
253
+ }
254
+ }
255
+ }
@@ -24,7 +24,8 @@ export class NetworkSensor {
24
24
  failedRequests: [],
25
25
  failedByStatus: {}, // status code -> count
26
26
  unfinishedRequests: new Set(), // urls still pending
27
- lastErrors: []
27
+ lastErrors: [],
28
+ requestOrder: []
28
29
  };
29
30
 
30
31
  // Track all requests
@@ -40,8 +41,10 @@ export class NetworkSensor {
40
41
  status: null,
41
42
  failed: false,
42
43
  duration: 0,
43
- count: 0
44
+ count: 0,
45
+ completed: false
44
46
  });
47
+ state.requestOrder.push(url);
45
48
  }
46
49
 
47
50
  const reqData = state.requests.get(url);
@@ -58,11 +61,15 @@ export class NetworkSensor {
58
61
  reqData.endTime = Date.now();
59
62
  reqData.status = status;
60
63
  reqData.duration = reqData.endTime - reqData.startTime;
64
+ reqData.completed = true;
61
65
 
62
66
  if (status >= 400) {
63
67
  reqData.failed = true;
64
68
  state.failedRequests.push({ url, status, duration: reqData.duration });
65
69
  state.failedByStatus[status] = (state.failedByStatus[status] || 0) + 1;
70
+ } else if (status >= 200 && status < 300) {
71
+ // Track successful 2xx responses explicitly
72
+ reqData.successful = true;
66
73
  }
67
74
  }
68
75
 
@@ -77,9 +84,20 @@ export class NetworkSensor {
77
84
  reqData.endTime = Date.now();
78
85
  reqData.duration = reqData.endTime - reqData.startTime;
79
86
  reqData.failed = true;
87
+ } else {
88
+ state.requests.set(url, {
89
+ url: url,
90
+ startTime: Date.now(),
91
+ endTime: Date.now(),
92
+ status: null,
93
+ failed: true,
94
+ duration: 0,
95
+ count: 1
96
+ });
80
97
  }
81
98
 
82
99
  state.failedRequests.push({ url, status: 'FAILED', duration: 0 });
100
+ state.failedByStatus['FAILED'] = (state.failedByStatus['FAILED'] || 0) + 1;
83
101
  state.unfinishedRequests.delete(url);
84
102
  };
85
103
 
@@ -111,10 +129,31 @@ export class NetworkSensor {
111
129
  const endTime = Date.now();
112
130
  const duration = endTime - state.startTime;
113
131
 
114
- // Find slow requests
132
+ // Count failed requests: requests that had 4xx/5xx status or failed completely
133
+ // Note: incomplete requests are NOT counted as failed - they might just be slow
134
+ const failedCount = Array.from(state.requests.values()).filter(
135
+ (r) => r.failed === true
136
+ ).length;
137
+
138
+ // Find slow requests (completed requests that took longer than threshold)
139
+ // Also include incomplete requests that have been pending longer than threshold
140
+ const now = Date.now();
115
141
  const slowRequests = Array.from(state.requests.values())
116
- .filter((r) => r.duration > this.slowThresholdMs)
117
- .sort((a, b) => b.duration - a.duration)
142
+ .filter((r) => {
143
+ if (r.completed && r.duration && r.duration > this.slowThresholdMs) {
144
+ return true;
145
+ }
146
+ // Incomplete request that's been pending longer than threshold
147
+ if (!r.completed && r.startTime && (now - r.startTime) > this.slowThresholdMs) {
148
+ // Estimate duration as time since start
149
+ if (!r.duration) {
150
+ r.duration = now - r.startTime;
151
+ }
152
+ return true;
153
+ }
154
+ return false;
155
+ })
156
+ .sort((a, b) => (b.duration || 0) - (a.duration || 0))
118
157
  .slice(0, 5);
119
158
 
120
159
  // Get top failed URLs (limit to 5)
@@ -122,10 +161,16 @@ export class NetworkSensor {
122
161
  .slice(0, 5)
123
162
  .map((f) => ({ url: f.url, status: f.status, duration: f.duration }));
124
163
 
164
+ // Count successful 2xx requests
165
+ const successfulRequests = Array.from(state.requests.values()).filter(
166
+ (r) => r.successful === true && r.status >= 200 && r.status < 300
167
+ );
168
+
125
169
  const summary = {
126
170
  windowId,
127
171
  totalRequests: state.requests.size,
128
- failedRequests: state.failedRequests.length,
172
+ failedRequests: failedCount,
173
+ successfulRequests: successfulRequests.length,
129
174
  failedByStatus: state.failedByStatus,
130
175
  hasNetworkActivity: state.requests.size > 0,
131
176
  slowRequestsCount: slowRequests.length,
@@ -135,7 +180,9 @@ export class NetworkSensor {
135
180
  })),
136
181
  topFailedUrls: topFailedUrls,
137
182
  duration: duration,
138
- unfinishedCount: state.unfinishedRequests.size
183
+ unfinishedCount: state.unfinishedRequests.size,
184
+ firstRequestUrl: state.requestOrder[0] || null,
185
+ observedRequestUrls: state.requestOrder.slice(0, 5)
139
186
  };
140
187
 
141
188
  this.windows.delete(windowId);
@@ -161,6 +208,7 @@ export class NetworkSensor {
161
208
  windowId: -1,
162
209
  totalRequests: 0,
163
210
  failedRequests: 0,
211
+ successfulRequests: 0,
164
212
  failedByStatus: {},
165
213
  hasNetworkActivity: false,
166
214
  slowRequestsCount: 0,