@veraxhq/verax 0.1.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/LICENSE +21 -0
- package/README.md +237 -0
- package/bin/verax.js +452 -0
- package/package.json +57 -0
- package/src/verax/detect/comparison.js +69 -0
- package/src/verax/detect/confidence-engine.js +498 -0
- package/src/verax/detect/evidence-validator.js +33 -0
- package/src/verax/detect/expectation-model.js +204 -0
- package/src/verax/detect/findings-writer.js +31 -0
- package/src/verax/detect/index.js +397 -0
- package/src/verax/detect/skip-classifier.js +202 -0
- package/src/verax/flow/flow-engine.js +265 -0
- package/src/verax/flow/flow-spec.js +145 -0
- package/src/verax/flow/redaction.js +74 -0
- package/src/verax/index.js +97 -0
- package/src/verax/learn/action-contract-extractor.js +281 -0
- package/src/verax/learn/ast-contract-extractor.js +255 -0
- package/src/verax/learn/index.js +18 -0
- package/src/verax/learn/manifest-writer.js +97 -0
- package/src/verax/learn/project-detector.js +87 -0
- package/src/verax/learn/react-router-extractor.js +73 -0
- package/src/verax/learn/route-extractor.js +122 -0
- package/src/verax/learn/route-validator.js +215 -0
- package/src/verax/learn/source-instrumenter.js +214 -0
- package/src/verax/learn/static-extractor.js +222 -0
- package/src/verax/learn/truth-assessor.js +96 -0
- package/src/verax/learn/ts-contract-resolver.js +395 -0
- package/src/verax/observe/browser.js +22 -0
- package/src/verax/observe/console-sensor.js +166 -0
- package/src/verax/observe/dom-signature.js +23 -0
- package/src/verax/observe/domain-boundary.js +38 -0
- package/src/verax/observe/evidence-capture.js +5 -0
- package/src/verax/observe/human-driver.js +376 -0
- package/src/verax/observe/index.js +67 -0
- package/src/verax/observe/interaction-discovery.js +269 -0
- package/src/verax/observe/interaction-runner.js +410 -0
- package/src/verax/observe/network-sensor.js +173 -0
- package/src/verax/observe/selector-generator.js +74 -0
- package/src/verax/observe/settle.js +155 -0
- package/src/verax/observe/state-ui-sensor.js +200 -0
- package/src/verax/observe/traces-writer.js +82 -0
- package/src/verax/observe/ui-signal-sensor.js +197 -0
- package/src/verax/resolve-workspace-root.js +173 -0
- package/src/verax/scan-summary-writer.js +41 -0
- package/src/verax/shared/artifact-manager.js +139 -0
- package/src/verax/shared/caching.js +104 -0
- package/src/verax/shared/expectation-proof.js +4 -0
- package/src/verax/shared/redaction.js +227 -0
- package/src/verax/shared/retry-policy.js +89 -0
- package/src/verax/shared/timing-metrics.js +44 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { generateSelector } from './selector-generator.js';
|
|
2
|
+
import { isExternalHref } from './domain-boundary.js';
|
|
3
|
+
import { HumanBehaviorDriver } from './human-driver.js';
|
|
4
|
+
|
|
5
|
+
const MAX_INTERACTIONS_PER_PAGE = 30;
|
|
6
|
+
|
|
7
|
+
function computePriority(candidate, viewportHeight) {
|
|
8
|
+
const hasAboveFold = candidate.boundingAvailable && typeof viewportHeight === 'number' && candidate.boundingY < viewportHeight;
|
|
9
|
+
const isFooter = candidate.boundingAvailable && typeof viewportHeight === 'number' && candidate.boundingY >= viewportHeight;
|
|
10
|
+
const isInternalLink = candidate.type === 'link' && candidate.href && candidate.href !== '#' && (!candidate.isExternal || candidate.href.startsWith('/'));
|
|
11
|
+
|
|
12
|
+
if (candidate.type === 'form') return 1;
|
|
13
|
+
if (candidate.type === 'link' && isFooter) return 6;
|
|
14
|
+
if (isInternalLink) return 2;
|
|
15
|
+
if (candidate.type === 'button' && (candidate.dataHref || (candidate.isRoleButton && (candidate.id || candidate.dataTestId)))) return 3;
|
|
16
|
+
if (hasAboveFold) return 4;
|
|
17
|
+
if (candidate.type === 'button') return 5;
|
|
18
|
+
if (isFooter) return 6;
|
|
19
|
+
return 7;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sortCandidates(candidates) {
|
|
23
|
+
return candidates.sort((a, b) => {
|
|
24
|
+
if (a.priority !== b.priority) {
|
|
25
|
+
return a.priority - b.priority;
|
|
26
|
+
}
|
|
27
|
+
const selectorCompare = (a.selector || '').localeCompare(b.selector || '');
|
|
28
|
+
if (selectorCompare !== 0) return selectorCompare;
|
|
29
|
+
return (a.label || '').localeCompare(b.label || '');
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function isLanguageToggle(elementHandle) {
|
|
34
|
+
if (!elementHandle) return false;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const text = await elementHandle.evaluate(el => el.textContent?.trim() || '');
|
|
38
|
+
const label = await elementHandle.evaluate(el => el.getAttribute('aria-label') || '');
|
|
39
|
+
const combined = (text + ' ' + label).toLowerCase();
|
|
40
|
+
|
|
41
|
+
const languagePatterns = [
|
|
42
|
+
/^(en|de|fr|es|it|pt|ru|zh|ja|ko|ar|he)$/i,
|
|
43
|
+
/\blanguage\b/i,
|
|
44
|
+
/\blang\b/i
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
return languagePatterns.some(pattern => pattern.test(combined));
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function extractLabel(element) {
|
|
54
|
+
try {
|
|
55
|
+
const innerText = await element.evaluate(el => el.innerText?.trim() || '');
|
|
56
|
+
if (innerText) return innerText.substring(0, 100);
|
|
57
|
+
|
|
58
|
+
const ariaLabel = await element.getAttribute('aria-label');
|
|
59
|
+
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim().substring(0, 100);
|
|
60
|
+
|
|
61
|
+
const title = await element.getAttribute('title');
|
|
62
|
+
if (title && title.trim()) return title.trim().substring(0, 100);
|
|
63
|
+
|
|
64
|
+
return '';
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function discoverInteractions(page, baseOrigin) {
|
|
71
|
+
// Wave 2: Apply scrolling before discovery to reveal lazy-loaded elements
|
|
72
|
+
const driver = new HumanBehaviorDriver({ maxScrollSteps: 5 });
|
|
73
|
+
await driver.discoverInteractionsWithScroll(page);
|
|
74
|
+
|
|
75
|
+
// Now run the full discovery with all elements visible
|
|
76
|
+
const currentUrl = page.url();
|
|
77
|
+
const interactions = [];
|
|
78
|
+
const seenElements = new Set();
|
|
79
|
+
|
|
80
|
+
const allInteractions = [];
|
|
81
|
+
|
|
82
|
+
const links = await page.locator('a[href]').all();
|
|
83
|
+
for (const link of links) {
|
|
84
|
+
const href = await link.getAttribute('href');
|
|
85
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
86
|
+
const isExternal = isExternalHref(href, baseOrigin, currentUrl);
|
|
87
|
+
const selector = await generateSelector(link);
|
|
88
|
+
const selectorKey = `link:${selector}`;
|
|
89
|
+
|
|
90
|
+
if (!seenElements.has(selectorKey)) {
|
|
91
|
+
seenElements.add(selectorKey);
|
|
92
|
+
const label = await extractLabel(link);
|
|
93
|
+
const tagName = await link.evaluate(el => el.tagName.toLowerCase());
|
|
94
|
+
const id = await link.getAttribute('id');
|
|
95
|
+
const text = await link.evaluate(el => el.textContent?.trim() || '');
|
|
96
|
+
|
|
97
|
+
allInteractions.push({
|
|
98
|
+
type: 'link',
|
|
99
|
+
selector: selector,
|
|
100
|
+
label: label,
|
|
101
|
+
element: link,
|
|
102
|
+
tagName: tagName,
|
|
103
|
+
id: id || '',
|
|
104
|
+
text: text,
|
|
105
|
+
isExternal: isExternal,
|
|
106
|
+
href: href
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const buttons = await page.locator('button:not([disabled])').all();
|
|
113
|
+
for (const button of buttons) {
|
|
114
|
+
const selector = await generateSelector(button);
|
|
115
|
+
const selectorKey = `button:${selector}`;
|
|
116
|
+
|
|
117
|
+
if (!seenElements.has(selectorKey)) {
|
|
118
|
+
seenElements.add(selectorKey);
|
|
119
|
+
const label = await extractLabel(button);
|
|
120
|
+
const elementHandle = await button.elementHandle();
|
|
121
|
+
const isLangToggle = elementHandle ? await isLanguageToggle(elementHandle) : false;
|
|
122
|
+
const tagName = await button.evaluate(el => el.tagName.toLowerCase());
|
|
123
|
+
const id = await button.getAttribute('id');
|
|
124
|
+
const text = await button.evaluate(el => el.textContent?.trim() || '');
|
|
125
|
+
const dataHref = await button.getAttribute('data-href');
|
|
126
|
+
const dataTestId = await button.getAttribute('data-testid');
|
|
127
|
+
|
|
128
|
+
allInteractions.push({
|
|
129
|
+
type: isLangToggle ? 'toggle' : 'button',
|
|
130
|
+
selector: selector,
|
|
131
|
+
label: label,
|
|
132
|
+
element: button,
|
|
133
|
+
tagName: tagName,
|
|
134
|
+
id: id || '',
|
|
135
|
+
text: text,
|
|
136
|
+
dataHref: dataHref || '',
|
|
137
|
+
dataTestId: dataTestId || '',
|
|
138
|
+
isRoleButton: false
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const submitInputs = await page.locator('input[type="submit"]:not([disabled]), input[type="button"]:not([disabled])').all();
|
|
144
|
+
for (const input of submitInputs) {
|
|
145
|
+
const selector = await generateSelector(input);
|
|
146
|
+
const selectorKey = `input:${selector}`;
|
|
147
|
+
|
|
148
|
+
if (!seenElements.has(selectorKey)) {
|
|
149
|
+
seenElements.add(selectorKey);
|
|
150
|
+
const label = await extractLabel(input);
|
|
151
|
+
const tagName = await input.evaluate(el => el.tagName.toLowerCase());
|
|
152
|
+
const id = await input.getAttribute('id');
|
|
153
|
+
const text = await input.getAttribute('value') || '';
|
|
154
|
+
const dataHref = await input.getAttribute('data-href');
|
|
155
|
+
const dataTestId = await input.getAttribute('data-testid');
|
|
156
|
+
|
|
157
|
+
allInteractions.push({
|
|
158
|
+
type: 'button',
|
|
159
|
+
selector: selector,
|
|
160
|
+
label: label || text,
|
|
161
|
+
element: input,
|
|
162
|
+
tagName: tagName,
|
|
163
|
+
id: id || '',
|
|
164
|
+
text: text,
|
|
165
|
+
dataHref: dataHref || '',
|
|
166
|
+
dataTestId: dataTestId || '',
|
|
167
|
+
isRoleButton: false
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const roleButtons = await page.locator('[role="button"]:not([disabled])').all();
|
|
173
|
+
for (const roleButton of roleButtons) {
|
|
174
|
+
const selector = await generateSelector(roleButton);
|
|
175
|
+
const selectorKey = `role-button:${selector}`;
|
|
176
|
+
|
|
177
|
+
if (!seenElements.has(selectorKey)) {
|
|
178
|
+
seenElements.add(selectorKey);
|
|
179
|
+
const label = await extractLabel(roleButton);
|
|
180
|
+
const tagName = await roleButton.evaluate(el => el.tagName.toLowerCase());
|
|
181
|
+
const id = await roleButton.getAttribute('id');
|
|
182
|
+
const text = await roleButton.evaluate(el => el.textContent?.trim() || '');
|
|
183
|
+
const dataHref = await roleButton.getAttribute('data-href');
|
|
184
|
+
const dataTestId = await roleButton.getAttribute('data-testid');
|
|
185
|
+
|
|
186
|
+
allInteractions.push({
|
|
187
|
+
type: 'button',
|
|
188
|
+
selector: selector,
|
|
189
|
+
label: label,
|
|
190
|
+
element: roleButton,
|
|
191
|
+
tagName: tagName,
|
|
192
|
+
id: id || '',
|
|
193
|
+
text: text,
|
|
194
|
+
dataHref: dataHref || '',
|
|
195
|
+
dataTestId: dataTestId || '',
|
|
196
|
+
isRoleButton: true
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const forms = await page.locator('form').all();
|
|
202
|
+
for (const form of forms) {
|
|
203
|
+
const submitButton = form.locator('button[type="submit"], input[type="submit"]').first();
|
|
204
|
+
if (await submitButton.count() > 0) {
|
|
205
|
+
const selector = await generateSelector(submitButton);
|
|
206
|
+
const selectorKey = `form:${selector}`;
|
|
207
|
+
|
|
208
|
+
if (!seenElements.has(selectorKey)) {
|
|
209
|
+
seenElements.add(selectorKey);
|
|
210
|
+
const label = await extractLabel(submitButton);
|
|
211
|
+
const tagName = await submitButton.evaluate(el => el.tagName.toLowerCase());
|
|
212
|
+
const id = await submitButton.getAttribute('id');
|
|
213
|
+
const text = await submitButton.evaluate(el => el.textContent?.trim() || el.getAttribute('value') || '');
|
|
214
|
+
|
|
215
|
+
allInteractions.push({
|
|
216
|
+
type: 'form',
|
|
217
|
+
selector: selector,
|
|
218
|
+
label: label || text,
|
|
219
|
+
element: submitButton,
|
|
220
|
+
tagName: tagName,
|
|
221
|
+
id: id || '',
|
|
222
|
+
text: text,
|
|
223
|
+
dataHref: '',
|
|
224
|
+
dataTestId: '',
|
|
225
|
+
isRoleButton: false
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const viewport = page.viewportSize();
|
|
232
|
+
const viewportHeight = viewport ? viewport.height : undefined;
|
|
233
|
+
|
|
234
|
+
for (const item of allInteractions) {
|
|
235
|
+
try {
|
|
236
|
+
const box = await item.element.boundingBox();
|
|
237
|
+
if (box) {
|
|
238
|
+
item.boundingY = box.y;
|
|
239
|
+
item.boundingAvailable = true;
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
item.boundingAvailable = false;
|
|
243
|
+
}
|
|
244
|
+
item.priority = computePriority(item, viewportHeight);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const sorted = sortCandidates(allInteractions);
|
|
248
|
+
const capped = sorted.length > MAX_INTERACTIONS_PER_PAGE;
|
|
249
|
+
const selected = sorted.slice(0, MAX_INTERACTIONS_PER_PAGE);
|
|
250
|
+
|
|
251
|
+
const coverage = {
|
|
252
|
+
candidatesDiscovered: sorted.length,
|
|
253
|
+
candidatesSelected: selected.length,
|
|
254
|
+
cap: MAX_INTERACTIONS_PER_PAGE,
|
|
255
|
+
capped
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
interactions: selected.map(item => ({
|
|
260
|
+
type: item.type,
|
|
261
|
+
selector: item.selector,
|
|
262
|
+
label: item.label,
|
|
263
|
+
element: item.element,
|
|
264
|
+
isExternal: item.isExternal || false
|
|
265
|
+
})),
|
|
266
|
+
coverage
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { captureScreenshot } from './evidence-capture.js';
|
|
3
|
+
import { isExternalUrl } from './domain-boundary.js';
|
|
4
|
+
import { captureDomSignature } from './dom-signature.js';
|
|
5
|
+
import { waitForSettle } from './settle.js';
|
|
6
|
+
import { NetworkSensor } from './network-sensor.js';
|
|
7
|
+
import { ConsoleSensor } from './console-sensor.js';
|
|
8
|
+
import { UISignalSensor } from './ui-signal-sensor.js';
|
|
9
|
+
import { StateUISensor } from './state-ui-sensor.js';
|
|
10
|
+
|
|
11
|
+
const INTERACTION_TIMEOUT_MS = 10000;
|
|
12
|
+
const NAVIGATION_TIMEOUT_MS = 15000;
|
|
13
|
+
const STABILIZATION_SAMPLE_MID_MS = 500;
|
|
14
|
+
const STABILIZATION_SAMPLE_END_MS = 1500;
|
|
15
|
+
|
|
16
|
+
// Runtime truth sensors for silent failure detection
|
|
17
|
+
const networkSensor = new NetworkSensor();
|
|
18
|
+
const consoleSensor = new ConsoleSensor();
|
|
19
|
+
const uiSignalSensor = new UISignalSensor();
|
|
20
|
+
const stateUISensor = new StateUISensor();
|
|
21
|
+
|
|
22
|
+
function markTimeoutPolicy(trace, phase) {
|
|
23
|
+
trace.policy = {
|
|
24
|
+
...(trace.policy || {}),
|
|
25
|
+
timeout: true,
|
|
26
|
+
reason: 'interaction_timeout',
|
|
27
|
+
phase
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function computeDomChangedDuringSettle(samples) {
|
|
32
|
+
if (!samples || samples.length < 3) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return samples[0] !== samples[1] || samples[1] !== samples[2];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function captureSettledDom(page) {
|
|
39
|
+
const samples = [];
|
|
40
|
+
|
|
41
|
+
const sampleDom = async () => {
|
|
42
|
+
const hash = await captureDomSignature(page);
|
|
43
|
+
samples.push(hash);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
await sampleDom();
|
|
47
|
+
await page.waitForTimeout(STABILIZATION_SAMPLE_MID_MS);
|
|
48
|
+
await sampleDom();
|
|
49
|
+
await page.waitForTimeout(STABILIZATION_SAMPLE_END_MS - STABILIZATION_SAMPLE_MID_MS);
|
|
50
|
+
await sampleDom();
|
|
51
|
+
|
|
52
|
+
const domChangedDuringSettle = computeDomChangedDuringSettle(samples);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
samples,
|
|
56
|
+
domChangedDuringSettle,
|
|
57
|
+
afterHash: samples[samples.length - 1]
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runInteraction(page, interaction, timestamp, i, screenshotsDir, baseOrigin, startTime, maxDurationMs) {
|
|
62
|
+
const trace = {
|
|
63
|
+
interaction: {
|
|
64
|
+
type: interaction.type,
|
|
65
|
+
selector: interaction.selector,
|
|
66
|
+
label: interaction.label
|
|
67
|
+
},
|
|
68
|
+
before: {
|
|
69
|
+
url: '',
|
|
70
|
+
screenshot: ''
|
|
71
|
+
},
|
|
72
|
+
after: {
|
|
73
|
+
url: '',
|
|
74
|
+
screenshot: ''
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Declare window IDs outside try block so they're available in catch
|
|
79
|
+
let networkWindowId = null;
|
|
80
|
+
let consoleWindowId = null;
|
|
81
|
+
let uiSignalsBefore = null;
|
|
82
|
+
let stateUiBefore = null;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (Date.now() - startTime > maxDurationMs) {
|
|
86
|
+
trace.policy = { timeout: true, reason: 'max_scan_duration_exceeded' };
|
|
87
|
+
return trace;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const beforeUrl = page.url();
|
|
91
|
+
const beforeScreenshot = resolve(screenshotsDir, `before-${timestamp}-${i}.png`);
|
|
92
|
+
await captureScreenshot(page, beforeScreenshot);
|
|
93
|
+
const beforeDomHash = await captureDomSignature(page);
|
|
94
|
+
|
|
95
|
+
// Capture UI signals before interaction
|
|
96
|
+
try {
|
|
97
|
+
uiSignalsBefore = await uiSignalSensor.snapshot(page);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
// If snapshot fails (e.g., page mock incomplete), use empty object
|
|
100
|
+
uiSignalsBefore = {
|
|
101
|
+
hasLoadingIndicator: false,
|
|
102
|
+
hasDialog: false,
|
|
103
|
+
hasErrorSignal: false,
|
|
104
|
+
explanation: []
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Capture state UI signals before interaction (Wave 8)
|
|
109
|
+
try {
|
|
110
|
+
stateUiBefore = await stateUISensor.snapshot(page);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// If snapshot fails, use empty object
|
|
113
|
+
stateUiBefore = {
|
|
114
|
+
signals: {
|
|
115
|
+
dialogs: [],
|
|
116
|
+
expandedElements: [],
|
|
117
|
+
selectedTabs: [],
|
|
118
|
+
checkedElements: [],
|
|
119
|
+
alerts: []
|
|
120
|
+
},
|
|
121
|
+
rawSnapshot: {}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
trace.before.url = beforeUrl;
|
|
126
|
+
trace.before.screenshot = `screenshots/before-${timestamp}-${i}.png`;
|
|
127
|
+
if (beforeDomHash) {
|
|
128
|
+
trace.dom = { beforeHash: beforeDomHash };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Capture sourceRef and handlerRef if present (Wave 5/6 — Action Contracts)
|
|
132
|
+
let sourceRef = null;
|
|
133
|
+
let handlerRef = null;
|
|
134
|
+
try {
|
|
135
|
+
sourceRef = await interaction.element.getAttribute('data-verax-source');
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// Element may not have the attribute
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
handlerRef = await interaction.element.getAttribute('data-verax-handler');
|
|
141
|
+
} catch (e) {
|
|
142
|
+
// Element may not have the attribute
|
|
143
|
+
}
|
|
144
|
+
if (sourceRef || handlerRef) {
|
|
145
|
+
trace.meta = {
|
|
146
|
+
...(trace.meta || {}),
|
|
147
|
+
...(sourceRef ? { sourceRef } : {}),
|
|
148
|
+
...(handlerRef ? { handlerRef } : {})
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Initialize sensors for this interaction
|
|
153
|
+
try {
|
|
154
|
+
networkWindowId = networkSensor.startWindow(page);
|
|
155
|
+
consoleWindowId = consoleSensor.startWindow(page);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// If sensors fail to initialize (e.g., in test environments with incomplete mocks),
|
|
158
|
+
// continue without them - they'll be gracefully handled with null checks
|
|
159
|
+
networkWindowId = null;
|
|
160
|
+
consoleWindowId = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (interaction.isExternal && interaction.type === 'link') {
|
|
164
|
+
const href = await interaction.element.getAttribute('href');
|
|
165
|
+
const resolvedUrl = href.startsWith('http') ? href : new URL(href, beforeUrl).href;
|
|
166
|
+
|
|
167
|
+
trace.policy = {
|
|
168
|
+
externalNavigationBlocked: true,
|
|
169
|
+
blockedUrl: resolvedUrl
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Stop sensors even on external navigation
|
|
173
|
+
let networkSummary = null;
|
|
174
|
+
let consoleSummary = null;
|
|
175
|
+
if (networkWindowId !== null) {
|
|
176
|
+
networkSummary = networkSensor.stopWindow(networkWindowId);
|
|
177
|
+
}
|
|
178
|
+
if (consoleWindowId !== null) {
|
|
179
|
+
consoleSummary = consoleSensor.stopWindow(consoleWindowId, page);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { settleResult, afterUrl } = await captureAfterState(page, screenshotsDir, timestamp, i, trace);
|
|
183
|
+
trace.after.url = afterUrl;
|
|
184
|
+
trace.after.screenshot = `screenshots/after-${timestamp}-${i}.png`;
|
|
185
|
+
if (!trace.dom) {
|
|
186
|
+
trace.dom = {};
|
|
187
|
+
}
|
|
188
|
+
if (settleResult.afterHash) {
|
|
189
|
+
trace.dom.afterHash = settleResult.afterHash;
|
|
190
|
+
}
|
|
191
|
+
trace.dom.settle = {
|
|
192
|
+
samples: settleResult.samples,
|
|
193
|
+
domChangedDuringSettle: settleResult.domChangedDuringSettle
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Add sensor evidence to trace
|
|
197
|
+
trace.sensors = {
|
|
198
|
+
network: networkSummary,
|
|
199
|
+
console: consoleSummary
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return trace;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const clickPromise = interaction.element.click({ timeout: INTERACTION_TIMEOUT_MS });
|
|
206
|
+
const shouldWaitForNavigation = interaction.type === 'link' || interaction.type === 'form';
|
|
207
|
+
const navigationPromise = shouldWaitForNavigation
|
|
208
|
+
? page.waitForNavigation({ timeout: NAVIGATION_TIMEOUT_MS, waitUntil: 'domcontentloaded' })
|
|
209
|
+
.catch((error) => {
|
|
210
|
+
if (error && error.name === 'TimeoutError') {
|
|
211
|
+
markTimeoutPolicy(trace, 'navigation');
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
})
|
|
215
|
+
: null;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await Promise.race([
|
|
219
|
+
clickPromise,
|
|
220
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')),
|
|
221
|
+
INTERACTION_TIMEOUT_MS))
|
|
222
|
+
]);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.message === 'timeout' || error.name === 'TimeoutError') {
|
|
225
|
+
// Stop sensors on timeout
|
|
226
|
+
if (networkWindowId !== null) {
|
|
227
|
+
networkSensor.stopWindow(networkWindowId);
|
|
228
|
+
}
|
|
229
|
+
if (consoleWindowId !== null) {
|
|
230
|
+
consoleSensor.stopWindow(consoleWindowId, page);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
markTimeoutPolicy(trace, 'click');
|
|
234
|
+
await captureAfterOnly(page, screenshotsDir, timestamp, i, trace);
|
|
235
|
+
return trace;
|
|
236
|
+
}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const navigationResult = navigationPromise ? await navigationPromise : null;
|
|
241
|
+
|
|
242
|
+
if (navigationResult) {
|
|
243
|
+
const afterUrl = page.url();
|
|
244
|
+
if (isExternalUrl(afterUrl, baseOrigin)) {
|
|
245
|
+
await page.goBack({ timeout: NAVIGATION_TIMEOUT_MS }).catch(() => {});
|
|
246
|
+
trace.policy = {
|
|
247
|
+
...(trace.policy || {}),
|
|
248
|
+
externalNavigationBlocked: true,
|
|
249
|
+
blockedUrl: afterUrl
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const { settleResult, afterUrl } = await captureAfterState(page, screenshotsDir, timestamp, i, trace);
|
|
255
|
+
|
|
256
|
+
// Stop sensors after interaction settled
|
|
257
|
+
const networkSummary = networkWindowId !== null ? networkSensor.stopWindow(networkWindowId) : null;
|
|
258
|
+
const consoleSummary = consoleWindowId !== null ? consoleSensor.stopWindow(consoleWindowId, page) : null;
|
|
259
|
+
let uiSignalsAfter = null;
|
|
260
|
+
let uiSignalChanges = null;
|
|
261
|
+
let stateUIAfter = null;
|
|
262
|
+
let stateUIChanges = null;
|
|
263
|
+
try {
|
|
264
|
+
uiSignalsAfter = await uiSignalSensor.snapshot(page);
|
|
265
|
+
uiSignalChanges = uiSignalSensor.diff(uiSignalsBefore, uiSignalsAfter);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
// If snapshot fails, use empty objects
|
|
268
|
+
uiSignalsAfter = {
|
|
269
|
+
hasLoadingIndicator: false,
|
|
270
|
+
hasDialog: false,
|
|
271
|
+
hasErrorSignal: false,
|
|
272
|
+
explanation: []
|
|
273
|
+
};
|
|
274
|
+
uiSignalChanges = { changed: false, explanation: '', summary: [] };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Capture state UI after interaction (Wave 8)
|
|
278
|
+
try {
|
|
279
|
+
stateUIAfter = await stateUISensor.snapshot(page);
|
|
280
|
+
stateUIChanges = stateUISensor.diff(stateUiBefore, stateUIAfter);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
stateUIAfter = {
|
|
283
|
+
signals: {
|
|
284
|
+
dialogs: [],
|
|
285
|
+
expandedElements: [],
|
|
286
|
+
selectedTabs: [],
|
|
287
|
+
checkedElements: [],
|
|
288
|
+
alerts: []
|
|
289
|
+
},
|
|
290
|
+
rawSnapshot: {}
|
|
291
|
+
};
|
|
292
|
+
stateUIChanges = { changed: false, reasons: [] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
trace.after.url = afterUrl;
|
|
296
|
+
trace.after.screenshot = `screenshots/after-${timestamp}-${i}.png`;
|
|
297
|
+
if (!trace.dom) {
|
|
298
|
+
trace.dom = {};
|
|
299
|
+
}
|
|
300
|
+
if (settleResult.afterHash) {
|
|
301
|
+
trace.dom.afterHash = settleResult.afterHash;
|
|
302
|
+
}
|
|
303
|
+
trace.dom.settle = {
|
|
304
|
+
samples: settleResult.samples,
|
|
305
|
+
domChangedDuringSettle: settleResult.domChangedDuringSettle
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Add sensor evidence to trace
|
|
309
|
+
trace.sensors = {
|
|
310
|
+
network: networkSummary,
|
|
311
|
+
console: consoleSummary,
|
|
312
|
+
uiSignals: {
|
|
313
|
+
before: uiSignalsBefore,
|
|
314
|
+
after: uiSignalsAfter,
|
|
315
|
+
changes: uiSignalChanges
|
|
316
|
+
},
|
|
317
|
+
stateUI: {
|
|
318
|
+
before: stateUiBefore,
|
|
319
|
+
after: stateUIAfter,
|
|
320
|
+
changed: stateUIChanges.changed,
|
|
321
|
+
reasons: stateUIChanges.reasons
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
return trace;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error.message === 'timeout' || error.name === 'TimeoutError') {
|
|
328
|
+
// Stop sensors on timeout
|
|
329
|
+
if (networkWindowId !== null) {
|
|
330
|
+
try {
|
|
331
|
+
networkSensor.stopWindow(networkWindowId);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
// Ignore sensor cleanup errors
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (consoleWindowId !== null) {
|
|
337
|
+
try {
|
|
338
|
+
consoleSensor.stopWindow(consoleWindowId, page);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
// Ignore sensor cleanup errors
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
markTimeoutPolicy(trace, 'click');
|
|
344
|
+
await captureAfterOnly(page, screenshotsDir, timestamp, i, trace);
|
|
345
|
+
return trace;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Stop sensors on unexpected error
|
|
349
|
+
if (networkWindowId !== null) {
|
|
350
|
+
try {
|
|
351
|
+
networkSensor.stopWindow(networkWindowId);
|
|
352
|
+
} catch (e) {
|
|
353
|
+
// Ignore sensor cleanup errors
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (consoleWindowId !== null) {
|
|
357
|
+
try {
|
|
358
|
+
consoleSensor.stopWindow(consoleWindowId, page);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
// Ignore sensor cleanup errors
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function captureAfterState(page, screenshotsDir, timestamp, interactionIndex, trace) {
|
|
368
|
+
// Note: We don't call waitForSettle here because captureSettledDom does its own
|
|
369
|
+
// sampling to capture async updates. Calling waitForSettle would interfere.
|
|
370
|
+
|
|
371
|
+
let settleResult = {
|
|
372
|
+
samples: [],
|
|
373
|
+
domChangedDuringSettle: false,
|
|
374
|
+
afterHash: null
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
settleResult = await captureSettledDom(page);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (error.message === 'timeout' || error.name === 'TimeoutError') {
|
|
381
|
+
markTimeoutPolicy(trace, 'settle');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const afterUrl = page.url();
|
|
386
|
+
const afterScreenshot = resolve(screenshotsDir, `after-${timestamp}-${interactionIndex}.png`);
|
|
387
|
+
await captureScreenshot(page, afterScreenshot);
|
|
388
|
+
|
|
389
|
+
return { settleResult, afterUrl };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function captureAfterOnly(page, screenshotsDir, timestamp, interactionIndex, trace) {
|
|
393
|
+
const afterUrl = page.url();
|
|
394
|
+
const afterScreenshot = resolve(screenshotsDir, `after-${timestamp}-${interactionIndex}.png`);
|
|
395
|
+
try {
|
|
396
|
+
await captureScreenshot(page, afterScreenshot);
|
|
397
|
+
const afterDomHash = await captureDomSignature(page);
|
|
398
|
+
trace.after.url = afterUrl;
|
|
399
|
+
trace.after.screenshot = `screenshots/after-${timestamp}-${interactionIndex}.png`;
|
|
400
|
+
if (afterDomHash) {
|
|
401
|
+
if (!trace.dom) {
|
|
402
|
+
trace.dom = {};
|
|
403
|
+
}
|
|
404
|
+
trace.dom.afterHash = afterDomHash;
|
|
405
|
+
}
|
|
406
|
+
} catch (e) {
|
|
407
|
+
// Ignore screenshot errors on timeout
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|