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