@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/bin/verax.js +452 -0
  4. package/package.json +57 -0
  5. package/src/verax/detect/comparison.js +69 -0
  6. package/src/verax/detect/confidence-engine.js +498 -0
  7. package/src/verax/detect/evidence-validator.js +33 -0
  8. package/src/verax/detect/expectation-model.js +204 -0
  9. package/src/verax/detect/findings-writer.js +31 -0
  10. package/src/verax/detect/index.js +397 -0
  11. package/src/verax/detect/skip-classifier.js +202 -0
  12. package/src/verax/flow/flow-engine.js +265 -0
  13. package/src/verax/flow/flow-spec.js +145 -0
  14. package/src/verax/flow/redaction.js +74 -0
  15. package/src/verax/index.js +97 -0
  16. package/src/verax/learn/action-contract-extractor.js +281 -0
  17. package/src/verax/learn/ast-contract-extractor.js +255 -0
  18. package/src/verax/learn/index.js +18 -0
  19. package/src/verax/learn/manifest-writer.js +97 -0
  20. package/src/verax/learn/project-detector.js +87 -0
  21. package/src/verax/learn/react-router-extractor.js +73 -0
  22. package/src/verax/learn/route-extractor.js +122 -0
  23. package/src/verax/learn/route-validator.js +215 -0
  24. package/src/verax/learn/source-instrumenter.js +214 -0
  25. package/src/verax/learn/static-extractor.js +222 -0
  26. package/src/verax/learn/truth-assessor.js +96 -0
  27. package/src/verax/learn/ts-contract-resolver.js +395 -0
  28. package/src/verax/observe/browser.js +22 -0
  29. package/src/verax/observe/console-sensor.js +166 -0
  30. package/src/verax/observe/dom-signature.js +23 -0
  31. package/src/verax/observe/domain-boundary.js +38 -0
  32. package/src/verax/observe/evidence-capture.js +5 -0
  33. package/src/verax/observe/human-driver.js +376 -0
  34. package/src/verax/observe/index.js +67 -0
  35. package/src/verax/observe/interaction-discovery.js +269 -0
  36. package/src/verax/observe/interaction-runner.js +410 -0
  37. package/src/verax/observe/network-sensor.js +173 -0
  38. package/src/verax/observe/selector-generator.js +74 -0
  39. package/src/verax/observe/settle.js +155 -0
  40. package/src/verax/observe/state-ui-sensor.js +200 -0
  41. package/src/verax/observe/traces-writer.js +82 -0
  42. package/src/verax/observe/ui-signal-sensor.js +197 -0
  43. package/src/verax/resolve-workspace-root.js +173 -0
  44. package/src/verax/scan-summary-writer.js +41 -0
  45. package/src/verax/shared/artifact-manager.js +139 -0
  46. package/src/verax/shared/caching.js +104 -0
  47. package/src/verax/shared/expectation-proof.js +4 -0
  48. package/src/verax/shared/redaction.js +227 -0
  49. package/src/verax/shared/retry-policy.js +89 -0
  50. 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
+