@veraxhq/verax 0.2.1 → 0.3.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.
- package/README.md +14 -18
- package/bin/verax.js +7 -0
- package/package.json +3 -3
- package/src/cli/commands/baseline.js +104 -0
- package/src/cli/commands/default.js +79 -25
- package/src/cli/commands/ga.js +243 -0
- package/src/cli/commands/gates.js +95 -0
- package/src/cli/commands/inspect.js +131 -2
- package/src/cli/commands/release-check.js +213 -0
- package/src/cli/commands/run.js +246 -35
- package/src/cli/commands/security-check.js +211 -0
- package/src/cli/commands/truth.js +114 -0
- package/src/cli/entry.js +304 -67
- package/src/cli/util/angular-component-extractor.js +179 -0
- package/src/cli/util/angular-navigation-detector.js +141 -0
- package/src/cli/util/angular-network-detector.js +161 -0
- package/src/cli/util/angular-state-detector.js +162 -0
- package/src/cli/util/ast-interactive-detector.js +546 -0
- package/src/cli/util/ast-network-detector.js +603 -0
- package/src/cli/util/ast-usestate-detector.js +602 -0
- package/src/cli/util/bootstrap-guard.js +86 -0
- package/src/cli/util/determinism-runner.js +123 -0
- package/src/cli/util/determinism-writer.js +129 -0
- package/src/cli/util/env-url.js +4 -0
- package/src/cli/util/expectation-extractor.js +369 -73
- package/src/cli/util/findings-writer.js +126 -16
- package/src/cli/util/learn-writer.js +3 -1
- package/src/cli/util/observe-writer.js +3 -1
- package/src/cli/util/paths.js +3 -12
- package/src/cli/util/project-discovery.js +3 -0
- package/src/cli/util/project-writer.js +3 -1
- package/src/cli/util/run-resolver.js +64 -0
- package/src/cli/util/source-requirement.js +55 -0
- package/src/cli/util/summary-writer.js +1 -0
- package/src/cli/util/svelte-navigation-detector.js +163 -0
- package/src/cli/util/svelte-network-detector.js +80 -0
- package/src/cli/util/svelte-sfc-extractor.js +147 -0
- package/src/cli/util/svelte-state-detector.js +243 -0
- package/src/cli/util/vue-navigation-detector.js +177 -0
- package/src/cli/util/vue-sfc-extractor.js +162 -0
- package/src/cli/util/vue-state-detector.js +215 -0
- package/src/verax/cli/finding-explainer.js +56 -3
- package/src/verax/core/artifacts/registry.js +154 -0
- package/src/verax/core/artifacts/verifier.js +980 -0
- package/src/verax/core/baseline/baseline.enforcer.js +137 -0
- package/src/verax/core/baseline/baseline.snapshot.js +231 -0
- package/src/verax/core/capabilities/gates.js +499 -0
- package/src/verax/core/capabilities/registry.js +475 -0
- package/src/verax/core/confidence/confidence-compute.js +137 -0
- package/src/verax/core/confidence/confidence-invariants.js +234 -0
- package/src/verax/core/confidence/confidence-report-writer.js +112 -0
- package/src/verax/core/confidence/confidence-weights.js +44 -0
- package/src/verax/core/confidence/confidence.defaults.js +65 -0
- package/src/verax/core/confidence/confidence.loader.js +79 -0
- package/src/verax/core/confidence/confidence.schema.js +94 -0
- package/src/verax/core/confidence-engine-refactor.js +484 -0
- package/src/verax/core/confidence-engine.js +486 -0
- package/src/verax/core/confidence-engine.js.backup +471 -0
- package/src/verax/core/contracts/index.js +29 -0
- package/src/verax/core/contracts/types.js +185 -0
- package/src/verax/core/contracts/validators.js +381 -0
- package/src/verax/core/decision-snapshot.js +30 -3
- package/src/verax/core/decisions/decision.trace.js +276 -0
- package/src/verax/core/determinism/contract-writer.js +89 -0
- package/src/verax/core/determinism/contract.js +139 -0
- package/src/verax/core/determinism/diff.js +364 -0
- package/src/verax/core/determinism/engine.js +221 -0
- package/src/verax/core/determinism/finding-identity.js +148 -0
- package/src/verax/core/determinism/normalize.js +438 -0
- package/src/verax/core/determinism/report-writer.js +92 -0
- package/src/verax/core/determinism/run-fingerprint.js +118 -0
- package/src/verax/core/dynamic-route-intelligence.js +528 -0
- package/src/verax/core/evidence/evidence-capture-service.js +307 -0
- package/src/verax/core/evidence/evidence-intent-ledger.js +165 -0
- package/src/verax/core/evidence-builder.js +487 -0
- package/src/verax/core/execution-mode-context.js +77 -0
- package/src/verax/core/execution-mode-detector.js +190 -0
- package/src/verax/core/failures/exit-codes.js +86 -0
- package/src/verax/core/failures/failure-summary.js +76 -0
- package/src/verax/core/failures/failure.factory.js +225 -0
- package/src/verax/core/failures/failure.ledger.js +132 -0
- package/src/verax/core/failures/failure.types.js +196 -0
- package/src/verax/core/failures/index.js +10 -0
- package/src/verax/core/ga/ga-report-writer.js +43 -0
- package/src/verax/core/ga/ga.artifact.js +49 -0
- package/src/verax/core/ga/ga.contract.js +434 -0
- package/src/verax/core/ga/ga.enforcer.js +86 -0
- package/src/verax/core/guardrails/guardrails-report-writer.js +109 -0
- package/src/verax/core/guardrails/policy.defaults.js +210 -0
- package/src/verax/core/guardrails/policy.loader.js +83 -0
- package/src/verax/core/guardrails/policy.schema.js +110 -0
- package/src/verax/core/guardrails/truth-reconciliation.js +136 -0
- package/src/verax/core/guardrails-engine.js +505 -0
- package/src/verax/core/observe/run-timeline.js +316 -0
- package/src/verax/core/perf/perf.contract.js +186 -0
- package/src/verax/core/perf/perf.display.js +65 -0
- package/src/verax/core/perf/perf.enforcer.js +91 -0
- package/src/verax/core/perf/perf.monitor.js +209 -0
- package/src/verax/core/perf/perf.report.js +198 -0
- package/src/verax/core/pipeline-tracker.js +238 -0
- package/src/verax/core/product-definition.js +127 -0
- package/src/verax/core/release/provenance.builder.js +271 -0
- package/src/verax/core/release/release-report-writer.js +40 -0
- package/src/verax/core/release/release.enforcer.js +159 -0
- package/src/verax/core/release/reproducibility.check.js +221 -0
- package/src/verax/core/release/sbom.builder.js +283 -0
- package/src/verax/core/report/cross-index.js +192 -0
- package/src/verax/core/report/human-summary.js +222 -0
- package/src/verax/core/route-intelligence.js +419 -0
- package/src/verax/core/security/secrets.scan.js +326 -0
- package/src/verax/core/security/security-report.js +50 -0
- package/src/verax/core/security/security.enforcer.js +124 -0
- package/src/verax/core/security/supplychain.defaults.json +38 -0
- package/src/verax/core/security/supplychain.policy.js +326 -0
- package/src/verax/core/security/vuln.scan.js +265 -0
- package/src/verax/core/truth/truth.certificate.js +250 -0
- package/src/verax/core/ui-feedback-intelligence.js +515 -0
- package/src/verax/detect/confidence-engine.js +628 -40
- package/src/verax/detect/confidence-helper.js +33 -0
- package/src/verax/detect/detection-engine.js +18 -1
- package/src/verax/detect/dynamic-route-findings.js +335 -0
- package/src/verax/detect/expectation-chain-detector.js +417 -0
- package/src/verax/detect/expectation-model.js +3 -1
- package/src/verax/detect/findings-writer.js +141 -5
- package/src/verax/detect/index.js +229 -5
- package/src/verax/detect/journey-stall-detector.js +558 -0
- package/src/verax/detect/route-findings.js +218 -0
- package/src/verax/detect/ui-feedback-findings.js +207 -0
- package/src/verax/detect/verdict-engine.js +57 -3
- package/src/verax/detect/view-switch-correlator.js +242 -0
- package/src/verax/index.js +413 -45
- package/src/verax/learn/action-contract-extractor.js +682 -64
- package/src/verax/learn/route-validator.js +4 -1
- package/src/verax/observe/index.js +88 -843
- package/src/verax/observe/interaction-runner.js +25 -8
- package/src/verax/observe/observe-context.js +205 -0
- package/src/verax/observe/observe-helpers.js +191 -0
- package/src/verax/observe/observe-runner.js +226 -0
- package/src/verax/observe/observers/budget-observer.js +185 -0
- package/src/verax/observe/observers/console-observer.js +102 -0
- package/src/verax/observe/observers/coverage-observer.js +107 -0
- package/src/verax/observe/observers/interaction-observer.js +471 -0
- package/src/verax/observe/observers/navigation-observer.js +132 -0
- package/src/verax/observe/observers/network-observer.js +87 -0
- package/src/verax/observe/observers/safety-observer.js +82 -0
- package/src/verax/observe/observers/ui-feedback-observer.js +99 -0
- package/src/verax/observe/ui-feedback-detector.js +742 -0
- package/src/verax/observe/ui-signal-sensor.js +148 -2
- package/src/verax/scan-summary-writer.js +42 -8
- package/src/verax/shared/artifact-manager.js +8 -5
- package/src/verax/shared/css-spinner-rules.js +204 -0
- package/src/verax/shared/view-switch-rules.js +208 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gap 5.1: Runtime UI Feedback Detection
|
|
3
|
+
*
|
|
4
|
+
* Detects strong, evidence-backed UI feedback signals after interactions.
|
|
5
|
+
* Conservative approach: prefers false negatives over false positives.
|
|
6
|
+
*
|
|
7
|
+
* Signals Detected:
|
|
8
|
+
* 1. DOM Change Significance - meaningful changes in viewport/target container
|
|
9
|
+
* 2. Loading Indicators - spinners, skeletons, progressbars with aria/CSS evidence
|
|
10
|
+
* 3. Button State Transitions - disabled/enabled changes, label changes
|
|
11
|
+
* 4. Notifications - toasts, alerts, aria-live updates
|
|
12
|
+
* 5. Navigation - URL changes, history state transitions
|
|
13
|
+
* 6. Focus/Scroll Changes - meaningful focus movements, significant scrolls
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* UI Feedback Detector
|
|
18
|
+
* Captures before/after state and computes feedback signals
|
|
19
|
+
*/
|
|
20
|
+
export class UIFeedbackDetector {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.beforeState = null;
|
|
23
|
+
this.afterState = null;
|
|
24
|
+
this.interactionTarget = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Capture UI state before interaction
|
|
29
|
+
* @param {import('playwright').Page} page - Playwright page
|
|
30
|
+
* @param {Object} options - { targetSelector?: string }
|
|
31
|
+
*/
|
|
32
|
+
async captureBefore(page, options = {}) {
|
|
33
|
+
this.beforeState = await this._captureState(page, options.targetSelector);
|
|
34
|
+
this.interactionTarget = options.targetSelector || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Capture UI state after interaction
|
|
39
|
+
* @param {import('playwright').Page} page - Playwright page
|
|
40
|
+
*/
|
|
41
|
+
async captureAfter(page) {
|
|
42
|
+
this.afterState = await this._captureState(page, this.interactionTarget);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute UI feedback signals from before/after states
|
|
47
|
+
* @returns {Object} Feedback signals with evidence
|
|
48
|
+
*/
|
|
49
|
+
computeFeedbackSignals() {
|
|
50
|
+
if (!this.beforeState || !this.afterState) {
|
|
51
|
+
return this._emptySignals();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const signals = {
|
|
55
|
+
domChange: this._computeDomChangeSignal(),
|
|
56
|
+
loading: this._computeLoadingSignal(),
|
|
57
|
+
buttonStateTransition: this._computeButtonStateSignal(),
|
|
58
|
+
notification: this._computeNotificationSignal(),
|
|
59
|
+
navigation: this._computeNavigationSignal(),
|
|
60
|
+
focusChange: this._computeFocusChangeSignal(),
|
|
61
|
+
scrollChange: this._computeScrollChangeSignal()
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Compute overall feedback score (0..1)
|
|
65
|
+
const overallScore = this._computeOverallScore(signals);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
interactionId: this.beforeState.timestamp,
|
|
69
|
+
signals,
|
|
70
|
+
overallUiFeedbackScore: overallScore,
|
|
71
|
+
_metadata: {
|
|
72
|
+
capturedAt: new Date().toISOString(),
|
|
73
|
+
interactionTarget: this.interactionTarget
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Capture state snapshot from page
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
async _captureState(page, targetSelector = null) {
|
|
83
|
+
try {
|
|
84
|
+
const state = await page.evaluate((selector) => {
|
|
85
|
+
const result = {
|
|
86
|
+
timestamp: Date.now(),
|
|
87
|
+
url: window.location.href,
|
|
88
|
+
|
|
89
|
+
// DOM Structure
|
|
90
|
+
viewport: {
|
|
91
|
+
elementCount: 0,
|
|
92
|
+
textContent: '',
|
|
93
|
+
visibleText: ''
|
|
94
|
+
},
|
|
95
|
+
targetContainer: selector ? {
|
|
96
|
+
elementCount: 0,
|
|
97
|
+
textContent: '',
|
|
98
|
+
innerHTML: ''
|
|
99
|
+
} : null,
|
|
100
|
+
|
|
101
|
+
// Loading Indicators
|
|
102
|
+
loading: {
|
|
103
|
+
ariaBusy: [],
|
|
104
|
+
progressBars: [],
|
|
105
|
+
spinners: [],
|
|
106
|
+
skeletons: []
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Button States
|
|
110
|
+
buttons: [],
|
|
111
|
+
|
|
112
|
+
// Notifications/Alerts
|
|
113
|
+
notifications: {
|
|
114
|
+
alerts: [],
|
|
115
|
+
liveRegions: [],
|
|
116
|
+
toasts: []
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// Navigation
|
|
120
|
+
navigation: {
|
|
121
|
+
pathname: window.location.pathname,
|
|
122
|
+
search: window.location.search,
|
|
123
|
+
hash: window.location.hash
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Focus
|
|
127
|
+
focus: {
|
|
128
|
+
activeElement: null,
|
|
129
|
+
hasFocus: document.hasFocus()
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// Scroll
|
|
133
|
+
scroll: {
|
|
134
|
+
x: window.scrollX || window.pageXOffset || 0,
|
|
135
|
+
y: window.scrollY || window.pageYOffset || 0
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Capture viewport content (above-the-fold)
|
|
140
|
+
const viewportHeight = window.innerHeight;
|
|
141
|
+
const viewportElements = Array.from(document.body?.querySelectorAll('*') || []).filter(el => {
|
|
142
|
+
const rect = el.getBoundingClientRect();
|
|
143
|
+
return rect.top < viewportHeight && rect.bottom > 0;
|
|
144
|
+
});
|
|
145
|
+
result.viewport.elementCount = viewportElements.length;
|
|
146
|
+
result.viewport.visibleText = viewportElements
|
|
147
|
+
.map(el => (el.textContent || '').trim())
|
|
148
|
+
.filter(t => t.length > 0 && t.length < 200)
|
|
149
|
+
.slice(0, 10)
|
|
150
|
+
.join(' | ');
|
|
151
|
+
|
|
152
|
+
// Capture target container if selector provided
|
|
153
|
+
if (selector) {
|
|
154
|
+
try {
|
|
155
|
+
const target = document.querySelector(selector);
|
|
156
|
+
if (target) {
|
|
157
|
+
const containerElements = target.querySelectorAll('*');
|
|
158
|
+
result.targetContainer.elementCount = containerElements.length;
|
|
159
|
+
result.targetContainer.textContent = (target.textContent || '').trim().slice(0, 500);
|
|
160
|
+
result.targetContainer.innerHTML = target.innerHTML.slice(0, 1000);
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// Selector not found - leave null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Detect loading indicators
|
|
168
|
+
// 1. aria-busy
|
|
169
|
+
const ariaBusyElements = Array.from(document.querySelectorAll('[aria-busy="true"]'));
|
|
170
|
+
ariaBusyElements.forEach(el => {
|
|
171
|
+
const style = window.getComputedStyle(el);
|
|
172
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
173
|
+
result.loading.ariaBusy.push({
|
|
174
|
+
tag: el.tagName.toLowerCase(),
|
|
175
|
+
text: (el.textContent || '').trim().slice(0, 50),
|
|
176
|
+
role: el.getAttribute('role')
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// 2. role="progressbar"
|
|
182
|
+
const progressBars = Array.from(document.querySelectorAll('[role="progressbar"]'));
|
|
183
|
+
progressBars.forEach(el => {
|
|
184
|
+
const style = window.getComputedStyle(el);
|
|
185
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
186
|
+
result.loading.progressBars.push({
|
|
187
|
+
valueNow: el.getAttribute('aria-valuenow'),
|
|
188
|
+
valueMin: el.getAttribute('aria-valuemin'),
|
|
189
|
+
valueMax: el.getAttribute('aria-valuemax'),
|
|
190
|
+
label: el.getAttribute('aria-label')
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// 3. Common spinner/loading classes (conservative: require animation)
|
|
196
|
+
const spinnerCandidates = Array.from(document.querySelectorAll(
|
|
197
|
+
'[class*="spinner"], [class*="loading"], [class*="loader"], [data-loading]'
|
|
198
|
+
));
|
|
199
|
+
spinnerCandidates.forEach(el => {
|
|
200
|
+
const style = window.getComputedStyle(el);
|
|
201
|
+
const hasAnimation = style.animationName !== 'none' || style.animationDuration !== '0s';
|
|
202
|
+
if (style.display !== 'none' && style.visibility !== 'hidden' && hasAnimation) {
|
|
203
|
+
result.loading.spinners.push({
|
|
204
|
+
className: el.className.slice(0, 100),
|
|
205
|
+
tag: el.tagName.toLowerCase()
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// 4. Skeleton loaders (conservative: require specific patterns)
|
|
211
|
+
const skeletonCandidates = Array.from(document.querySelectorAll(
|
|
212
|
+
'[class*="skeleton"], [aria-label*="skeleton"], [aria-label*="placeholder"]'
|
|
213
|
+
));
|
|
214
|
+
skeletonCandidates.forEach(el => {
|
|
215
|
+
const style = window.getComputedStyle(el);
|
|
216
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
217
|
+
result.loading.skeletons.push({
|
|
218
|
+
className: el.className.slice(0, 100),
|
|
219
|
+
ariaLabel: el.getAttribute('aria-label')
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Capture button states (all buttons + actionable elements)
|
|
225
|
+
const actionableElements = Array.from(document.querySelectorAll(
|
|
226
|
+
'button, [role="button"], input[type="submit"], input[type="button"], a[role="button"]'
|
|
227
|
+
));
|
|
228
|
+
actionableElements.forEach(el => {
|
|
229
|
+
const style = window.getComputedStyle(el);
|
|
230
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
231
|
+
result.buttons.push({
|
|
232
|
+
selector: el.id ? `#${el.id}` : (el.className ? `.${el.className.split(' ')[0]}` : el.tagName.toLowerCase()),
|
|
233
|
+
text: (el.textContent || '').trim().slice(0, 100),
|
|
234
|
+
disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true',
|
|
235
|
+
ariaBusy: el.getAttribute('aria-busy'),
|
|
236
|
+
tag: el.tagName.toLowerCase(),
|
|
237
|
+
type: el.getAttribute('type')
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Capture notifications/alerts
|
|
243
|
+
// 1. role="alert"
|
|
244
|
+
const alerts = Array.from(document.querySelectorAll('[role="alert"]'));
|
|
245
|
+
alerts.forEach(el => {
|
|
246
|
+
const style = window.getComputedStyle(el);
|
|
247
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
248
|
+
result.notifications.alerts.push({
|
|
249
|
+
text: (el.textContent || '').trim().slice(0, 200),
|
|
250
|
+
className: el.className.slice(0, 100)
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// 2. aria-live regions
|
|
256
|
+
const liveRegions = Array.from(document.querySelectorAll('[aria-live]'));
|
|
257
|
+
liveRegions.forEach(el => {
|
|
258
|
+
const style = window.getComputedStyle(el);
|
|
259
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
260
|
+
result.notifications.liveRegions.push({
|
|
261
|
+
text: (el.textContent || '').trim().slice(0, 200),
|
|
262
|
+
liveValue: el.getAttribute('aria-live'),
|
|
263
|
+
role: el.getAttribute('role')
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// 3. Toast/snackbar patterns (conservative: require visibility + specific classes)
|
|
269
|
+
const toastCandidates = Array.from(document.querySelectorAll(
|
|
270
|
+
'[class*="toast"], [class*="snackbar"], [class*="notification"], [class*="alert"]'
|
|
271
|
+
));
|
|
272
|
+
toastCandidates.forEach(el => {
|
|
273
|
+
const style = window.getComputedStyle(el);
|
|
274
|
+
const rect = el.getBoundingClientRect();
|
|
275
|
+
// Toast should be visible and positioned (not in normal flow)
|
|
276
|
+
const isPositioned = style.position === 'fixed' || style.position === 'absolute';
|
|
277
|
+
if (style.display !== 'none' && style.visibility !== 'hidden' && isPositioned && rect.width > 0) {
|
|
278
|
+
result.notifications.toasts.push({
|
|
279
|
+
text: (el.textContent || '').trim().slice(0, 200),
|
|
280
|
+
className: el.className.slice(0, 100),
|
|
281
|
+
position: style.position
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Capture active element
|
|
287
|
+
if (document.activeElement && document.activeElement !== document.body) {
|
|
288
|
+
const activeEl = document.activeElement;
|
|
289
|
+
result.focus.activeElement = {
|
|
290
|
+
tag: activeEl.tagName.toLowerCase(),
|
|
291
|
+
id: activeEl.id || null,
|
|
292
|
+
className: activeEl.className ? activeEl.className.slice(0, 100) : null,
|
|
293
|
+
type: activeEl.getAttribute('type'),
|
|
294
|
+
name: activeEl.getAttribute('name'),
|
|
295
|
+
role: activeEl.getAttribute('role'),
|
|
296
|
+
ariaLabel: activeEl.getAttribute('aria-label')
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return result;
|
|
301
|
+
}, targetSelector);
|
|
302
|
+
|
|
303
|
+
return state;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
// Return minimal state on error
|
|
306
|
+
return {
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
url: '',
|
|
309
|
+
viewport: { elementCount: 0, textContent: '', visibleText: '' },
|
|
310
|
+
targetContainer: null,
|
|
311
|
+
loading: { ariaBusy: [], progressBars: [], spinners: [], skeletons: [] },
|
|
312
|
+
buttons: [],
|
|
313
|
+
notifications: { alerts: [], liveRegions: [], toasts: [] },
|
|
314
|
+
navigation: { pathname: '', search: '', hash: '' },
|
|
315
|
+
focus: { activeElement: null, hasFocus: false },
|
|
316
|
+
scroll: { x: 0, y: 0 },
|
|
317
|
+
_error: error.message
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Compute DOM change signal
|
|
324
|
+
* @private
|
|
325
|
+
*/
|
|
326
|
+
_computeDomChangeSignal() {
|
|
327
|
+
const before = this.beforeState;
|
|
328
|
+
const after = this.afterState;
|
|
329
|
+
|
|
330
|
+
// Check viewport changes
|
|
331
|
+
const viewportElementDelta = Math.abs(after.viewport.elementCount - before.viewport.elementCount);
|
|
332
|
+
const viewportTextChanged = before.viewport.visibleText !== after.viewport.visibleText;
|
|
333
|
+
|
|
334
|
+
// Check target container changes (if available)
|
|
335
|
+
let targetChanged = false;
|
|
336
|
+
let targetScore = 0;
|
|
337
|
+
const targetEvidence = [];
|
|
338
|
+
|
|
339
|
+
if (before.targetContainer && after.targetContainer) {
|
|
340
|
+
const targetElementDelta = Math.abs(after.targetContainer.elementCount - before.targetContainer.elementCount);
|
|
341
|
+
const targetTextChanged = before.targetContainer.textContent !== after.targetContainer.textContent;
|
|
342
|
+
|
|
343
|
+
targetChanged = targetElementDelta > 0 || targetTextChanged;
|
|
344
|
+
|
|
345
|
+
if (targetElementDelta > 5) {
|
|
346
|
+
targetScore += 0.4;
|
|
347
|
+
targetEvidence.push(`${targetElementDelta} elements added/removed in target`);
|
|
348
|
+
} else if (targetElementDelta > 0) {
|
|
349
|
+
targetScore += 0.2;
|
|
350
|
+
targetEvidence.push(`${targetElementDelta} element(s) changed in target`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (targetTextChanged) {
|
|
354
|
+
targetScore += 0.3;
|
|
355
|
+
targetEvidence.push('Text content changed in target');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Compute viewport score
|
|
360
|
+
let viewportScore = 0;
|
|
361
|
+
const viewportEvidence = [];
|
|
362
|
+
|
|
363
|
+
if (viewportElementDelta > 10) {
|
|
364
|
+
viewportScore += 0.3;
|
|
365
|
+
viewportEvidence.push(`${viewportElementDelta} elements added/removed in viewport`);
|
|
366
|
+
} else if (viewportElementDelta > 3) {
|
|
367
|
+
viewportScore += 0.15;
|
|
368
|
+
viewportEvidence.push(`${viewportElementDelta} element(s) changed in viewport`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (viewportTextChanged) {
|
|
372
|
+
viewportScore += 0.2;
|
|
373
|
+
viewportEvidence.push('Visible text changed in viewport');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Overall DOM change score (prioritize target over viewport)
|
|
377
|
+
const score = targetChanged ? Math.min(targetScore + viewportScore * 0.3, 1.0) : viewportScore;
|
|
378
|
+
const happened = score > 0.1; // Conservative threshold
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
happened,
|
|
382
|
+
score,
|
|
383
|
+
evidence: {
|
|
384
|
+
viewport: {
|
|
385
|
+
elementDelta: viewportElementDelta,
|
|
386
|
+
textChanged: viewportTextChanged,
|
|
387
|
+
changes: viewportEvidence
|
|
388
|
+
},
|
|
389
|
+
target: targetChanged ? {
|
|
390
|
+
elementDelta: after.targetContainer.elementCount - before.targetContainer.elementCount,
|
|
391
|
+
textChanged: before.targetContainer.textContent !== after.targetContainer.textContent,
|
|
392
|
+
changes: targetEvidence
|
|
393
|
+
} : null
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Compute loading indicator signal
|
|
400
|
+
* @private
|
|
401
|
+
*/
|
|
402
|
+
_computeLoadingSignal() {
|
|
403
|
+
const before = this.beforeState.loading;
|
|
404
|
+
const after = this.afterState.loading;
|
|
405
|
+
|
|
406
|
+
const appeared = {
|
|
407
|
+
ariaBusy: after.ariaBusy.length > before.ariaBusy.length,
|
|
408
|
+
progressBars: after.progressBars.length > before.progressBars.length,
|
|
409
|
+
spinners: after.spinners.length > before.spinners.length,
|
|
410
|
+
skeletons: after.skeletons.length > before.skeletons.length
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const disappeared = {
|
|
414
|
+
ariaBusy: before.ariaBusy.length > after.ariaBusy.length,
|
|
415
|
+
progressBars: before.progressBars.length > after.progressBars.length,
|
|
416
|
+
spinners: before.spinners.length > after.spinners.length,
|
|
417
|
+
skeletons: before.skeletons.length > after.skeletons.length
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const evidence = [];
|
|
421
|
+
|
|
422
|
+
if (appeared.ariaBusy) {
|
|
423
|
+
evidence.push(`${after.ariaBusy.length - before.ariaBusy.length} aria-busy elements appeared`);
|
|
424
|
+
}
|
|
425
|
+
if (appeared.progressBars) {
|
|
426
|
+
evidence.push(`${after.progressBars.length - before.progressBars.length} progress bars appeared`);
|
|
427
|
+
}
|
|
428
|
+
if (appeared.spinners) {
|
|
429
|
+
evidence.push(`${after.spinners.length - before.spinners.length} spinners appeared`);
|
|
430
|
+
}
|
|
431
|
+
if (appeared.skeletons) {
|
|
432
|
+
evidence.push(`${after.skeletons.length - before.skeletons.length} skeleton loaders appeared`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (disappeared.ariaBusy) {
|
|
436
|
+
evidence.push(`${before.ariaBusy.length - after.ariaBusy.length} aria-busy elements disappeared`);
|
|
437
|
+
}
|
|
438
|
+
if (disappeared.progressBars) {
|
|
439
|
+
evidence.push(`${before.progressBars.length - after.progressBars.length} progress bars disappeared`);
|
|
440
|
+
}
|
|
441
|
+
if (disappeared.spinners) {
|
|
442
|
+
evidence.push(`${before.spinners.length - after.spinners.length} spinners disappeared`);
|
|
443
|
+
}
|
|
444
|
+
if (disappeared.skeletons) {
|
|
445
|
+
evidence.push(`${before.skeletons.length - after.skeletons.length} skeleton loaders disappeared`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const hasAppeared = appeared.ariaBusy || appeared.progressBars || appeared.spinners || appeared.skeletons;
|
|
449
|
+
const hasDisappeared = disappeared.ariaBusy || disappeared.progressBars || disappeared.spinners || disappeared.skeletons;
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
appeared: hasAppeared,
|
|
453
|
+
disappeared: hasDisappeared,
|
|
454
|
+
evidence: {
|
|
455
|
+
appeared,
|
|
456
|
+
disappeared,
|
|
457
|
+
details: evidence,
|
|
458
|
+
beforeCount: before.ariaBusy.length + before.progressBars.length + before.spinners.length + before.skeletons.length,
|
|
459
|
+
afterCount: after.ariaBusy.length + after.progressBars.length + after.spinners.length + after.skeletons.length
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Compute button state transition signal
|
|
466
|
+
* @private
|
|
467
|
+
*/
|
|
468
|
+
_computeButtonStateSignal() {
|
|
469
|
+
const before = this.beforeState.buttons;
|
|
470
|
+
const after = this.afterState.buttons;
|
|
471
|
+
|
|
472
|
+
const transitions = [];
|
|
473
|
+
|
|
474
|
+
// Match buttons by selector+text (fuzzy matching)
|
|
475
|
+
// First pass: exact selector match (for text changes)
|
|
476
|
+
before.forEach(beforeBtn => {
|
|
477
|
+
const afterBtn = after.find(a => a.selector === beforeBtn.selector);
|
|
478
|
+
|
|
479
|
+
if (afterBtn) {
|
|
480
|
+
// Check for state transitions
|
|
481
|
+
if (beforeBtn.disabled !== afterBtn.disabled) {
|
|
482
|
+
transitions.push({
|
|
483
|
+
selector: beforeBtn.selector,
|
|
484
|
+
type: 'disabled-toggle',
|
|
485
|
+
before: beforeBtn.disabled,
|
|
486
|
+
after: afterBtn.disabled,
|
|
487
|
+
text: beforeBtn.text
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Check for text changes (e.g., "Submit" -> "Saving...")
|
|
492
|
+
if (beforeBtn.text !== afterBtn.text && beforeBtn.text.length > 0 && afterBtn.text.length > 0) {
|
|
493
|
+
transitions.push({
|
|
494
|
+
selector: beforeBtn.selector,
|
|
495
|
+
type: 'text-change',
|
|
496
|
+
before: beforeBtn.text,
|
|
497
|
+
after: afterBtn.text
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Check for aria-busy changes
|
|
502
|
+
if (beforeBtn.ariaBusy !== afterBtn.ariaBusy) {
|
|
503
|
+
transitions.push({
|
|
504
|
+
selector: beforeBtn.selector,
|
|
505
|
+
type: 'aria-busy-change',
|
|
506
|
+
before: beforeBtn.ariaBusy,
|
|
507
|
+
after: afterBtn.ariaBusy,
|
|
508
|
+
text: beforeBtn.text
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
happened: transitions.length > 0,
|
|
516
|
+
evidence: {
|
|
517
|
+
transitionCount: transitions.length,
|
|
518
|
+
transitions: transitions.slice(0, 5) // Limit to first 5 transitions
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Compute notification signal
|
|
525
|
+
* @private
|
|
526
|
+
*/
|
|
527
|
+
_computeNotificationSignal() {
|
|
528
|
+
const before = this.beforeState.notifications;
|
|
529
|
+
const after = this.afterState.notifications;
|
|
530
|
+
|
|
531
|
+
const newAlerts = after.alerts.filter(a =>
|
|
532
|
+
!before.alerts.some(b => b.text === a.text)
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const newLiveRegions = after.liveRegions.filter(a =>
|
|
536
|
+
!before.liveRegions.some(b => b.text === a.text)
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const newToasts = after.toasts.filter(a =>
|
|
540
|
+
!before.toasts.some(b => b.text === a.text)
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const happened = newAlerts.length > 0 || newLiveRegions.length > 0 || newToasts.length > 0;
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
happened,
|
|
547
|
+
evidence: {
|
|
548
|
+
newAlerts: newAlerts.slice(0, 3),
|
|
549
|
+
newLiveRegions: newLiveRegions.slice(0, 3),
|
|
550
|
+
newToasts: newToasts.slice(0, 3),
|
|
551
|
+
totalNew: newAlerts.length + newLiveRegions.length + newToasts.length
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Compute navigation signal
|
|
558
|
+
* @private
|
|
559
|
+
*/
|
|
560
|
+
_computeNavigationSignal() {
|
|
561
|
+
const before = this.beforeState.navigation;
|
|
562
|
+
const after = this.afterState.navigation;
|
|
563
|
+
|
|
564
|
+
const pathnameChanged = before.pathname !== after.pathname;
|
|
565
|
+
const searchChanged = before.search !== after.search;
|
|
566
|
+
const hashChanged = before.hash !== after.hash;
|
|
567
|
+
|
|
568
|
+
const happened = pathnameChanged || searchChanged || hashChanged;
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
happened,
|
|
572
|
+
from: `${before.pathname}${before.search}${before.hash}`,
|
|
573
|
+
to: `${after.pathname}${after.search}${after.hash}`,
|
|
574
|
+
evidence: {
|
|
575
|
+
pathnameChanged,
|
|
576
|
+
searchChanged,
|
|
577
|
+
hashChanged,
|
|
578
|
+
urlChanged: this.beforeState.url !== this.afterState.url
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Compute focus change signal
|
|
585
|
+
* @private
|
|
586
|
+
*/
|
|
587
|
+
_computeFocusChangeSignal() {
|
|
588
|
+
const before = this.beforeState.focus;
|
|
589
|
+
const after = this.afterState.focus;
|
|
590
|
+
|
|
591
|
+
// Check if active element changed
|
|
592
|
+
const beforeId = before.activeElement ?
|
|
593
|
+
`${before.activeElement.tag}#${before.activeElement.id || before.activeElement.name || ''}` : null;
|
|
594
|
+
const afterId = after.activeElement ?
|
|
595
|
+
`${after.activeElement.tag}#${after.activeElement.id || after.activeElement.name || ''}` : null;
|
|
596
|
+
|
|
597
|
+
const happened = beforeId !== afterId;
|
|
598
|
+
|
|
599
|
+
// Conservative: only flag as meaningful if focus moved to form field or error element
|
|
600
|
+
const isMeaningful = happened && after.activeElement && (
|
|
601
|
+
after.activeElement.tag === 'input' ||
|
|
602
|
+
after.activeElement.tag === 'textarea' ||
|
|
603
|
+
after.activeElement.tag === 'select' ||
|
|
604
|
+
(after.activeElement.ariaLabel && after.activeElement.ariaLabel.toLowerCase().includes('error')) || false
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
happened: isMeaningful,
|
|
609
|
+
from: before.activeElement ? {
|
|
610
|
+
tag: before.activeElement.tag,
|
|
611
|
+
id: before.activeElement.id,
|
|
612
|
+
name: before.activeElement.name,
|
|
613
|
+
role: before.activeElement.role
|
|
614
|
+
} : null,
|
|
615
|
+
to: after.activeElement ? {
|
|
616
|
+
tag: after.activeElement.tag,
|
|
617
|
+
id: after.activeElement.id,
|
|
618
|
+
name: after.activeElement.name,
|
|
619
|
+
role: after.activeElement.role
|
|
620
|
+
} : null,
|
|
621
|
+
evidence: {
|
|
622
|
+
focusMovedToFormField: isMeaningful,
|
|
623
|
+
beforeHasFocus: before.hasFocus,
|
|
624
|
+
afterHasFocus: after.hasFocus
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Compute scroll change signal
|
|
631
|
+
* @private
|
|
632
|
+
*/
|
|
633
|
+
_computeScrollChangeSignal() {
|
|
634
|
+
const before = this.beforeState.scroll;
|
|
635
|
+
const after = this.afterState.scroll;
|
|
636
|
+
|
|
637
|
+
const deltaX = Math.abs(after.x - before.x);
|
|
638
|
+
const deltaY = Math.abs(after.y - before.y);
|
|
639
|
+
|
|
640
|
+
// Conservative threshold: 100px vertical scroll or 50px horizontal
|
|
641
|
+
const isSignificant = deltaY > 100 || deltaX > 50;
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
happened: isSignificant,
|
|
645
|
+
delta: {
|
|
646
|
+
x: after.x - before.x,
|
|
647
|
+
y: after.y - before.y
|
|
648
|
+
},
|
|
649
|
+
evidence: {
|
|
650
|
+
beforePosition: { x: before.x, y: before.y },
|
|
651
|
+
afterPosition: { x: after.x, y: after.y },
|
|
652
|
+
scrollDistance: Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Compute overall UI feedback score (0..1)
|
|
659
|
+
* @private
|
|
660
|
+
*/
|
|
661
|
+
_computeOverallScore(signals) {
|
|
662
|
+
let score = 0;
|
|
663
|
+
let weights = 0;
|
|
664
|
+
|
|
665
|
+
// DOM change: weight 0.25
|
|
666
|
+
if (signals.domChange.happened) {
|
|
667
|
+
score += signals.domChange.score * 0.25;
|
|
668
|
+
weights += 0.25;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Loading indicators: weight 0.2
|
|
672
|
+
if (signals.loading.appeared || signals.loading.disappeared) {
|
|
673
|
+
score += 0.2;
|
|
674
|
+
weights += 0.2;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Button state: weight 0.2
|
|
678
|
+
if (signals.buttonStateTransition.happened) {
|
|
679
|
+
score += 0.2;
|
|
680
|
+
weights += 0.2;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Notifications: weight 0.15
|
|
684
|
+
if (signals.notification.happened) {
|
|
685
|
+
score += 0.15;
|
|
686
|
+
weights += 0.15;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Navigation: weight 0.15
|
|
690
|
+
if (signals.navigation.happened) {
|
|
691
|
+
score += 0.15;
|
|
692
|
+
weights += 0.15;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Focus change: weight 0.05 (lower weight, auxiliary signal)
|
|
696
|
+
if (signals.focusChange.happened) {
|
|
697
|
+
score += 0.05;
|
|
698
|
+
weights += 0.05;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Scroll change: weight 0.05 (lower weight, auxiliary signal)
|
|
702
|
+
if (signals.scrollChange.happened) {
|
|
703
|
+
score += 0.05;
|
|
704
|
+
weights += 0.05;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Normalize by weights (if no signals, score = 0)
|
|
708
|
+
return weights > 0 ? score : 0;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Return empty signals structure
|
|
713
|
+
* @private
|
|
714
|
+
*/
|
|
715
|
+
_emptySignals() {
|
|
716
|
+
return {
|
|
717
|
+
interactionId: null,
|
|
718
|
+
signals: {
|
|
719
|
+
domChange: { happened: false, score: 0, evidence: {} },
|
|
720
|
+
loading: { appeared: false, disappeared: false, evidence: {} },
|
|
721
|
+
buttonStateTransition: { happened: false, evidence: {} },
|
|
722
|
+
notification: { happened: false, evidence: {} },
|
|
723
|
+
navigation: { happened: false, from: '', to: '', evidence: {} },
|
|
724
|
+
focusChange: { happened: false, from: null, to: null, evidence: {} },
|
|
725
|
+
scrollChange: { happened: false, delta: { x: 0, y: 0 }, evidence: {} }
|
|
726
|
+
},
|
|
727
|
+
overallUiFeedbackScore: 0,
|
|
728
|
+
_metadata: {
|
|
729
|
+
error: 'No before/after state captured'
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Reset detector state
|
|
736
|
+
*/
|
|
737
|
+
reset() {
|
|
738
|
+
this.beforeState = null;
|
|
739
|
+
this.afterState = null;
|
|
740
|
+
this.interactionTarget = null;
|
|
741
|
+
}
|
|
742
|
+
}
|