@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +24 -36
- package/src/cli/commands/default.js +681 -0
- package/src/cli/commands/doctor.js +197 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +586 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +297 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +110 -0
- package/src/cli/util/expectation-extractor.js +388 -0
- package/src/cli/util/findings-writer.js +32 -0
- package/src/cli/util/idgen.js +87 -0
- package/src/cli/util/learn-writer.js +39 -0
- package/src/cli/util/observation-engine.js +412 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +30 -0
- package/src/cli/util/project-discovery.js +297 -0
- package/src/cli/util/project-writer.js +26 -0
- package/src/cli/util/redact.js +128 -0
- package/src/cli/util/run-id.js +30 -0
- package/src/cli/util/runtime-budget.js +147 -0
- package/src/cli/util/summary-writer.js +43 -0
- package/src/types/global.d.ts +28 -0
- package/src/types/ts-ast.d.ts +24 -0
- package/src/verax/cli/ci-summary.js +35 -0
- package/src/verax/cli/context-explanation.js +89 -0
- package/src/verax/cli/doctor.js +277 -0
- package/src/verax/cli/error-normalizer.js +154 -0
- package/src/verax/cli/explain-output.js +105 -0
- package/src/verax/cli/finding-explainer.js +130 -0
- package/src/verax/cli/init.js +237 -0
- package/src/verax/cli/run-overview.js +163 -0
- package/src/verax/cli/url-safety.js +111 -0
- package/src/verax/cli/wizard.js +109 -0
- package/src/verax/cli/zero-findings-explainer.js +57 -0
- package/src/verax/cli/zero-interaction-explainer.js +127 -0
- package/src/verax/core/action-classifier.js +86 -0
- package/src/verax/core/budget-engine.js +218 -0
- package/src/verax/core/canonical-outcomes.js +157 -0
- package/src/verax/core/decision-snapshot.js +335 -0
- package/src/verax/core/determinism-model.js +432 -0
- package/src/verax/core/incremental-store.js +245 -0
- package/src/verax/core/invariants.js +356 -0
- package/src/verax/core/promise-model.js +230 -0
- package/src/verax/core/replay-validator.js +350 -0
- package/src/verax/core/replay.js +222 -0
- package/src/verax/core/run-id.js +175 -0
- package/src/verax/core/run-manifest.js +99 -0
- package/src/verax/core/silence-impact.js +369 -0
- package/src/verax/core/silence-model.js +523 -0
- package/src/verax/detect/comparison.js +7 -34
- package/src/verax/detect/confidence-engine.js +764 -329
- package/src/verax/detect/detection-engine.js +293 -0
- package/src/verax/detect/evidence-index.js +127 -0
- package/src/verax/detect/expectation-model.js +241 -168
- package/src/verax/detect/explanation-helpers.js +187 -0
- package/src/verax/detect/finding-detector.js +450 -0
- package/src/verax/detect/findings-writer.js +41 -12
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +200 -288
- package/src/verax/detect/interactive-findings.js +612 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/skip-classifier.js +4 -4
- package/src/verax/detect/verdict-engine.js +561 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/flow/flow-engine.js +3 -2
- package/src/verax/flow/flow-spec.js +1 -2
- package/src/verax/index.js +103 -15
- package/src/verax/intel/effect-detector.js +368 -0
- package/src/verax/intel/handler-mapper.js +249 -0
- package/src/verax/intel/index.js +281 -0
- package/src/verax/intel/route-extractor.js +280 -0
- package/src/verax/intel/ts-program.js +256 -0
- package/src/verax/intel/vue-navigation-extractor.js +642 -0
- package/src/verax/intel/vue-router-extractor.js +325 -0
- package/src/verax/learn/action-contract-extractor.js +338 -104
- package/src/verax/learn/ast-contract-extractor.js +148 -6
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/index.js +36 -2
- package/src/verax/learn/manifest-writer.js +122 -58
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +28 -97
- package/src/verax/learn/route-validator.js +8 -7
- package/src/verax/learn/state-extractor.js +212 -0
- package/src/verax/learn/static-extractor-navigation.js +114 -0
- package/src/verax/learn/static-extractor-validation.js +88 -0
- package/src/verax/learn/static-extractor.js +119 -10
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/learn/ts-contract-resolver.js +14 -12
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +30 -6
- package/src/verax/observe/console-sensor.js +2 -18
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +513 -0
- package/src/verax/observe/flow-matcher.js +143 -0
- package/src/verax/observe/focus-sensor.js +196 -0
- package/src/verax/observe/human-driver.js +660 -273
- package/src/verax/observe/index.js +910 -26
- package/src/verax/observe/interaction-discovery.js +378 -15
- package/src/verax/observe/interaction-runner.js +562 -197
- package/src/verax/observe/loading-sensor.js +145 -0
- package/src/verax/observe/navigation-sensor.js +255 -0
- package/src/verax/observe/network-sensor.js +55 -7
- package/src/verax/observe/observed-expectation-deriver.js +186 -0
- package/src/verax/observe/observed-expectation.js +305 -0
- package/src/verax/observe/page-frontier.js +234 -0
- package/src/verax/observe/settle.js +38 -17
- package/src/verax/observe/state-sensor.js +393 -0
- package/src/verax/observe/state-ui-sensor.js +7 -1
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +73 -21
- package/src/verax/observe/ui-signal-sensor.js +143 -17
- package/src/verax/scan-summary-writer.js +80 -15
- package/src/verax/shared/artifact-manager.js +111 -9
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/caching.js +1 -1
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +169 -0
- package/src/verax/shared/dynamic-route-utils.js +224 -0
- package/src/verax/shared/expectation-coverage.js +44 -0
- package/src/verax/shared/expectation-prover.js +81 -0
- package/src/verax/shared/expectation-tracker.js +201 -0
- package/src/verax/shared/expectations-writer.js +60 -0
- package/src/verax/shared/first-run.js +44 -0
- package/src/verax/shared/progress-reporter.js +171 -0
- package/src/verax/shared/retry-policy.js +9 -1
- package/src/verax/shared/root-artifacts.js +49 -0
- package/src/verax/shared/scan-budget.js +86 -0
- package/src/verax/shared/url-normalizer.js +162 -0
- package/src/verax/shared/zip-artifacts.js +66 -0
- 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
|
+
}
|