@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,612 @@
1
+ // Interactive Finding Detection Module
2
+ // Handles keyboard, hover, file_upload, login, logout, auth_guard interactions
3
+ // Plus accessibility detections: focus, ARIA, keyboard trap, feedback gap, freeze
4
+
5
+ import { hasMeaningfulUrlChange, hasDomChange } from './comparison.js';
6
+ import { computeConfidence } from './confidence-engine.js';
7
+ import { enrichFindingWithExplanations } from './finding-detector.js';
8
+
9
+ /**
10
+ * Detect interactive and accessibility-related silent failures
11
+ * Covers: keyboard, hover, file_upload, login, logout, auth_guard interactions
12
+ * Plus focus loss, ARIA changes, keyboard traps, feedback gaps, freeze-like behavior
13
+ *
14
+ * @param {Array} traces - Interaction traces to analyze
15
+ * @param {Object} manifest - Project manifest (not used currently)
16
+ * @param {Array} findings - Findings array to append to
17
+ * @returns {Array} Array of detected interactive findings
18
+ */
19
+ export function detectInteractiveFindings(traces, manifest, findings, _helpers = {}) {
20
+ const interactiveFindings = [];
21
+
22
+ for (const trace of traces) {
23
+ const interaction = trace.interaction || {};
24
+ const beforeUrl = trace.beforeUrl || '';
25
+ const afterUrl = trace.afterUrl || '';
26
+ const beforeScreenshot = trace.beforeScreenshot || '';
27
+ const afterScreenshot = trace.afterScreenshot || '';
28
+
29
+ // Handle specific interaction types: keyboard, hover, file_upload, login, logout, auth_guard
30
+ if (['keyboard', 'hover', 'file_upload', 'login', 'logout', 'auth_guard'].includes(interaction.type)) {
31
+ const sensors = trace.sensors || {};
32
+ const uiSignals = sensors.uiSignals || {};
33
+ const uiDiff = uiSignals.diff || uiSignals.changes || {};
34
+ const uiChanged = uiDiff.changed === true;
35
+ const domChanged = hasDomChange(trace);
36
+ const urlChanged = hasMeaningfulUrlChange(beforeUrl, afterUrl);
37
+ const network = sensors.network || {};
38
+ const hasNetwork = (network.totalRequests || 0) > 0;
39
+
40
+ let findingType = null;
41
+ let reason = '';
42
+ const evidence = {
43
+ before: beforeScreenshot,
44
+ after: afterScreenshot,
45
+ beforeUrl,
46
+ afterUrl,
47
+ interactionType: interaction.type,
48
+ uiChanged,
49
+ domChanged,
50
+ urlChanged,
51
+ networkRequests: network.totalRequests || 0
52
+ };
53
+
54
+ if (interaction.type === 'keyboard') {
55
+ const keyboardMeta = trace.keyboard || {};
56
+ evidence.focusOrder = keyboardMeta.focusOrder || [];
57
+ evidence.actions = keyboardMeta.actions || [];
58
+ const noEffect = !urlChanged && !domChanged && !uiChanged && !hasNetwork;
59
+ if (noEffect) {
60
+ findingType = 'keyboard_silent_failure';
61
+ reason = 'Keyboard navigation produced no visible, DOM, or network effect';
62
+ }
63
+ } else if (interaction.type === 'hover') {
64
+ const hoverMeta = trace.hover || {};
65
+ evidence.hoveredSelector = hoverMeta.selector || interaction.selector;
66
+ const noReveal = !domChanged && !uiChanged && !urlChanged;
67
+ if (noReveal) {
68
+ findingType = 'hover_silent_failure';
69
+ reason = 'Hover interaction did not reveal any observable change';
70
+ }
71
+ } else if (interaction.type === 'file_upload') {
72
+ const uploadMeta = trace.fileUpload || {};
73
+ evidence.filePath = uploadMeta.filePath || null;
74
+ evidence.submitted = uploadMeta.submitted || false;
75
+ const notAttached = uploadMeta && uploadMeta.attached === false;
76
+ const noEffect = !domChanged && !uiChanged && !hasNetwork;
77
+ if (notAttached || noEffect) {
78
+ findingType = 'file_upload_silent_failure';
79
+ reason = notAttached ? 'File was not attached' : 'Upload produced no network, DOM, or UI change';
80
+ }
81
+ } else if (interaction.type === 'login' || trace.interactionType === 'login') {
82
+ const loginMeta = trace.login || {};
83
+ evidence.submitted = loginMeta.submitted || false;
84
+ evidence.found = loginMeta.found !== false; // Default to true if not set
85
+ evidence.redirected = loginMeta.redirected || false;
86
+ evidence.storageChanged = loginMeta.storageChanged || false;
87
+ evidence.cookiesChanged = loginMeta.cookiesChanged || false;
88
+ evidence.beforeStorage = loginMeta.beforeStorage || [];
89
+ evidence.afterStorage = loginMeta.afterStorage || [];
90
+ const noEffect = !loginMeta.redirected && !loginMeta.storageChanged && !loginMeta.cookiesChanged && !hasNetwork;
91
+ if (loginMeta.submitted && noEffect) {
92
+ findingType = 'auth_silent_failure';
93
+ reason = 'Login submitted but produced no redirect, session storage change, cookies change, or network activity';
94
+ }
95
+ } else if (interaction.type === 'logout' || trace.interactionType === 'logout') {
96
+ const logoutMeta = trace.logout || {};
97
+ evidence.clicked = logoutMeta.clicked || false;
98
+ evidence.found = logoutMeta.found !== false; // Default to true if not set
99
+ evidence.redirected = logoutMeta.redirected || false;
100
+ evidence.storageChanged = logoutMeta.storageChanged || false;
101
+ evidence.cookiesChanged = logoutMeta.cookiesChanged || false;
102
+ evidence.beforeStorage = logoutMeta.beforeStorage || [];
103
+ evidence.afterStorage = logoutMeta.afterStorage || [];
104
+ const noEffect = !logoutMeta.redirected && !logoutMeta.storageChanged && !logoutMeta.cookiesChanged;
105
+ if (logoutMeta.clicked && noEffect) {
106
+ findingType = 'logout_silent_failure';
107
+ reason = 'Logout clicked but produced no redirect or session state change (storage/cookies unchanged)';
108
+ }
109
+ } else if (interaction.type === 'auth_guard' || trace.interactionType === 'auth_guard') {
110
+ const guardMeta = trace.authGuard || {};
111
+ evidence.url = guardMeta.url || null;
112
+ evidence.isProtected = guardMeta.isProtected || false;
113
+ evidence.redirectedToLogin = guardMeta.redirectedToLogin || false;
114
+ evidence.hasAccessDenied = guardMeta.hasAccessDenied || false;
115
+ evidence.httpStatus = guardMeta.httpStatus || null;
116
+ const notProtected = !guardMeta.isProtected;
117
+ if (guardMeta.url && notProtected && guardMeta.httpStatus !== 401 && guardMeta.httpStatus !== 403) {
118
+ findingType = 'protected_route_silent_failure';
119
+ reason = 'Route expected to be protected was accessible without authentication';
120
+ }
121
+ }
122
+
123
+ if (findingType) {
124
+ const finding = {
125
+ type: findingType,
126
+ interaction: {
127
+ type: interaction.type,
128
+ selector: interaction.selector,
129
+ label: interaction.label
130
+ },
131
+ interactionType: interaction.type,
132
+ reason,
133
+ evidence
134
+ };
135
+
136
+ finding.confidence = computeConfidence({
137
+ findingType: 'no_effect_silent_failure',
138
+ expectation: { expectationStrength: 'OBSERVED' },
139
+ sensors: {
140
+ network,
141
+ console: sensors.console || {},
142
+ uiSignals: uiSignals
143
+ },
144
+ comparisons: {
145
+ hasUrlChange: urlChanged,
146
+ hasDomChange: domChanged,
147
+ hasVisibleChange: false
148
+ },
149
+ attemptMeta: {}
150
+ });
151
+
152
+ enrichFindingWithExplanations(finding, trace);
153
+ interactiveFindings.push(finding);
154
+ }
155
+ }
156
+
157
+ // ASYNC INTELLIGENCE: Detect partial success, loading stuck, and async state mismatch
158
+ // These detections apply to ALL interaction types, not just keyboard/hover/auth
159
+ {
160
+ const sensors = trace.sensors || {};
161
+ const uiSignals = sensors.uiSignals || {};
162
+ const uiDiff = uiSignals.diff || uiSignals.changes || {};
163
+ const uiChanged = uiDiff.changed === true;
164
+ const domChanged = hasDomChange(trace);
165
+ const urlChanged = hasMeaningfulUrlChange(beforeUrl, afterUrl);
166
+ const network = sensors.network || {};
167
+ const _hasNetwork = (network.totalRequests || 0) > 0;
168
+ const loading = sensors.loading || {};
169
+ const stateData = sensors.state || {};
170
+
171
+ // Detection: partial_success_silent_failure
172
+ // Network request succeeded (2xx) but no observable effect
173
+ const hasSuccessfulNetwork = (network.successfulRequests && network.successfulRequests > 0) ||
174
+ (network.topFailedUrls && network.topFailedUrls.length === 0 && network.totalRequests > 0);
175
+ if (hasSuccessfulNetwork && !domChanged && !uiChanged && !urlChanged) {
176
+ const partialFinding = {
177
+ type: 'partial_success_silent_failure',
178
+ interaction: {
179
+ type: interaction.type,
180
+ selector: interaction.selector,
181
+ label: interaction.label
182
+ },
183
+ reason: 'Network request succeeded (2xx) but produced no DOM, UI, or URL change',
184
+ evidence: {
185
+ before: beforeScreenshot,
186
+ after: afterScreenshot,
187
+ beforeUrl,
188
+ afterUrl,
189
+ networkRequests: network.totalRequests || 0,
190
+ networkSuccessful: hasSuccessfulNetwork,
191
+ domChanged: false,
192
+ uiChanged: false,
193
+ urlChanged: false
194
+ }
195
+ };
196
+
197
+ partialFinding.confidence = computeConfidence({
198
+ findingType: 'partial_success_silent_failure',
199
+ expectation: { expectationStrength: 'OBSERVED' },
200
+ sensors: { network, console: sensors.console || {}, uiSignals: uiSignals },
201
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
202
+ attemptMeta: {}
203
+ });
204
+
205
+ enrichFindingWithExplanations(partialFinding, trace);
206
+
207
+ interactiveFindings.push(partialFinding);
208
+ }
209
+
210
+ // Detection: loading_stuck_silent_failure
211
+ // Loading indicator present but not resolved within timeout
212
+ if (loading.unresolved === true && (loading.isLoading === true || loading.timeout === true)) {
213
+ const loadingStuckFinding = {
214
+ type: 'loading_stuck_silent_failure',
215
+ interaction: {
216
+ type: interaction.type,
217
+ selector: interaction.selector,
218
+ label: interaction.label
219
+ },
220
+ reason: 'Loading indicator detected but did not resolve within deterministic timeout (5s)',
221
+ evidence: {
222
+ before: beforeScreenshot,
223
+ after: afterScreenshot,
224
+ beforeUrl,
225
+ afterUrl,
226
+ loadingIndicators: loading.loadingIndicators || [],
227
+ duration: loading.duration || 0,
228
+ timeout: true
229
+ }
230
+ };
231
+
232
+ loadingStuckFinding.confidence = computeConfidence({
233
+ findingType: 'loading_stuck_silent_failure',
234
+ expectation: { expectationStrength: 'OBSERVED' },
235
+ sensors: { network, console: sensors.console || {}, uiSignals: uiSignals },
236
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
237
+ attemptMeta: {}
238
+ });
239
+
240
+ enrichFindingWithExplanations(loadingStuckFinding, trace);
241
+
242
+ interactiveFindings.push(loadingStuckFinding);
243
+ }
244
+
245
+ // Detection: async_state_silent_failure
246
+ // State/storage changed but UI did not reflect it
247
+ const stateChanged = stateData.changed && stateData.changed.length > 0;
248
+ if (stateChanged && !uiChanged && !domChanged) {
249
+ const asyncStateFinding = {
250
+ type: 'async_state_silent_failure',
251
+ interaction: {
252
+ type: interaction.type,
253
+ selector: interaction.selector,
254
+ label: interaction.label
255
+ },
256
+ reason: 'Application state changed but no DOM or UI change was observed',
257
+ evidence: {
258
+ before: beforeScreenshot,
259
+ after: afterScreenshot,
260
+ beforeUrl,
261
+ afterUrl,
262
+ stateChanged: stateChanged,
263
+ changedProperties: stateData.changed || [],
264
+ storeType: stateData.storeType || 'unknown',
265
+ domChanged: false,
266
+ uiChanged: false
267
+ }
268
+ };
269
+
270
+ asyncStateFinding.confidence = computeConfidence({
271
+ findingType: 'async_state_silent_failure',
272
+ expectation: { expectationStrength: 'OBSERVED' },
273
+ sensors: { network, console: sensors.console || {}, uiSignals: uiSignals, state: stateData },
274
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
275
+ attemptMeta: {}
276
+ });
277
+
278
+ enrichFindingWithExplanations(asyncStateFinding, trace);
279
+
280
+ interactiveFindings.push(asyncStateFinding);
281
+ }
282
+ }
283
+
284
+ // A11Y INTELLIGENCE: Detect accessibility-related silent failures
285
+ {
286
+ const sensors = trace.sensors || {};
287
+ const focus = sensors.focus || {};
288
+ const aria = sensors.aria || {};
289
+
290
+ // Detection: focus_silent_failure
291
+ // Focus lost (moved to body/null) after interaction
292
+ if (focus.after && (focus.after.selector === 'body' || focus.after.selector === 'null') &&
293
+ !['body', 'null'].includes(focus.before?.selector)) {
294
+ const focusLossFinding = {
295
+ type: 'focus_silent_failure',
296
+ interaction: {
297
+ type: interaction.type,
298
+ selector: interaction.selector,
299
+ label: interaction.label
300
+ },
301
+ reason: 'Focus was lost after interaction (moved to body or null)',
302
+ evidence: {
303
+ before: beforeScreenshot,
304
+ after: afterScreenshot,
305
+ beforeUrl,
306
+ afterUrl,
307
+ focusBefore: focus.before?.selector || 'unknown',
308
+ focusAfter: focus.after?.selector || 'unknown',
309
+ focusLost: true
310
+ }
311
+ };
312
+
313
+ focusLossFinding.confidence = computeConfidence({
314
+ findingType: 'focus_silent_failure',
315
+ expectation: { expectationStrength: 'OBSERVED' },
316
+ sensors: { network: sensors.network || {}, console: sensors.console || {} },
317
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
318
+ attemptMeta: {}
319
+ });
320
+
321
+ enrichFindingWithExplanations(focusLossFinding, trace);
322
+
323
+ interactiveFindings.push(focusLossFinding);
324
+ }
325
+
326
+ // Detection: focus_silent_failure - Modal focus failure
327
+ // Modal/dialog opened but focus didn't move into it
328
+ if (focus.after && focus.after.hasModal === true && focus.after.focusInModal === false) {
329
+ // Modal is present but focus is not within it
330
+ // Check if focus changed (modal was likely just opened)
331
+ const focusChanged = focus.before?.selector !== focus.after?.selector;
332
+ if (focusChanged || focus.before?.hasModal !== true) {
333
+ // Modal opened but focus didn't move into it
334
+ const modalFocusFinding = {
335
+ type: 'focus_silent_failure',
336
+ interaction: {
337
+ type: interaction.type,
338
+ selector: interaction.selector,
339
+ label: interaction.label
340
+ },
341
+ reason: 'Modal/dialog opened but focus did not move into it',
342
+ evidence: {
343
+ before: beforeScreenshot,
344
+ after: afterScreenshot,
345
+ beforeUrl,
346
+ afterUrl,
347
+ focusBefore: focus.before?.selector || 'unknown',
348
+ focusAfter: focus.after?.selector || 'unknown',
349
+ modalOpened: true,
350
+ focusInModal: false
351
+ }
352
+ };
353
+
354
+ modalFocusFinding.confidence = computeConfidence({
355
+ findingType: 'focus_silent_failure',
356
+ expectation: { expectationStrength: 'OBSERVED' },
357
+ sensors: { network: sensors.network || {}, console: sensors.console || {} },
358
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
359
+ attemptMeta: {}
360
+ });
361
+
362
+ enrichFindingWithExplanations(modalFocusFinding, trace);
363
+
364
+ interactiveFindings.push(modalFocusFinding);
365
+ }
366
+ }
367
+
368
+ // Detection: aria_announce_silent_failure
369
+ // Meaningful event occurred but ARIA state didn't change
370
+ const network = sensors.network || {};
371
+ const hasNetwork = (network.totalRequests || 0) > 0;
372
+ const ariaChanged = aria.changed === true;
373
+
374
+ // Form submission, network success, or validation should trigger ARIA
375
+ if ((interaction.type === 'form' || hasNetwork) && !ariaChanged) {
376
+ const missingAnnouncementFinding = {
377
+ type: 'aria_announce_silent_failure',
378
+ interaction: {
379
+ type: interaction.type,
380
+ selector: interaction.selector,
381
+ label: interaction.label
382
+ },
383
+ reason: 'Meaningful event occurred but no ARIA announcement was detected',
384
+ evidence: {
385
+ before: beforeScreenshot,
386
+ after: afterScreenshot,
387
+ beforeUrl,
388
+ afterUrl,
389
+ eventType: interaction.type === 'form' ? 'form_submission' : 'network_activity',
390
+ ariaChangedBefore: aria.before?.statusRoles?.length || 0,
391
+ ariaChangedAfter: aria.after?.statusRoles?.length || 0,
392
+ liveRegionsBefore: aria.before?.liveRegions?.length || 0,
393
+ liveRegionsAfter: aria.after?.liveRegions?.length || 0,
394
+ ariaChanged: false
395
+ }
396
+ };
397
+
398
+ missingAnnouncementFinding.confidence = computeConfidence({
399
+ findingType: 'aria_announce_silent_failure',
400
+ expectation: { expectationStrength: 'OBSERVED' },
401
+ sensors: { network, console: sensors.console || {} },
402
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
403
+ attemptMeta: {}
404
+ });
405
+
406
+ enrichFindingWithExplanations(missingAnnouncementFinding, trace);
407
+
408
+ interactiveFindings.push(missingAnnouncementFinding);
409
+ }
410
+
411
+ // Detection: keyboard_trap_silent_failure
412
+ // Keyboard navigation traps focus within small set of elements
413
+ if (interaction.type === 'keyboard' && trace.keyboard) {
414
+ const focusSequence = trace.keyboard.focusOrder || [];
415
+
416
+ // Check if focus cycles within small set (trap)
417
+ if (focusSequence.length >= 4) {
418
+ const uniqueElements = new Set(focusSequence);
419
+
420
+ // If we have many steps but few unique elements, it's a trap
421
+ if (uniqueElements.size <= 3 && focusSequence.length >= 6) {
422
+ const keyboardTrapFinding = {
423
+ type: 'keyboard_trap_silent_failure',
424
+ interaction: {
425
+ type: interaction.type,
426
+ selector: interaction.selector,
427
+ label: interaction.label
428
+ },
429
+ reason: 'Keyboard navigation trapped focus within small set of elements',
430
+ evidence: {
431
+ before: beforeScreenshot,
432
+ after: afterScreenshot,
433
+ beforeUrl,
434
+ afterUrl,
435
+ focusSequence: focusSequence,
436
+ uniqueElements: Array.from(uniqueElements),
437
+ sequenceLength: focusSequence.length,
438
+ uniqueCount: uniqueElements.size
439
+ }
440
+ };
441
+
442
+ keyboardTrapFinding.confidence = computeConfidence({
443
+ findingType: 'keyboard_trap_silent_failure',
444
+ expectation: { expectationStrength: 'OBSERVED' },
445
+ sensors: { network: sensors.network || {}, console: sensors.console || {} },
446
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
447
+ attemptMeta: {}
448
+ });
449
+
450
+ enrichFindingWithExplanations(keyboardTrapFinding, trace);
451
+
452
+ interactiveFindings.push(keyboardTrapFinding);
453
+ }
454
+ }
455
+
456
+ // Check for consecutive repeats (same element repeatedly)
457
+ if (focusSequence.length >= 3) {
458
+ let consecutiveRepeats = 0;
459
+ for (let i = 1; i < focusSequence.length; i++) {
460
+ if (focusSequence[i] === focusSequence[i - 1]) {
461
+ consecutiveRepeats++;
462
+ }
463
+ }
464
+
465
+ if (consecutiveRepeats >= 2) {
466
+ const keyboardTrapFinding = {
467
+ type: 'keyboard_trap_silent_failure',
468
+ interaction: {
469
+ type: interaction.type,
470
+ selector: interaction.selector,
471
+ label: interaction.label
472
+ },
473
+ reason: 'Keyboard navigation stuck on same element repeatedly',
474
+ evidence: {
475
+ before: beforeScreenshot,
476
+ after: afterScreenshot,
477
+ beforeUrl,
478
+ afterUrl,
479
+ focusSequence: focusSequence,
480
+ consecutiveRepeats: consecutiveRepeats
481
+ }
482
+ };
483
+
484
+ keyboardTrapFinding.confidence = computeConfidence({
485
+ findingType: 'keyboard_trap_silent_failure',
486
+ expectation: { expectationStrength: 'OBSERVED' },
487
+ sensors: { network: sensors.network || {}, console: sensors.console || {} },
488
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
489
+ attemptMeta: {}
490
+ });
491
+
492
+ enrichFindingWithExplanations(keyboardTrapFinding, trace);
493
+
494
+ interactiveFindings.push(keyboardTrapFinding);
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ // PERFORMANCE INTELLIGENCE: Detect feedback gap silent failures
501
+ {
502
+ const sensors = trace.sensors || {};
503
+ const timing = sensors.timing || {};
504
+ const network = sensors.network || {};
505
+
506
+ // Detection: feedback_gap_silent_failure
507
+ // Interaction triggered work (network OR loading) but no user feedback appeared within 1500ms
508
+ // Work must have started (network or loading), and feedback must be missing or too late
509
+ const loadingIndicators = sensors.loading || {};
510
+ const workStarted = timing.networkActivityDetected || (loadingIndicators && loadingIndicators.hasLoadingIndicators);
511
+
512
+ if (workStarted && timing.feedbackDelayMs !== undefined) {
513
+ const hasFeedbackGap =
514
+ !timing.feedbackDetected ||
515
+ timing.feedbackDelayMs > (timing.feedbackGapThreshold || 1500);
516
+
517
+ if (hasFeedbackGap) {
518
+ const feedbackGapFinding = {
519
+ type: 'feedback_gap_silent_failure',
520
+ interaction: {
521
+ type: interaction.type,
522
+ selector: interaction.selector,
523
+ label: interaction.label
524
+ },
525
+ reason: `Interaction started work but no user feedback appeared within ${timing.feedbackGapThreshold}ms`,
526
+ evidence: {
527
+ before: beforeScreenshot,
528
+ after: afterScreenshot,
529
+ beforeUrl,
530
+ afterUrl,
531
+ timingBreakdown: {
532
+ interactionStartMs: 0,
533
+ networkStartMs: timing.workStartMs,
534
+ feedbackStartMs: timing.feedbackDetected ? timing.feedbackDelayMs : -1,
535
+ totalElapsedMs: timing.elapsedMs
536
+ },
537
+ feedbackDetected: timing.feedbackDetected,
538
+ feedbackDelayMs: timing.feedbackDelayMs,
539
+ networkActivityDetected: timing.networkActivityDetected,
540
+ workStartMs: timing.workStartMs,
541
+ feedbackGapThreshold: timing.feedbackGapThreshold,
542
+ missingFeedback: {
543
+ loadingIndicator: !timing.tLoadingStart,
544
+ ariaAnnouncement: !timing.tAriaFirst,
545
+ uiChange: !timing.tUiFirst
546
+ }
547
+ }
548
+ };
549
+
550
+ feedbackGapFinding.confidence = computeConfidence({
551
+ findingType: 'feedback_gap_silent_failure',
552
+ expectation: { expectationStrength: 'OBSERVED' },
553
+ sensors: { network, console: sensors.console || {} },
554
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
555
+ attemptMeta: {}
556
+ });
557
+
558
+ enrichFindingWithExplanations(feedbackGapFinding, trace);
559
+
560
+ interactiveFindings.push(feedbackGapFinding);
561
+ }
562
+ }
563
+
564
+ // Detection: freeze_like_silent_failure
565
+ // Interaction triggered work but significant delay (>3000ms) before any feedback
566
+ // Only detect if feedback WAS eventually detected (not missing entirely)
567
+ if (timing.networkActivityDetected && timing.isFreezeLike && timing.feedbackDetected) {
568
+ const freezeLikeFinding = {
569
+ type: 'freeze_like_silent_failure',
570
+ interaction: {
571
+ type: interaction.type,
572
+ selector: interaction.selector,
573
+ label: interaction.label
574
+ },
575
+ reason: `Interaction caused UI freeze-like behavior: ${timing.feedbackDelayMs}ms delay before feedback`,
576
+ evidence: {
577
+ before: beforeScreenshot,
578
+ after: afterScreenshot,
579
+ beforeUrl,
580
+ afterUrl,
581
+ timingBreakdown: {
582
+ interactionStartMs: 0,
583
+ networkStartMs: timing.workStartMs,
584
+ feedbackStartMs: timing.feedbackDetected ? timing.feedbackDelayMs : -1,
585
+ totalElapsedMs: timing.elapsedMs
586
+ },
587
+ feedbackDelayMs: timing.feedbackDelayMs,
588
+ freezeLikeThreshold: timing.freezeLikeThreshold,
589
+ workStartMs: timing.workStartMs
590
+ }
591
+ };
592
+
593
+ freezeLikeFinding.confidence = computeConfidence({
594
+ findingType: 'freeze_like_silent_failure',
595
+ expectation: { expectationStrength: 'OBSERVED' },
596
+ sensors: { network, console: sensors.console || {} },
597
+ comparisons: { hasUrlChange: false, hasDomChange: false, hasVisibleChange: false },
598
+ attemptMeta: {}
599
+ });
600
+
601
+ enrichFindingWithExplanations(freezeLikeFinding, trace);
602
+
603
+ interactiveFindings.push(freezeLikeFinding);
604
+ }
605
+ }
606
+ }
607
+
608
+ // Merge all detected findings into the main findings array
609
+ findings.push(...interactiveFindings);
610
+
611
+ return interactiveFindings;
612
+ }