@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,389 @@
1
+ /**
2
+ * State Sensor v1
3
+ * Safe, opt-in state change detection for Redux and Zustand.
4
+ *
5
+ * SAFETY:
6
+ * - Keys only, no values
7
+ * - Opt-in via store detection
8
+ * - Cleanup after interaction
9
+ * - Non-destructive
10
+ */
11
+
12
+ const MAX_DIFF_KEYS = 10;
13
+
14
+ /**
15
+ * Computes a shallow diff between two state objects.
16
+ * Returns array of changed keys (no values for privacy).
17
+ */
18
+ function computeStateDiff(before, after) {
19
+ const changed = [];
20
+ const allKeys = new Set([
21
+ ...Object.keys(before || {}),
22
+ ...Object.keys(after || {})
23
+ ]);
24
+
25
+ for (const key of allKeys) {
26
+ if (before[key] !== after[key]) {
27
+ changed.push(key);
28
+ if (changed.length >= MAX_DIFF_KEYS) break;
29
+ }
30
+ }
31
+
32
+ return changed;
33
+ }
34
+
35
+ /**
36
+ * Redux store sensor.
37
+ * Subscribes to store changes and captures state snapshots.
38
+ */
39
+ class ReduxSensor {
40
+ constructor() {
41
+ this.store = null;
42
+ this.unsubscribe = null;
43
+ this.beforeState = null;
44
+ this.afterState = null;
45
+ this.active = false;
46
+ }
47
+
48
+ /**
49
+ * Attempts to detect and hook into Redux store.
50
+ * Returns true if store found and hooked.
51
+ */
52
+ async detect(page) {
53
+ try {
54
+ // First, wait for the store to be initialized
55
+ await page.evaluate(() => {
56
+ return new Promise((resolve) => {
57
+ if (window.__REDUX_STORE__) {
58
+ resolve();
59
+ } else {
60
+ // Wait up to 5 seconds for store initialization
61
+ const timeout = setTimeout(() => resolve(), 5000);
62
+ const check = setInterval(() => {
63
+ if (window.__REDUX_STORE__) {
64
+ clearInterval(check);
65
+ clearTimeout(timeout);
66
+ resolve();
67
+ }
68
+ }, 100);
69
+ }
70
+ });
71
+ });
72
+
73
+ const hasRedux = await page.evaluate(() => {
74
+ // Try to find Redux store via common patterns
75
+ if (window.__REDUX_STORE__) return true;
76
+ if (window.store && typeof window.store.getState === 'function') return true;
77
+
78
+ // Check React context provider (common pattern)
79
+ const reduxProvider = document.querySelector('[data-redux-provider]');
80
+ if (reduxProvider) return true;
81
+
82
+ // Check for Redux DevTools extension
83
+ if (window.__REDUX_DEVTOOLS_EXTENSION__) return true;
84
+
85
+ // Try to find store in React component tree (best-effort)
86
+ // This is a heuristic but safe - we only read, never modify
87
+ try {
88
+ const reactRoot = document.querySelector('#root, [data-reactroot], [id^="root"]');
89
+ if (reactRoot && window.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
90
+ return true; // Likely Redux if React DevTools present
91
+ }
92
+ } catch (e) {
93
+ // Ignore
94
+ }
95
+
96
+ return false;
97
+ });
98
+
99
+ if (!hasRedux) {
100
+ return false;
101
+ }
102
+
103
+ // Install sensor in page context
104
+ await page.evaluate(() => {
105
+ if (window.__VERAX_STATE_SENSOR__) {
106
+ return;
107
+ }
108
+
109
+ window.__VERAX_STATE_SENSOR__ = {
110
+ type: 'redux',
111
+ snapshots: [],
112
+ store: null,
113
+ unsubscribe: null,
114
+ captureSnapshot() {
115
+ let state = null;
116
+ if (window.__REDUX_STORE__) {
117
+ state = window.__REDUX_STORE__.getState();
118
+ this.store = window.__REDUX_STORE__;
119
+ } else if (window.store && typeof window.store.getState === 'function') {
120
+ state = window.store.getState();
121
+ this.store = window.store;
122
+ }
123
+
124
+ if (state && typeof state === 'object') {
125
+ // Shallow copy of top-level keys only (privacy: no values)
126
+ const snapshot = {};
127
+ for (const key in state) {
128
+ if (state.hasOwnProperty(key)) {
129
+ snapshot[key] = '[REDACTED]'; // Never store values, only keys
130
+ }
131
+ }
132
+ this.snapshots.push({ timestamp: Date.now(), state: snapshot });
133
+ }
134
+ },
135
+ getSnapshots() {
136
+ return this.snapshots;
137
+ },
138
+ reset() {
139
+ this.snapshots = [];
140
+ if (this.unsubscribe) {
141
+ this.unsubscribe();
142
+ this.unsubscribe = null;
143
+ }
144
+ }
145
+ };
146
+ });
147
+
148
+ this.active = true;
149
+ return true;
150
+ } catch (error) {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ async captureBefore(page) {
156
+ if (!this.active) return;
157
+
158
+ try {
159
+ await page.evaluate(() => {
160
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
161
+ });
162
+
163
+ this.beforeState = await page.evaluate(() => {
164
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
165
+ return snapshots[snapshots.length - 1]?.state || null;
166
+ });
167
+ } catch (error) {
168
+ this.beforeState = null;
169
+ }
170
+ }
171
+
172
+ async captureAfter(page) {
173
+ if (!this.active) return;
174
+
175
+ try {
176
+ await page.evaluate(() => {
177
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
178
+ });
179
+
180
+ this.afterState = await page.evaluate(() => {
181
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
182
+ return snapshots[snapshots.length - 1]?.state || null;
183
+ });
184
+ } catch (error) {
185
+ this.afterState = null;
186
+ }
187
+ }
188
+
189
+ getDiff() {
190
+ if (!this.beforeState || !this.afterState) {
191
+ return { changed: [], available: false };
192
+ }
193
+
194
+ const changed = computeStateDiff(this.beforeState, this.afterState);
195
+ return { changed, available: true };
196
+ }
197
+
198
+ cleanup() {
199
+ this.beforeState = null;
200
+ this.afterState = null;
201
+ this.active = false;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Zustand store sensor.
207
+ * Wraps set() calls to capture state changes.
208
+ */
209
+ class ZustandSensor {
210
+ constructor() {
211
+ this.beforeState = null;
212
+ this.afterState = null;
213
+ this.active = false;
214
+ }
215
+
216
+ async detect(page) {
217
+ try {
218
+ const hasZustand = await page.evaluate(() => {
219
+ // Check for Zustand store markers
220
+ if (window.__ZUSTAND_STORE__) return true;
221
+
222
+ // Look for common Zustand patterns in window object
223
+ for (const key in window) {
224
+ if (key.startsWith('use') && typeof window[key] === 'function') {
225
+ const store = window[key];
226
+ if (store.getState && typeof store.getState === 'function') {
227
+ return true;
228
+ }
229
+ }
230
+ }
231
+
232
+ return false;
233
+ });
234
+
235
+ if (!hasZustand) return false;
236
+
237
+ // Install sensor
238
+ await page.evaluate(() => {
239
+ if (window.__VERAX_STATE_SENSOR__) return;
240
+
241
+ window.__VERAX_STATE_SENSOR__ = {
242
+ type: 'zustand',
243
+ snapshots: [],
244
+ captureSnapshot() {
245
+ // Try to find and capture Zustand store state
246
+ if (window.__ZUSTAND_STORE__) {
247
+ const state = window.__ZUSTAND_STORE__.getState();
248
+ if (state && typeof state === 'object') {
249
+ const snapshot = {};
250
+ for (const key in state) {
251
+ if (typeof state[key] !== 'function') {
252
+ snapshot[key] = '[REDACTED]'; // Never store values, only keys
253
+ }
254
+ }
255
+ this.snapshots.push({ timestamp: Date.now(), state: snapshot });
256
+ }
257
+ }
258
+ },
259
+ getSnapshots() {
260
+ return this.snapshots;
261
+ },
262
+ reset() {
263
+ this.snapshots = [];
264
+ }
265
+ };
266
+ });
267
+
268
+ this.active = true;
269
+ return true;
270
+ } catch (error) {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ async captureBefore(page) {
276
+ if (!this.active) return;
277
+
278
+ try {
279
+ await page.evaluate(() => {
280
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
281
+ });
282
+
283
+ this.beforeState = await page.evaluate(() => {
284
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
285
+ return snapshots[snapshots.length - 1]?.state || null;
286
+ });
287
+ } catch (error) {
288
+ this.beforeState = null;
289
+ }
290
+ }
291
+
292
+ async captureAfter(page) {
293
+ if (!this.active) return;
294
+
295
+ try {
296
+ await page.evaluate(() => {
297
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
298
+ });
299
+
300
+ this.afterState = await page.evaluate(() => {
301
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
302
+ return snapshots[snapshots.length - 1]?.state || null;
303
+ });
304
+ } catch (error) {
305
+ this.afterState = null;
306
+ }
307
+ }
308
+
309
+ getDiff() {
310
+ if (!this.beforeState || !this.afterState) {
311
+ return { changed: [], available: false };
312
+ }
313
+
314
+ const changed = computeStateDiff(this.beforeState, this.afterState);
315
+ return { changed, available: true };
316
+ }
317
+
318
+ cleanup() {
319
+ this.beforeState = null;
320
+ this.afterState = null;
321
+ this.active = false;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * State Sensor orchestrator.
327
+ * Detects store type and delegates to appropriate sensor.
328
+ */
329
+ export class StateSensor {
330
+ constructor() {
331
+ this.reduxSensor = new ReduxSensor();
332
+ this.zustandSensor = new ZustandSensor();
333
+ this.activeType = null;
334
+ }
335
+
336
+ /**
337
+ * Detects state stores and activates appropriate sensor.
338
+ * Returns { detected: bool, type: 'redux' | 'zustand' | null }
339
+ */
340
+ async detect(page) {
341
+ // Try Redux first
342
+ const reduxDetected = await this.reduxSensor.detect(page);
343
+ if (reduxDetected) {
344
+ this.activeType = 'redux';
345
+ return { detected: true, type: 'redux' };
346
+ }
347
+
348
+ // Try Zustand
349
+ const zustandDetected = await this.zustandSensor.detect(page);
350
+ if (zustandDetected) {
351
+ this.activeType = 'zustand';
352
+ return { detected: true, type: 'zustand' };
353
+ }
354
+
355
+ return { detected: false, type: null };
356
+ }
357
+
358
+ async captureBefore(page) {
359
+ if (this.activeType === 'redux') {
360
+ await this.reduxSensor.captureBefore(page);
361
+ } else if (this.activeType === 'zustand') {
362
+ await this.zustandSensor.captureBefore(page);
363
+ }
364
+ }
365
+
366
+ async captureAfter(page) {
367
+ if (this.activeType === 'redux') {
368
+ await this.reduxSensor.captureAfter(page);
369
+ } else if (this.activeType === 'zustand') {
370
+ await this.zustandSensor.captureAfter(page);
371
+ }
372
+ }
373
+
374
+ getDiff() {
375
+ if (this.activeType === 'redux') {
376
+ return this.reduxSensor.getDiff();
377
+ } else if (this.activeType === 'zustand') {
378
+ return this.zustandSensor.getDiff();
379
+ }
380
+
381
+ return { changed: [], available: false };
382
+ }
383
+
384
+ cleanup() {
385
+ this.reduxSensor.cleanup();
386
+ this.zustandSensor.cleanup();
387
+ this.activeType = null;
388
+ }
389
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Timing Sensor
3
+ * Tracks timing of feedback signals (UI changes, ARIA, loading indicators)
4
+ * Detects delayed or missing feedback after interactions
5
+ */
6
+
7
+ export class TimingSensor {
8
+ constructor(options = {}) {
9
+ this.feedbackGapThresholdMs = options.feedbackGapThresholdMs || 1500;
10
+ this.freezeLikeThresholdMs = options.freezeLikeThresholdMs || 3000;
11
+
12
+ this.t0 = null; // Interaction start time
13
+ this.tNetworkFirst = null; // First network request time
14
+ this.tLoadingStart = null; // Loading indicator appears
15
+ this.tAriaFirst = null; // First ARIA change
16
+ this.tUiFirst = null; // First DOM/UI change
17
+ this.tFeedback = null; // First feedback signal (min of above)
18
+
19
+ this.networkActivityDetected = false;
20
+ this.feedbackDetected = false;
21
+ this.feedbackDelayMs = 0;
22
+ this.workStartMs = 0;
23
+ }
24
+
25
+ /**
26
+ * Start timing from interaction initiation
27
+ */
28
+ startTiming() {
29
+ this.t0 = Date.now();
30
+ return this.t0;
31
+ }
32
+
33
+ /**
34
+ * Monitor for feedback signals during interaction
35
+ * Call periodically or at key moments to track timing
36
+ */
37
+ async captureTimingSnapshot(page) {
38
+ if (!this.t0) {
39
+ this.t0 = Date.now();
40
+ }
41
+
42
+ const now = Date.now();
43
+ const elapsedMs = now - this.t0;
44
+
45
+ // Capture current state
46
+ const state = await page.evaluate(() => {
47
+ const result = {
48
+ loadingPresent: false,
49
+ ariaStatusPresent: false,
50
+ ariaLivePresent: false,
51
+ buttonDisabled: false,
52
+ domChanged: false
53
+ };
54
+
55
+ // Check for loading indicators
56
+ const loading = document.querySelectorAll('[aria-busy="true"], [class*="load"], [class*="spin"], .loader, .spinner');
57
+ result.loadingPresent = loading.length > 0;
58
+
59
+ // Check for ARIA status/alert
60
+ const ariaStatus = document.querySelectorAll('[role="status"], [role="alert"]');
61
+ ariaStatus.forEach(el => {
62
+ if (el.textContent?.length > 0) {
63
+ result.ariaStatusPresent = true;
64
+ }
65
+ });
66
+
67
+ // Check for ARIA live regions
68
+ const ariaLive = document.querySelectorAll('[aria-live]');
69
+ ariaLive.forEach(el => {
70
+ if (el.textContent?.length > 0) {
71
+ result.ariaLivePresent = true;
72
+ }
73
+ });
74
+
75
+ // Check for disabled submit buttons (common feedback)
76
+ const disabledButtons = document.querySelectorAll('button[type="submit"]:disabled, button:disabled');
77
+ result.buttonDisabled = disabledButtons.length > 0;
78
+
79
+ return result;
80
+ });
81
+
82
+ // First loading indicator
83
+ if (state.loadingPresent && !this.tLoadingStart) {
84
+ this.tLoadingStart = now;
85
+ }
86
+
87
+ // First ARIA change
88
+ if ((state.ariaStatusPresent || state.ariaLivePresent) && !this.tAriaFirst) {
89
+ this.tAriaFirst = now;
90
+ }
91
+
92
+ // Record button disabled state as feedback signal
93
+ if (state.buttonDisabled) {
94
+ if (!this.tFeedback) {
95
+ this.tFeedback = now;
96
+ }
97
+ // Also record as button disabled time
98
+ this.recordButtonDisabled(now);
99
+ }
100
+
101
+ // Determine first feedback signal
102
+ if (!this.tFeedback) {
103
+ const signals = [this.tLoadingStart, this.tAriaFirst, this.tUiFirst].filter(t => t !== null);
104
+ if (signals.length > 0) {
105
+ this.tFeedback = Math.min(...signals);
106
+ }
107
+ }
108
+
109
+ return {
110
+ elapsedMs,
111
+ state,
112
+ hasLoadingIndicator: state.loadingPresent,
113
+ hasAriaFeedback: state.ariaStatusPresent || state.ariaLivePresent,
114
+ hasButtonDisabled: state.buttonDisabled
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Analyze network summary to detect if work started
120
+ * Records the time when first network request was detected
121
+ */
122
+ analyzeNetworkSummary(networkSummary) {
123
+ if (!networkSummary || networkSummary.totalRequests === 0) {
124
+ return;
125
+ }
126
+
127
+ if (!this.tNetworkFirst) {
128
+ // Network requests started - estimate based on interaction start + small delay
129
+ // Network sensor tracks when requests were made, but we estimate based on t0
130
+ // Most network requests start within 50-200ms after interaction
131
+ const estimatedNetworkStart = this.t0 + 100; // Conservative estimate
132
+ this.tNetworkFirst = estimatedNetworkStart;
133
+ this.networkActivityDetected = true;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Record when loading sensor detected activity
139
+ * Called when loading indicators appear
140
+ */
141
+ recordLoadingStart(timestamp = null) {
142
+ if (!this.tLoadingStart) {
143
+ this.tLoadingStart = timestamp || Date.now();
144
+ // Loading indicates work started even if network not detected yet
145
+ if (!this.networkActivityDetected) {
146
+ this.networkActivityDetected = true;
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Record when UI changed (from sensor)
153
+ */
154
+ recordUiChange(timestamp = null) {
155
+ if (!this.tUiFirst) {
156
+ this.tUiFirst = timestamp || Date.now();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Record when button disabled state detected (feedback signal)
162
+ */
163
+ recordButtonDisabled(timestamp = null) {
164
+ if (!this.tFeedback) {
165
+ const now = timestamp || Date.now();
166
+ // Check if this is earlier than other feedback signals
167
+ const signals = [this.tLoadingStart, this.tAriaFirst, this.tUiFirst].filter(t => t !== null);
168
+ if (signals.length === 0 || now < Math.min(...signals)) {
169
+ this.tFeedback = now;
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Get final timing analysis
176
+ */
177
+ getTimingAnalysis() {
178
+ if (!this.t0) {
179
+ return null;
180
+ }
181
+
182
+ const now = Date.now();
183
+
184
+ // Determine when work started (network or loading indicator)
185
+ const workStartTime = this.tNetworkFirst || this.tLoadingStart;
186
+ this.workStartMs = workStartTime ? workStartTime - this.t0 : -1;
187
+
188
+ // Determine when feedback appeared (first of: loading, ARIA, UI change, button disabled)
189
+ const feedbackTimes = [this.tLoadingStart, this.tAriaFirst, this.tUiFirst].filter(t => t !== null);
190
+ if (this.tFeedback && !feedbackTimes.includes(this.tFeedback)) {
191
+ feedbackTimes.push(this.tFeedback);
192
+ }
193
+ this.tFeedback = feedbackTimes.length > 0 ? Math.min(...feedbackTimes) : null;
194
+ this.feedbackDelayMs = this.tFeedback ? this.tFeedback - this.t0 : -1;
195
+
196
+ // Check if feedback gap exists: work started but no feedback within threshold
197
+ const hasFeedbackGap =
198
+ this.networkActivityDetected &&
199
+ (!this.tFeedback || this.feedbackDelayMs > this.feedbackGapThresholdMs);
200
+
201
+ // Check if freeze-like: significant delay (>3000ms) before feedback
202
+ const isFreezeLike =
203
+ this.networkActivityDetected &&
204
+ this.tFeedback !== null &&
205
+ this.feedbackDelayMs > this.freezeLikeThresholdMs;
206
+
207
+ return {
208
+ t0: this.t0,
209
+ tNetworkFirst: this.tNetworkFirst,
210
+ tLoadingStart: this.tLoadingStart,
211
+ tAriaFirst: this.tAriaFirst,
212
+ tUiFirst: this.tUiFirst,
213
+ tFeedback: this.tFeedback,
214
+
215
+ elapsedMs: now - this.t0,
216
+ workStartMs: this.workStartMs,
217
+ feedbackDelayMs: this.feedbackDelayMs,
218
+
219
+ networkActivityDetected: this.networkActivityDetected,
220
+ feedbackDetected: this.tFeedback !== null,
221
+ hasFeedbackGap,
222
+ isFreezeLike,
223
+
224
+ feedbackGapThreshold: this.feedbackGapThresholdMs,
225
+ freezeLikeThreshold: this.freezeLikeThresholdMs
226
+ };
227
+ }
228
+ }