@veraxhq/verax 0.1.0 → 0.2.1

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 (135) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +24 -36
  4. package/src/cli/commands/default.js +681 -0
  5. package/src/cli/commands/doctor.js +197 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +586 -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 +297 -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 +110 -0
  14. package/src/cli/util/expectation-extractor.js +388 -0
  15. package/src/cli/util/findings-writer.js +32 -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 +412 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +30 -0
  21. package/src/cli/util/project-discovery.js +297 -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/runtime-budget.js +147 -0
  26. package/src/cli/util/summary-writer.js +43 -0
  27. package/src/types/global.d.ts +28 -0
  28. package/src/types/ts-ast.d.ts +24 -0
  29. package/src/verax/cli/ci-summary.js +35 -0
  30. package/src/verax/cli/context-explanation.js +89 -0
  31. package/src/verax/cli/doctor.js +277 -0
  32. package/src/verax/cli/error-normalizer.js +154 -0
  33. package/src/verax/cli/explain-output.js +105 -0
  34. package/src/verax/cli/finding-explainer.js +130 -0
  35. package/src/verax/cli/init.js +237 -0
  36. package/src/verax/cli/run-overview.js +163 -0
  37. package/src/verax/cli/url-safety.js +111 -0
  38. package/src/verax/cli/wizard.js +109 -0
  39. package/src/verax/cli/zero-findings-explainer.js +57 -0
  40. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  41. package/src/verax/core/action-classifier.js +86 -0
  42. package/src/verax/core/budget-engine.js +218 -0
  43. package/src/verax/core/canonical-outcomes.js +157 -0
  44. package/src/verax/core/decision-snapshot.js +335 -0
  45. package/src/verax/core/determinism-model.js +432 -0
  46. package/src/verax/core/incremental-store.js +245 -0
  47. package/src/verax/core/invariants.js +356 -0
  48. package/src/verax/core/promise-model.js +230 -0
  49. package/src/verax/core/replay-validator.js +350 -0
  50. package/src/verax/core/replay.js +222 -0
  51. package/src/verax/core/run-id.js +175 -0
  52. package/src/verax/core/run-manifest.js +99 -0
  53. package/src/verax/core/silence-impact.js +369 -0
  54. package/src/verax/core/silence-model.js +523 -0
  55. package/src/verax/detect/comparison.js +7 -34
  56. package/src/verax/detect/confidence-engine.js +764 -329
  57. package/src/verax/detect/detection-engine.js +293 -0
  58. package/src/verax/detect/evidence-index.js +127 -0
  59. package/src/verax/detect/expectation-model.js +241 -168
  60. package/src/verax/detect/explanation-helpers.js +187 -0
  61. package/src/verax/detect/finding-detector.js +450 -0
  62. package/src/verax/detect/findings-writer.js +41 -12
  63. package/src/verax/detect/flow-detector.js +366 -0
  64. package/src/verax/detect/index.js +200 -288
  65. package/src/verax/detect/interactive-findings.js +612 -0
  66. package/src/verax/detect/signal-mapper.js +308 -0
  67. package/src/verax/detect/skip-classifier.js +4 -4
  68. package/src/verax/detect/verdict-engine.js +561 -0
  69. package/src/verax/evidence-index-writer.js +61 -0
  70. package/src/verax/flow/flow-engine.js +3 -2
  71. package/src/verax/flow/flow-spec.js +1 -2
  72. package/src/verax/index.js +103 -15
  73. package/src/verax/intel/effect-detector.js +368 -0
  74. package/src/verax/intel/handler-mapper.js +249 -0
  75. package/src/verax/intel/index.js +281 -0
  76. package/src/verax/intel/route-extractor.js +280 -0
  77. package/src/verax/intel/ts-program.js +256 -0
  78. package/src/verax/intel/vue-navigation-extractor.js +642 -0
  79. package/src/verax/intel/vue-router-extractor.js +325 -0
  80. package/src/verax/learn/action-contract-extractor.js +338 -104
  81. package/src/verax/learn/ast-contract-extractor.js +148 -6
  82. package/src/verax/learn/flow-extractor.js +172 -0
  83. package/src/verax/learn/index.js +36 -2
  84. package/src/verax/learn/manifest-writer.js +122 -58
  85. package/src/verax/learn/project-detector.js +40 -0
  86. package/src/verax/learn/route-extractor.js +28 -97
  87. package/src/verax/learn/route-validator.js +8 -7
  88. package/src/verax/learn/state-extractor.js +212 -0
  89. package/src/verax/learn/static-extractor-navigation.js +114 -0
  90. package/src/verax/learn/static-extractor-validation.js +88 -0
  91. package/src/verax/learn/static-extractor.js +119 -10
  92. package/src/verax/learn/truth-assessor.js +24 -21
  93. package/src/verax/learn/ts-contract-resolver.js +14 -12
  94. package/src/verax/observe/aria-sensor.js +211 -0
  95. package/src/verax/observe/browser.js +30 -6
  96. package/src/verax/observe/console-sensor.js +2 -18
  97. package/src/verax/observe/domain-boundary.js +10 -1
  98. package/src/verax/observe/expectation-executor.js +513 -0
  99. package/src/verax/observe/flow-matcher.js +143 -0
  100. package/src/verax/observe/focus-sensor.js +196 -0
  101. package/src/verax/observe/human-driver.js +660 -273
  102. package/src/verax/observe/index.js +910 -26
  103. package/src/verax/observe/interaction-discovery.js +378 -15
  104. package/src/verax/observe/interaction-runner.js +562 -197
  105. package/src/verax/observe/loading-sensor.js +145 -0
  106. package/src/verax/observe/navigation-sensor.js +255 -0
  107. package/src/verax/observe/network-sensor.js +55 -7
  108. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  109. package/src/verax/observe/observed-expectation.js +305 -0
  110. package/src/verax/observe/page-frontier.js +234 -0
  111. package/src/verax/observe/settle.js +38 -17
  112. package/src/verax/observe/state-sensor.js +393 -0
  113. package/src/verax/observe/state-ui-sensor.js +7 -1
  114. package/src/verax/observe/timing-sensor.js +228 -0
  115. package/src/verax/observe/traces-writer.js +73 -21
  116. package/src/verax/observe/ui-signal-sensor.js +143 -17
  117. package/src/verax/scan-summary-writer.js +80 -15
  118. package/src/verax/shared/artifact-manager.js +111 -9
  119. package/src/verax/shared/budget-profiles.js +136 -0
  120. package/src/verax/shared/caching.js +1 -1
  121. package/src/verax/shared/ci-detection.js +39 -0
  122. package/src/verax/shared/config-loader.js +169 -0
  123. package/src/verax/shared/dynamic-route-utils.js +224 -0
  124. package/src/verax/shared/expectation-coverage.js +44 -0
  125. package/src/verax/shared/expectation-prover.js +81 -0
  126. package/src/verax/shared/expectation-tracker.js +201 -0
  127. package/src/verax/shared/expectations-writer.js +60 -0
  128. package/src/verax/shared/first-run.js +44 -0
  129. package/src/verax/shared/progress-reporter.js +171 -0
  130. package/src/verax/shared/retry-policy.js +9 -1
  131. package/src/verax/shared/root-artifacts.js +49 -0
  132. package/src/verax/shared/scan-budget.js +86 -0
  133. package/src/verax/shared/url-normalizer.js +162 -0
  134. package/src/verax/shared/zip-artifacts.js +66 -0
  135. package/src/verax/validate/context-validator.js +244 -0
@@ -0,0 +1,393 @@
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(() => {
62
+ clearInterval(check);
63
+ resolve();
64
+ }, 5000);
65
+ const check = setInterval(() => {
66
+ if (window.__REDUX_STORE__) {
67
+ clearInterval(check);
68
+ clearTimeout(timeout);
69
+ resolve();
70
+ }
71
+ }, 100);
72
+ }
73
+ });
74
+ });
75
+
76
+ const hasRedux = await page.evaluate(() => {
77
+ // Try to find Redux store via common patterns
78
+ if (window.__REDUX_STORE__) return true;
79
+ if (window.store && typeof window.store.getState === 'function') return true;
80
+
81
+ // Check React context provider (common pattern)
82
+ const reduxProvider = document.querySelector('[data-redux-provider]');
83
+ if (reduxProvider) return true;
84
+
85
+ // Check for Redux DevTools extension
86
+ if (window.__REDUX_DEVTOOLS_EXTENSION__) return true;
87
+
88
+ // Try to find store in React component tree (best-effort)
89
+ // This is a heuristic but safe - we only read, never modify
90
+ try {
91
+ const reactRoot = document.querySelector('#root, [data-reactroot], [id^="root"]');
92
+ if (reactRoot && window.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
93
+ return true; // Likely Redux if React DevTools present
94
+ }
95
+ } catch (e) {
96
+ // Ignore
97
+ }
98
+
99
+ return false;
100
+ });
101
+
102
+ if (!hasRedux) {
103
+ return false;
104
+ }
105
+
106
+ // Install sensor in page context
107
+ await page.evaluate(() => {
108
+ if (window.__VERAX_STATE_SENSOR__) {
109
+ return;
110
+ }
111
+
112
+ window.__VERAX_STATE_SENSOR__ = {
113
+ type: 'redux',
114
+ snapshots: [],
115
+ store: null,
116
+ unsubscribe: null,
117
+ captureSnapshot() {
118
+ let state = null;
119
+ if (window.__REDUX_STORE__) {
120
+ state = window.__REDUX_STORE__.getState();
121
+ this.store = window.__REDUX_STORE__;
122
+ } else if (window.store && typeof window.store.getState === 'function') {
123
+ state = window.store.getState();
124
+ this.store = window.store;
125
+ }
126
+
127
+ if (state && typeof state === 'object') {
128
+ // Shallow copy of top-level keys only (privacy: no values)
129
+ const snapshot = {};
130
+ for (const key in state) {
131
+ if (Object.prototype.hasOwnProperty.call(state, key)) {
132
+ snapshot[key] = '[REDACTED]'; // Never store values, only keys
133
+ }
134
+ }
135
+ this.snapshots.push({ timestamp: Date.now(), state: snapshot });
136
+ }
137
+ },
138
+ getSnapshots() {
139
+ return this.snapshots;
140
+ },
141
+ reset() {
142
+ this.snapshots = [];
143
+ if (this.unsubscribe) {
144
+ this.unsubscribe();
145
+ this.unsubscribe = null;
146
+ }
147
+ }
148
+ };
149
+ });
150
+
151
+ this.active = true;
152
+ return true;
153
+ } catch (error) {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ async captureBefore(page) {
159
+ if (!this.active) return;
160
+
161
+ try {
162
+ await page.evaluate(() => {
163
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
164
+ });
165
+
166
+ this.beforeState = await page.evaluate(() => {
167
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
168
+ return snapshots[snapshots.length - 1]?.state || null;
169
+ });
170
+ } catch (error) {
171
+ this.beforeState = null;
172
+ }
173
+ }
174
+
175
+ async captureAfter(page) {
176
+ if (!this.active) return;
177
+
178
+ try {
179
+ await page.evaluate(() => {
180
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
181
+ });
182
+
183
+ this.afterState = await page.evaluate(() => {
184
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
185
+ return snapshots[snapshots.length - 1]?.state || null;
186
+ });
187
+ } catch (error) {
188
+ this.afterState = null;
189
+ }
190
+ }
191
+
192
+ getDiff() {
193
+ if (!this.beforeState || !this.afterState) {
194
+ return { changed: [], available: false };
195
+ }
196
+
197
+ const changed = computeStateDiff(this.beforeState, this.afterState);
198
+ return { changed, available: true };
199
+ }
200
+
201
+ cleanup() {
202
+ this.beforeState = null;
203
+ this.afterState = null;
204
+ this.active = false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Zustand store sensor.
210
+ * Wraps set() calls to capture state changes.
211
+ */
212
+ class ZustandSensor {
213
+ constructor() {
214
+ this.beforeState = null;
215
+ this.afterState = null;
216
+ this.active = false;
217
+ }
218
+
219
+ async detect(page) {
220
+ try {
221
+ const hasZustand = await page.evaluate(() => {
222
+ // Check for Zustand store markers
223
+ if (window.__ZUSTAND_STORE__) return true;
224
+
225
+ // Look for common Zustand patterns in window object
226
+ for (const key in window) {
227
+ if (key.startsWith('use') && typeof window[key] === 'function') {
228
+ // Dynamic property access on window for Zustand store detection (runtime property)
229
+ const store = /** @type {any} */ (window[key]);
230
+ if (store && typeof store === 'object' && 'getState' in store && typeof store.getState === 'function') {
231
+ return true;
232
+ }
233
+ }
234
+ }
235
+
236
+ return false;
237
+ });
238
+
239
+ if (!hasZustand) return false;
240
+
241
+ // Install sensor
242
+ await page.evaluate(() => {
243
+ if (window.__VERAX_STATE_SENSOR__) return;
244
+
245
+ window.__VERAX_STATE_SENSOR__ = {
246
+ type: 'zustand',
247
+ snapshots: [],
248
+ captureSnapshot() {
249
+ // Try to find and capture Zustand store state
250
+ if (window.__ZUSTAND_STORE__) {
251
+ const state = window.__ZUSTAND_STORE__.getState();
252
+ if (state && typeof state === 'object') {
253
+ const snapshot = {};
254
+ for (const key in state) {
255
+ if (typeof state[key] !== 'function') {
256
+ snapshot[key] = '[REDACTED]'; // Never store values, only keys
257
+ }
258
+ }
259
+ this.snapshots.push({ timestamp: Date.now(), state: snapshot });
260
+ }
261
+ }
262
+ },
263
+ getSnapshots() {
264
+ return this.snapshots;
265
+ },
266
+ reset() {
267
+ this.snapshots = [];
268
+ }
269
+ };
270
+ });
271
+
272
+ this.active = true;
273
+ return true;
274
+ } catch (error) {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ async captureBefore(page) {
280
+ if (!this.active) return;
281
+
282
+ try {
283
+ await page.evaluate(() => {
284
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
285
+ });
286
+
287
+ this.beforeState = await page.evaluate(() => {
288
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
289
+ return snapshots[snapshots.length - 1]?.state || null;
290
+ });
291
+ } catch (error) {
292
+ this.beforeState = null;
293
+ }
294
+ }
295
+
296
+ async captureAfter(page) {
297
+ if (!this.active) return;
298
+
299
+ try {
300
+ await page.evaluate(() => {
301
+ window.__VERAX_STATE_SENSOR__?.captureSnapshot();
302
+ });
303
+
304
+ this.afterState = await page.evaluate(() => {
305
+ const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
306
+ return snapshots[snapshots.length - 1]?.state || null;
307
+ });
308
+ } catch (error) {
309
+ this.afterState = null;
310
+ }
311
+ }
312
+
313
+ getDiff() {
314
+ if (!this.beforeState || !this.afterState) {
315
+ return { changed: [], available: false };
316
+ }
317
+
318
+ const changed = computeStateDiff(this.beforeState, this.afterState);
319
+ return { changed, available: true };
320
+ }
321
+
322
+ cleanup() {
323
+ this.beforeState = null;
324
+ this.afterState = null;
325
+ this.active = false;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * State Sensor orchestrator.
331
+ * Detects store type and delegates to appropriate sensor.
332
+ */
333
+ export class StateSensor {
334
+ constructor() {
335
+ this.reduxSensor = new ReduxSensor();
336
+ this.zustandSensor = new ZustandSensor();
337
+ this.activeType = null;
338
+ }
339
+
340
+ /**
341
+ * Detects state stores and activates appropriate sensor.
342
+ * Returns { detected: bool, type: 'redux' | 'zustand' | null }
343
+ */
344
+ async detect(page) {
345
+ // Try Redux first
346
+ const reduxDetected = await this.reduxSensor.detect(page);
347
+ if (reduxDetected) {
348
+ this.activeType = 'redux';
349
+ return { detected: true, type: 'redux' };
350
+ }
351
+
352
+ // Try Zustand
353
+ const zustandDetected = await this.zustandSensor.detect(page);
354
+ if (zustandDetected) {
355
+ this.activeType = 'zustand';
356
+ return { detected: true, type: 'zustand' };
357
+ }
358
+
359
+ return { detected: false, type: null };
360
+ }
361
+
362
+ async captureBefore(page) {
363
+ if (this.activeType === 'redux') {
364
+ await this.reduxSensor.captureBefore(page);
365
+ } else if (this.activeType === 'zustand') {
366
+ await this.zustandSensor.captureBefore(page);
367
+ }
368
+ }
369
+
370
+ async captureAfter(page) {
371
+ if (this.activeType === 'redux') {
372
+ await this.reduxSensor.captureAfter(page);
373
+ } else if (this.activeType === 'zustand') {
374
+ await this.zustandSensor.captureAfter(page);
375
+ }
376
+ }
377
+
378
+ getDiff() {
379
+ if (this.activeType === 'redux') {
380
+ return this.reduxSensor.getDiff();
381
+ } else if (this.activeType === 'zustand') {
382
+ return this.zustandSensor.getDiff();
383
+ }
384
+
385
+ return { changed: [], available: false };
386
+ }
387
+
388
+ cleanup() {
389
+ this.reduxSensor.cleanup();
390
+ this.zustandSensor.cleanup();
391
+ this.activeType = null;
392
+ }
393
+ }
@@ -22,13 +22,14 @@ export class StateUISensor {
22
22
  */
23
23
  async snapshot(page, contextSelector = null) {
24
24
  try {
25
- const snapshot = await page.evaluate(({ selector }) => {
25
+ const snapshot = await page.evaluate(() => {
26
26
  const signals = {};
27
27
  const rawSnapshot = {};
28
28
 
29
29
  // 1. Dialog/Modal signals
30
30
  signals.dialogs = [];
31
31
  const dialogs = document.querySelectorAll('[role="dialog"]');
32
+ // @ts-expect-error - NodeListOf is iterable in browser context
32
33
  for (const dialog of dialogs) {
33
34
  const isVisible = dialog.offsetParent !== null || dialog.hasAttribute('open');
34
35
  const ariaModal = dialog.getAttribute('aria-modal');
@@ -46,6 +47,7 @@ export class StateUISensor {
46
47
  // 2. Expansion state (aria-expanded)
47
48
  signals.expandedElements = [];
48
49
  const expandables = document.querySelectorAll('[aria-expanded]');
50
+ // @ts-expect-error - NodeListOf is iterable in browser context
49
51
  for (const el of expandables) {
50
52
  const expanded = el.getAttribute('aria-expanded') === 'true';
51
53
  signals.expandedElements.push({
@@ -58,6 +60,7 @@ export class StateUISensor {
58
60
  // 3. Tab selection (role=tab with aria-selected)
59
61
  signals.selectedTabs = [];
60
62
  const tabs = document.querySelectorAll('[role="tab"]');
63
+ // @ts-expect-error - NodeListOf is iterable in browser context
61
64
  for (const tab of tabs) {
62
65
  const selected = tab.getAttribute('aria-selected') === 'true';
63
66
  signals.selectedTabs.push({
@@ -70,6 +73,7 @@ export class StateUISensor {
70
73
  // 4. Checkbox/toggle state (aria-checked)
71
74
  signals.checkedElements = [];
72
75
  const checkables = document.querySelectorAll('[aria-checked]');
76
+ // @ts-expect-error - NodeListOf is iterable in browser context
73
77
  for (const el of checkables) {
74
78
  const checked = el.getAttribute('aria-checked') === 'true';
75
79
  signals.checkedElements.push({
@@ -82,6 +86,7 @@ export class StateUISensor {
82
86
  // 5. Alert/Status changes (role=alert, role=status)
83
87
  signals.alerts = [];
84
88
  const alerts = document.querySelectorAll('[role="alert"], [role="status"]');
89
+ // @ts-expect-error - NodeListOf is iterable in browser context
85
90
  for (const alert of alerts) {
86
91
  const text = alert.textContent?.trim() || '';
87
92
  signals.alerts.push({
@@ -96,6 +101,7 @@ export class StateUISensor {
96
101
  // Count visible nodes and text nodes (excluding style/comment nodes)
97
102
  const countMeaningfulNodes = () => {
98
103
  let count = 0;
104
+ // @ts-expect-error - NodeListOf is iterable in browser context
99
105
  for (const node of document.querySelectorAll('*')) {
100
106
  if (node.offsetParent !== null) { // Visible
101
107
  count++;
@@ -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
+ }